feat: support selectable cli ai agents

This commit is contained in:
lucaronin
2026-04-13 19:37:59 +02:00
parent cdfc0c9135
commit d540d76f73
45 changed files with 2473 additions and 254 deletions

View File

@@ -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

View File

@@ -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 <msg> --output-format stream-json --mcp-config <json>
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

View File

@@ -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/<slug>.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`

View File

@@ -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.

View File

@@ -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 |

463
src-tauri/src/ai_agents.rs Normal file
View File

@@ -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<String>,
}
#[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<String>,
},
ToolDone {
tool_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
output: Option<String>,
},
Error {
message: String,
},
Done,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AiAgentStreamRequest {
pub agent: AiAgentId,
pub message: String,
pub system_prompt: Option<String>,
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<F>(request: AiAgentStreamRequest, mut emit: F) -> Result<String, String>
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<String> {
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<PathBuf, String> {
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<Option<PathBuf>, 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<PathBuf> {
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<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
}
fn run_codex_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Result<String, String>
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::<serde_json::Value>(&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<Vec<String>, 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<F>(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<F>(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::<Vec<_>>().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<AiAgentStreamEvent> {
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"
));
}
}

View File

@@ -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<Event> = Box<dyn Fn(Event) + Send>;
#[cfg(desktop)]
async fn run_desktop_stream<Event, Request, Runner>(
app_handle: tauri::AppHandle,
event_name: &'static str,
request: Request,
runner: Runner,
) -> Result<String, String>
where
Event: serde::Serialize + Send + 'static,
Request: Send + 'static,
Runner: FnOnce(Request, StreamEmitter<Event>) -> Result<String, String> + 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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
Err("CLI AI agents are not available on mobile".into())
}

View File

@@ -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::Wry>) -> tauri::Builder<ta
commands::is_git_repo,
commands::init_git_repo,
commands::check_claude_cli,
commands::get_ai_agents_status,
commands::stream_claude_chat,
commands::stream_claude_agent,
commands::stream_ai_agent,
commands::reload_vault,
commands::reload_vault_entry,
commands::sync_note_title,

View File

@@ -13,6 +13,7 @@ pub struct Settings {
pub analytics_enabled: Option<bool>,
pub anonymous_id: Option<String>,
pub release_channel: Option<String>,
pub default_ai_agent: Option<String>,
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
@@ -36,6 +37,13 @@ pub fn effective_release_channel(value: Option<&str>) -> &'static str {
}
}
pub fn normalize_default_ai_agent(value: Option<&str>) -> Option<String> {
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<bool>,
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,
)
);
}

View File

@@ -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 <LoadingView />
}
if (!noteWindowParams && onboarding.state.status === 'ready' && claudeCodeOnboarding.showPrompt) {
if (!noteWindowParams && onboarding.state.status === 'ready' && aiAgentsOnboarding.showPrompt) {
return (
<ClaudeCodeOnboardingView
status={claudeCodeStatus}
onContinue={claudeCodeOnboarding.dismissPrompt}
<AiAgentsOnboardingView
statuses={aiAgentsStatus}
onContinue={aiAgentsOnboarding.dismissPrompt}
/>
)
}
@@ -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() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={vaultSwitcher.restoreGettingStarted} onClickPending={() => 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} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={vaultSwitcher.restoreGettingStarted} onClickPending={() => 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} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette
open={dialogs.showCommandPalette}
commands={commands}
entries={vault.entries}
claudeCodeReady={claudeCodeStatus === 'installed'}
aiAgentReady={aiAgentPreferences.defaultAiAgentReady}
aiAgentLabel={aiAgentPreferences.defaultAiAgentLabel}
onClose={dialogs.closeCommandPalette}
/>
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
@@ -791,7 +802,7 @@ function App() {
onCommit={conflictResolver.commitResolution}
onClose={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
{deleteActions.confirmDelete && (
@@ -832,16 +843,16 @@ function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; i
)
}
function ClaudeCodeOnboardingView({
status,
function AiAgentsOnboardingView({
statuses,
onContinue,
}: {
status: ReturnType<typeof useClaudeCodeStatus>['status']
statuses: ReturnType<typeof useAiAgentsStatus>
onContinue: () => void
}) {
return (
<div className="app-shell">
<ClaudeCodeOnboardingPrompt status={status} onContinue={onContinue} />
<AiAgentsOnboardingPrompt statuses={statuses} onContinue={onContinue} />
</div>
)
}

View File

@@ -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(
<AiAgentsOnboardingPrompt
statuses={{
claude_code: { status: 'installed', version: '1.0.20' },
codex: { status: 'missing', version: null },
}}
onContinue={vi.fn()}
/>,
)
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(
<AiAgentsOnboardingPrompt
statuses={{
claude_code: { status: 'missing', version: null },
codex: { status: 'missing', version: null },
}}
onContinue={vi.fn()}
/>,
)
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(
<AiAgentsOnboardingPrompt
statuses={{
claude_code: { status: 'missing', version: null },
codex: { status: 'missing', version: null },
}}
onContinue={vi.fn()}
/>,
)
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')
})
})

View File

@@ -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: <Loader2 className="size-7 animate-spin" />,
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: <Bot className="size-7" />,
title: 'No AI agents detected',
}
}
return {
accentClassName: 'bg-emerald-100 text-emerald-700',
description: 'Your AI agents are ready to use in Tolaria.',
icon: <CheckCircle2 className="size-7" />,
title: 'AI agents ready',
}
}
function AgentStatusList({ statuses }: { statuses: AiAgentsStatus }) {
return (
<div className="space-y-3">
{AI_AGENT_DEFINITIONS.map((definition) => {
const status = statuses[definition.id]
const ready = status.status === 'installed'
return (
<div
key={definition.id}
className="flex items-center justify-between rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm"
>
<div className="space-y-1 text-left">
<div className="font-medium text-foreground">{definition.label}</div>
<div className="text-xs text-muted-foreground">
{ready
? `${definition.label}${status.version ? ` ${status.version}` : ''} is ready.`
: `${definition.label} is not installed yet.`}
</div>
</div>
<span
className={`rounded-full px-2 py-1 text-[11px] font-medium ${ready ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}
>
{ready ? 'Installed' : 'Missing'}
</span>
</div>
)
})}
</div>
)
}
export function AiAgentsOnboardingPrompt({
statuses,
onContinue,
}: AiAgentsOnboardingPromptProps) {
const copy = getPromptCopy(statuses)
const missingAgents = AI_AGENT_DEFINITIONS.filter((definition) => statuses[definition.id].status === 'missing')
return (
<div
className="flex h-full w-full items-center justify-center bg-sidebar px-6 py-10"
data-testid="ai-agents-onboarding-screen"
>
<Card className="w-full max-w-2xl border-border bg-background shadow-sm">
<CardHeader className="items-center gap-5 text-center">
<div className={`flex size-16 items-center justify-center rounded-2xl ${copy.accentClassName}`}>
{copy.icon}
</div>
<div className="space-y-2">
<CardTitle className="text-3xl tracking-tight">
{copy.title}
</CardTitle>
<p className="text-sm leading-6 text-muted-foreground" data-testid="ai-agents-onboarding-description">
{copy.description}
</p>
</div>
</CardHeader>
<CardContent className="space-y-4">
<AgentStatusList statuses={statuses} />
</CardContent>
<CardFooter className="flex-wrap justify-center gap-3">
{missingAgents.map((definition) => (
<Button
key={definition.id}
type="button"
variant="outline"
onClick={() => void openExternalUrl(getAiAgentDefinition(definition.id).installUrl)}
data-testid={`ai-agents-onboarding-install-${definition.id}`}
>
Install {definition.label}
<ArrowUpRight className="size-4" />
</Button>
))}
<Button
type="button"
onClick={onContinue}
disabled={isAiAgentsStatusChecking(statuses)}
data-testid="ai-agents-onboarding-continue"
>
{hasAnyInstalledAiAgent(statuses) ? 'Continue' : 'Continue without it'}
</Button>
</CardFooter>
</Card>
</div>
)
}

