feat: support selectable cli ai agents
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
32
docs/adr/0062-selectable-cli-ai-agents.md
Normal file
32
docs/adr/0062-selectable-cli-ai-agents.md
Normal 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.
|
||||
@@ -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
463
src-tauri/src/ai_agents.rs
Normal 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"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
47
src/App.tsx
47
src/App.tsx
@@ -16,14 +16,15 @@ import { StatusBar } from './components/StatusBar'
|
||||
import { SettingsPanel } from './components/SettingsPanel'
|
||||
import { CloneVaultModal } from './components/CloneVaultModal'
|
||||
import { WelcomeScreen } from './components/WelcomeScreen'
|
||||
import { ClaudeCodeOnboardingPrompt } from './components/ClaudeCodeOnboardingPrompt'
|
||||
import { AiAgentsOnboardingPrompt } from './components/AiAgentsOnboardingPrompt'
|
||||
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
|
||||
import { FeedbackDialog } from './components/FeedbackDialog'
|
||||
import { useTelemetry } from './hooks/useTelemetry'
|
||||
import { useMcpStatus } from './hooks/useMcpStatus'
|
||||
import { useClaudeCodeOnboarding } from './hooks/useClaudeCodeOnboarding'
|
||||
import { useClaudeCodeStatus } from './hooks/useClaudeCodeStatus'
|
||||
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
|
||||
import { useAiAgentsStatus } from './hooks/useAiAgentsStatus'
|
||||
import { useVaultLoader } from './hooks/useVaultLoader'
|
||||
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useNoteActions } from './hooks/useNoteActions'
|
||||
import { useCommitFlow } from './hooks/useCommitFlow'
|
||||
@@ -126,8 +127,8 @@ function App() {
|
||||
})
|
||||
|
||||
const onboarding = useOnboarding(vaultSwitcher.vaultPath)
|
||||
const { status: claudeCodeStatus, version: claudeCodeVersion } = useClaudeCodeStatus()
|
||||
const claudeCodeOnboarding = useClaudeCodeOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
|
||||
const aiAgentsStatus = useAiAgentsStatus()
|
||||
const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
|
||||
|
||||
// When onboarding resolves to a different vault path, update the switcher
|
||||
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
|
||||
@@ -168,6 +169,12 @@ function App() {
|
||||
})
|
||||
}, [updateConfig, vaultConfig.inbox?.noteListProperties])
|
||||
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
|
||||
const aiAgentPreferences = useAiAgentPreferences({
|
||||
settings,
|
||||
saveSettings,
|
||||
aiAgentsStatus,
|
||||
onToast: setToastMessage,
|
||||
})
|
||||
useTelemetry(settings, settingsLoaded)
|
||||
|
||||
const vaultOpenedRef = useRef('')
|
||||
@@ -593,8 +600,9 @@ function App() {
|
||||
vaultCount: vaultSwitcher.allVaults.length,
|
||||
mcpStatus,
|
||||
onInstallMcp: installMcp,
|
||||
claudeCodeStatus: claudeCodeStatus ?? undefined,
|
||||
claudeCodeVersion: claudeCodeVersion ?? undefined,
|
||||
onOpenAiAgents: dialogs.openSettings,
|
||||
onCycleDefaultAiAgent: aiAgentPreferences.cycleDefaultAiAgent,
|
||||
selectedAiAgentLabel: aiAgentPreferences.defaultAiAgentLabel,
|
||||
onReloadVault: vault.reloadVault,
|
||||
onRepairVault: handleRepairVault,
|
||||
onSetNoteIcon: handleSetNoteIconCommand,
|
||||
@@ -642,11 +650,11 @@ function App() {
|
||||
return <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>
|
||||
)
|
||||
}
|
||||
|
||||
66
src/components/AiAgentsOnboardingPrompt.test.tsx
Normal file
66
src/components/AiAgentsOnboardingPrompt.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
132
src/components/AiAgentsOnboardingPrompt.tsx
Normal file
132
src/components/AiAgentsOnboardingPrompt.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
260
src/components/AiPanelChrome.tsx
Normal file
260
src/components/AiPanelChrome.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
128
src/components/status-bar/AiAgentsBadge.tsx
Normal file
128
src/components/status-bar/AiAgentsBadge.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
32
src/hooks/commands/aiAgentCommands.ts
Normal file
32
src/hooks/commands/aiAgentCommands.ts
Normal 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?.(),
|
||||
},
|
||||
]
|
||||
}
|
||||
58
src/hooks/useAiAgentPreferences.test.ts
Normal file
58
src/hooks/useAiAgentPreferences.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
52
src/hooks/useAiAgentPreferences.ts
Normal file
52
src/hooks/useAiAgentPreferences.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
38
src/hooks/useAiAgentsOnboarding.ts
Normal file
38
src/hooks/useAiAgentsOnboarding.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
53
src/hooks/useAiAgentsStatus.test.ts
Normal file
53
src/hooks/useAiAgentsStatus.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
39
src/hooks/useAiAgentsStatus.ts
Normal file
39
src/hooks/useAiAgentsStatus.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
91
src/hooks/useCliAiAgent.ts
Normal file
91
src/hooks/useCliAiAgent.ts
Normal 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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
101
src/lib/aiAgentConversation.ts
Normal file
101
src/lib/aiAgentConversation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
64
src/lib/aiAgentMessageState.ts
Normal file
64
src/lib/aiAgentMessageState.ts
Normal 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
101
src/lib/aiAgentSession.ts
Normal 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')
|
||||
}
|
||||
111
src/lib/aiAgentStreamCallbacks.ts
Normal file
111
src/lib/aiAgentStreamCallbacks.ts
Normal 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
35
src/lib/aiAgents.test.ts
Normal 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
101
src/lib/aiAgents.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
106
src/utils/streamAiAgent.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user