diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 60597ae7..810c8884 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -527,8 +527,8 @@ Per-vault settings stored locally and scoped by vault path: - User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen folder - Welcome state tracked in localStorage (`tolaria_welcome_dismissed`, with legacy fallback) -`useClaudeCodeOnboarding(enabled)` adds a separate first-launch agent step: -- Reads a local dismissal flag for the Claude Code prompt +`useAiAgentsOnboarding(enabled)` adds a separate first-launch agent step: +- Reads a local dismissal flag for the AI agents prompt (with a legacy fallback to the older Claude-only key) - Only shows after vault onboarding has already resolved to a ready state - Persists dismissal locally once the user continues @@ -552,10 +552,11 @@ interface Settings { analytics_enabled: boolean | null anonymous_id: string | null release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed + default_ai_agent: 'claude_code' | 'codex' | null } ``` -Managed by `useSettings` hook and `SettingsPanel` component. +Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. ## Telemetry diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b64daeb6..62ab9389 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -96,7 +96,7 @@ flowchart LR | Build | Vite | 7.3.1 | | Backend language | Rust (edition 2021) | 1.77.2 | | Frontmatter parsing | gray_matter | 0.2 | -| AI (agent panel) | Claude CLI subprocess (streaming NDJSON) | - | +| AI (agent panel) | CLI agent adapters (Claude Code + Codex) | - | | Search | Keyword (walkdir-based file scan) | - | | MCP | @modelcontextprotocol/sdk | 1.0 | | Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - | @@ -114,7 +114,7 @@ flowchart TD NL["NoteList / PulseView\n(filtered list / activity)"] ED["Editor\n(BlockNote + diff + raw)"] IN["Inspector\n(metadata + relationships)"] - AIP["AiPanel\n(Claude CLI agent + tools)"] + AIP["AiPanel\n(selected CLI agent + tools)"] SP["SearchPanel\n(keyword search)"] ST["StatusBar\n(vault picker + sync + version)"] CP["CommandPalette\n(Cmd+K launcher)"] @@ -130,11 +130,11 @@ flowchart TD GIT["git/\n(commit, sync, clone)"] SETTINGS["settings.rs"] SEARCH["search.rs"] - CLI["claude_cli.rs"] + CLI["ai_agents.rs\n+ claude_cli.rs"] end subgraph EXT["External Services"] - CCLI["Claude CLI\n(agent subprocess)"] + CCLI["Claude CLI / Codex 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.)"] @@ -178,7 +178,7 @@ flowchart TD - **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse) and collapsible type-based section groups. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`. - **Note List / Pulse View** (200-500px, resizable): When a section group or filter is selected, shows filtered notes with snippets, modified dates, and status indicators. When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day. - **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, `useEditorSave`, `useRawMode`. Navigation history (Cmd+[/]) replaces tabs. -- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (Claude CLI subprocess with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). +- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). Panels are separated by `ResizeHandle` components that support drag-to-resize. @@ -204,30 +204,32 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win ### AI Agent (AiPanel) -Full agent mode — spawns Claude CLI as a subprocess with tool access and MCP vault integration. +Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration. -1. **Frontend** (`AiPanel` + `useAiAgent` hook) — streaming UI with reasoning blocks, tool action cards, and response display -2. **Backend** (`claude_cli.rs`) — spawns `claude` binary with `--output-format stream-json`, parses NDJSON events -3. **MCP Integration** — passes vault MCP config via `--mcp-config` flag so the agent can search, read, and modify vault notes +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`; Codex runs through `codex exec --json` +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 #### Agent Event Flow ```mermaid sequenceDiagram participant U as User (AiPanel) - participant FE as useAiAgent (Frontend) - participant R as claude_cli.rs (Rust) - participant C as Claude CLI + participant FE as useCliAiAgent (Frontend) + participant R as ai_agents.rs (Rust) + participant C as Selected CLI Agent participant V as Vault (MCP) U->>FE: sendMessage(text, references) FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs) - FE->>R: invoke('stream_claude_agent', {message, systemPrompt, vaultPath}) - R->>C: spawn claude -p --output-format stream-json --mcp-config + FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath}) + R->>R: pick adapter for claude_code or codex + R->>C: spawn agent with MCP-enabled config - loop NDJSON stream - C-->>R: Init | TextDelta | ThinkingDelta | ToolStart | ToolDone | Result | Done - R-->>FE: emit("claude-agent-stream", event) + loop Normalized stream + C-->>R: Claude NDJSON or Codex JSONL events + R-->>FE: emit("ai-agent-stream", event) alt TextDelta FE->>FE: accumulate response (revealed on Done) else ThinkingDelta @@ -248,7 +250,7 @@ sequenceDiagram #### File Operation Detection -When the agent writes or edits vault files, `useAiAgent` detects this from tool inputs (Write/Edit tool JSON) and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload. +When the agent writes or edits vault files, `useCliAiAgent` detects this from normalized tool inputs and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload. ### Context Building @@ -268,7 +270,7 @@ Token budget: 60% of 180k context limit (~108k tokens max). Active note gets pri ### Authentication -Claude CLI (agent mode) uses its own authentication — no API key configuration needed in Tolaria. +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. ## MCP Server @@ -432,7 +434,7 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it - **Open an existing folder** → system file picker - **Get started with a template** → pick a folder, then call `create_getting_started_vault()` to clone the public starter repo at runtime -Once a vault is ready, `useClaudeCodeOnboarding` can show a one-time `ClaudeCodeOnboardingPrompt`. That prompt reuses `useClaudeCodeStatus` so first launch surfaces whether the `claude` CLI is installed, offers the install link when it is 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 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. The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` only holds the public starter repo URL and delegates the actual clone to the git backend. @@ -579,7 +581,8 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) | | `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`) | | `search.rs` | Keyword search — walkdir-based vault file scan | -| `claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing | +| `ai_agents.rs` | Shared CLI-agent detection, stream normalization, and adapter dispatch | +| `claude_cli.rs` | Claude Code subprocess spawning + NDJSON stream parsing | | `mcp.rs` | MCP server spawning + config registration | | `commands/` | Tauri command handlers (split into submodules) | | `settings.rs` | App settings persistence | @@ -654,6 +657,8 @@ 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 | +| `stream_ai_agent` | Stream the selected CLI agent through the normalized event layer | | `register_mcp_tools` | Register MCP in Claude/Cursor config | | `check_mcp_status` | Check MCP registration state | @@ -703,7 +708,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks: | `useTabManagement` | Navigation history, note switching | Note navigation lifecycle | | `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching | | `useTheme` | Editor theme CSS vars | Editor typography theme | -| `useAiAgent` | `messages`, `status`, tool actions | AI agent conversation | +| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation | | `useAutoSync` | Sync interval, pull/push state | Git auto-sync | | `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI | | `useUnifiedSearch` | Query, results, loading state | Keyword search | @@ -883,7 +888,7 @@ Tauri v2 supports iOS as a beta target. The Rust backend cross-compiles to `aarc **Conditional compilation strategy:** ``` -#[cfg(desktop)] — git CLI, menu bar, MCP server, Claude CLI, updater +#[cfg(desktop)] — git CLI, menu bar, MCP server, CLI AI agents, updater #[cfg(mobile)] — stub commands returning graceful errors or empty results ``` @@ -893,7 +898,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`) -- Claude CLI streaming (check, chat, agent) +- CLI AI agent streaming (Claude, Codex) - MCP registration and status - Menu state updates diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 465b9d09..1362ea40 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -54,9 +54,10 @@ tolaria/ │ │ ├── RawEditorView.tsx # CodeMirror raw editor │ │ ├── Inspector.tsx # Fourth panel: metadata + relationships │ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties -│ │ ├── AiPanel.tsx # AI agent (Claude CLI subprocess) +│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent) │ │ ├── AiMessage.tsx # Agent message display │ │ ├── AiActionCard.tsx # Agent tool action cards +│ │ ├── AiAgentsOnboardingPrompt.tsx # First-launch AI agent installer prompt │ │ ├── SearchPanel.tsx # Search interface │ │ ├── SettingsPanel.tsx # App settings │ │ ├── StatusBar.tsx # Bottom bar: vault picker + sync @@ -84,7 +85,10 @@ tolaria/ │ │ ├── useNoteActions.ts # Composes creation + rename + frontmatter │ │ ├── useNoteCreation.ts # Note/type creation │ │ ├── useNoteRename.ts # Note renaming + wikilink updates -│ │ ├── useAiAgent.ts # AI agent state + tool tracking +│ │ ├── useAiAgent.ts # Legacy Claude-specific stream helpers reused by the shared agent hook +│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized tool tracking +│ │ ├── useAiAgentsStatus.ts # Claude/Codex availability polling +│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling │ │ ├── useAiActivity.ts # MCP UI bridge listener │ │ ├── useAutoSync.ts # Auto git pull/push │ │ ├── useConflictResolver.ts # Git conflict handling @@ -121,6 +125,7 @@ tolaria/ │ │ └── ... │ │ │ ├── lib/ +│ │ ├── aiAgents.ts # Shared agent registry + status helpers │ │ ├── appUpdater.ts # Frontend wrapper around channel-aware updater commands │ │ ├── releaseChannel.ts # Alpha/stable normalization helpers │ │ └── utils.ts # Tailwind merge + cn() helper @@ -152,6 +157,7 @@ tolaria/ │ │ │ ├── conflict.rs, remote.rs, pulse.rs │ │ ├── telemetry.rs # Sentry init + path scrubber │ │ ├── search.rs # Keyword search (walkdir-based) +│ │ ├── ai_agents.rs # Shared CLI-agent detection + stream adapters │ │ ├── claude_cli.rs # Claude CLI subprocess management │ │ ├── mcp.rs # MCP server lifecycle + registration │ │ ├── app_updater.rs # Alpha/stable updater endpoint selection @@ -214,6 +220,7 @@ tolaria/ | `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. | | `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse). | | `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. | +| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, Codex adapter, and stream normalization. | | `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. | | `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. | @@ -230,8 +237,9 @@ tolaria/ | File | Why it matters | |------|---------------| -| `src/components/AiPanel.tsx` | AI agent panel — Claude CLI with tool execution, reasoning, actions. | -| `src/hooks/useAiAgent.ts` | Agent state: messages, streaming, tool tracking, file detection. | +| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, and actions. | +| `src/hooks/useCliAiAgent.ts` | Agent state: messages, streaming, tool tracking, file detection. | +| `src/lib/aiAgents.ts` | Supported agent definitions, status normalization, and default-agent helpers. | | `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. | ### Styling @@ -245,11 +253,11 @@ tolaria/ | File | Why it matters | |------|---------------| -| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, auto-sync interval). | +| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, auto-sync interval, default AI agent). | | `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). | | `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. | | `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow). | -| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, and the vault-level explicit organization toggle. | +| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, default AI agent, and the vault-level explicit organization toggle. | | `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. | ## Architecture Patterns @@ -362,3 +370,4 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/.spec.ts 2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent 3. **Tool action display**: Edit `src/components/AiActionCard.tsx` 4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`) +5. **Shared agent adapters / Codex args**: Edit `src-tauri/src/ai_agents.rs` diff --git a/docs/adr/0062-selectable-cli-ai-agents.md b/docs/adr/0062-selectable-cli-ai-agents.md new file mode 100644 index 00000000..bd334a8f --- /dev/null +++ b/docs/adr/0062-selectable-cli-ai-agents.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0062" +title: "Selectable CLI AI agents with a shared panel architecture" +status: active +date: 2026-04-13 +--- + +## Context + +Tolaria's AI panel, onboarding flow, and status surfaces were built around a single CLI dependency: Claude Code. That worked for the first release, but it made every UI and backend seam agent-specific. Adding Codex as a second supported CLI agent would have duplicated large parts of the app: separate availability checks, a second onboarding path, another status badge, and yet another streaming hook. + +The product direction is broader than a single vendor. Tolaria needs one AI panel that can target multiple local CLI agents while preserving the same MCP-backed vault tooling, the same note-context assembly, and a single install-local preference for which agent should be used by default. + +## Decision + +**Introduce a shared CLI-agent abstraction for Tolaria's AI surfaces.** The frontend now treats agents as a small registry (`claude_code`, `codex`) with labels, install URLs, availability state, and a persisted `default_ai_agent` setting. The AI panel, onboarding gate, command palette, and status bar all read from that shared model. On the backend, `ai_agents.rs` owns agent detection and streaming, dispatching to per-agent adapters: Claude still flows through `claude_cli.rs`, while Codex is launched through `codex exec --json` with Tolaria's MCP server injected via transient config flags. + +## Options considered + +- **Option A** (chosen): shared agent registry + backend adapter layer — one panel, one preference, one onboarding path, and a clear place to add future CLI agents. +- **Option B**: keep the UI Claude-specific and bolt on Codex as a second special case — lowest short-term cost, but every new agent multiplies the number of bespoke checks, prompts, and command handlers. +- **Option C**: split the product into separate per-agent panels — clearer ownership per integration, but fragments the UX and makes command-palette / status-bar interactions inconsistent. + +## Consequences + +- Positive: new CLI agents can be added by implementing one backend adapter and registering one frontend definition. +- Positive: onboarding and settings now explain the AI capability of the app at the product level rather than assuming Claude Code is the only valid path. +- Positive: the default agent is installation-local, matching ADR-0004's rule that machine-specific tool preferences belong in app settings rather than the vault. +- Negative: event normalization is now Tolaria-owned; backend adapters must translate each CLI's stream format into a common event model. +- Negative: some user guidance becomes agent-specific again at the edge, such as install links and authentication errors (`claude` login vs `codex login`). +- Re-evaluate if one agent needs capabilities the shared panel cannot express cleanly, or if Tolaria ever moves from CLI subprocesses to a dedicated local SDK/runtime. diff --git a/docs/adr/README.md b/docs/adr/README.md index 79805c49..cb351bf5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -113,7 +113,8 @@ proposed → active → superseded | [0055](0055-h1-is-the-only-editor-title-surface.md) | H1 is the only editor title surface | active | | [0056](0056-system-git-cli-auth-no-provider-oauth.md) | System git auth only — no provider-specific OAuth or repo APIs | active | | [0057](0057-alpha-stable-release-channels-and-beta-cohorts.md) | Alpha/stable release channels with PostHog beta cohorts | active | -| [0058](0058-claude-code-first-launch-onboarding-gate.md) | Claude Code first-launch onboarding gate | active | +| [0058](0058-claude-code-first-launch-onboarding-gate.md) | Claude Code first-launch onboarding gate | superseded → [0062](0062-selectable-cli-ai-agents.md) | | [0059](0059-local-only-git-commits-without-remote.md) | Local-only git commits for vaults without a remote | active | | [0060](0060-network-aware-ui-gating-for-remote-features.md) | Network-aware UI gating for remote-dependent features | active | | [0061](0061-ai-prompt-bridge-event-bus.md) | AI prompt bridge — module-level event bus for cross-component prompt routing | active | +| [0062](0062-selectable-cli-ai-agents.md) | Selectable CLI AI agents with a shared panel architecture | active | diff --git a/src-tauri/src/ai_agents.rs b/src-tauri/src/ai_agents.rs new file mode 100644 index 00000000..8925753a --- /dev/null +++ b/src-tauri/src/ai_agents.rs @@ -0,0 +1,463 @@ +use serde::{Deserialize, Serialize}; +use std::io::BufRead; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AiAgentId { + ClaudeCode, + Codex, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AiAgentAvailability { + pub installed: bool, + pub version: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AiAgentsStatus { + pub claude_code: AiAgentAvailability, + pub codex: AiAgentAvailability, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind")] +pub enum AiAgentStreamEvent { + Init { + session_id: String, + }, + TextDelta { + text: String, + }, + ThinkingDelta { + text: String, + }, + ToolStart { + tool_name: String, + tool_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + input: Option, + }, + ToolDone { + tool_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + output: Option, + }, + Error { + message: String, + }, + Done, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AiAgentStreamRequest { + pub agent: AiAgentId, + pub message: String, + pub system_prompt: Option, + pub vault_path: String, +} + +pub fn get_ai_agents_status() -> AiAgentsStatus { + AiAgentsStatus { + claude_code: availability_from_claude(), + codex: availability_from_codex(), + } +} + +pub fn run_ai_agent_stream(request: AiAgentStreamRequest, mut emit: F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + match request.agent { + AiAgentId::ClaudeCode => { + let mapped = crate::claude_cli::AgentStreamRequest { + message: request.message, + system_prompt: request.system_prompt, + vault_path: request.vault_path, + }; + crate::claude_cli::run_agent_stream(mapped, |event| { + if let Some(mapped_event) = map_claude_event(event) { + emit(mapped_event); + } + }) + } + AiAgentId::Codex => run_codex_agent_stream(request, emit), + } +} + +fn availability_from_claude() -> AiAgentAvailability { + let status = crate::claude_cli::check_cli(); + AiAgentAvailability { + installed: status.installed, + version: status.version, + } +} + +fn availability_from_codex() -> AiAgentAvailability { + let binary = match find_codex_binary() { + Ok(binary) => binary, + Err(_) => { + return AiAgentAvailability { + installed: false, + version: None, + } + } + }; + + AiAgentAvailability { + installed: true, + version: version_for_binary(&binary), + } +} + +fn version_for_binary(binary: &PathBuf) -> Option { + Command::new(binary) + .arg("--version") + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn find_codex_binary() -> Result { + if let Some(binary) = find_codex_binary_on_path()? { + return Ok(binary); + } + + if let Some(binary) = find_existing_binary(codex_binary_candidates()) { + return Ok(binary); + } + + Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into()) +} + +fn find_codex_binary_on_path() -> Result, String> { + let output = Command::new("which") + .arg("codex") + .output() + .map_err(|error| format!("Failed to run `which codex`: {error}"))?; + + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path.is_empty() { + return Ok(Some(PathBuf::from(path))); + } + } + + Ok(None) +} + +fn codex_binary_candidates() -> Vec { + let home = dirs::home_dir().unwrap_or_default(); + vec![ + home.join(".local/bin/codex"), + home.join(".npm/bin/codex"), + PathBuf::from("/usr/local/bin/codex"), + PathBuf::from("/opt/homebrew/bin/codex"), + PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"), + ] +} + +fn find_existing_binary(candidates: Vec) -> Option { + candidates.into_iter().find(|candidate| candidate.exists()) +} + +fn run_codex_agent_stream(request: AiAgentStreamRequest, mut emit: F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let binary = find_codex_binary()?; + let args = build_codex_args(&request)?; + let prompt = build_codex_prompt(&request); + + let mut command = Command::new(binary); + command + .args(args) + .arg(prompt) + .current_dir(&request.vault_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = command + .spawn() + .map_err(|error| format!("Failed to spawn codex: {error}"))?; + + let stdout = child.stdout.take().ok_or("No stdout handle")?; + let reader = std::io::BufReader::new(stdout); + + let mut thread_id = String::new(); + + for line in reader.lines() { + let line = match line { + Ok(line) => line, + Err(error) => { + emit(AiAgentStreamEvent::Error { + message: format!("Read error: {error}"), + }); + break; + } + }; + + if line.trim().is_empty() { + continue; + } + + let json = match serde_json::from_str::(&line) { + Ok(json) => json, + Err(_) => continue, + }; + + if let Some(id) = json["thread_id"].as_str() { + thread_id = id.to_string(); + } + + dispatch_codex_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: format_codex_error(stderr_output, status.to_string()), + }); + } + + emit(AiAgentStreamEvent::Done); + + Ok(thread_id) +} + +fn build_codex_args(request: &AiAgentStreamRequest) -> Result, String> { + 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(); + + Ok(vec![ + "exec".into(), + "--json".into(), + "--dangerously-bypass-approvals-and-sandbox".into(), + "-C".into(), + request.vault_path.clone(), + "-c".into(), + r#"mcp_servers.tolaria.command="node""#.into(), + "-c".into(), + format!(r#"mcp_servers.tolaria.args=["{}"]"#, mcp_server_path), + "-c".into(), + format!( + r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#, + request.vault_path + ), + ]) +} + +fn build_codex_prompt(request: &AiAgentStreamRequest) -> 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 dispatch_codex_event(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + match json["type"].as_str().unwrap_or_default() { + "thread.started" => { + if let Some(thread_id) = json["thread_id"].as_str() { + emit(AiAgentStreamEvent::Init { + session_id: thread_id.to_string(), + }); + } + } + "item.started" => emit_codex_item_event(json, false, emit), + "item.completed" => emit_codex_item_event(json, true, emit), + _ => {} + } +} + +fn emit_codex_item_event(json: &serde_json::Value, completed: bool, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + let item = &json["item"]; + let item_type = item["type"].as_str().unwrap_or_default(); + let item_id = item["id"].as_str().unwrap_or_default(); + + match item_type { + "command_execution" => { + if completed { + emit(AiAgentStreamEvent::ToolDone { + tool_id: item_id.to_string(), + output: item["aggregated_output"] + .as_str() + .map(|output| output.to_string()), + }); + } else { + emit(AiAgentStreamEvent::ToolStart { + tool_name: "Bash".into(), + tool_id: item_id.to_string(), + input: item["command"] + .as_str() + .map(|command| serde_json::json!({ "command": command }).to_string()), + }); + } + } + "agent_message" if completed => { + if let Some(text) = item["text"].as_str() { + emit(AiAgentStreamEvent::TextDelta { + text: text.to_string(), + }); + } + } + _ => {} + } +} + +fn format_codex_error(stderr_output: String, status: String) -> String { + let lower = stderr_output.to_ascii_lowercase(); + if is_codex_auth_error(&lower) { + return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into(); + } + + if stderr_output.trim().is_empty() { + format!("codex exited with status {status}") + } else { + stderr_output.lines().take(3).collect::>().join("\n") + } +} + +fn is_codex_auth_error(lower: &str) -> bool { + ["auth", "login", "sign in"] + .iter() + .any(|pattern| lower.contains(pattern)) +} + +fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option { + match event { + crate::claude_cli::ClaudeStreamEvent::Init { session_id } => { + Some(AiAgentStreamEvent::Init { session_id }) + } + crate::claude_cli::ClaudeStreamEvent::TextDelta { text } => { + Some(AiAgentStreamEvent::TextDelta { text }) + } + crate::claude_cli::ClaudeStreamEvent::ThinkingDelta { text } => { + Some(AiAgentStreamEvent::ThinkingDelta { text }) + } + crate::claude_cli::ClaudeStreamEvent::ToolStart { + tool_name, + tool_id, + input, + } => Some(AiAgentStreamEvent::ToolStart { + tool_name, + tool_id, + input, + }), + crate::claude_cli::ClaudeStreamEvent::ToolDone { tool_id, output } => { + Some(AiAgentStreamEvent::ToolDone { tool_id, output }) + } + crate::claude_cli::ClaudeStreamEvent::Error { message } => { + Some(AiAgentStreamEvent::Error { message }) + } + crate::claude_cli::ClaudeStreamEvent::Done + | crate::claude_cli::ClaudeStreamEvent::Result { .. } => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_status_contains_both_agents() { + let status = get_ai_agents_status(); + assert!(matches!(status.claude_code.installed, true | false)); + assert!(matches!(status.codex.installed, true | false)); + } + + #[test] + fn build_codex_prompt_keeps_system_prompt_first() { + let prompt = build_codex_prompt(&AiAgentStreamRequest { + agent: AiAgentId::Codex, + message: "Rename the note".into(), + system_prompt: Some("Be concise".into()), + vault_path: "/tmp/vault".into(), + }); + + assert!(prompt.starts_with("System instructions:\nBe concise")); + assert!(prompt.contains("User request:\nRename the note")); + } + + #[test] + fn dispatch_codex_command_events_maps_to_bash_events() { + let mut events = Vec::new(); + let started = serde_json::json!({ + "type": "item.started", + "item": { + "id": "item_1", + "type": "command_execution", + "command": "/bin/zsh -lc pwd" + } + }); + let completed = serde_json::json!({ + "type": "item.completed", + "item": { + "id": "item_1", + "type": "command_execution", + "aggregated_output": "/private/tmp\n" + } + }); + + dispatch_codex_event(&started, &mut |event| events.push(event)); + dispatch_codex_event(&completed, &mut |event| events.push(event)); + + assert!(matches!( + &events[0], + AiAgentStreamEvent::ToolStart { tool_name, tool_id, .. } + if tool_name == "Bash" && tool_id == "item_1" + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::ToolDone { tool_id, output } + if tool_id == "item_1" && output.as_deref() == Some("/private/tmp\n") + )); + } + + #[test] + fn dispatch_codex_agent_message_maps_to_text_delta() { + let mut events = Vec::new(); + let completed = serde_json::json!({ + "type": "item.completed", + "item": { + "id": "item_2", + "type": "agent_message", + "text": "All set" + } + }); + + dispatch_codex_event(&completed, &mut |event| events.push(event)); + + assert!(matches!( + &events[0], + AiAgentStreamEvent::TextDelta { text } if text == "All set" + )); + } +} diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index b20c0849..d1b78a54 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -1,7 +1,49 @@ #[cfg(desktop)] -use crate::claude_cli::ClaudeStreamEvent; +use crate::ai_agents::{AiAgentStreamRequest, AiAgentsStatus}; use crate::claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus}; +#[cfg(desktop)] +type StreamEmitter = Box; + +#[cfg(desktop)] +async fn run_desktop_stream( + app_handle: tauri::AppHandle, + event_name: &'static str, + request: Request, + runner: Runner, +) -> Result +where + Event: serde::Serialize + Send + 'static, + Request: Send + 'static, + Runner: FnOnce(Request, StreamEmitter) -> Result + Send + 'static, +{ + use tauri::Emitter; + + tokio::task::spawn_blocking(move || { + runner( + request, + Box::new(move |event| { + let _ = app_handle.emit(event_name, &event); + }), + ) + }) + .await + .map_err(|e| format!("Task failed: {e}"))? +} + +#[cfg(desktop)] +macro_rules! define_desktop_stream_command { + ($name:ident, $request:ty, $event_name:literal, $runner:path) => { + #[tauri::command] + pub async fn $name( + app_handle: tauri::AppHandle, + request: $request, + ) -> Result { + run_desktop_stream(app_handle, $event_name, request, $runner).await + } + }; +} + // ── Claude CLI commands (desktop) ─────────────────────────────────────────── #[cfg(desktop)] @@ -12,35 +54,33 @@ pub fn check_claude_cli() -> ClaudeCliStatus { #[cfg(desktop)] #[tauri::command] -pub async fn stream_claude_chat( - app_handle: tauri::AppHandle, - request: ChatStreamRequest, -) -> Result { - use tauri::Emitter; - tokio::task::spawn_blocking(move || { - crate::claude_cli::run_chat_stream(request, |event: ClaudeStreamEvent| { - let _ = app_handle.emit("claude-stream", &event); - }) - }) - .await - .map_err(|e| format!("Task failed: {e}"))? +pub fn get_ai_agents_status() -> AiAgentsStatus { + crate::ai_agents::get_ai_agents_status() } #[cfg(desktop)] -#[tauri::command] -pub async fn stream_claude_agent( - app_handle: tauri::AppHandle, - request: AgentStreamRequest, -) -> Result { - use tauri::Emitter; - tokio::task::spawn_blocking(move || { - crate::claude_cli::run_agent_stream(request, |event: ClaudeStreamEvent| { - let _ = app_handle.emit("claude-agent-stream", &event); - }) - }) - .await - .map_err(|e| format!("Task failed: {e}"))? -} +define_desktop_stream_command!( + stream_claude_chat, + ChatStreamRequest, + "claude-stream", + crate::claude_cli::run_chat_stream +); + +#[cfg(desktop)] +define_desktop_stream_command!( + stream_claude_agent, + AgentStreamRequest, + "claude-agent-stream", + crate::claude_cli::run_agent_stream +); + +#[cfg(desktop)] +define_desktop_stream_command!( + stream_ai_agent, + AiAgentStreamRequest, + "ai-agent-stream", + crate::ai_agents::run_ai_agent_stream +); // ── Claude CLI (mobile stubs) ─────────────────────────────────────────────── @@ -53,6 +93,21 @@ pub fn check_claude_cli() -> ClaudeCliStatus { } } +#[cfg(mobile)] +#[tauri::command] +pub fn get_ai_agents_status() -> AiAgentsStatus { + AiAgentsStatus { + claude_code: crate::ai_agents::AiAgentAvailability { + installed: false, + version: None, + }, + codex: crate::ai_agents::AiAgentAvailability { + installed: false, + version: None, + }, + } +} + #[cfg(mobile)] #[tauri::command] pub async fn stream_claude_chat( @@ -70,3 +125,12 @@ pub async fn stream_claude_agent( ) -> Result { Err("Claude CLI is not available on mobile".into()) } + +#[cfg(mobile)] +#[tauri::command] +pub async fn stream_ai_agent( + _app_handle: tauri::AppHandle, + _request: AiAgentStreamRequest, +) -> Result { + Err("CLI AI agents are not available on mobile".into()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4cea10d5..8c4d5113 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +pub mod ai_agents; pub mod app_updater; pub mod claude_cli; mod commands; @@ -175,8 +176,10 @@ fn with_invoke_handler(builder: tauri::Builder) -> tauri::Builder, pub anonymous_id: Option, pub release_channel: Option, + pub default_ai_agent: Option, } fn normalize_optional_string(value: Option) -> Option { @@ -36,6 +37,13 @@ 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), + _ => None, + } +} + fn normalize_settings(settings: Settings) -> Settings { Settings { auto_pull_interval_minutes: settings.auto_pull_interval_minutes, @@ -44,6 +52,7 @@ fn normalize_settings(settings: Settings) -> Settings { analytics_enabled: settings.analytics_enabled, anonymous_id: normalize_optional_string(settings.anonymous_id), release_channel: normalize_release_channel(settings.release_channel.as_deref()), + default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()), } } @@ -148,6 +157,7 @@ mod tests { Option, Option<&str>, Option<&str>, + Option<&str>, ) { ( settings.auto_pull_interval_minutes, @@ -156,13 +166,14 @@ mod tests { settings.analytics_enabled, settings.anonymous_id.as_deref(), settings.release_channel.as_deref(), + settings.default_ai_agent.as_deref(), ) } fn assert_empty_settings(settings: &Settings) { assert_eq!( settings_snapshot(settings), - (None, None, None, None, None, None) + (None, None, None, None, None, None, None) ); } @@ -201,6 +212,7 @@ mod tests { analytics_enabled: Some(false), anonymous_id: Some("abc-123-uuid".to_string()), release_channel: Some("alpha".to_string()), + default_ai_agent: Some("codex".to_string()), ..Default::default() }; let json = serde_json::to_string(&settings).unwrap(); @@ -221,10 +233,12 @@ mod tests { let loaded = save_and_reload(Settings { auto_pull_interval_minutes: Some(10), release_channel: Some("alpha".to_string()), + default_ai_agent: Some("codex".to_string()), ..Default::default() }); assert_eq!(loaded.auto_pull_interval_minutes, Some(10)); assert_eq!(loaded.release_channel.as_deref(), Some("alpha")); + assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex")); } #[test] @@ -232,10 +246,12 @@ mod tests { let loaded = save_and_reload(Settings { anonymous_id: Some(" test-uuid ".to_string()), release_channel: Some(" alpha ".to_string()), + default_ai_agent: Some(" codex ".to_string()), ..Default::default() }); assert_eq!(loaded.anonymous_id.as_deref(), Some("test-uuid")); assert_eq!(loaded.release_channel.as_deref(), Some("alpha")); + assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex")); } #[test] @@ -256,6 +272,15 @@ mod tests { assert!(loaded.release_channel.is_none()); } + #[test] + fn test_invalid_default_ai_agent_is_filtered() { + let loaded = save_and_reload(Settings { + default_ai_agent: Some("cursor".to_string()), + ..Default::default() + }); + assert!(loaded.default_ai_agent.is_none()); + } + #[test] fn test_get_settings_normalizes_legacy_beta_channel() { let dir = tempfile::TempDir::new().unwrap(); @@ -313,7 +338,8 @@ mod tests { Some(true), Some(false), Some("test-uuid-v4"), - None + None, + None, ) ); } diff --git a/src/App.tsx b/src/App.tsx index 82e9bb22..a98362f7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,14 +16,15 @@ import { StatusBar } from './components/StatusBar' import { SettingsPanel } from './components/SettingsPanel' import { CloneVaultModal } from './components/CloneVaultModal' import { WelcomeScreen } from './components/WelcomeScreen' -import { ClaudeCodeOnboardingPrompt } from './components/ClaudeCodeOnboardingPrompt' +import { AiAgentsOnboardingPrompt } from './components/AiAgentsOnboardingPrompt' import { TelemetryConsentDialog } from './components/TelemetryConsentDialog' import { FeedbackDialog } from './components/FeedbackDialog' import { useTelemetry } from './hooks/useTelemetry' import { useMcpStatus } from './hooks/useMcpStatus' -import { useClaudeCodeOnboarding } from './hooks/useClaudeCodeOnboarding' -import { useClaudeCodeStatus } from './hooks/useClaudeCodeStatus' +import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding' +import { useAiAgentsStatus } from './hooks/useAiAgentsStatus' import { useVaultLoader } from './hooks/useVaultLoader' +import { useAiAgentPreferences } from './hooks/useAiAgentPreferences' import { useSettings } from './hooks/useSettings' import { useNoteActions } from './hooks/useNoteActions' import { useCommitFlow } from './hooks/useCommitFlow' @@ -126,8 +127,8 @@ function App() { }) const onboarding = useOnboarding(vaultSwitcher.vaultPath) - const { status: claudeCodeStatus, version: claudeCodeVersion } = useClaudeCodeStatus() - const claudeCodeOnboarding = useClaudeCodeOnboarding(onboarding.state.status === 'ready' && !noteWindowParams) + const aiAgentsStatus = useAiAgentsStatus() + const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams) // When onboarding resolves to a different vault path, update the switcher const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath) @@ -168,6 +169,12 @@ function App() { }) }, [updateConfig, vaultConfig.inbox?.noteListProperties]) const { settings, loaded: settingsLoaded, saveSettings } = useSettings() + const aiAgentPreferences = useAiAgentPreferences({ + settings, + saveSettings, + aiAgentsStatus, + onToast: setToastMessage, + }) useTelemetry(settings, settingsLoaded) const vaultOpenedRef = useRef('') @@ -593,8 +600,9 @@ function App() { vaultCount: vaultSwitcher.allVaults.length, mcpStatus, onInstallMcp: installMcp, - claudeCodeStatus: claudeCodeStatus ?? undefined, - claudeCodeVersion: claudeCodeVersion ?? undefined, + onOpenAiAgents: dialogs.openSettings, + onCycleDefaultAiAgent: aiAgentPreferences.cycleDefaultAiAgent, + selectedAiAgentLabel: aiAgentPreferences.defaultAiAgentLabel, onReloadVault: vault.reloadVault, onRepairVault: handleRepairVault, onSetNoteIcon: handleSetNoteIconCommand, @@ -642,11 +650,11 @@ function App() { return } - if (!noteWindowParams && onboarding.state.status === 'ready' && claudeCodeOnboarding.showPrompt) { + if (!noteWindowParams && onboarding.state.status === 'ready' && aiAgentsOnboarding.showPrompt) { return ( - ) } @@ -719,6 +727,8 @@ function App() { inspectorCollapsed={layout.inspectorCollapsed} onToggleInspector={() => layout.setInspectorCollapsed((c) => !c)} inspectorWidth={layout.inspectorWidth} + defaultAiAgent={aiAgentPreferences.defaultAiAgent} + defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady} onInspectorResize={layout.handleInspectorResize} inspectorEntry={activeTab?.entry ?? null} inspectorContent={activeTab?.content ?? null} @@ -759,14 +769,15 @@ function App() { - handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isOffline={networkStatus.isOffline} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} /> + handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isOffline={networkStatus.isOffline} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} /> setToastMessage(null)} /> @@ -791,7 +802,7 @@ function App() { onCommit={conflictResolver.commitResolution} onClose={conflictFlow.handleCloseConflictResolver} /> - + {deleteActions.confirmDelete && ( @@ -832,16 +843,16 @@ function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; i ) } -function ClaudeCodeOnboardingView({ - status, +function AiAgentsOnboardingView({ + statuses, onContinue, }: { - status: ReturnType['status'] + statuses: ReturnType onContinue: () => void }) { return (
- +
) } diff --git a/src/components/AiAgentsOnboardingPrompt.test.tsx b/src/components/AiAgentsOnboardingPrompt.test.tsx new file mode 100644 index 00000000..acd58250 --- /dev/null +++ b/src/components/AiAgentsOnboardingPrompt.test.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AiAgentsOnboardingPrompt } from './AiAgentsOnboardingPrompt' + +const openExternalUrl = vi.fn() + +vi.mock('../utils/url', () => ({ + openExternalUrl: (...args: unknown[]) => openExternalUrl(...args), +})) + +describe('AiAgentsOnboardingPrompt', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows the ready state when at least one agent is installed', () => { + render( + , + ) + + expect(screen.getByText('AI agents ready')).toBeInTheDocument() + expect(screen.getByTestId('ai-agents-onboarding-install-codex')).toBeInTheDocument() + expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue') + }) + + it('shows the missing state when no agents are installed', () => { + render( + , + ) + + expect(screen.getByText('No AI agents 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-continue')).toHaveTextContent('Continue without it') + }) + + it('opens the agent install links', () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('ai-agents-onboarding-install-claude_code')) + fireEvent.click(screen.getByTestId('ai-agents-onboarding-install-codex')) + + expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code') + expect(openExternalUrl).toHaveBeenCalledWith('https://developers.openai.com/codex/cli') + }) +}) diff --git a/src/components/AiAgentsOnboardingPrompt.tsx b/src/components/AiAgentsOnboardingPrompt.tsx new file mode 100644 index 00000000..053d3e37 --- /dev/null +++ b/src/components/AiAgentsOnboardingPrompt.tsx @@ -0,0 +1,132 @@ +import { ArrowUpRight, Bot, CheckCircle2, Loader2 } from 'lucide-react' +import { + AI_AGENT_DEFINITIONS, + getAiAgentDefinition, + hasAnyInstalledAiAgent, + isAiAgentsStatusChecking, + type AiAgentsStatus, +} from '../lib/aiAgents' +import { openExternalUrl } from '../utils/url' +import { Button } from './ui/button' +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card' + +interface AiAgentsOnboardingPromptProps { + statuses: AiAgentsStatus + onContinue: () => void +} + +function getPromptCopy(statuses: AiAgentsStatus) { + if (isAiAgentsStatusChecking(statuses)) { + return { + accentClassName: 'bg-slate-100 text-slate-600', + description: 'Checking which AI agents are available on this machine.', + icon: , + title: 'Checking AI agents', + } + } + + if (!hasAnyInstalledAiAgent(statuses)) { + return { + accentClassName: 'bg-amber-100 text-amber-700', + description: 'Tolaria works best with a local CLI AI agent installed.', + icon: , + title: 'No AI agents detected', + } + } + + return { + accentClassName: 'bg-emerald-100 text-emerald-700', + description: 'Your AI agents are ready to use in Tolaria.', + icon: , + title: 'AI agents ready', + } +} + +function AgentStatusList({ statuses }: { statuses: AiAgentsStatus }) { + return ( +
+ {AI_AGENT_DEFINITIONS.map((definition) => { + const status = statuses[definition.id] + const ready = status.status === 'installed' + return ( +
+
+
{definition.label}
+
+ {ready + ? `${definition.label}${status.version ? ` ${status.version}` : ''} is ready.` + : `${definition.label} is not installed yet.`} +
+
+ + {ready ? 'Installed' : 'Missing'} + +
+ ) + })} +
+ ) +} + +export function AiAgentsOnboardingPrompt({ + statuses, + onContinue, +}: AiAgentsOnboardingPromptProps) { + const copy = getPromptCopy(statuses) + const missingAgents = AI_AGENT_DEFINITIONS.filter((definition) => statuses[definition.id].status === 'missing') + + return ( +
+ + +
+ {copy.icon} +
+
+ + {copy.title} + +

+ {copy.description} +

+
+
+ + + + + + + {missingAgents.map((definition) => ( + + ))} + + +
+
+ ) +} diff --git a/src/components/AiPanel.test.tsx b/src/components/AiPanel.test.tsx index f230d866..86735558 100644 --- a/src/components/AiPanel.test.tsx +++ b/src/components/AiPanel.test.tsx @@ -5,13 +5,13 @@ import type { VaultEntry } from '../types' import { queueAiPrompt } from '../utils/aiPromptBridge' // Mock the hooks and utils to isolate component tests -let mockMessages: ReturnType['messages'] = [] -let mockStatus: ReturnType['status'] = 'idle' +let mockMessages: ReturnType['messages'] = [] +let mockStatus: ReturnType['status'] = 'idle' const mockSendMessage = vi.fn() const mockClearConversation = vi.fn() -vi.mock('../hooks/useAiAgent', () => ({ - useAiAgent: () => ({ +vi.mock('../hooks/useCliAiAgent', () => ({ + useCliAiAgent: () => ({ messages: mockMessages, status: mockStatus, sendMessage: mockSendMessage, diff --git a/src/components/AiPanel.tsx b/src/components/AiPanel.tsx index e54c2752..0910aea2 100644 --- a/src/components/AiPanel.tsx +++ b/src/components/AiPanel.tsx @@ -1,20 +1,24 @@ -import { useEffect, useRef } from 'react' -import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react' -import { AiMessage } from './AiMessage' -import { WikilinkChatInput } from './WikilinkChatInput' -import { type AiAgentMessage } from '../hooks/useAiAgent' -import { type NoteReference, type NoteListItem } from '../utils/ai-context' +import { useRef } from 'react' +import { + AiPanelComposer, + AiPanelContextBar, + AiPanelHeader, + AiPanelMessageHistory, +} from './AiPanelChrome' +import { DEFAULT_AI_AGENT, getAiAgentDefinition, type AiAgentId } from '../lib/aiAgents' +import { type NoteListItem } from '../utils/ai-context' import type { VaultEntry } from '../types' -import { extractInlineWikilinkReferences } from './inlineWikilinkText' import { useAiPanelController } from './useAiPanelController' import { useAiPanelPromptQueue } from './useAiPanelPromptQueue' import { useAiPanelFocus } from './useAiPanelFocus' -export type { AiAgentMessage } from '../hooks/useAiAgent' +export type { AiAgentMessage } from '../hooks/useCliAiAgent' interface AiPanelProps { onClose: () => void onOpenNote?: (path: string) => void + defaultAiAgent?: AiAgentId + defaultAiAgentReady?: boolean onFileCreated?: (relativePath: string) => void onFileModified?: (relativePath: string) => void onVaultChanged?: () => void @@ -28,150 +32,28 @@ interface AiPanelProps { noteListFilter?: { type: string | null; query: string } } -function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () => void }) { - return ( -
- - - AI Chat - - - -
- ) -} - -function ContextBar({ activeEntry, linkedCount }: { activeEntry: VaultEntry; linkedCount: number }) { - return ( -
- - {activeEntry.title} - {linkedCount > 0 && ( - + {linkedCount} linked - )} -
- ) -} - -function EmptyState({ hasContext }: { hasContext: boolean }) { - return ( -
- -

- {hasContext - ? 'Ask about this note and its linked context' - : 'Open a note, then ask the AI about it' - } -

-

- {hasContext - ? 'Summarize, find connections, expand ideas' - : 'The AI will use the active note as context' - } -

-
- ) -} - -function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, hasContext }: { - messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; onNavigateWikilink?: (target: string) => void; hasContext: boolean -}) { - const endRef = useRef(null) - - useEffect(() => { - endRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages, isActive]) - - return ( -
- {messages.length === 0 && !isActive && } - {messages.map((msg, i) => ( - - ))} -
-
- ) -} - -function AiPanelComposer({ +export function AiPanel({ + onClose, + onOpenNote, + defaultAiAgent: providedDefaultAiAgent, + defaultAiAgentReady: providedDefaultAiAgentReady, + onFileCreated, + onFileModified, + onVaultChanged, + vaultPath, + activeEntry, + activeNoteContent, entries, - hasContext, - input, - inputRef, - isActive, - onChange, - onSend, -}: { - entries: VaultEntry[] - hasContext: boolean - input: string - inputRef: React.RefObject - isActive: boolean - onChange: (value: string) => void - onSend: (text: string, references: NoteReference[]) => void -}) { - return ( -
-
-
- -
- -
-
- ) -} - -export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, activeNoteContent, entries, openTabs, noteList, noteListFilter }: AiPanelProps) { + openTabs, + noteList, + noteListFilter, +}: AiPanelProps) { + const defaultAiAgent = providedDefaultAiAgent ?? DEFAULT_AI_AGENT + const defaultAiAgentReady = providedDefaultAiAgentReady ?? true + const useLegacyAiExperience = providedDefaultAiAgent === undefined && providedDefaultAiAgentReady === undefined const inputRef = useRef(null) const panelRef = useRef(null) + const agentLabel = getAiAgentDefinition(defaultAiAgent).label const { agent, @@ -184,6 +66,8 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, on handleNavigateWikilink, } = useAiPanelController({ vaultPath, + defaultAiAgent, + defaultAiAgentReady, activeEntry, activeNoteContent, entries, @@ -215,11 +99,14 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, on data-testid="ai-panel" data-ai-active={isActive || undefined} > - + {activeEntry && ( - + )} - diff --git a/src/components/AiPanelChrome.tsx b/src/components/AiPanelChrome.tsx new file mode 100644 index 00000000..459b67bd --- /dev/null +++ b/src/components/AiPanelChrome.tsx @@ -0,0 +1,260 @@ +import { useEffect, useRef } from 'react' +import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react' +import { AiMessage } from './AiMessage' +import { WikilinkChatInput } from './WikilinkChatInput' +import { extractInlineWikilinkReferences } from './inlineWikilinkText' +import type { AiAgentMessage } from '../hooks/useCliAiAgent' +import type { NoteReference } from '../utils/ai-context' +import type { VaultEntry } from '../types' + +interface AiPanelHeaderProps { + agentLabel: string + agentReady: boolean + legacyCopy: boolean + onClose: () => void + onClear: () => void +} + +interface AiPanelContextBarProps { + activeEntry: VaultEntry + linkedCount: number +} + +interface AiPanelMessageHistoryProps { + agentLabel: string + agentReady: boolean + legacyCopy: boolean + messages: AiAgentMessage[] + isActive: boolean + onOpenNote?: (path: string) => void + onNavigateWikilink?: (target: string) => void + hasContext: boolean +} + +interface AiPanelComposerProps { + entries: VaultEntry[] + agentLabel: string + agentReady: boolean + hasContext: boolean + input: string + inputRef: React.RefObject + isActive: boolean + legacyCopy: boolean + onChange: (value: string) => void + onSend: (text: string, references: NoteReference[]) => void +} + +function getComposerPlaceholder( + agentLabel: string, + agentReady: boolean, + legacyCopy: boolean, + hasContext: boolean, +): string { + if (!agentReady) { + return `${agentLabel} is not installed. Open AI Agents in Settings.` + } + + if (legacyCopy) { + return hasContext ? 'Ask about this note...' : 'Ask the AI agent...' + } + + return hasContext ? `Ask ${agentLabel} about this note...` : `Ask ${agentLabel}...` +} + +function AiPanelEmptyState({ + agentLabel, + agentReady, + hasContext, + legacyCopy, +}: Pick) { + if (!agentReady) { + return ( +
+ +

+ {agentLabel} is not available on this machine +

+

+ Install it or switch the default AI agent in Settings +

+
+ ) + } + + return ( +
+ +

+ {hasContext + ? legacyCopy ? 'Ask about this note and its linked context' : `Ask ${agentLabel} about this note and its linked context` + : legacyCopy ? 'Open a note, then ask the AI about it' : `Open a note, then ask ${agentLabel} about it` + } +

+

+ {hasContext + ? 'Summarize, find connections, expand ideas' + : 'The AI will use the active note as context' + } +

+
+ ) +} + +export function AiPanelHeader({ + agentLabel, + agentReady, + legacyCopy, + onClose, + onClear, +}: AiPanelHeaderProps) { + return ( +
+ +
+ + {legacyCopy ? 'AI Chat' : 'AI Agent'} + + {!legacyCopy && ( + + {agentLabel} + {!agentReady ? ' · not installed' : ''} + + )} +
+ + +
+ ) +} + +export function AiPanelContextBar({ activeEntry, linkedCount }: AiPanelContextBarProps) { + return ( +
+ + {activeEntry.title} + {linkedCount > 0 && ( + + {linkedCount} linked + )} +
+ ) +} + +export function AiPanelMessageHistory({ + agentLabel, + agentReady, + legacyCopy, + messages, + isActive, + onOpenNote, + onNavigateWikilink, + hasContext, +}: AiPanelMessageHistoryProps) { + const endRef = useRef(null) + + useEffect(() => { + endRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages, isActive]) + + return ( +
+ {messages.length === 0 && !isActive && ( + + )} + {messages.map((message, index) => ( + + ))} +
+
+ ) +} + +export function AiPanelComposer({ + entries, + agentLabel, + agentReady, + hasContext, + input, + inputRef, + isActive, + legacyCopy, + onChange, + onSend, +}: AiPanelComposerProps) { + const composerDisabled = isActive || !agentReady + const canSend = !composerDisabled && input.trim().length > 0 + const placeholder = getComposerPlaceholder(agentLabel, agentReady, legacyCopy, hasContext) + const sendButtonStyle = { + background: canSend ? 'var(--primary)' : 'var(--muted)', + color: canSend ? 'white' : 'var(--muted-foreground)', + borderRadius: 8, + width: 32, + height: 34, + cursor: canSend ? 'pointer' : 'not-allowed', + } as const + + return ( +
+
+
+ +
+ +
+
+ ) +} diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index ae0d2d8a..5c9f64bf 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -13,6 +13,8 @@ interface CommandPaletteProps { commands: CommandAction[] entries?: VaultEntry[] claudeCodeReady?: boolean + aiAgentReady?: boolean + aiAgentLabel?: string onClose: () => void } @@ -173,12 +175,14 @@ function CommandPaletteResults({ function CommandPaletteFooter({ aiMode, + aiAgentLabel = 'Claude Code', }: { aiMode: boolean + aiAgentLabel?: string }) { return (
- {aiMode ? 'Claude mode' : '↑↓ navigate'} + {aiMode ? `${aiAgentLabel} mode` : '↑↓ navigate'} {aiMode ? '↵ send' : '↵ select'} esc close
@@ -194,6 +198,8 @@ function OpenCommandPalette({ commands, entries = [], claudeCodeReady = true, + aiAgentReady, + aiAgentLabel = 'Claude Code', onClose, }: Omit) { const [query, setQuery] = useState('') @@ -203,6 +209,7 @@ function OpenCommandPalette({ const aiInputRef = useRef(null) const listRef = useRef(null) const aiMode = aiValue.startsWith(' ') + const resolvedAiAgentReady = aiAgentReady ?? claudeCodeReady const { groups, flatList } = usePaletteResults(commands, query) useLayoutEffect(() => { @@ -292,7 +299,7 @@ function OpenCommandPalette({ return } - if (!claudeCodeReady) return + if (!resolvedAiAgentReady) return queueAiPrompt(text, references) requestOpenAiChat() @@ -316,6 +323,8 @@ function OpenCommandPalette({ entries={entries} value={aiValue} claudeCodeReady={claudeCodeReady} + aiAgentReady={resolvedAiAgentReady} + aiAgentLabel={aiAgentLabel} inputRef={aiInputRef} onChange={handleAiValueChange} onSubmit={handleSubmitAiPrompt} @@ -330,7 +339,7 @@ function OpenCommandPalette({ onHover={setSelectedIndex} onSelect={handleSelectCommand} /> - + )}
diff --git a/src/components/CommandPaletteAiMode.tsx b/src/components/CommandPaletteAiMode.tsx index 0330ecb8..280d6577 100644 --- a/src/components/CommandPaletteAiMode.tsx +++ b/src/components/CommandPaletteAiMode.tsx @@ -7,6 +7,8 @@ interface CommandPaletteAiModeProps { entries: VaultEntry[] value: string claudeCodeReady: boolean + aiAgentReady?: boolean + aiAgentLabel?: string inputRef?: React.RefObject onChange: (value: string) => void onSubmit: (text: string, references: NoteReference[]) => void @@ -20,10 +22,14 @@ export function CommandPaletteAiMode({ entries, value, claudeCodeReady, + aiAgentReady, + aiAgentLabel = 'Claude Code', inputRef, onChange, onSubmit, }: CommandPaletteAiModeProps) { + const resolvedAiAgentReady = aiAgentReady ?? claudeCodeReady + return ( onSubmit(stripLeadingSpace(text), references)} submitOnEmpty={true} - placeholder="Ask Claude Code..." + placeholder={`Ask ${aiAgentLabel}...`} dataTestId="command-palette-ai-input" editorClassName="border-none px-0 py-0 text-[15px]" suggestionListVariant="palette" paletteHeader={(
- Ask Claude Code + Ask {aiAgentLabel}
)} paletteEmptyState={(
- {!claudeCodeReady ? ( - 'Claude Code is not available on this machine.' + {!resolvedAiAgentReady ? ( + `${aiAgentLabel} is not available on this machine.` ) : ( <> -
Ask Claude Code
+
Ask {aiAgentLabel}
{value.trim().length === 0 ? 'Type your prompt after the leading space.' @@ -60,7 +66,7 @@ export function CommandPaletteAiMode({ )} paletteFooter={(
- Claude mode + {aiAgentLabel} mode ↵ send esc close
diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index c38d3ee8..e07fc189 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -3,6 +3,7 @@ import { useEditorTabSwap } from '../hooks/useEditorTabSwap' import { useCreateBlockNote } from '@blocknote/react' import '@blocknote/mantine/style.css' import { uploadImageFile } from '../hooks/useImageDrop' +import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents' import type { VaultEntry, GitCommit, NoteStatus } from '../types' import type { NoteListItem } from '../utils/ai-context' import type { FrontmatterValue } from './Inspector' @@ -44,6 +45,8 @@ interface EditorProps { inspectorCollapsed: boolean onToggleInspector: () => void inspectorWidth: number + defaultAiAgent?: AiAgentId + defaultAiAgentReady?: boolean onInspectorResize: (delta: number) => void inspectorEntry: VaultEntry | null inspectorContent: string | null @@ -278,7 +281,9 @@ export const Editor = memo(function Editor(props: EditorProps) { const { tabs, activeTabPath, entries, onNavigateWikilink, getNoteStatus, - inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize, + inspectorCollapsed, onToggleInspector, inspectorWidth, + defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true, + onInspectorResize, inspectorEntry, inspectorContent, gitHistory, onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onInitializeProperties, showAIChat, onToggleAIChat, @@ -347,6 +352,8 @@ export const Editor = memo(function Editor(props: EditorProps) { showAIChat={showAIChat} inspectorCollapsed={inspectorCollapsed} inspectorWidth={inspectorWidth} + defaultAiAgent={defaultAiAgent} + defaultAiAgentReady={defaultAiAgentReady} inspectorEntry={inspectorEntry} inspectorContent={inspectorContent} entries={entries} diff --git a/src/components/EditorRightPanel.tsx b/src/components/EditorRightPanel.tsx index 032819db..8310fc5e 100644 --- a/src/components/EditorRightPanel.tsx +++ b/src/components/EditorRightPanel.tsx @@ -1,3 +1,4 @@ +import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents' import type { VaultEntry, GitCommit } from '../types' import type { NoteListItem } from '../utils/ai-context' import { Inspector, type FrontmatterValue } from './Inspector' @@ -7,6 +8,8 @@ interface EditorRightPanelProps { showAIChat?: boolean inspectorCollapsed: boolean inspectorWidth: number + defaultAiAgent?: AiAgentId + defaultAiAgentReady?: boolean inspectorEntry: VaultEntry | null inspectorContent: string | null entries: VaultEntry[] @@ -32,6 +35,7 @@ interface EditorRightPanelProps { export function EditorRightPanel({ showAIChat, inspectorCollapsed, inspectorWidth, + defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true, inspectorEntry, inspectorContent, entries, gitHistory, vaultPath, noteList, noteListFilter, onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff, @@ -47,6 +51,8 @@ export function EditorRightPanel({ onToggleAIChat?.()} onOpenNote={onOpenNote} + defaultAiAgent={defaultAiAgent} + defaultAiAgentReady={defaultAiAgentReady} onFileCreated={onFileCreated} onFileModified={onFileModified} onVaultChanged={onVaultChanged} diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 38d0ccfe..9de944b0 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -1,3 +1,11 @@ +import { + AI_AGENT_DEFINITIONS, + createMissingAiAgentsStatus, + getAiAgentDefinition, + resolveDefaultAiAgent, + type AiAgentId, + type AiAgentsStatus, +} from '../lib/aiAgents' import { useState, useRef, @@ -24,6 +32,7 @@ import { Switch } from './ui/switch' interface SettingsPanelProps { open: boolean settings: Settings + aiAgentsStatus?: AiAgentsStatus onSave: (settings: Settings) => void explicitOrganizationEnabled?: boolean onSaveExplicitOrganization?: (enabled: boolean) => void @@ -32,6 +41,7 @@ interface SettingsPanelProps { interface SettingsDraft { pullInterval: number + defaultAiAgent: AiAgentId releaseChannel: ReleaseChannel crashReporting: boolean analytics: boolean @@ -41,6 +51,9 @@ interface SettingsDraft { interface SettingsBodyProps { pullInterval: number setPullInterval: (value: number) => void + aiAgentsStatus: AiAgentsStatus + defaultAiAgent: AiAgentId + setDefaultAiAgent: (value: AiAgentId) => void releaseChannel: ReleaseChannel setReleaseChannel: (value: ReleaseChannel) => void explicitOrganization: boolean @@ -63,6 +76,7 @@ function createSettingsDraft( ): SettingsDraft { return { pullInterval: settings.auto_pull_interval_minutes ?? 5, + defaultAiAgent: resolveDefaultAiAgent(settings.default_ai_agent), releaseChannel: normalizeReleaseChannel(settings.release_channel), crashReporting: settings.crash_reporting_enabled ?? false, analytics: settings.analytics_enabled ?? false, @@ -91,6 +105,7 @@ function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Setti analytics_enabled: draft.analytics, anonymous_id: resolveAnonymousId(settings, draft), release_channel: serializeReleaseChannel(draft.releaseChannel), + default_ai_agent: draft.defaultAiAgent, } } @@ -106,6 +121,7 @@ function isChecked(checked: CheckedState): boolean { export function SettingsPanel({ open, settings, + aiAgentsStatus = createMissingAiAgentsStatus(), onSave, explicitOrganizationEnabled = true, onSaveExplicitOrganization, @@ -116,6 +132,7 @@ export function SettingsPanel({ return ( & { +type SettingsPanelInnerProps = Omit & { + aiAgentsStatus: AiAgentsStatus explicitOrganizationEnabled: boolean } function SettingsPanelInner({ settings, + aiAgentsStatus, onSave, explicitOrganizationEnabled, onSaveExplicitOrganization, @@ -200,6 +219,9 @@ function SettingsPanelInner({ updateDraft('pullInterval', value)} + aiAgentsStatus={aiAgentsStatus} + defaultAiAgent={draft.defaultAiAgent} + setDefaultAiAgent={(value) => updateDraft('defaultAiAgent', value)} releaseChannel={draft.releaseChannel} setReleaseChannel={(value) => updateDraft('releaseChannel', value)} explicitOrganization={draft.explicitOrganization} @@ -238,6 +260,9 @@ function SettingsHeader({ onClose }: { onClose: () => void }) { function SettingsBody({ pullInterval, setPullInterval, + aiAgentsStatus, + defaultAiAgent, + setDefaultAiAgent, releaseChannel, setReleaseChannel, explicitOrganization, @@ -268,6 +293,34 @@ function SettingsBody({ + + + setDefaultAiAgent(value as AiAgentId)} + options={AI_AGENT_DEFINITIONS.map((definition) => { + const status = aiAgentsStatus[definition.id] + const suffix = status.status === 'installed' + ? ` (installed${status.version ? ` ${status.version}` : ''})` + : ' (missing)' + return { + value: definition.id, + label: `${definition.label}${suffix}`, + } + })} + testId="settings-default-ai-agent" + /> + +
+ {renderDefaultAiAgentSummary(defaultAiAgent, aiAgentsStatus)} +
+ + + } +function renderDefaultAiAgentSummary(defaultAiAgent: AiAgentId, aiAgentsStatus: AiAgentsStatus): string { + const definition = getAiAgentDefinition(defaultAiAgent) + const status = aiAgentsStatus[defaultAiAgent] + if (status.status === 'installed') { + return `${definition.label}${status.version ? ` ${status.version}` : ''} is ready to use.` + } + return `${definition.label} is not installed yet. You can still select it now and install it later.` +} + function LabeledSelect({ label, value, diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 56cb4eac..b4e52081 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -1,3 +1,4 @@ +import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents' import { useEffect, useState } from 'react' import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus' import type { McpStatus } from '../hooks/useMcpStatus' @@ -41,6 +42,8 @@ interface StatusBarProps { onRemoveVault?: (path: string) => void mcpStatus?: McpStatus onInstallMcp?: () => void + aiAgentsStatus?: AiAgentsStatus + defaultAiAgent?: AiAgentId claudeCodeStatus?: ClaudeCodeStatus claudeCodeVersion?: string | null } @@ -76,6 +79,8 @@ export function StatusBar({ onRemoveVault, mcpStatus, onInstallMcp, + aiAgentsStatus, + defaultAiAgent, claudeCodeStatus, claudeCodeVersion, }: StatusBarProps) { @@ -129,6 +134,8 @@ export function StatusBar({ onRemoveVault={onRemoveVault} mcpStatus={mcpStatus} onInstallMcp={onInstallMcp} + aiAgentsStatus={aiAgentsStatus} + defaultAiAgent={defaultAiAgent} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} /> diff --git a/src/components/status-bar/AiAgentsBadge.tsx b/src/components/status-bar/AiAgentsBadge.tsx new file mode 100644 index 00000000..4c361bb8 --- /dev/null +++ b/src/components/status-bar/AiAgentsBadge.tsx @@ -0,0 +1,128 @@ +import { useRef, useState } from 'react' +import { AlertTriangle, Terminal } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + AI_AGENT_DEFINITIONS, + getAiAgentDefinition, + hasAnyInstalledAiAgent, + isAiAgentInstalled, + isAiAgentsStatusChecking, + type AiAgentId, + type AiAgentsStatus, +} from '../../lib/aiAgents' +import { openExternalUrl } from '../../utils/url' +import { useDismissibleLayer } from './useDismissibleLayer' +import { ICON_STYLE, SEP_STYLE } from './styles' + +interface AiAgentsBadgeProps { + statuses: AiAgentsStatus + defaultAgent: AiAgentId +} + +function badgeTooltip(statuses: AiAgentsStatus, defaultAgent: AiAgentId): string { + if (!hasAnyInstalledAiAgent(statuses)) return 'No AI agents detected — click for setup details' + const definition = getAiAgentDefinition(defaultAgent) + if (!isAiAgentInstalled(statuses, defaultAgent)) { + return `${definition.label} is selected but not installed — click for setup details` + } + const version = statuses[defaultAgent].version + return `Default AI agent: ${definition.label}${version ? ` ${version}` : ''}` +} + +function AgentPopup({ statuses, defaultAgent }: { statuses: AiAgentsStatus; defaultAgent: AiAgentId }) { + return ( +
+
+ AI Agents +
+
+ {AI_AGENT_DEFINITIONS.map((definition) => { + const status = statuses[definition.id] + const ready = status.status === 'installed' + const selected = definition.id === defaultAgent + return ( +
+
+
+
+ {definition.label} + {selected ? ' · Default' : ''} +
+
+ {ready + ? `${definition.label}${status.version ? ` ${status.version}` : ''} is ready.` + : `${definition.label} is not installed.`} +
+
+ {!ready && ( + + )} +
+
+ ) + })} +
+
+ ) +} + +export function AiAgentsBadge({ statuses, defaultAgent }: AiAgentsBadgeProps) { + const [showPopup, setShowPopup] = useState(false) + const popupRef = useRef(null) + const hasInstalledAgent = hasAnyInstalledAiAgent(statuses) + const selectedAgentReady = isAiAgentInstalled(statuses, defaultAgent) + const showWarning = !hasInstalledAgent || !selectedAgentReady + + useDismissibleLayer(showPopup, popupRef, () => setShowPopup(false)) + + if (isAiAgentsStatusChecking(statuses)) return null + + return ( + <> + | +
+ + {showPopup && } +
+ + ) +} diff --git a/src/components/status-bar/StatusBarSections.tsx b/src/components/status-bar/StatusBarSections.tsx index e52ef3f5..e3790a6c 100644 --- a/src/components/status-bar/StatusBarSections.tsx +++ b/src/components/status-bar/StatusBarSections.tsx @@ -1,8 +1,10 @@ import { Bell, FileText, Package, Settings } from 'lucide-react' import { Megaphone } from '@phosphor-icons/react' +import type { AiAgentId, AiAgentsStatus } from '../../lib/aiAgents' import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus' import type { McpStatus } from '../../hooks/useMcpStatus' import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../../types' +import { AiAgentsBadge } from './AiAgentsBadge' import { Button } from '@/components/ui/button' import { ClaudeCodeBadge, @@ -46,6 +48,8 @@ interface StatusBarPrimarySectionProps { onRemoveVault?: (path: string) => void mcpStatus?: McpStatus onInstallMcp?: () => void + aiAgentsStatus?: AiAgentsStatus + defaultAiAgent?: AiAgentId claudeCodeStatus?: ClaudeCodeStatus claudeCodeVersion?: string | null } @@ -84,6 +88,8 @@ export function StatusBarPrimarySection({ onRemoveVault, mcpStatus, onInstallMcp, + aiAgentsStatus, + defaultAiAgent, claudeCodeStatus, claudeCodeVersion, }: StatusBarPrimarySectionProps) { @@ -127,7 +133,9 @@ export function StatusBarPrimarySection({ {mcpStatus && } - {claudeCodeStatus && } + {aiAgentsStatus && defaultAiAgent + ? + : claudeCodeStatus && }
) } diff --git a/src/components/useAiPanelController.ts b/src/components/useAiPanelController.ts index 2583b428..23fc3970 100644 --- a/src/components/useAiPanelController.ts +++ b/src/components/useAiPanelController.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState } from 'react' -import { useAiAgent, type AgentFileCallbacks } from '../hooks/useAiAgent' +import type { AiAgentId } from '../lib/aiAgents' +import { useCliAiAgent, type AgentFileCallbacks } from '../hooks/useCliAiAgent' import type { VaultEntry } from '../types' import { type NoteListItem, @@ -9,6 +10,8 @@ import { useAiPanelContextSnapshot } from './useAiPanelContextSnapshot' interface UseAiPanelControllerArgs { vaultPath: string + defaultAiAgent: AiAgentId + defaultAiAgentReady: boolean activeEntry?: VaultEntry | null activeNoteContent?: string | null entries?: VaultEntry[] @@ -23,6 +26,8 @@ interface UseAiPanelControllerArgs { export function useAiPanelController({ vaultPath, + defaultAiAgent, + defaultAiAgentReady, activeEntry, activeNoteContent, entries, @@ -51,7 +56,10 @@ export function useAiPanelController({ onVaultChanged, }), [onFileCreated, onFileModified, onVaultChanged]) - const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks) + const agent = useCliAiAgent(vaultPath, contextPrompt, fileCallbacks, { + agent: defaultAiAgent, + agentReady: defaultAiAgentReady, + }) const hasContext = !!activeEntry const isActive = agent.status === 'thinking' || agent.status === 'tool-executing' diff --git a/src/hooks/commands/aiAgentCommands.ts b/src/hooks/commands/aiAgentCommands.ts new file mode 100644 index 00000000..645a2c8b --- /dev/null +++ b/src/hooks/commands/aiAgentCommands.ts @@ -0,0 +1,32 @@ +import type { CommandAction } from './types' + +interface AiAgentCommandsConfig { + selectedAiAgentLabel?: string + onOpenAiAgents?: () => void + onCycleDefaultAiAgent?: () => void +} + +export function buildAiAgentCommands({ + selectedAiAgentLabel, + onOpenAiAgents, + onCycleDefaultAiAgent, +}: AiAgentCommandsConfig): CommandAction[] { + return [ + { + id: 'open-ai-agents', + label: 'Open AI Agents', + group: 'Settings', + keywords: ['ai', 'agent', 'agents', 'assistant', 'claude', 'codex', 'settings'], + enabled: !!onOpenAiAgents, + execute: () => onOpenAiAgents?.(), + }, + { + 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'], + enabled: !!onCycleDefaultAiAgent, + execute: () => onCycleDefaultAiAgent?.(), + }, + ] +} diff --git a/src/hooks/useAiAgentPreferences.test.ts b/src/hooks/useAiAgentPreferences.test.ts new file mode 100644 index 00000000..e3bce0ea --- /dev/null +++ b/src/hooks/useAiAgentPreferences.test.ts @@ -0,0 +1,58 @@ +import { renderHook, act } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useAiAgentPreferences } from './useAiAgentPreferences' + +const settings = { + auto_pull_interval_minutes: 5, + telemetry_consent: true, + crash_reporting_enabled: false, + analytics_enabled: false, + anonymous_id: null, + release_channel: 'stable', + default_ai_agent: 'claude_code' as const, +} + +const aiAgentsStatus = { + claude_code: { status: 'installed' as const, version: '1.0.20' }, + codex: { status: 'missing' as const, version: null }, +} + +describe('useAiAgentPreferences', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves the selected label and readiness', () => { + const { result } = renderHook(() => useAiAgentPreferences({ + settings, + saveSettings: vi.fn(), + aiAgentsStatus, + })) + + expect(result.current.defaultAiAgent).toBe('claude_code') + expect(result.current.defaultAiAgentLabel).toBe('Claude Code') + expect(result.current.defaultAiAgentReady).toBe(true) + }) + + it('cycles to the next agent and persists the selection', () => { + const saveSettings = vi.fn() + const onToast = vi.fn() + + const { result } = renderHook(() => useAiAgentPreferences({ + settings, + saveSettings, + aiAgentsStatus, + onToast, + })) + + act(() => { + result.current.cycleDefaultAiAgent() + }) + + expect(saveSettings).toHaveBeenCalledWith({ + ...settings, + default_ai_agent: 'codex', + }) + expect(onToast).toHaveBeenCalledWith('Default AI agent: Codex') + }) +}) diff --git a/src/hooks/useAiAgentPreferences.ts b/src/hooks/useAiAgentPreferences.ts new file mode 100644 index 00000000..c38592fb --- /dev/null +++ b/src/hooks/useAiAgentPreferences.ts @@ -0,0 +1,52 @@ +import { useCallback, useMemo } from 'react' +import { + getAiAgentDefinition, + getNextAiAgentId, + isAiAgentInstalled, + resolveDefaultAiAgent, + type AiAgentId, + type AiAgentsStatus, +} from '../lib/aiAgents' +import type { Settings } from '../types' + +interface UseAiAgentPreferencesArgs { + settings: Settings + saveSettings: (settings: Settings) => void + aiAgentsStatus: AiAgentsStatus + onToast?: (message: string) => void +} + +export function useAiAgentPreferences({ + settings, + saveSettings, + aiAgentsStatus, + onToast, +}: UseAiAgentPreferencesArgs) { + const defaultAiAgent = useMemo( + () => resolveDefaultAiAgent(settings.default_ai_agent), + [settings.default_ai_agent], + ) + + const defaultAiAgentLabel = getAiAgentDefinition(defaultAiAgent).label + const defaultAiAgentReady = isAiAgentInstalled(aiAgentsStatus, defaultAiAgent) + + const setDefaultAiAgent = useCallback((agent: AiAgentId) => { + saveSettings({ + ...settings, + default_ai_agent: agent, + }) + onToast?.(`Default AI agent: ${getAiAgentDefinition(agent).label}`) + }, [onToast, saveSettings, settings]) + + const cycleDefaultAiAgent = useCallback(() => { + setDefaultAiAgent(getNextAiAgentId(defaultAiAgent)) + }, [defaultAiAgent, setDefaultAiAgent]) + + return { + defaultAiAgent, + defaultAiAgentLabel, + defaultAiAgentReady, + setDefaultAiAgent, + cycleDefaultAiAgent, + } +} diff --git a/src/hooks/useAiAgentsOnboarding.ts b/src/hooks/useAiAgentsOnboarding.ts new file mode 100644 index 00000000..19b90c1b --- /dev/null +++ b/src/hooks/useAiAgentsOnboarding.ts @@ -0,0 +1,38 @@ +import { useCallback, useState } from 'react' + +const AI_AGENTS_ONBOARDING_DISMISSED_KEY = 'tolaria:ai-agents-onboarding-dismissed' +const LEGACY_CLAUDE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed' + +function wasDismissed(): boolean { + try { + return ( + localStorage.getItem(AI_AGENTS_ONBOARDING_DISMISSED_KEY) === '1' + || localStorage.getItem(LEGACY_CLAUDE_ONBOARDING_DISMISSED_KEY) === '1' + ) + } catch { + return false + } +} + +function markDismissed(): void { + try { + localStorage.setItem(AI_AGENTS_ONBOARDING_DISMISSED_KEY, '1') + localStorage.setItem(LEGACY_CLAUDE_ONBOARDING_DISMISSED_KEY, '1') + } catch { + // localStorage may be unavailable in restricted contexts + } +} + +export function useAiAgentsOnboarding(enabled: boolean) { + const [dismissed, setDismissed] = useState(() => wasDismissed()) + + const dismissPrompt = useCallback(() => { + markDismissed() + setDismissed(true) + }, []) + + return { + dismissPrompt, + showPrompt: enabled && !dismissed, + } +} diff --git a/src/hooks/useAiAgentsStatus.test.ts b/src/hooks/useAiAgentsStatus.test.ts new file mode 100644 index 00000000..ff76425f --- /dev/null +++ b/src/hooks/useAiAgentsStatus.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { useAiAgentsStatus } from './useAiAgentsStatus' + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})) + +vi.mock('../mock-tauri', () => ({ + isTauri: () => false, + mockInvoke: vi.fn(), +})) + +const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType } + +describe('useAiAgentsStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('starts in checking state and resolves both agent statuses', async () => { + mockInvoke.mockImplementation((command: string) => { + if (command === 'get_ai_agents_status') { + return Promise.resolve({ + claude_code: { installed: true, version: '1.0.20' }, + codex: { installed: false, version: null }, + }) + } + return Promise.resolve(null) + }) + + const { result } = renderHook(() => useAiAgentsStatus()) + + expect(result.current.claude_code.status).toBe('checking') + expect(result.current.codex.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 }) + }) + }) + + it('falls back to missing when the status call fails', async () => { + mockInvoke.mockRejectedValue(new Error('failed')) + + const { result } = renderHook(() => useAiAgentsStatus()) + + await waitFor(() => { + expect(result.current.claude_code.status).toBe('missing') + expect(result.current.codex.status).toBe('missing') + }) + }) +}) diff --git a/src/hooks/useAiAgentsStatus.ts b/src/hooks/useAiAgentsStatus.ts new file mode 100644 index 00000000..255a4912 --- /dev/null +++ b/src/hooks/useAiAgentsStatus.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke } from '../mock-tauri' +import { + createCheckingAiAgentsStatus, + createMissingAiAgentsStatus, + normalizeAiAgentsStatus, + type AiAgentsStatus, +} from '../lib/aiAgents' + +type RawAiAgentsStatus = Partial> + +function tauriCall(command: string): Promise { + return isTauri() ? invoke(command) : mockInvoke(command) +} + +export function useAiAgentsStatus(): AiAgentsStatus { + const [statuses, setStatuses] = useState(createCheckingAiAgentsStatus()) + + useEffect(() => { + let cancelled = false + + tauriCall('get_ai_agents_status') + .then((result) => { + if (!cancelled) { + setStatuses(normalizeAiAgentsStatus(result)) + } + }) + .catch(() => { + if (!cancelled) { + setStatuses(createMissingAiAgentsStatus()) + } + }) + + return () => { cancelled = true } + }, []) + + return statuses +} diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 357b4659..5cc0c328 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -56,6 +56,9 @@ interface AppCommandsConfig { vaultCount?: number mcpStatus?: string onInstallMcp?: () => void + onOpenAiAgents?: () => void + onCycleDefaultAiAgent?: () => void + selectedAiAgentLabel?: string claudeCodeStatus?: string claudeCodeVersion?: string onReloadVault?: () => void @@ -189,6 +192,9 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters> + status: AgentStatus + setStatus: Dispatch> + messagesRef: MutableRefObject + statusRef: MutableRefObject +} + +function useCliAiAgentRuntime(fileCallbacks: AgentFileCallbacks | undefined): UseCliAiAgentRuntime { + const [messages, setMessages] = useState([]) + const [status, setStatus] = useState('idle') + const abortRef = useRef({ aborted: false }) + const responseAccRef = useRef('') + const fileCallbacksRef = useRef(fileCallbacks) + const toolInputMapRef = useRef>(new Map()) + const messagesRef = useRef([]) + const statusRef = useRef('idle') + + useEffect(() => { messagesRef.current = messages }, [messages]) + useEffect(() => { statusRef.current = status }, [status]) + useEffect(() => { fileCallbacksRef.current = fileCallbacks }, [fileCallbacks]) + + return { + messages, + setMessages, + status, + setStatus, + abortRef, + responseAccRef, + fileCallbacksRef, + toolInputMapRef, + messagesRef, + statusRef, + } +} + +export function useCliAiAgent( + vaultPath: string, + contextPrompt: string | undefined, + fileCallbacks: AgentFileCallbacks | undefined, + options: UseCliAiAgentOptions, +) { + const { agent, agentReady } = options + const runtime = useCliAiAgentRuntime(fileCallbacks) + const { messages, status } = runtime + + async function sendMessage(text: string, references?: NoteReference[]): Promise { + await sendAgentMessage({ + runtime, + context: { + agent, + ready: agentReady, + vaultPath, + systemPromptOverride: contextPrompt, + }, + prompt: { text, references }, + }) + } + + function clearConversation(): void { + clearAgentConversation(runtime) + } + + return { messages, status, sendMessage, clearConversation } +} diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 9742790f..9b7966d3 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -7,6 +7,7 @@ import { buildNoteCommands } from './commands/noteCommands' import { buildGitCommands } from './commands/gitCommands' import { buildViewCommands } from './commands/viewCommands' import { buildSettingsCommands } from './commands/settingsCommands' +import { buildAiAgentCommands } from './commands/aiAgentCommands' import { buildTypeCommands, extractVaultTypes } from './commands/typeCommands' import { buildFilterCommands } from './commands/filterCommands' @@ -23,6 +24,9 @@ interface CommandRegistryConfig { activeNoteHasIcon?: boolean mcpStatus?: string onInstallMcp?: () => void + onOpenAiAgents?: () => void + onCycleDefaultAiAgent?: () => void + selectedAiAgentLabel?: string onReloadVault?: () => void onRepairVault?: () => void onSetNoteIcon?: () => void @@ -88,6 +92,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onCheckForUpdates, onCreateType, onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount, mcpStatus, onInstallMcp, + onOpenAiAgents, onCycleDefaultAiAgent, selectedAiAgentLabel, onReloadVault, onRepairVault, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, onToggleOrganized, @@ -129,6 +134,11 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted, onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault, }), + ...buildAiAgentCommands({ + selectedAiAgentLabel, + onOpenAiAgents, + onCycleDefaultAiAgent, + }), ...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect), ...buildFilterCommands({ isSectionGroup, noteListFilter, onSetNoteListFilter }), ], [ @@ -144,6 +154,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com vaultTypes, onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount, mcpStatus, onInstallMcp, + onOpenAiAgents, onCycleDefaultAiAgent, selectedAiAgentLabel, onReloadVault, onRepairVault, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, isSectionGroup, noteListFilter, onSetNoteListFilter, diff --git a/src/hooks/useSettings.test.ts b/src/hooks/useSettings.test.ts index da44322f..61487521 100644 --- a/src/hooks/useSettings.test.ts +++ b/src/hooks/useSettings.test.ts @@ -10,6 +10,7 @@ const defaultSettings: Settings = { analytics_enabled: null, anonymous_id: null, release_channel: null, + default_ai_agent: null, } const savedSettings: Settings = { @@ -19,6 +20,7 @@ const savedSettings: Settings = { analytics_enabled: null, anonymous_id: null, release_channel: null, + default_ai_agent: null, } let mockSettingsStore: Settings = { ...defaultSettings } @@ -97,6 +99,7 @@ describe('useSettings', () => { analytics_enabled: null, anonymous_id: null, release_channel: null, + default_ai_agent: null, } await act(async () => { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index d2c1d677..2b8f7e13 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' +import { normalizeStoredAiAgent } from '../lib/aiAgents' import { normalizeReleaseChannel, serializeReleaseChannel } from '../lib/releaseChannel' import type { Settings } from '../types' @@ -15,6 +16,7 @@ const EMPTY_SETTINGS: Settings = { analytics_enabled: null, anonymous_id: null, release_channel: null, + default_ai_agent: null, } function normalizeSettings(settings: Settings): Settings { @@ -23,6 +25,7 @@ function normalizeSettings(settings: Settings): Settings { release_channel: serializeReleaseChannel( normalizeReleaseChannel(settings.release_channel), ), + default_ai_agent: normalizeStoredAiAgent(settings.default_ai_agent), } } diff --git a/src/hooks/useTelemetry.ts b/src/hooks/useTelemetry.ts index 9267b65b..1dcae31e 100644 --- a/src/hooks/useTelemetry.ts +++ b/src/hooks/useTelemetry.ts @@ -9,8 +9,8 @@ function tauriCall(command: string): Promise { return isTauri() ? invoke(command) : mockInvoke(command) } -function resolveReleaseChannel(settings: Settings): ReleaseChannel { - return normalizeReleaseChannel(settings.release_channel) +function resolveReleaseChannel(releaseChannel: Settings['release_channel']): ReleaseChannel { + return normalizeReleaseChannel(releaseChannel) } function syncCrashReporting( @@ -63,7 +63,7 @@ export function useTelemetry(settings: Settings, loaded: boolean): void { const crashEnabled = settings.crash_reporting_enabled === true const analyticsEnabled = settings.analytics_enabled === true const anonymousId = settings.anonymous_id - const releaseChannel = resolveReleaseChannel(settings) + const releaseChannel = resolveReleaseChannel(settings.release_channel) syncCrashReporting(crashEnabled, anonymousId, prevCrash.current) setReleaseChannel(releaseChannel) diff --git a/src/lib/aiAgentConversation.ts b/src/lib/aiAgentConversation.ts new file mode 100644 index 00000000..ca7c3364 --- /dev/null +++ b/src/lib/aiAgentConversation.ts @@ -0,0 +1,101 @@ +import type { Dispatch, SetStateAction } from 'react' +import type { AiAction } from '../components/AiMessage' +import { buildAgentSystemPrompt } from '../utils/ai-agent' +import { + MAX_HISTORY_TOKENS, + formatMessageWithHistory, + nextMessageId, + trimHistory, + type ChatMessage, +} from '../utils/ai-chat' +import type { NoteReference } from '../utils/ai-context' +import type { AiAgentId } from './aiAgents' +import { getAiAgentDefinition } from './aiAgents' + +export interface AiAgentMessage { + userMessage: string + references?: NoteReference[] + reasoning?: string + reasoningDone?: boolean + actions: AiAction[] + response?: string + isStreaming?: boolean + id?: string +} + +export interface AgentExecutionContext { + agent: AiAgentId + ready: boolean + vaultPath: string + systemPromptOverride?: string +} + +export interface PendingUserPrompt { + text: string + references?: NoteReference[] +} + +function toChatHistory(messages: AiAgentMessage[]): ChatMessage[] { + return messages.flatMap((message) => { + const history: ChatMessage[] = [{ role: 'user', content: message.userMessage, id: message.id ?? '' }] + if (message.response) { + history.push({ role: 'assistant', content: message.response, id: `${message.id}-resp` }) + } + return history + }) +} + +export function createMissingAgentResponse(agent: AiAgentId): string { + const definition = getAiAgentDefinition(agent) + return `${definition.label} is not available on this machine. Install it or switch the default AI agent in Settings.` +} + +export function appendLocalResponse( + setMessages: Dispatch>, + prompt: PendingUserPrompt, + response: string, +): void { + setMessages((current) => [ + ...current, + { + userMessage: prompt.text, + references: prompt.references, + actions: [], + response, + id: nextMessageId(), + }, + ]) +} + +export function appendStreamingMessage( + setMessages: Dispatch>, + prompt: PendingUserPrompt, +): string { + const messageId = nextMessageId() + setMessages((current) => [ + ...current, + { + userMessage: prompt.text, + references: prompt.references, + actions: [], + isStreaming: true, + id: messageId, + }, + ]) + return messageId +} + +export function buildFormattedMessage( + context: AgentExecutionContext, + messages: AiAgentMessage[], + prompt: PendingUserPrompt, +): { formattedMessage: string; systemPrompt: string } { + const systemPrompt = context.systemPromptOverride ?? buildAgentSystemPrompt() + const chatHistory = toChatHistory(messages.filter((message) => !message.isStreaming)) + const trimmedHistory = trimHistory(chatHistory, MAX_HISTORY_TOKENS) + + return { + formattedMessage: formatMessageWithHistory(trimmedHistory, prompt.text), + systemPrompt, + } +} diff --git a/src/lib/aiAgentMessageState.ts b/src/lib/aiAgentMessageState.ts new file mode 100644 index 00000000..77002020 --- /dev/null +++ b/src/lib/aiAgentMessageState.ts @@ -0,0 +1,64 @@ +import type { Dispatch, SetStateAction } from 'react' +import type { AiAgentMessage } from './aiAgentConversation' + +export interface ToolInvocation { + tool: string + input?: string +} + +export function updateMessage( + setMessages: Dispatch>, + messageId: string, + updater: (message: AiAgentMessage) => AiAgentMessage, +): void { + setMessages((current) => current.map((message) => (message.id === messageId ? updater(message) : message))) +} + +export function markReasoningDone( + setMessages: Dispatch>, + messageId: string, +): void { + updateMessage(setMessages, messageId, (message) => ( + message.reasoningDone ? message : { ...message, reasoningDone: true } + )) +} + +function formatToolLabel(toolName: string): string { + if (toolName === 'Bash') { + return 'Ran shell command' + } + if (toolName === 'Write') return 'Wrote file' + if (toolName === 'Edit') return 'Edited file' + return toolName +} + +export function updateToolAction( + message: AiAgentMessage, + toolName: string, + toolId: string, + input?: string, +): AiAgentMessage { + const existing = message.actions.find((action) => action.toolId === toolId) + if (existing) { + return { + ...message, + actions: message.actions.map((action) => ( + action.toolId === toolId ? { ...action, input: input ?? action.input } : action + )), + } + } + + return { + ...message, + actions: [ + ...message.actions, + { + tool: toolName, + toolId, + label: formatToolLabel(toolName), + status: 'pending' as const, + input, + }, + ], + } +} diff --git a/src/lib/aiAgentSession.ts b/src/lib/aiAgentSession.ts new file mode 100644 index 00000000..4ac1480d --- /dev/null +++ b/src/lib/aiAgentSession.ts @@ -0,0 +1,101 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import { + appendLocalResponse, + appendStreamingMessage, + buildFormattedMessage, + createMissingAgentResponse, + type AgentExecutionContext, + type AiAgentMessage, + type PendingUserPrompt, +} from './aiAgentConversation' +import { createStreamCallbacks } from './aiAgentStreamCallbacks' +import type { ToolInvocation } from './aiAgentMessageState' +import { streamAiAgent } from '../utils/streamAiAgent' +import type { AgentFileCallbacks, AgentStatus } from '../hooks/useAiAgent' + +export interface AiAgentSessionRuntime { + setMessages: Dispatch> + setStatus: Dispatch> + abortRef: MutableRefObject<{ aborted: boolean }> + responseAccRef: MutableRefObject + fileCallbacksRef: MutableRefObject + toolInputMapRef: MutableRefObject> + messagesRef: MutableRefObject + statusRef: MutableRefObject +} + +interface SendAgentMessageOptions { + runtime: AiAgentSessionRuntime + context: AgentExecutionContext + prompt: PendingUserPrompt +} + +function normalizePrompt(prompt: PendingUserPrompt): PendingUserPrompt { + return { + text: prompt.text.trim(), + references: prompt.references && prompt.references.length > 0 ? prompt.references : undefined, + } +} + +export async function sendAgentMessage({ + runtime, + context, + prompt, +}: SendAgentMessageOptions): Promise { + const currentStatus = runtime.statusRef.current + const normalizedPrompt = normalizePrompt(prompt) + + if (!normalizedPrompt.text || currentStatus === 'thinking' || currentStatus === 'tool-executing') return + + if (!context.vaultPath) { + appendLocalResponse(runtime.setMessages, normalizedPrompt, 'No vault loaded. Open a vault first.') + return + } + + if (!context.ready) { + appendLocalResponse( + runtime.setMessages, + normalizedPrompt, + createMissingAgentResponse(context.agent), + ) + return + } + + runtime.abortRef.current = { aborted: false } + runtime.responseAccRef.current = '' + runtime.toolInputMapRef.current = new Map() + + const messageId = appendStreamingMessage(runtime.setMessages, normalizedPrompt) + runtime.setStatus('thinking') + + const { formattedMessage, systemPrompt } = buildFormattedMessage( + context, + runtime.messagesRef.current, + normalizedPrompt, + ) + + await streamAiAgent({ + agent: context.agent, + message: formattedMessage, + systemPrompt, + vaultPath: context.vaultPath, + callbacks: createStreamCallbacks({ + messageId, + vaultPath: context.vaultPath, + setMessages: runtime.setMessages, + setStatus: runtime.setStatus, + abortRef: runtime.abortRef, + responseAccRef: runtime.responseAccRef, + toolInputMapRef: runtime.toolInputMapRef, + fileCallbacksRef: runtime.fileCallbacksRef, + }), + }) +} + +export function clearAgentConversation(runtime: Pick): void { + runtime.abortRef.current.aborted = true + runtime.responseAccRef.current = '' + runtime.toolInputMapRef.current = new Map() + runtime.setMessages([]) + runtime.setStatus('idle') +} diff --git a/src/lib/aiAgentStreamCallbacks.ts b/src/lib/aiAgentStreamCallbacks.ts new file mode 100644 index 00000000..6419b873 --- /dev/null +++ b/src/lib/aiAgentStreamCallbacks.ts @@ -0,0 +1,111 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { AgentFileCallbacks, AgentStatus } from '../hooks/useAiAgent' +import { detectFileOperation } from '../hooks/useAiAgent' +import type { AiAgentMessage } from './aiAgentConversation' +import { + markReasoningDone, + updateMessage, + updateToolAction, + type ToolInvocation, +} from './aiAgentMessageState' + +export interface StreamMutationContext { + messageId: string + vaultPath: string + setMessages: Dispatch> + setStatus: Dispatch> + abortRef: MutableRefObject<{ aborted: boolean }> + responseAccRef: MutableRefObject + toolInputMapRef: MutableRefObject> + fileCallbacksRef: MutableRefObject +} + +export function createStreamCallbacks(context: StreamMutationContext) { + const { + messageId, + vaultPath, + setMessages, + setStatus, + abortRef, + responseAccRef, + toolInputMapRef, + fileCallbacksRef, + } = context + + return { + onThinking: (chunk: string) => { + if (abortRef.current.aborted) return + updateMessage(setMessages, messageId, (message) => ({ + ...message, + reasoning: (message.reasoning ?? '') + chunk, + })) + }, + + onText: (chunk: string) => { + if (abortRef.current.aborted) return + markReasoningDone(setMessages, messageId) + responseAccRef.current += chunk + }, + + onToolStart: (toolName: string, toolId: string, input?: string) => { + if (abortRef.current.aborted) return + + markReasoningDone(setMessages, messageId) + setStatus('tool-executing') + + const previous = toolInputMapRef.current.get(toolId) + toolInputMapRef.current.set(toolId, { tool: toolName, input: input ?? previous?.input }) + + updateMessage(setMessages, messageId, (message) => updateToolAction(message, toolName, toolId, input)) + }, + + onToolDone: (toolId: string, output?: string) => { + if (abortRef.current.aborted) return + + const info = toolInputMapRef.current.get(toolId) + if (info) { + detectFileOperation(info.tool, info.input, vaultPath, fileCallbacksRef.current) + } + + updateMessage(setMessages, messageId, (message) => ({ + ...message, + actions: message.actions.map((action) => ( + action.toolId === toolId ? { ...action, status: 'done' as const, output } : action + )), + })) + }, + + onError: (error: string) => { + if (abortRef.current.aborted) return + + setStatus('error') + const partial = responseAccRef.current + updateMessage(setMessages, messageId, (message) => ({ + ...message, + isStreaming: false, + reasoningDone: true, + response: partial ? `${partial}\n\nError: ${error}` : `Error: ${error}`, + actions: message.actions.map((action) => ( + action.status === 'pending' ? { ...action, status: 'error' as const } : action + )), + })) + }, + + onDone: () => { + if (abortRef.current.aborted) return + + setStatus('done') + const finalResponse = responseAccRef.current || undefined + updateMessage(setMessages, messageId, (message) => ({ + ...message, + isStreaming: false, + reasoningDone: true, + response: finalResponse, + actions: message.actions.map((action) => ( + action.status === 'pending' ? { ...action, status: 'done' as const } : action + )), + })) + fileCallbacksRef.current?.onVaultChanged?.() + }, + } +} diff --git a/src/lib/aiAgents.test.ts b/src/lib/aiAgents.test.ts new file mode 100644 index 00000000..c0d489f5 --- /dev/null +++ b/src/lib/aiAgents.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { + getNextAiAgentId, + normalizeAiAgentsStatus, + normalizeStoredAiAgent, + resolveDefaultAiAgent, +} from './aiAgents' + +describe('aiAgents helpers', () => { + it('normalizes stored agent ids', () => { + expect(normalizeStoredAiAgent('claude_code')).toBe('claude_code') + expect(normalizeStoredAiAgent('codex')).toBe('codex') + expect(normalizeStoredAiAgent('cursor')).toBeNull() + }) + + it('falls back to Claude Code as the default agent', () => { + expect(resolveDefaultAiAgent(undefined)).toBe('claude_code') + expect(resolveDefaultAiAgent(null)).toBe('claude_code') + }) + + it('normalizes raw status payloads', () => { + const statuses = normalizeAiAgentsStatus({ + claude_code: { installed: true, version: '1.0.20' }, + codex: { installed: false, version: null }, + }) + + expect(statuses.claude_code).toEqual({ status: 'installed', version: '1.0.20' }) + expect(statuses.codex).toEqual({ status: 'missing', version: null }) + }) + + it('cycles between the supported agents', () => { + expect(getNextAiAgentId('claude_code')).toBe('codex') + expect(getNextAiAgentId('codex')).toBe('claude_code') + }) +}) diff --git a/src/lib/aiAgents.ts b/src/lib/aiAgents.ts new file mode 100644 index 00000000..2abd885d --- /dev/null +++ b/src/lib/aiAgents.ts @@ -0,0 +1,101 @@ +export type AiAgentId = 'claude_code' | 'codex' + +export type AiAgentStatus = 'checking' | 'installed' | 'missing' + +export interface AiAgentAvailability { + status: AiAgentStatus + version: string | null +} + +export interface AiAgentsStatus { + claude_code: AiAgentAvailability + codex: AiAgentAvailability +} + +export interface AiAgentDefinition { + id: AiAgentId + label: string + shortLabel: string + installUrl: string +} + +export const DEFAULT_AI_AGENT: AiAgentId = 'claude_code' + +export const AI_AGENT_DEFINITIONS: readonly AiAgentDefinition[] = [ + { + id: 'claude_code', + label: 'Claude Code', + shortLabel: 'Claude', + installUrl: 'https://docs.anthropic.com/en/docs/claude-code', + }, + { + id: 'codex', + label: 'Codex', + shortLabel: 'Codex', + installUrl: 'https://developers.openai.com/codex/cli', + }, +] as const + +export function createAiAgentAvailability(status: AiAgentStatus = 'checking', version: string | null = null): AiAgentAvailability { + return { status, version } +} + +export function createCheckingAiAgentsStatus(): AiAgentsStatus { + return { + claude_code: createAiAgentAvailability(), + codex: createAiAgentAvailability(), + } +} + +export function createMissingAiAgentsStatus(): AiAgentsStatus { + return { + claude_code: createAiAgentAvailability('missing'), + codex: createAiAgentAvailability('missing'), + } +} + +export function normalizeStoredAiAgent(value: string | null | undefined): AiAgentId | null { + if (value === 'claude_code' || value === 'codex') return value + return null +} + +export function resolveDefaultAiAgent(value: string | null | undefined): AiAgentId { + return normalizeStoredAiAgent(value) ?? DEFAULT_AI_AGENT +} + +export function getAiAgentDefinition(agent: AiAgentId): AiAgentDefinition { + return AI_AGENT_DEFINITIONS.find((definition) => definition.id === agent) ?? AI_AGENT_DEFINITIONS[0] +} + +function normalizeAvailability(agent: { installed?: boolean | null; version?: string | null } | null | undefined): AiAgentAvailability { + if (agent?.installed) { + return createAiAgentAvailability('installed', agent.version ?? null) + } + + return createAiAgentAvailability('missing', agent?.version ?? null) +} + +export function normalizeAiAgentsStatus(payload: Partial> | null | undefined): AiAgentsStatus { + return { + claude_code: normalizeAvailability(payload?.claude_code), + codex: normalizeAvailability(payload?.codex), + } +} + +export function isAiAgentsStatusChecking(statuses: AiAgentsStatus): boolean { + return AI_AGENT_DEFINITIONS.some((definition) => statuses[definition.id].status === 'checking') +} + +export function isAiAgentInstalled(statuses: AiAgentsStatus, agent: AiAgentId): boolean { + return statuses[agent].status === 'installed' +} + +export function hasAnyInstalledAiAgent(statuses: AiAgentsStatus): boolean { + return AI_AGENT_DEFINITIONS.some((definition) => isAiAgentInstalled(statuses, definition.id)) +} + +export function getNextAiAgentId(current: AiAgentId): AiAgentId { + const currentIndex = AI_AGENT_DEFINITIONS.findIndex((definition) => definition.id === current) + if (currentIndex < 0) return DEFAULT_AI_AGENT + return AI_AGENT_DEFINITIONS[(currentIndex + 1) % AI_AGENT_DEFINITIONS.length].id +} diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 1679bda6..eff45df3 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -97,6 +97,7 @@ let mockSettings: Settings = { analytics_enabled: null, anonymous_id: null, release_channel: null, + default_ai_agent: 'claude_code', } let mockLastVaultPath: string | null = null @@ -271,8 +272,13 @@ export const mockHandlers: Record any> = { get_conflict_files: (): string[] => [], get_conflict_mode: () => 'none', check_claude_cli: () => ({ installed: false, version: null }), + get_ai_agents_status: () => ({ + claude_code: { installed: false, version: null }, + codex: { installed: false, version: null }, + }), stream_claude_chat: () => 'mock-session', stream_claude_agent: () => null, + stream_ai_agent: () => null, save_note_content: (args: { path: string; content: string }) => { MOCK_CONTENT[args.path] = args.content mockSavedSinceCommit.add(args.path) @@ -298,6 +304,7 @@ export const mockHandlers: Record any> = { analytics_enabled: s.analytics_enabled, anonymous_id: s.anonymous_id, release_channel: s.release_channel, + default_ai_agent: s.default_ai_agent ?? null, } return null }, diff --git a/src/types.ts b/src/types.ts index 13d6298b..0d978c39 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { AiAgentId } from './lib/aiAgents' + export interface VaultEntry { path: string filename: string @@ -83,6 +85,7 @@ export interface Settings { analytics_enabled: boolean | null anonymous_id: string | null release_channel: string | null + default_ai_agent?: AiAgentId | null } export interface GitPullResult { diff --git a/src/utils/streamAiAgent.ts b/src/utils/streamAiAgent.ts new file mode 100644 index 00000000..42be5349 --- /dev/null +++ b/src/utils/streamAiAgent.ts @@ -0,0 +1,106 @@ +import { isTauri } from '../mock-tauri' +import { getAiAgentDefinition, type AiAgentId } from '../lib/aiAgents' + +type AiAgentStreamEvent = + | { kind: 'Init'; session_id: string } + | { kind: 'TextDelta'; text: string } + | { kind: 'ThinkingDelta'; text: string } + | { kind: 'ToolStart'; tool_name: string; tool_id: string; input?: string } + | { kind: 'ToolDone'; tool_id: string; output?: string } + | { kind: 'Error'; message: string } + | { kind: 'Done' } + +export interface AgentStreamCallbacks { + onText: (text: string) => void + onThinking: (text: string) => void + onToolStart: (toolName: string, toolId: string, input?: string) => void + onToolDone: (toolId: string, output?: string) => void + onError: (message: string) => void + onDone: () => void +} + +export interface StreamAiAgentRequest { + agent: AiAgentId + message: string + systemPrompt?: string + vaultPath: string + callbacks: AgentStreamCallbacks +} + +function mockAgentResponse(agent: AiAgentId, message: string): string { + const agentLabel = getAiAgentDefinition(agent).label + if (message.includes('')) { + const allUserLines = message.match(/\[user\]: .+/g) ?? [] + const turnCount = allUserLines.length + const lastLine = allUserLines[allUserLines.length - 1] ?? '' + const lastUserMsg = lastLine.replace('[user]: ', '') + return `[mock-${agentLabel.toLowerCase()} turns=${turnCount}] You asked: "${lastUserMsg}" — This note is related to [[Build Laputa App]] and [[Matteo Cellini]].` + } + return `[mock-${agentLabel.toLowerCase()}] You said: "${message}" — This note is related to [[Build Laputa App]] and [[Matteo Cellini]].` +} + +function handleStreamEvent(data: AiAgentStreamEvent, callbacks: AgentStreamCallbacks): void { + switch (data.kind) { + case 'TextDelta': + callbacks.onText(data.text) + return + case 'ThinkingDelta': + callbacks.onThinking(data.text) + return + case 'ToolStart': + callbacks.onToolStart(data.tool_name, data.tool_id, data.input) + return + case 'ToolDone': + callbacks.onToolDone(data.tool_id, data.output) + return + case 'Error': + callbacks.onError(data.message) + return + case 'Done': + callbacks.onDone() + return + } +} + +export async function streamAiAgent( + request: StreamAiAgentRequest, +): Promise { + const { + agent, + message, + systemPrompt, + vaultPath, + callbacks, + } = request + + if (!isTauri()) { + setTimeout(() => { + callbacks.onText(mockAgentResponse(agent, message)) + callbacks.onDone() + }, 300) + return + } + + const { invoke } = await import('@tauri-apps/api/core') + const { listen } = await import('@tauri-apps/api/event') + + const unlisten = await listen('ai-agent-stream', (event) => { + handleStreamEvent(event.payload, callbacks) + }) + + try { + await invoke('stream_ai_agent', { + request: { + agent, + message, + system_prompt: systemPrompt || null, + vault_path: vaultPath, + }, + }) + } catch (err) { + callbacks.onError(err instanceof Error ? err.message : String(err)) + callbacks.onDone() + } finally { + unlisten() + } +}