View File

@@ -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<typeof import('../hooks/useAiAgent').useAiAgent>['messages'] = []
let mockStatus: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['status'] = 'idle'
let mockMessages: ReturnType<typeof import('../hooks/useCliAiAgent').useCliAiAgent>['messages'] = []
let mockStatus: ReturnType<typeof import('../hooks/useCliAiAgent').useCliAiAgent>['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,

View File

@@ -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 (
<div
className="flex shrink-0 items-center border-b border-border"
style={{ height: 52, padding: '0 12px', gap: 8 }}
>
<Robot size={16} className="shrink-0 text-muted-foreground" />
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
AI Chat
</span>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={onClear}
title="New conversation"
>
<Plus size={16} />
</button>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={onClose}
title="Close AI panel"
>
<X size={16} />
</button>
</div>
)
}
function ContextBar({ activeEntry, linkedCount }: { activeEntry: VaultEntry; linkedCount: number }) {
return (
<div
className="flex shrink-0 items-center border-b border-border text-muted-foreground"
style={{ padding: '6px 12px', gap: 6, fontSize: 11 }}
data-testid="context-bar"
>
<Link size={12} className="shrink-0" />
<span className="truncate" style={{ fontWeight: 500 }}>{activeEntry.title}</span>
{linkedCount > 0 && (
<span style={{ opacity: 0.6 }}>+ {linkedCount} linked</span>
)}
</div>
)
}
function EmptyState({ hasContext }: { hasContext: boolean }) {
return (
<div
className="flex flex-col items-center justify-center text-center text-muted-foreground"
style={{ paddingTop: 40 }}
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
{hasContext
? 'Ask about this note and its linked context'
: 'Open a note, then ask the AI about it'
}
</p>
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
{hasContext
? 'Summarize, find connections, expand ideas'
: 'The AI will use the active note as context'
}
</p>
</div>
)
}
function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, hasContext }: {
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; onNavigateWikilink?: (target: string) => void; hasContext: boolean
}) {
const endRef = useRef<HTMLDivElement>(null)
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, isActive])
return (
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
{messages.length === 0 && !isActive && <EmptyState hasContext={hasContext} />}
{messages.map((msg, i) => (
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} onNavigateWikilink={onNavigateWikilink} />
))}
<div ref={endRef} />
</div>
)
}
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<HTMLDivElement | null>
isActive: boolean
onChange: (value: string) => void
onSend: (text: string, references: NoteReference[]) => void
}) {
return (
<div
className="flex shrink-0 flex-col border-t border-border"
style={{ padding: '8px 12px' }}
>
<div className="flex items-end gap-2">
<div className="flex-1">
<WikilinkChatInput
entries={entries}
value={input}
onChange={onChange}
onSend={onSend}
disabled={isActive}
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
inputRef={inputRef}
/>
</div>
<button
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
style={{
background: (isActive || !input.trim()) ? 'var(--muted)' : 'var(--primary)',
color: (isActive || !input.trim()) ? 'var(--muted-foreground)' : 'white',
borderRadius: 8, width: 32, height: 34,
cursor: (isActive || !input.trim()) ? 'not-allowed' : 'pointer',
}}
onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
disabled={isActive || !input.trim()}
title="Send message"
data-testid="agent-send"
>
<PaperPlaneRight size={16} />
</button>
</div>
</div>
)
}
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<HTMLDivElement>(null)
const panelRef = useRef<HTMLElement>(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}
>
<PanelHeader onClose={onClose} onClear={agent.clearConversation} />
<AiPanelHeader agentLabel={agentLabel} agentReady={defaultAiAgentReady} legacyCopy={useLegacyAiExperience} onClose={onClose} onClear={agent.clearConversation} />
{activeEntry && (
<ContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} />
<AiPanelContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} />
)}
<MessageHistory
<AiPanelMessageHistory
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
legacyCopy={useLegacyAiExperience}
messages={agent.messages}
isActive={isActive}
onOpenNote={onOpenNote}
@@ -228,10 +115,13 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, on
/>
<AiPanelComposer
entries={entries ?? []}
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
hasContext={hasContext}
input={input}
inputRef={inputRef}
isActive={isActive}
legacyCopy={useLegacyAiExperience}
onChange={setInput}
onSend={handleSend}
/>

View File

@@ -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<HTMLDivElement | null>
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<AiPanelMessageHistoryProps, 'agentLabel' | 'agentReady' | 'hasContext' | 'legacyCopy'>) {
if (!agentReady) {
return (
<div
className="flex flex-col items-center justify-center text-center text-muted-foreground"
style={{ paddingTop: 40 }}
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
{agentLabel} is not available on this machine
</p>
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
Install it or switch the default AI agent in Settings
</p>
</div>
)
}
return (
<div
className="flex flex-col items-center justify-center text-center text-muted-foreground"
style={{ paddingTop: 40 }}
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
{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`
}
</p>
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
{hasContext
? 'Summarize, find connections, expand ideas'
: 'The AI will use the active note as context'
}
</p>
</div>
)
}
export function AiPanelHeader({
agentLabel,
agentReady,
legacyCopy,
onClose,
onClear,
}: AiPanelHeaderProps) {
return (
<div
className="flex shrink-0 items-center border-b border-border"
style={{ height: 52, padding: '0 12px', gap: 8 }}
>
<Robot size={16} className="shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col overflow-hidden">
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
{legacyCopy ? 'AI Chat' : 'AI Agent'}
</span>
{!legacyCopy && (
<span className="truncate text-[11px] text-muted-foreground">
{agentLabel}
{!agentReady ? ' · not installed' : ''}
</span>
)}
</div>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={onClear}
title="New conversation"
>
<Plus size={16} />
</button>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={onClose}
title="Close AI panel"
>
<X size={16} />
</button>
</div>
)
}
export function AiPanelContextBar({ activeEntry, linkedCount }: AiPanelContextBarProps) {
return (
<div
className="flex shrink-0 items-center border-b border-border text-muted-foreground"
style={{ padding: '6px 12px', gap: 6, fontSize: 11 }}
data-testid="context-bar"
>
<Link size={12} className="shrink-0" />
<span className="truncate" style={{ fontWeight: 500 }}>{activeEntry.title}</span>
{linkedCount > 0 && (
<span style={{ opacity: 0.6 }}>+ {linkedCount} linked</span>
)}
</div>
)
}
export function AiPanelMessageHistory({
agentLabel,
agentReady,
legacyCopy,
messages,
isActive,
onOpenNote,
onNavigateWikilink,
hasContext,
}: AiPanelMessageHistoryProps) {
const endRef = useRef<HTMLDivElement>(null)
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, isActive])
return (
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
{messages.length === 0 && !isActive && (
<AiPanelEmptyState
agentLabel={agentLabel}
agentReady={agentReady}
legacyCopy={legacyCopy}
hasContext={hasContext}
/>
)}
{messages.map((message, index) => (
<AiMessage
key={message.id ?? index}
{...message}
onOpenNote={onOpenNote}
onNavigateWikilink={onNavigateWikilink}
/>
))}
<div ref={endRef} />
</div>
)
}
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 (
<div
className="flex shrink-0 flex-col border-t border-border"
style={{ padding: '8px 12px' }}
>
<div className="flex items-end gap-2">
<div className="flex-1">
<WikilinkChatInput
entries={entries}
value={input}
onChange={onChange}
onSend={onSend}
disabled={composerDisabled}
placeholder={placeholder}
inputRef={inputRef}
/>
</div>
<button
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
style={sendButtonStyle}
onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
disabled={!canSend}
title="Send message"
data-testid="agent-send"
>
<PaperPlaneRight size={16} />
</button>
</div>
</div>
)
}

View File

@@ -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 (
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
<span>{aiMode ? 'Claude mode' : '↑↓ navigate'}</span>
<span>{aiMode ? `${aiAgentLabel} mode` : '↑↓ navigate'}</span>
<span>{aiMode ? '↵ send' : '↵ select'}</span>
<span>esc close</span>
</div>
@@ -194,6 +198,8 @@ function OpenCommandPalette({
commands,
entries = [],
claudeCodeReady = true,
aiAgentReady,
aiAgentLabel = 'Claude Code',
onClose,
}: Omit<CommandPaletteProps, 'open'>) {
const [query, setQuery] = useState('')
@@ -203,6 +209,7 @@ function OpenCommandPalette({
const aiInputRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(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}
/>
<CommandPaletteFooter aiMode={false} />
<CommandPaletteFooter aiMode={false} aiAgentLabel={aiAgentLabel} />
</>
)}
</div>

View File

@@ -7,6 +7,8 @@ interface CommandPaletteAiModeProps {
entries: VaultEntry[]
value: string
claudeCodeReady: boolean
aiAgentReady?: boolean
aiAgentLabel?: string
inputRef?: React.RefObject<HTMLDivElement | null>
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 (
<InlineWikilinkInput
entries={entries}
@@ -32,23 +38,23 @@ export function CommandPaletteAiMode({
onChange={onChange}
onSubmit={(text, references) => 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={(
<div className="mb-2 flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
<Sparkle size={12} weight="fill" />
<span>Ask Claude Code</span>
<span>Ask {aiAgentLabel}</span>
</div>
)}
paletteEmptyState={(
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
{!claudeCodeReady ? (
'Claude Code is not available on this machine.'
{!resolvedAiAgentReady ? (
`${aiAgentLabel} is not available on this machine.`
) : (
<>
<div className="mb-1 font-medium text-foreground">Ask Claude Code</div>
<div className="mb-1 font-medium text-foreground">Ask {aiAgentLabel}</div>
<div>
{value.trim().length === 0
? 'Type your prompt after the leading space.'
@@ -60,7 +66,7 @@ export function CommandPaletteAiMode({
)}
paletteFooter={(
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
<span>Claude mode</span>
<span>{aiAgentLabel} mode</span>
<span> send</span>
<span>esc close</span>
</div>

View File

@@ -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}

View File

@@ -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({
<AiPanel
onClose={() => onToggleAIChat?.()}
onOpenNote={onOpenNote}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReady={defaultAiAgentReady}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}

View File

@@ -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 (
<SettingsPanelInner
settings={settings}
aiAgentsStatus={aiAgentsStatus}
onSave={onSave}
explicitOrganizationEnabled={explicitOrganizationEnabled}
onSaveExplicitOrganization={onSaveExplicitOrganization}
@@ -124,12 +141,14 @@ export function SettingsPanel({
)
}
type SettingsPanelInnerProps = Omit<SettingsPanelProps, 'open' | 'explicitOrganizationEnabled'> & {
type SettingsPanelInnerProps = Omit<SettingsPanelProps, 'open' | 'explicitOrganizationEnabled' | 'aiAgentsStatus'> & {
aiAgentsStatus: AiAgentsStatus
explicitOrganizationEnabled: boolean
}
function SettingsPanelInner({
settings,
aiAgentsStatus,
onSave,
explicitOrganizationEnabled,
onSaveExplicitOrganization,
@@ -200,6 +219,9 @@ function SettingsPanelInner({
<SettingsBody
pullInterval={draft.pullInterval}
setPullInterval={(value) => 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({
<Divider />
<SectionHeading
title="AI Agents"
description="Choose which CLI AI agent Tolaria uses in the AI panel and command palette."
/>
<LabeledSelect
label="Default AI agent"
value={defaultAiAgent}
onValueChange={(value) => 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"
/>
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
{renderDefaultAiAgentSummary(defaultAiAgent, aiAgentsStatus)}
</div>
<Divider />
<SectionHeading
title="Release Channel"
description="Controls which update feed Tolaria polls. Stable only receives manually promoted releases. Alpha follows every push to main."
@@ -337,6 +390,15 @@ function Divider() {
return <div style={{ height: 1, background: 'var(--border)' }} />
}
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,

View File

@@ -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}
/>

View File

@@ -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 (
<div
data-testid="status-ai-agents-popup"
style={{
position: 'absolute',
bottom: '100%',
left: 0,
marginBottom: 4,
minWidth: 280,
border: '1px solid var(--border)',
borderRadius: 8,
padding: 10,
background: 'var(--sidebar)',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
zIndex: 1000,
}}
>
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.08em] text-muted-foreground">
AI Agents
</div>
<div className="space-y-3">
{AI_AGENT_DEFINITIONS.map((definition) => {
const status = statuses[definition.id]
const ready = status.status === 'installed'
const selected = definition.id === defaultAgent
return (
<div
key={definition.id}
className="rounded-md border border-border bg-background/80 px-3 py-2 text-sm"
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium text-foreground">
{definition.label}
{selected ? ' · Default' : ''}
</div>
<div className="text-xs text-muted-foreground">
{ready
? `${definition.label}${status.version ? ` ${status.version}` : ''} is ready.`
: `${definition.label} is not installed.`}
</div>
</div>
{!ready && (
<Button
type="button"
size="xs"
variant="outline"
onClick={() => void openExternalUrl(definition.installUrl)}
>
Install
</Button>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
export function AiAgentsBadge({ statuses, defaultAgent }: AiAgentsBadgeProps) {
const [showPopup, setShowPopup] = useState(false)
const popupRef = useRef<HTMLDivElement>(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 (
<>
<span style={SEP_STYLE}>|</span>
<div ref={popupRef} style={{ position: 'relative' }}>
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 px-2 text-[11px] font-medium"
title={badgeTooltip(statuses, defaultAgent)}
data-testid="status-ai-agents"
onClick={() => setShowPopup((current) => !current)}
>
<span style={{ ...ICON_STYLE, color: showWarning ? 'var(--accent-orange)' : 'var(--muted-foreground)' }}>
<Terminal size={13} />
AI Agents
{showWarning && <AlertTriangle size={10} style={{ marginLeft: 2 }} />}
</span>
</Button>
{showPopup && <AgentPopup statuses={statuses} defaultAgent={defaultAgent} />}
</div>
</>
)
}

View File

@@ -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({
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
<PulseBadge onClick={onClickPulse} disabled={isGitVault === false} />
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
{claudeCodeStatus && <ClaudeCodeBadge status={claudeCodeStatus} version={claudeCodeVersion} />}
{aiAgentsStatus && defaultAiAgent
? <AiAgentsBadge statuses={aiAgentsStatus} defaultAgent={defaultAiAgent} />
: claudeCodeStatus && <ClaudeCodeBadge status={claudeCodeStatus} version={claudeCodeVersion} />}
</div>
)
}

View File

@@ -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'

View File

@@ -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?.(),
},
]
}

View File

@@ -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')
})
})

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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<typeof vi.fn> }
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')
})
})
})

View File

@@ -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<Record<'claude_code' | 'codex', { installed?: boolean | null; version?: string | null }>>
function tauriCall<T>(command: string): Promise<T> {
return isTauri() ? invoke<T>(command) : mockInvoke<T>(command)
}
export function useAiAgentsStatus(): AiAgentsStatus {
const [statuses, setStatuses] = useState<AiAgentsStatus>(createCheckingAiAgentsStatus())
useEffect(() => {
let cancelled = false
tauriCall<RawAiAgentsStatus>('get_ai_agents_status')
.then((result) => {
if (!cancelled) {
setStatuses(normalizeAiAgentsStatus(result))
}
})
.catch(() => {
if (!cancelled) {
setStatuses(createMissingAiAgentsStatus())
}
})
return () => { cancelled = true }
}, [])
return statuses
}

View File

@@ -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<type
vaultCount: config.vaultCount,
mcpStatus: config.mcpStatus,
onInstallMcp: config.onInstallMcp,
onOpenAiAgents: config.onOpenAiAgents,
onCycleDefaultAiAgent: config.onCycleDefaultAiAgent,
selectedAiAgentLabel: config.selectedAiAgentLabel,
onReloadVault: config.onReloadVault,
onRepairVault: config.onRepairVault,
onSetNoteIcon: config.onSetNoteIcon,

View File

@@ -0,0 +1,91 @@
import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import type { AiAgentId } from '../lib/aiAgents'
import type { NoteReference } from '../utils/ai-context'
import {
type AiAgentMessage,
} from '../lib/aiAgentConversation'
import {
clearAgentConversation,
sendAgentMessage,
type AiAgentSessionRuntime,
} from '../lib/aiAgentSession'
import type { ToolInvocation } from '../lib/aiAgentMessageState'
import {
type AgentFileCallbacks,
type AgentStatus,
} from './useAiAgent'
export type { AgentFileCallbacks, AgentStatus } from './useAiAgent'
export type { AiAgentMessage } from '../lib/aiAgentConversation'
interface UseCliAiAgentOptions {
agent: AiAgentId
agentReady: boolean
}
interface UseCliAiAgentRuntime extends AiAgentSessionRuntime {
messages: AiAgentMessage[]
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>
status: AgentStatus
setStatus: Dispatch<SetStateAction<AgentStatus>>
messagesRef: MutableRefObject<AiAgentMessage[]>
statusRef: MutableRefObject<AgentStatus>
}
function useCliAiAgentRuntime(fileCallbacks: AgentFileCallbacks | undefined): UseCliAiAgentRuntime {
const [messages, setMessages] = useState<AiAgentMessage[]>([])
const [status, setStatus] = useState<AgentStatus>('idle')
const abortRef = useRef({ aborted: false })
const responseAccRef = useRef('')
const fileCallbacksRef = useRef(fileCallbacks)
const toolInputMapRef = useRef<Map<string, ToolInvocation>>(new Map())
const messagesRef = useRef<AiAgentMessage[]>([])
const statusRef = useRef<AgentStatus>('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<void> {
await sendAgentMessage({
runtime,
context: {
agent,
ready: agentReady,
vaultPath,
systemPromptOverride: contextPrompt,
},
prompt: { text, references },
})
}
function clearConversation(): void {
clearAgentConversation(runtime)
}
return { messages, status, sendMessage, clearConversation }
}

View File

@@ -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,

View File

@@ -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 () => {

View File

@@ -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),
}
}

View File

@@ -9,8 +9,8 @@ function tauriCall(command: string): Promise<void> {
return isTauri() ? invoke<void>(command) : mockInvoke<void>(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)

View File

@@ -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<SetStateAction<AiAgentMessage[]>>,
prompt: PendingUserPrompt,
response: string,
): void {
setMessages((current) => [
...current,
{
userMessage: prompt.text,
references: prompt.references,
actions: [],
response,
id: nextMessageId(),
},
])
}
export function appendStreamingMessage(
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>,
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,
}
}

View File

@@ -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<SetStateAction<AiAgentMessage[]>>,
messageId: string,
updater: (message: AiAgentMessage) => AiAgentMessage,
): void {
setMessages((current) => current.map((message) => (message.id === messageId ? updater(message) : message)))
}
export function markReasoningDone(
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>,
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,
},
],
}
}

101
src/lib/aiAgentSession.ts Normal file
View File

@@ -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<SetStateAction<AiAgentMessage[]>>
setStatus: Dispatch<SetStateAction<AgentStatus>>
abortRef: MutableRefObject<{ aborted: boolean }>
responseAccRef: MutableRefObject<string>
fileCallbacksRef: MutableRefObject<AgentFileCallbacks | undefined>
toolInputMapRef: MutableRefObject<Map<string, ToolInvocation>>
messagesRef: MutableRefObject<AiAgentMessage[]>
statusRef: MutableRefObject<AgentStatus>
}
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<void> {
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<AiAgentSessionRuntime, 'abortRef' | 'responseAccRef' | 'toolInputMapRef' | 'setMessages' | 'setStatus'>): void {
runtime.abortRef.current.aborted = true
runtime.responseAccRef.current = ''
runtime.toolInputMapRef.current = new Map()
runtime.setMessages([])
runtime.setStatus('idle')
}

View File

@@ -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<SetStateAction<AiAgentMessage[]>>
setStatus: Dispatch<SetStateAction<AgentStatus>>
abortRef: MutableRefObject<{ aborted: boolean }>
responseAccRef: MutableRefObject<string>
toolInputMapRef: MutableRefObject<Map<string, ToolInvocation>>
fileCallbacksRef: MutableRefObject<AgentFileCallbacks | undefined>
}
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?.()
},
}
}

35
src/lib/aiAgents.test.ts Normal file
View File

@@ -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')
})
})

101
src/lib/aiAgents.ts Normal file
View File

@@ -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<Record<AiAgentId, { installed?: boolean | null; version?: string | null }>> | 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
}

View File

@@ -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<string, (args: any) => 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<string, (args: any) => 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
},

View File

@@ -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 {

106
src/utils/streamAiAgent.ts Normal file
View File

@@ -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('<conversation_history>')) {
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<void> {
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<AiAgentStreamEvent>('ai-agent-stream', (event) => {
handleStreamEvent(event.payload, callbacks)
})
try {
await invoke<string>('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()
}
}