Compare commits

...

25 Commits

Author SHA1 Message Date
lucaronin
58f8a774b5 chore: ratchet codescene thresholds 2026-04-29 01:26:33 +02:00
lucaronin
0bf280d3ec fix: satisfy ai permission mode clippy 2026-04-29 01:26:33 +02:00
lucaronin
d2d4746d5c feat: add vault ai agent permission modes 2026-04-29 01:26:33 +02:00
lucaronin
91db7c0098 fix: pause saves without active vault 2026-04-29 00:19:47 +02:00
lucaronin
87908d982f fix: allow guidance open path 2026-04-28 23:56:08 +02:00
lucaronin
40544fc551 fix: detect nvm ai agent binaries 2026-04-28 23:39:48 +02:00
lucaronin
f34fdb0289 fix: guard stale suggestion menu actions 2026-04-28 23:29:09 +02:00
lucaronin
e601a42f41 fix: surface ai agent final replies 2026-04-28 23:00:06 +02:00
lucaronin
de627c2478 fix: normalize missing vault metadata 2026-04-28 22:52:07 +02:00
lucaronin
46d9aaed48 fix: dedupe editor toolbar state closes 2026-04-28 21:57:26 +02:00
lucaronin
6b96b5caf6 fix: reset editor selection before note swaps 2026-04-28 21:31:54 +02:00
lucaronin
595c48ca59 fix: apply settings theme changes immediately 2026-04-28 21:16:30 +02:00
lucaronin
fd92ff5e9b feat: allow manual saved view ordering 2026-04-28 20:41:07 +02:00
lucaronin
a39b346a75 fix: detect mise node for mcp setup 2026-04-28 20:28:56 +02:00
lucaronin
deac10caa2 fix: detect nvm pi installs 2026-04-28 19:54:39 +02:00
lucaronin
0e1e0ff47a fix: preserve ai composer line breaks 2026-04-28 19:32:08 +02:00
lucaronin
a645edbc19 fix: repair malformed editor block ids 2026-04-28 18:56:36 +02:00
lucaronin
48f1fb0b50 feat: add gemini cli external ai setup 2026-04-28 18:30:14 +02:00
lucaronin
1aede09834 fix: preserve wikilinks inside markdown tables 2026-04-28 18:00:15 +02:00
lucaronin
d51b76f992 fix: restrict sentry releases to stable builds 2026-04-28 17:17:23 +02:00
lucaronin
8e66fb4c19 fix: preserve table math markdown 2026-04-28 16:47:52 +02:00
lucaronin
a2843a0cf3 fix: persist immediate note creation before open 2026-04-28 15:01:32 +02:00
lucaronin
38fb8a7370 feat: copy mcp config from app 2026-04-28 14:03:10 +02:00
lucaronin
be5a8bb300 fix: make editor left handle draggable 2026-04-28 12:31:13 +02:00
lucaronin
ce040c75fd test: cover table hover stability 2026-04-28 10:46:06 +02:00
124 changed files with 6036 additions and 894 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=10.0
AVERAGE_THRESHOLD=9.89
AVERAGE_THRESHOLD=9.91

View File

@@ -27,7 +27,7 @@ You can find some Loom walkthroughs below — they are short and to the point:
- 🔬 **Open source** — Tolaria is free and open source. I built this for [myself](https://x.com/lucaronin) and for sharing it with others.
- 📋 **Standards-based** — Notes are markdown files with YAML frontmatter. No proprietary formats, no locked-in data. Everything works with standard tools if you decide to move away from Tolaria.
- 🔍 **Types as lenses, not schemas** — Types in Tolaria are navigation aids, not enforcement mechanisms. There's no required fields, no validation, just helpful categories for finding notes.
- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code and Codex CLI (for now), but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out.
- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code, Codex CLI, and Gemini CLI setup paths, but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out.
- ⌨️ **Keyboard-first** — Tolaria is designed for power-users who want to use keyboard as much as possible. A lot of how we designed the Editor and the Command Palette is based on this.
- 💪 **Built from real use** — Tolaria was created for manage my personal vault of 10,000+ notes, and I use it every day. Every feature exists because it solved a real problem.
@@ -71,6 +71,12 @@ Tauri 2 on Linux requires WebKit2GTK 4.1 and GTK 3:
The bundled MCP server still spawns the system `node` binary at runtime on Linux, so install Node from your distro package manager if you want the external AI tooling flow.
### Gemini CLI setup
Tolaria can register the active vault's MCP server in `~/.gemini/settings.json` from the status bar or command palette action "Set Up External AI Tools". The generated entry is scoped to the selected vault with `VAULT_PATH` and requires Node.js 18+ plus a separately installed and signed-in Gemini CLI.
Use "Restore Tolaria AI Guidance" if you also want Tolaria to create a non-destructive `GEMINI.md` compatibility shim in the vault root. The shim points Gemini back to the shared `AGENTS.md` instructions and is not written over custom `GEMINI.md` content.
### Quick start
```bash

View File

@@ -175,6 +175,7 @@ Type is determined **purely** from the `type:` frontmatter field — it is never
├── some-topic.md ← type: Topic
├── AGENTS.md ← canonical Tolaria AI guidance
├── CLAUDE.md ← compatibility shim pointing at AGENTS.md
├── GEMINI.md ← optional Gemini CLI shim pointing at AGENTS.md
├── ...
└── type/ ← type definition documents
```
@@ -303,6 +304,12 @@ type SidebarSelection =
- A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected.
- Folder deletion clears pending rename state, confirms destructive intent, drops affected folder-scoped tabs, reloads vault data, and resets folder selection if the deleted subtree owned the current selection.
### Saved Views
Saved Views live as YAML files under `views/`. Their definition includes user-visible fields (`name`, `icon`, `color`), note-list preferences (`sort`, `listPropertiesDisplay`), filters, and an optional top-level `order` number. The `order` value is stored directly in the YAML document, not in Markdown frontmatter, and lower values render earlier in every saved-View list. Views without an explicit order sort after ordered views by filename for stable fallback behavior.
The renderer uses `viewOrdering` helpers to convert drag or move-button intent into dense order updates before saving each affected view file through `save_view_cmd`. The sidebar exposes both a drag handle and explicit move-up/move-down controls, and the command palette mirrors those move actions for the currently selected saved View.
### Neighborhood Mode
`SidebarSelection.kind === 'entity'` is Tolaria's Neighborhood mode for note-list browsing.
@@ -345,7 +352,7 @@ Command-layer path access is fenced to the active vault before file operations r
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder and external-open calls route through the Tauri opener plugin, while copy-path uses the browser clipboard API; none of those actions mutate vault contents or bypass the backend write boundary.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same generated stdio entry as registration, so the copied snippet remains scoped to the active vault without writing third-party config files.
### Vault Caching
@@ -669,7 +676,8 @@ No indexing step required — search runs directly against the filesystem.
Per-vault settings stored locally and scoped by vault path:
- Managed by `useVaultConfig` hook and `vaultConfigStore`
- Settings: zoom, view mode, editor mode, note layout, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle
- Settings: zoom, view mode, editor mode, note layout, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, AI agent permission mode (`safe` / `power_user`)
- Missing, null, and unknown AI agent permission modes normalize to `safe`; the AI panel can switch modes per vault, preserving the transcript and applying the new mode only to the next agent run
- One-time migration from localStorage (`configMigration.ts`)
### AI Guidance Files
@@ -677,8 +685,9 @@ Per-vault settings stored locally and scoped by vault path:
Tolaria tracks managed vault-level AI guidance separately from normal note content:
- `AGENTS.md` is the canonical managed guidance file for Tolaria-aware coding agents
- `CLAUDE.md` is a compatibility shim that points Claude Code back to `AGENTS.md`
- `GEMINI.md` is an optional Gemini CLI compatibility shim that points Gemini back to `AGENTS.md`
- `useVaultAiGuidanceStatus` reads `get_vault_ai_guidance_status` and normalizes the backend state into four UI cases: `managed`, `missing`, `broken`, and `custom`
- `restore_vault_ai_guidance` repairs only Tolaria-managed files; user-authored custom `AGENTS.md` / `CLAUDE.md` files are surfaced as custom and left untouched
- `restore_vault_ai_guidance` repairs only Tolaria-managed files and creates the optional Gemini shim on explicit request; user-authored custom `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` files are surfaced as custom and left untouched
- The status bar AI badge and command palette consume that abstraction to expose restore actions only when the managed guidance is missing or broken
### Getting Started / Onboarding
@@ -697,7 +706,7 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte
`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
- Uses `get_ai_agents_status`, whose backend treats the app process path, login-shell path, and supported local/toolchain/app install locations, including Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources
- Uses `get_ai_agents_status`, whose backend treats the app process path, login-shell path, and supported local/toolchain/app install locations, including nvm-managed Node installs plus Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources
- Persists dismissal locally once the user continues
### Remote Git Operations
@@ -743,9 +752,9 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
- **`useTelemetry(settings, loaded)`** — Reactively initializes/tears down Sentry and PostHog based on settings. Called once in `App`.
### Libraries
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/release/key from `VITE_SENTRY_DSN`, `VITE_SENTRY_RELEASE`, and `VITE_POSTHOG_KEY` env vars.
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/key from `VITE_SENTRY_DSN` and `VITE_POSTHOG_KEY`; `VITE_SENTRY_RELEASE` is treated as the build version and only becomes Sentry's `release` for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds tag `tolaria.build_version` and `tolaria.release_kind` without creating normal Sentry Releases entries.
- **`src/main.tsx`** — React root error callbacks (`onCaughtError`, `onUncaughtError`, `onRecoverableError`) forward component-stack context to `Sentry.reactErrorHandler()` for debuggable production React errors.
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes. `reinit_sentry()` for runtime toggle.
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes; stable calendar `CARGO_PKG_VERSION` values become Sentry releases, while alpha/prerelease/internal versions are kept as diagnostic tags only. `reinit_sentry()` for runtime toggle.
### Tauri Commands
- **`reinit_telemetry`** — Re-reads settings and toggles Rust Sentry on/off. Called from frontend when user changes crash reporting setting.
@@ -771,6 +780,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
### CI/CD
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`.
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.

View File

@@ -186,7 +186,7 @@ flowchart TD
└──────────────────────────────────────────────────────────────┘
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the handle, while keyboard users can use explicit move buttons or command-palette move actions. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. 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, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image binaries get an image indicator and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). 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 and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide-screen left-aligned note column while preserving the same readable max width. Binary image files render through `FilePreview` as ordinary vault files using Tauri asset URLs, with explicit unsupported/broken fallback states and keyboard focus returning to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. 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 (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).
@@ -224,12 +224,12 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration.
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, and default-agent selection
2. **Backend** (`ai_agents.rs`) — normalizes agent availability and streaming, dispatching to per-agent adapters
3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, a file/search-only built-in tool list, hidden Windows subprocess launches, and closed stdin for print-mode subprocesses so Windows launches receive EOF; Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json`; OpenCode runs through `opencode run --format json`; Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`. OpenCode and Pi both launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-agent selection, and the per-vault Safe / Power User permission mode shown in the panel header
2. **Backend** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters
3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools, while Power User adds Bash without using dangerous bypass flags. Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json` in both modes. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config. OpenCode and Pi both launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
4. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, and Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs.
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs.
#### Agent Event Flow
@@ -243,7 +243,7 @@ sequenceDiagram
U->>FE: sendMessage(text, references)
FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs)
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath})
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath, permissionMode})
R->>R: pick adapter for claude_code, codex, opencode, or pi
R->>C: spawn agent with MCP-enabled config
@@ -294,7 +294,7 @@ Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existi
## MCP Server
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Cursor, or any MCP-compatible client).
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Gemini CLI, Cursor, or any MCP-compatible client).
### Tool Surface (14 tools)
@@ -326,10 +326,11 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
Tolaria can register itself as an MCP server in:
- `~/.claude.json` and `~/.claude/mcp.json` (Claude Code compatibility across current CLI and legacy MCP-file setups)
- `~/.gemini/settings.json` (Gemini CLI)
- `~/.cursor/mcp.json` (Cursor)
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`). The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`.
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria only writes the MCP entry and optional vault guidance shim. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`.
### Architecture
@@ -376,8 +377,9 @@ flowchart LR
|----------|---------|
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
| `sync_mcp_bridge_vault(vault_path?)` | Starts, restarts, or stops the desktop WebSocket bridge as the selected vault changes |
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Cursor, and generic MCP configs on user request |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Cursor, and generic MCP configs |
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Gemini CLI, Cursor, and generic MCP configs on user request |
| `mcp_config_snippet(vault_path)` | Builds the exact `mcpServers.tolaria` JSON users can copy into any compatible client without writing third-party config files |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Gemini CLI, Cursor, and generic MCP configs |
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is replaced on vault switches, stopped when no active vault is selected, and killed on app exit via the `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
@@ -476,7 +478,7 @@ Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnbo
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions, and Tolaria seeds it as an organized `Note` so it stays out of the way in a fresh vault. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions, and Tolaria seeds it as an organized `Note` so it stays out of the way in a fresh vault. Optional `GEMINI.md` guidance is created only by the explicit AI guidance restore action. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
After the clone completes, Tolaria removes every configured git remote from the new starter vault. Getting Started vaults therefore open as local-only by default, and users opt into a remote later with the explicit Add Remote flow.
@@ -625,7 +627,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
| `image.rs` | `save_image` / `copy_image_to_vault` — save editor image attachments with sanitized filenames |
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md`, `CLAUDE.md`, and optional `GEMINI.md` shims), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
| `getting_started.rs` | Clones and normalizes the public Getting Started starter vault |
## Rust Backend Modules
@@ -670,7 +672,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `check_vault_exists` | Check if vault path exists |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `type.md`, and `note.md` defaults |
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean |
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md` and the `CLAUDE.md` shim are managed, missing, broken, or custom |
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md`, `CLAUDE.md`, and optional `GEMINI.md` guidance are managed, missing, broken, or custom |
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |
### Frontmatter
@@ -724,9 +726,10 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `check_claude_cli` | Check if Claude CLI is available |
| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi availability |
| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, or Pi through the normalized agent event layer |
| `register_mcp_tools` | Register MCP in Claude/Cursor/generic config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor/generic config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor/generic config |
| `register_mcp_tools` | Register MCP in Claude/Gemini/Cursor/generic config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Gemini/Cursor/generic config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Gemini/Cursor/generic config |
| `get_mcp_config_snippet` | Return the exact manual MCP JSON snippet for the active vault |
| `sync_mcp_bridge_vault` | Sync the desktop WebSocket bridge process to the selected vault, or stop it when no vault is selected |
The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly.
@@ -785,14 +788,14 @@ 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 and theme-mode bridge | Editor typography and app theme runtime |
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline |
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline and vault permission mode |
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
| `useAutoGit` | Last activity timestamp, idle/inactive checkpoint triggers | Automatic commit/push checkpoints |
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
| `useVaultConfig` | Per-vault UI preferences, AI permission mode | Vault-specific config |
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`.
@@ -942,7 +945,7 @@ sequenceDiagram
**Architecture:**
- **Rust:** `sentry` crate initialized in `lib.rs::setup()` via `telemetry::init_sentry_from_settings()`
- **JS:** `@sentry/react` + `posthog-js` initialized lazily by `useTelemetry` hook; the React root also wires `onCaughtError`, `onUncaughtError`, and `onRecoverableError` through `Sentry.reactErrorHandler()` so production React invariants include component stack context when crash reporting is enabled.
- **Release grouping:** packaged release workflows pass `VITE_SENTRY_RELEASE` from the computed build version so frontend Sentry events group by the shipped alpha or stable version.
- **Release grouping:** packaged release workflows pass `VITE_SENTRY_RELEASE` from the computed build version, but the app only assigns Sentry's `release` field for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds omit `release` so they do not create normal Sentry Releases entries, while both frontend and Rust Sentry scopes tag `tolaria.build_version` and `tolaria.release_kind` for diagnostics.
- **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct
- **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null`

View File

@@ -79,7 +79,7 @@ tolaria/
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent)
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent + per-vault permission mode)
│ │ ├── AiMessage.tsx # Agent message display
│ │ ├── AiActionCard.tsx # Agent tool action cards
│ │ ├── AiAgentsOnboardingPrompt.tsx # First-launch AI agent installer prompt
@@ -114,6 +114,7 @@ tolaria/
│ │ ├── useNoteCreation.ts # Note/type creation
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline
│ │ ├── aiAgentPermissionMode.ts # Safe/Power User mode normalization + labels
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi availability polling
│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling
│ │ ├── useAiActivity.ts # MCP UI bridge listener
@@ -261,8 +262,8 @@ 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, add-remote). |
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, adapter dispatch, and stream normalization. |
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, adapter dispatch, stream normalization, and permission-mode request normalization. |
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning, permission-mode tool mapping, and NDJSON stream parsing. |
| `src-tauri/src/pi_cli.rs` | Pi subprocess spawning through JSON mode and transient MCP adapter config. |
| `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. |
@@ -282,9 +283,10 @@ tolaria/
| File | Why it matters |
|------|---------------|
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, and actions. |
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, actions, and per-vault permission mode. |
| `src/hooks/useCliAiAgent.ts` | Thin React owner for the selected CLI agent session state. |
| `src/lib/aiAgentSession.ts` | Single message/session lifecycle for prompt normalization, history, streaming, and reset behavior. |
| `src/lib/aiAgentPermissionMode.ts` | Safe/Power User mode normalization, display labels, and local transcript marker text. |
| `src/lib/aiAgentFileOperations.ts` | Detects agent-created or modified vault files from normalized tool inputs. |
| `src/lib/aiAgents.ts` | Supported agent definitions, status normalization, and default-agent helpers. |
| `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. |
@@ -304,7 +306,7 @@ tolaria/
| `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/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. |
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow). |
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow, AI permission mode). |
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, default AI agent, and the vault-level explicit organization toggle. |
| `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. |
@@ -420,11 +422,13 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
1. **Agent system prompt**: Edit `src/utils/ai-agent.ts` (inline system prompt string)
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()`; keep app-managed launches on strict Tolaria MCP config, `acceptEdits`, and the scoped file/search tool list)
5. **Shared agent adapters / Codex/Pi args**: Edit `src-tauri/src/ai_agents.rs` plus the per-agent adapter modules (keep Codex sandboxed with active-vault `workspace-write`, keep Pi on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode)
4. **Permission-mode UI and request plumbing**: Edit `src/lib/aiAgentPermissionMode.ts`, `src/components/AiPanel*.tsx`, `src/hooks/useCliAiAgent.ts`, and `src/utils/streamAiAgent.ts`
5. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`; keep app-managed launches on strict Tolaria MCP config, `acceptEdits`, and the scoped file/search tool list unless the permission mode explicitly broadens it)
6. **Shared agent adapters / Codex/Pi args**: Edit `src-tauri/src/ai_agents.rs` plus the per-agent adapter modules (keep Codex sandboxed with active-vault `workspace-write`, keep Pi on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode)
### Work with external MCP setup
1. **Backend registration/status**: Edit `src-tauri/src/mcp.rs`; registration must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and write an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx`; users should see the Node.js prerequisite and the manual config shape before Tolaria writes third-party config files
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes
4. **Gemini CLI compatibility**: Keep `~/.gemini/settings.json` in the registration path list and keep optional `GEMINI.md` generation behind `restore_vault_ai_guidance`; Gemini itself still needs its own install and sign-in outside Tolaria

View File

@@ -0,0 +1,35 @@
---
type: ADR
id: "0091"
title: "Gemini CLI external AI setup"
status: active
date: 2026-04-28
---
## Context
Tolaria already supports explicit MCP setup for external desktop AI tools. Users asked for Gemini CLI support so the same active-vault MCP server can be registered where Gemini reads tool configuration, with optional Gemini-specific vault guidance.
Gemini CLI reads MCP server definitions from `~/.gemini/settings.json` and can load project guidance from `GEMINI.md`. Tolaria must support those conventions without silently rewriting global user settings or overwriting user-authored vault instructions.
## Decision
Tolaria adds `~/.gemini/settings.json` to the explicit external AI setup path list. The existing MCP entry shape is reused: `mcpServers.tolaria` runs the packaged stdio server through Node.js, pins `VAULT_PATH` to the selected vault, and sets `WS_UI_PORT=9711`.
Vault guidance keeps `AGENTS.md` as the canonical shared source. `restore_vault_ai_guidance` can create or repair a managed `GEMINI.md` shim that imports `AGENTS.md`, while bootstrap and repair flows continue to seed only required Tolaria guidance (`AGENTS.md` and `CLAUDE.md`) plus type scaffolding. Custom `GEMINI.md` files are classified as custom and are not overwritten.
The setup dialog documents that Gemini CLI still needs its own install and sign-in. Tolaria does not store Gemini credentials or model-provider API keys.
## Options Considered
- **Add Gemini to explicit MCP setup and optional guidance restore** (chosen): matches the existing consent boundary, preserves user settings, and gives Gemini the shared vault context.
- **Generate `GEMINI.md` automatically for every vault**: simpler discovery, but turns an optional third-party compatibility file into startup side effect and dirties vaults for users who do not use Gemini.
- **Document manual Gemini setup only**: avoids code changes, but leaves users to transpose paths and loses the active-vault safety already available for other MCP clients.
- **Create a Gemini-specific MCP entry shape**: unnecessary because Gemini accepts the same `mcpServers` entry structure used by the existing Tolaria MCP server.
## Consequences
- External AI setup now writes/removes Tolaria's MCP entry in Claude, Gemini, Cursor, and generic MCP config files.
- Gemini users can create a managed `GEMINI.md` shim without duplicating the canonical `AGENTS.md` guidance.
- Existing vault bootstrap and repair remain non-invasive for users who do not use Gemini.
- Native QA requires a local Gemini CLI install and authentication for an end-to-end Gemini prompt; otherwise the app can still verify the generated config and documentation paths.

View File

@@ -0,0 +1,39 @@
---
type: ADR
id: "0092"
title: "Vault-scoped AI agent permission modes"
status: active
date: 2026-04-28
---
## Context
ADR-0074 established explicit setup and least-privilege defaults for desktop AI tools. The in-app AI panel now supports multiple local CLI agents, and users need a clear per-vault way to choose whether an agent should stay in the narrow vault-safe profile or use broader local-work tools for that vault.
The mode must be visible at the point of use, must not mutate global CLI settings, and must not silently restore dangerous bypass flags. Existing transcripts should remain intact when the mode changes because a change applies to the next agent run, not to a process that is already streaming.
## Decision
**Tolaria stores an `ai_agent_permission_mode` per vault with values `safe` and `power_user`, defaulting missing or null values to `safe`, and passes that normalized mode through the AI panel stream request into each CLI adapter.**
The AI panel header displays the current mode and offers a compact Vault Safe / Power User control that is disabled while an agent run is active. Changing the mode preserves the transcript and inserts a local transcript marker.
Adapter mappings remain conservative:
- Claude Code Safe keeps `acceptEdits`, strict Tolaria MCP config, and file/search/edit tools only; Power User adds Bash to the allowed tool list without using `--dangerously-skip-permissions`.
- Codex keeps the active-vault `workspace-write` sandbox and `--ask-for-approval never` in both modes.
- OpenCode uses transient `OPENCODE_CONFIG_CONTENT`; Safe denies bash and external directories, while Power User allows bash but still denies external directories.
- Pi receives the mode on the adapter request path; both modes currently use the same transient MCP adapter config.
## Options Considered
- **Per-vault Safe / Power User modes** (chosen): makes the permission surface explicit where the agent is used and preserves least-privilege defaults for each vault.
- **Global app setting**: simpler storage, but a single toggle can over-apply a power-user profile to unrelated vaults.
- **Dangerous bypass mode**: maximizes CLI freedom, but violates ADR-0074's least-privilege boundary and needs a separate explicit security decision.
- **Adapter-specific UI switches**: exposes too much implementation detail and makes cross-agent behavior harder to reason about.
## Consequences
- Vault config normalization owns the safe default for old vaults and malformed values.
- Agent requests now carry a permission mode through frontend and Rust boundaries, so new adapters must choose an explicit mapping.
- Power User is intentionally not equivalent across agents; where an adapter lacks a safe broader local-work switch, both modes may map to the same conservative behavior and must document that with tests.
- Any future dangerous mode requires a new ADR and separate UI language.

View File

@@ -142,3 +142,6 @@ proposed → active → superseded
| [0085](0085-non-git-vault-support.md) | Non-git vaults open with explicit later Git initialization | active |
| [0088](0088-markdown-durable-mermaid-diagrams.md) | Markdown-durable Mermaid diagrams in notes | active |
| [0089](0089-active-vault-filesystem-watcher.md) | Active vault filesystem watcher | active |
| [0090](0090-pi-cli-agent-adapter.md) | Pi CLI agent adapter | active |
| [0091](0091-gemini-cli-external-ai-setup.md) | Gemini CLI external AI setup | active |
| [0092](0092-vault-ai-agent-permission-modes.md) | Vault-scoped AI agent permission modes | active |

View File

@@ -17,7 +17,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "node scripts/run-vitest-coverage.mjs",

View File

@@ -17,6 +17,7 @@
"dialog:default",
"updater:default",
"process:default",
"opener:default"
"opener:default",
"opener:allow-open-path"
]
}

View File

@@ -12,6 +12,14 @@ pub enum AiAgentId {
Pi,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum AiAgentPermissionMode {
#[default]
Safe,
PowerUser,
}
#[derive(Debug, Clone, Serialize)]
pub struct AiAgentAvailability {
pub installed: bool,
@@ -61,6 +69,13 @@ pub struct AiAgentStreamRequest {
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: String,
pub permission_mode: Option<AiAgentPermissionMode>,
}
impl AiAgentStreamRequest {
fn permission_mode(&self) -> AiAgentPermissionMode {
self.permission_mode.unwrap_or_default()
}
}
pub fn get_ai_agents_status() -> AiAgentsStatus {
@@ -76,12 +91,14 @@ pub fn run_ai_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Res
where
F: FnMut(AiAgentStreamEvent),
{
let permission_mode = request.permission_mode();
match request.agent {
AiAgentId::ClaudeCode => {
let mapped = crate::claude_cli::AgentStreamRequest {
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
permission_mode,
};
crate::claude_cli::run_agent_stream(mapped, |event| {
if let Some(mapped_event) = map_claude_event(event) {
@@ -95,6 +112,7 @@ where
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
permission_mode,
};
crate::opencode_cli::run_agent_stream(mapped, emit)
}
@@ -103,6 +121,7 @@ where
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
permission_mode,
};
crate::pi_cli::run_agent_stream(mapped, emit)
}
@@ -229,7 +248,7 @@ fn codex_binary_candidates() -> Vec<PathBuf> {
}
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
let mut candidates = vec![
home.join(".local/bin/codex"),
home.join(".codex/bin/codex"),
home.join(".local/share/mise/shims/codex"),
@@ -240,22 +259,50 @@ fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
PathBuf::from("/usr/local/bin/codex"),
PathBuf::from("/opt/homebrew/bin/codex"),
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
]
];
candidates.extend(nvm_node_binary_candidates_for_home(home, "codex"));
candidates
}
fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
return Vec::new();
};
let mut candidates = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.map(|path| path.join("bin").join(binary_name))
.collect::<Vec<_>>();
candidates.sort();
candidates
}
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>
fn run_codex_agent_stream<F>(request: AiAgentStreamRequest, emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = find_codex_binary()?;
run_codex_agent_stream_with_binary(&binary, request, emit)
}
fn run_codex_agent_stream_with_binary<F>(
binary: &Path,
request: AiAgentStreamRequest,
mut emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let args = build_codex_args(&request)?;
let prompt = build_codex_prompt(&request);
let mut command = build_codex_command(&binary, args, prompt, &request.vault_path);
let mut command = build_codex_command(binary, args, prompt, &request.vault_path);
let mut child = command
.spawn()
@@ -501,13 +548,85 @@ mod tests {
use super::*;
use std::ffi::OsStr;
#[cfg(unix)]
fn executable_script(dir: &Path, name: &str, body: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let script = dir.join(name);
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
script
}
fn codex_request(
vault_path: &Path,
permission_mode: Option<AiAgentPermissionMode>,
) -> AiAgentStreamRequest {
AiAgentStreamRequest {
agent: AiAgentId::Codex,
message: "Summarize".into(),
system_prompt: None,
vault_path: vault_path.to_string_lossy().into_owned(),
permission_mode,
}
}
fn assert_codex_workspace_write_contract(args: &[String]) {
let prefix = [
"--sandbox",
"workspace-write",
"--ask-for-approval",
"never",
];
assert_eq!(&args[..prefix.len()], prefix);
assert!(!args.iter().any(|arg| arg == "danger-full-access"));
assert!(!args
.iter()
.any(|arg| arg == "--dangerously-bypass-approvals-and-sandbox"));
}
#[cfg(unix)]
fn run_codex_script(body: &str) -> (String, Vec<AiAgentStreamEvent>) {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(dir.path(), "codex", body);
let mut events = Vec::new();
let thread_id = run_codex_agent_stream_with_binary(
&binary,
codex_request(vault.path(), Some(AiAgentPermissionMode::Safe)),
|event| events.push(event),
)
.unwrap();
(thread_id, events)
}
fn assert_codex_text_flow(events: &[AiAgentStreamEvent], session: &str, text_delta: &str) {
assert!(matches!(
&events[0],
AiAgentStreamEvent::Init { session_id } if session_id == session
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::TextDelta { text } if text == text_delta
));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
#[test]
fn normalize_status_contains_all_agents() {
let status = get_ai_agents_status();
assert!(matches!(status.claude_code.installed, true | false));
assert!(matches!(status.codex.installed, true | false));
assert!(matches!(status.opencode.installed, true | false));
assert!(matches!(status.pi.installed, true | false));
let install_flags = [
status.claude_code.installed,
status.codex.installed,
status.opencode.installed,
status.pi.installed,
];
assert!(install_flags
.iter()
.all(|installed| matches!(installed, true | false)));
}
#[test]
@@ -517,6 +636,7 @@ mod tests {
message: "Rename the note".into(),
system_prompt: Some("Be concise".into()),
vault_path: "/tmp/vault".into(),
permission_mode: Some(AiAgentPermissionMode::Safe),
});
assert!(prompt.starts_with("System instructions:\nBe concise"));
@@ -530,19 +650,33 @@ mod tests {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: None,
}) {
assert_eq!(args[0], "--sandbox");
assert_eq!(args[1], "workspace-write");
assert_eq!(args[2], "--ask-for-approval");
assert_eq!(args[3], "never");
assert_eq!(args[4], "exec");
assert!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
assert!(!args.contains(&"danger-full-access".to_string()));
assert_codex_workspace_write_contract(&args);
assert!(args.contains(&"--json".to_string()));
assert!(args.contains(&"-C".to_string()));
}
}
#[test]
fn codex_permission_modes_keep_workspace_write_without_dangerous_bypass() {
for permission_mode in [
AiAgentPermissionMode::Safe,
AiAgentPermissionMode::PowerUser,
] {
if let Ok(args) = build_codex_args(&AiAgentStreamRequest {
agent: AiAgentId::Codex,
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: Some(permission_mode),
}) {
assert_codex_workspace_write_contract(&args);
}
}
}
#[test]
fn build_codex_command_keeps_agent_process_contract() {
let binary = PathBuf::from("codex");
@@ -562,6 +696,37 @@ mod tests {
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
}
#[cfg(unix)]
#[test]
fn run_codex_agent_stream_reads_ndjson_and_returns_thread_id() {
let (thread_id, events) = run_codex_script(
r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"Done"}}'
"#,
);
assert_eq!(thread_id, "thread_1");
assert_codex_text_flow(&events, "thread_1", "Done");
}
#[cfg(unix)]
#[test]
fn run_codex_agent_stream_reports_nonzero_exit_errors() {
let (thread_id, events) = run_codex_script(
r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
printf '%s\n' 'login required' >&2
exit 2
"#,
);
assert_eq!(thread_id, "thread_1");
assert!(events.iter().any(|event| matches!(
event,
AiAgentStreamEvent::Error { message } if message.contains("not authenticated")
)));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
#[test]
fn codex_binary_candidates_include_supported_macos_installs() {
let home = PathBuf::from("/Users/alex");
@@ -585,6 +750,18 @@ mod tests {
}
}
#[test]
fn codex_binary_candidates_include_nvm_managed_node_installs() {
let home = tempfile::tempdir().unwrap();
let codex = home.path().join(".nvm/versions/node/v22.12.0/bin/codex");
std::fs::create_dir_all(codex.parent().unwrap()).unwrap();
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
let candidates = codex_binary_candidates_for_home(home.path());
assert!(candidates.contains(&codex), "missing {}", codex.display());
}
#[test]
fn first_existing_path_skips_empty_and_missing_lines() {
let dir = tempfile::tempdir().unwrap();

View File

@@ -1,3 +1,4 @@
use crate::ai_agents::AiAgentPermissionMode;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::BufRead;
@@ -56,6 +57,7 @@ pub struct AgentStreamRequest {
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: String,
pub permission_mode: AiAgentPermissionMode,
}
// ---------------------------------------------------------------------------
@@ -148,7 +150,7 @@ fn claude_binary_candidates() -> Vec<PathBuf> {
}
fn claude_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
let mut candidates = vec![
home.join(".local/bin/claude"),
home.join(".local/bin/claude.exe"),
home.join(".claude/local/claude"),
@@ -170,7 +172,24 @@ fn claude_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
home.join("scoop/shims/claude.exe"),
PathBuf::from("/opt/homebrew/bin/claude"),
PathBuf::from("/usr/local/bin/claude"),
]
];
candidates.extend(nvm_node_binary_candidates_for_home(home, "claude"));
candidates
}
fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
return Vec::new();
};
let mut candidates = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.map(|path| path.join("bin").join(binary_name))
.collect::<Vec<_>>();
candidates.sort();
candidates
}
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
@@ -273,7 +292,7 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
"--permission-mode".into(),
"acceptEdits".into(),
"--tools".into(),
"Read,Edit,MultiEdit,Write,Glob,Grep,LS".into(),
agent_tools(req.permission_mode).into(),
"--no-session-persistence".into(),
];
@@ -287,6 +306,13 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
Ok(args)
}
fn agent_tools(permission_mode: AiAgentPermissionMode) -> &'static str {
match permission_mode {
AiAgentPermissionMode::Safe => "Read,Edit,MultiEdit,Write,Glob,Grep,LS",
AiAgentPermissionMode::PowerUser => "Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash",
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
@@ -314,6 +340,8 @@ struct StreamState {
tool_inputs: HashMap<String, String>,
/// The tool_use id of the block currently being streamed.
current_tool_id: Option<String>,
/// Tracks whether response text has already been emitted for this run.
emitted_text: bool,
}
/// Core subprocess runner shared by chat and agent modes.
@@ -339,6 +367,7 @@ where
session_id: String::new(),
tool_inputs: HashMap::new(),
current_tool_id: None,
emitted_text: false,
};
for line in reader.lines() {
@@ -473,7 +502,15 @@ where
if !sid.is_empty() {
state.session_id = sid.clone();
}
let text = json["result"].as_str().unwrap_or("").to_string();
let text = if state.emitted_text {
String::new()
} else {
let text = json["result"].as_str().unwrap_or("").to_string();
if !text.is_empty() {
state.emitted_text = true;
}
text
};
emit(ClaudeStreamEvent::Result {
text,
session_id: sid,
@@ -483,19 +520,9 @@ where
// --- Complete assistant message (fallback for text when no partials) ---
"assistant" => {
if let Some(content) = json["message"]["content"].as_array() {
let emit_text = !state.emitted_text;
for block in content {
if block["type"].as_str() == Some("tool_use") {
if let (Some(id), Some(name)) =
(block["id"].as_str(), block["name"].as_str())
{
let input = format_tool_input(&block["input"], state, id);
emit(ClaudeStreamEvent::ToolStart {
tool_name: name.to_string(),
tool_id: id.to_string(),
input,
});
}
}
dispatch_assistant_content_block(block, emit_text, state, emit);
}
}
}
@@ -518,9 +545,7 @@ where
match delta["type"].as_str() {
Some("text_delta") => {
if let Some(text) = delta["text"].as_str() {
emit(ClaudeStreamEvent::TextDelta {
text: text.to_string(),
});
emit_text_delta(text, state, emit);
}
}
Some("thinking_delta") => {
@@ -565,6 +590,46 @@ where
}
}
fn dispatch_assistant_content_block<F>(
block: &serde_json::Value,
emit_text: bool,
state: &mut StreamState,
emit: &mut F,
) where
F: FnMut(ClaudeStreamEvent),
{
match block["type"].as_str() {
Some("text") if emit_text => {
if let Some(text) = block["text"].as_str() {
emit_text_delta(text, state, emit);
}
}
Some("tool_use") => {
if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) {
let input = format_tool_input(&block["input"], state, id);
emit(ClaudeStreamEvent::ToolStart {
tool_name: name.to_string(),
tool_id: id.to_string(),
input,
});
}
}
_ => {}
}
}
fn emit_text_delta<F>(text: &str, state: &mut StreamState, emit: &mut F)
where
F: FnMut(ClaudeStreamEvent),
{
if !text.is_empty() {
state.emitted_text = true;
}
emit(ClaudeStreamEvent::TextDelta {
text: text.to_string(),
});
}
/// Build the tool input string, preferring accumulated delta chunks over the
/// block's `input` field (which may be empty at stream start).
fn format_tool_input(
@@ -607,6 +672,82 @@ mod tests {
use std::ffi::OsString;
use std::process::Command;
macro_rules! chat_request {
($message:expr, None, None $(,)?) => {
ChatStreamRequest {
message: $message.into(),
system_prompt: None,
session_id: None,
}
};
($message:expr, Some($system_prompt:expr), None $(,)?) => {
ChatStreamRequest {
message: $message.into(),
system_prompt: Some($system_prompt.to_string()),
session_id: None,
}
};
($message:expr, None, Some($session_id:expr) $(,)?) => {
ChatStreamRequest {
message: $message.into(),
system_prompt: None,
session_id: Some($session_id.to_string()),
}
};
}
macro_rules! agent_request {
($message:expr, None, $permission_mode:expr $(,)?) => {
AgentStreamRequest {
message: $message.into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: $permission_mode,
}
};
($message:expr, Some($system_prompt:expr), $permission_mode:expr $(,)?) => {
AgentStreamRequest {
message: $message.into(),
system_prompt: Some($system_prompt.to_string()),
vault_path: "/tmp/vault".into(),
permission_mode: $permission_mode,
}
};
}
macro_rules! assert_args_contain {
($args:expr, [$($value:expr),+ $(,)?] $(,)?) => {
$(
assert!($args.contains(&$value.to_string()), "missing {}", $value);
)+
};
}
macro_rules! assert_args_lack {
($args:expr, [$($value:expr),+ $(,)?] $(,)?) => {
$(
assert!(!$args.contains(&$value.to_string()), "unexpected {}", $value);
)+
};
}
macro_rules! assert_no_arg_contains {
($args:expr, $fragment:expr $(,)?) => {
assert!(!$args.iter().any(|arg| arg.contains($fragment)));
};
}
fn assert_binary_candidates_include(home: &Path, expected: &[PathBuf]) {
let candidates = claude_binary_candidates_for_home(home);
for candidate in expected {
assert!(
candidates.contains(candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn check_cli_returns_status() {
let status = check_cli();
@@ -617,6 +758,50 @@ mod tests {
}
}
#[test]
fn claude_binary_candidates_include_nvm_managed_node_installs() {
let home = tempfile::tempdir().unwrap();
let claude = home.path().join(".nvm/versions/node/v22.12.0/bin/claude");
std::fs::create_dir_all(claude.parent().unwrap()).unwrap();
std::fs::write(&claude, "#!/bin/sh\n").unwrap();
let candidates = claude_binary_candidates_for_home(home.path());
assert!(candidates.contains(&claude), "missing {}", claude.display());
}
#[test]
fn agent_args_use_safe_mode_without_bash_by_default() {
let args = build_agent_args(&agent_request!(
"Rename the note",
None,
AiAgentPermissionMode::Safe,
))
.unwrap();
assert_args_contain!(
args,
["--strict-mcp-config", "--permission-mode", "acceptEdits"]
);
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]);
assert_no_arg_contains!(args, "Bash");
assert_args_lack!(args, ["--dangerously-skip-permissions"]);
}
#[test]
fn agent_args_allow_bash_in_power_user_mode_without_dangerous_bypass() {
let args = build_agent_args(&agent_request!(
"Rename the note",
None,
AiAgentPermissionMode::PowerUser,
))
.unwrap();
assert_args_contain!(args, ["--strict-mcp-config"]);
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash"]);
assert_args_lack!(args, ["--dangerously-skip-permissions"]);
}
#[test]
fn build_mcp_config_is_valid_json() {
if let Ok(config_str) = build_mcp_config("/tmp/test-vault") {
@@ -636,6 +821,7 @@ mod tests {
session_id: String::new(),
tool_inputs: HashMap::new(),
current_tool_id: None,
emitted_text: false,
}
}
@@ -765,10 +951,35 @@ mod tests {
{ "type": "tool_use", "id": "tu_1", "name": "search_notes", "input": {} }
] }
}));
assert_eq!(events.len(), 1);
assert_eq!(events.len(), 2);
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1")
matches!(&events[0], ClaudeStreamEvent::TextDelta { text } if text == "Let me search.")
);
assert!(
matches!(&events[1], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1")
);
}
#[test]
fn dispatch_event_result_after_text_delta_does_not_duplicate_response_text() {
let (state, events) = run_dispatch_sequence(vec![
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_delta", "delta": { "type": "text_delta", "text": "Visible reply" } }
}),
serde_json::json!({
"type": "result",
"session_id": "session-1",
"result": "Visible reply"
}),
]);
assert_eq!(state.session_id, "session-1");
assert!(matches!(&events[..],
[
ClaudeStreamEvent::TextDelta { text },
ClaudeStreamEvent::Result { text: result_text, session_id },
] if text == "Visible reply" && result_text.is_empty() && session_id == "session-1"));
}
#[test]
@@ -1142,52 +1353,31 @@ mod tests {
#[test]
fn build_chat_args_basic() {
let req = ChatStreamRequest {
message: "hello".into(),
system_prompt: None,
session_id: None,
};
let args = build_chat_args(&req);
assert!(args.contains(&"-p".to_string()));
assert!(args.contains(&"hello".to_string()));
assert!(args.contains(&"stream-json".to_string()));
assert!(!args.contains(&"--system-prompt".to_string()));
assert!(!args.contains(&"--resume".to_string()));
let args = build_chat_args(&chat_request!("hello", None, None));
assert_args_contain!(args, ["-p", "hello", "stream-json"]);
assert_args_lack!(args, ["--system-prompt", "--resume"]);
}
#[test]
fn build_chat_args_with_system_prompt() {
let req = ChatStreamRequest {
message: "hi".into(),
system_prompt: Some("You are helpful.".into()),
session_id: None,
};
let args = build_chat_args(&req);
assert!(args.contains(&"--system-prompt".to_string()));
assert!(args.contains(&"You are helpful.".to_string()));
let args = build_chat_args(&chat_request!("hi", Some("You are helpful."), None));
assert_args_contain!(args, ["--system-prompt", "You are helpful."]);
}
#[test]
fn build_chat_args_empty_system_prompt_is_skipped() {
let req = ChatStreamRequest {
message: "hi".into(),
system_prompt: Some(String::new()),
session_id: None,
};
let args = build_chat_args(&req);
let args = build_chat_args(&chat_request!("hi", Some(""), None));
assert!(!args.contains(&"--system-prompt".to_string()));
}
#[test]
fn build_chat_args_with_session_id() {
let req = ChatStreamRequest {
message: "continue".into(),
system_prompt: None,
session_id: Some("sess-abc".into()),
};
let args = build_chat_args(&req);
assert!(args.contains(&"--resume".to_string()));
assert!(args.contains(&"sess-abc".to_string()));
let args = build_chat_args(&chat_request!("continue", None, Some("sess-abc")));
assert_args_contain!(args, ["--resume", "sess-abc"]);
}
// --- build_agent_args ---
@@ -1195,46 +1385,44 @@ mod tests {
#[test]
fn build_agent_args_basic() {
// build_agent_args calls build_mcp_config which needs mcp_server_dir
if let Ok(args) = build_agent_args(&AgentStreamRequest {
message: "create note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
}) {
assert!(args.contains(&"-p".to_string()));
assert!(args.contains(&"create note".to_string()));
assert!(args.contains(&"--mcp-config".to_string()));
assert!(args.contains(&"--strict-mcp-config".to_string()));
assert!(args.contains(&"--permission-mode".to_string()));
assert!(args.contains(&"acceptEdits".to_string()));
assert!(args.contains(&"--tools".to_string()));
assert!(args.contains(&"Read,Edit,MultiEdit,Write,Glob,Grep,LS".to_string()));
assert!(!args.contains(&"--dangerously-skip-permissions".to_string()));
assert!(!args.contains(&"bypassPermissions".to_string()));
assert!(!args.contains(&"Bash".to_string()));
assert!(args.contains(&"--no-session-persistence".to_string()));
assert!(!args.contains(&"--append-system-prompt".to_string()));
if let Ok(args) = build_agent_args(&agent_request!(
"create note",
None,
AiAgentPermissionMode::Safe,
)) {
assert_args_contain!(args, ["-p", "create note", "--mcp-config"]);
assert_args_contain!(args, ["--strict-mcp-config", "--permission-mode"]);
assert_args_contain!(args, ["acceptEdits", "--tools"]);
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]);
assert_args_contain!(args, ["--no-session-persistence"]);
assert_args_lack!(
args,
[
"--dangerously-skip-permissions",
"bypassPermissions",
"--append-system-prompt",
],
);
assert_no_arg_contains!(args, "Bash");
}
}
#[test]
fn build_agent_args_with_system_prompt() {
if let Ok(args) = build_agent_args(&AgentStreamRequest {
message: "do it".into(),
system_prompt: Some("Act as expert.".into()),
vault_path: "/tmp/v".into(),
}) {
assert!(args.contains(&"--append-system-prompt".to_string()));
assert!(args.contains(&"Act as expert.".to_string()));
if let Ok(args) = build_agent_args(&agent_request!(
"do it",
Some("Act as expert."),
AiAgentPermissionMode::Safe,
)) {
assert_args_contain!(args, ["--append-system-prompt", "Act as expert."]);
}
}
#[test]
fn build_agent_args_empty_system_prompt_is_skipped() {
if let Ok(args) = build_agent_args(&AgentStreamRequest {
message: "x".into(),
system_prompt: Some(String::new()),
vault_path: "/tmp/v".into(),
}) {
if let Ok(args) =
build_agent_args(&agent_request!("x", Some(""), AiAgentPermissionMode::Safe))
{
assert!(!args.contains(&"--append-system-prompt".to_string()));
}
}
@@ -1244,7 +1432,6 @@ mod tests {
#[test]
fn claude_binary_candidates_include_supported_local_and_toolchain_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = claude_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/claude"),
home.join(".claude/local/claude"),
@@ -1252,32 +1439,19 @@ mod tests {
home.join(".npm-global/bin/claude"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
assert_binary_candidates_include(&home, &expected);
}
#[test]
fn claude_binary_candidates_include_windows_exe_installs() {
let home = PathBuf::from(r"C:\Users\alex");
let candidates = claude_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/claude.exe"),
home.join(".claude/local/claude.exe"),
home.join("AppData/Roaming/npm/claude.cmd"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
assert_binary_candidates_include(&home, &expected);
}
#[test]
@@ -1333,6 +1507,7 @@ mod tests {
message: "test".into(),
system_prompt: Some("sys".into()),
vault_path: "/tmp/nonexistent".into(),
permission_mode: AiAgentPermissionMode::Safe,
};
let mut events = vec![];
let result = run_agent_stream(req, |e| events.push(e));

View File

@@ -145,6 +145,8 @@ pub async fn stream_ai_agent(
mod tests {
use super::*;
use crate::vault::AiGuidanceFileState;
use serde_json::Value;
use std::{fs, path::Path};
#[test]
fn guidance_commands_report_and_restore_vault_guidance_files() {
@@ -154,14 +156,37 @@ mod tests {
let initial = get_vault_ai_guidance_status(vault_path.clone()).unwrap();
assert_eq!(initial.agents_state, AiGuidanceFileState::Missing);
assert_eq!(initial.claude_state, AiGuidanceFileState::Missing);
assert_eq!(initial.gemini_state, AiGuidanceFileState::Missing);
assert!(initial.can_restore);
let restored = restore_vault_ai_guidance(vault_path.clone()).unwrap();
assert_eq!(restored.agents_state, AiGuidanceFileState::Managed);
assert_eq!(restored.claude_state, AiGuidanceFileState::Managed);
assert_eq!(restored.gemini_state, AiGuidanceFileState::Managed);
assert!(!restored.can_restore);
assert!(dir.path().join("AGENTS.md").exists());
assert!(dir.path().join("CLAUDE.md").exists());
assert!(dir.path().join("GEMINI.md").exists());
}
#[test]
fn desktop_capability_allows_opening_restored_guidance_files() {
let capability_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("capabilities")
.join("default.json");
let capability_json = fs::read_to_string(capability_path).unwrap();
let capability: Value = serde_json::from_str(&capability_json).unwrap();
let permissions = capability
.get("permissions")
.and_then(Value::as_array)
.unwrap();
assert!(
permissions
.iter()
.any(|permission| permission.as_str() == Some("opener:allow-open-path")),
"desktop capabilities must allow opener open_path so restored guidance files can be opened"
);
}
}

View File

@@ -131,6 +131,15 @@ pub async fn check_mcp_status(vault_path: String) -> Result<crate::mcp::McpStatu
.map_err(|e| format!("MCP status check failed: {e}"))
}
#[cfg(desktop)]
#[tauri::command]
pub async fn get_mcp_config_snippet(vault_path: String) -> Result<String, String> {
let vault_path = super::expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::mcp::mcp_config_snippet(&vault_path))
.await
.map_err(|e| format!("MCP config task failed: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(
@@ -167,6 +176,12 @@ pub async fn check_mcp_status(_vault_path: String) -> Result<crate::mcp::McpStat
Ok(crate::mcp::McpStatus::NotInstalled)
}
#[cfg(mobile)]
#[tauri::command]
pub async fn get_mcp_config_snippet(_vault_path: String) -> Result<String, String> {
Err("MCP is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(_vault_path: Option<String>) -> Result<String, String> {

View File

@@ -53,6 +53,7 @@ mod tests {
name: "Inbox".to_string(),
icon: None,
color: None,
order: None,
sort: None,
list_properties_display: vec![],
filters: crate::vault::FilterGroup::All(vec![]),

View File

@@ -435,6 +435,7 @@ macro_rules! app_invoke_handler {
commands::register_mcp_tools,
commands::remove_mcp_tools,
commands::check_mcp_status,
commands::get_mcp_config_snippet,
commands::sync_mcp_bridge_vault,
commands::repair_vault,
commands::reinit_telemetry,

View File

@@ -17,30 +17,88 @@ pub enum McpStatus {
/// Find the `node` binary path at runtime.
pub(crate) fn find_node() -> Result<PathBuf, String> {
let output = node_lookup_command()
.output()
.map_err(|e| format!("Failed to locate node on PATH: {e}"))?;
if output.status.success() {
if let Some(path) = first_node_lookup_path(&output.stdout) {
verify_node_version(&path)?;
return Ok(path);
let mut last_error = None;
for path in node_binary_candidates() {
match verify_node_version(&path) {
Ok(()) => return Ok(path),
Err(error) => last_error = Some(error),
}
}
if let Some(path) = fallback_node_path() {
verify_node_version(&path)?;
return Ok(path);
}
Err("node not found in PATH or common install locations".into())
Err(last_error.unwrap_or_else(|| "node not found in PATH or common install locations".into()))
}
fn first_node_lookup_path(stdout: &[u8]) -> Option<PathBuf> {
fn node_binary_candidates() -> Vec<PathBuf> {
let mut candidates = find_node_on_path();
candidates.extend(find_node_in_user_shell());
candidates.extend(fallback_node_paths());
candidates
}
fn find_node_on_path() -> Vec<PathBuf> {
node_lookup_command()
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| node_lookup_paths(&output.stdout))
.unwrap_or_default()
}
fn find_node_in_user_shell() -> Vec<PathBuf> {
user_shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.filter_map(|shell| command_path_from_shell(&shell, "node"))
.collect()
}
fn node_lookup_paths(stdout: &[u8]) -> Vec<PathBuf> {
String::from_utf8_lossy(stdout)
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.collect()
}
fn user_shell_candidates() -> Vec<PathBuf> {
let mut shells = Vec::new();
if let Some(shell) = std::env::var_os("SHELL") {
if !shell.is_empty() {
shells.push(PathBuf::from(shell));
}
}
shells.push(PathBuf::from("/bin/zsh"));
shells.push(PathBuf::from("/bin/bash"));
shells
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
crate::hidden_command(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
if output.status.success() {
first_existing_path(&String::from_utf8_lossy(&output.stdout))
} else {
None
}
}
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
stdout.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
})
}
fn verify_node_version(node: &Path) -> Result<(), String> {
@@ -91,7 +149,7 @@ fn node_lookup_command() -> Command {
command
}
fn fallback_node_path() -> Option<PathBuf> {
fn fallback_node_paths() -> Vec<PathBuf> {
let mut candidates = vec![
PathBuf::from("/opt/homebrew/bin/node"),
PathBuf::from("/usr/local/bin/node"),
@@ -120,24 +178,39 @@ fn fallback_node_path() -> Option<PathBuf> {
}
if let Some(home) = dirs::home_dir() {
candidates.push(home.join(".volta").join("bin").join(node_binary_name()));
let nvm_dir = home.join(".nvm").join("versions").join("node");
if let Ok(entries) = std::fs::read_dir(nvm_dir) {
let mut versions = entries
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.collect::<Vec<_>>();
versions.sort();
versions.reverse();
candidates.extend(
versions
.into_iter()
.map(|version| version.join("bin").join("node")),
);
}
candidates.extend(node_binary_candidates_for_home(&home));
}
candidates.into_iter().find(|path| path.is_file())
candidates
.into_iter()
.filter(|path| path.is_file())
.collect()
}
fn node_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let mut candidates = vec![
home.join(".local/share/mise/shims")
.join(node_binary_name()),
home.join(".mise").join("shims").join(node_binary_name()),
home.join(".asdf").join("shims").join(node_binary_name()),
home.join(".volta").join("bin").join(node_binary_name()),
];
let nvm_dir = home.join(".nvm").join("versions").join("node");
if let Ok(entries) = std::fs::read_dir(nvm_dir) {
let mut versions = entries
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.collect::<Vec<_>>();
versions.sort();
versions.reverse();
candidates.extend(
versions
.into_iter()
.map(|version| version.join("bin").join("node")),
);
}
candidates
}
fn node_binary_name() -> &'static str {
@@ -250,6 +323,7 @@ fn mcp_config_paths_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".claude.json"),
home.join(".claude").join("mcp.json"),
home.join(".gemini").join("settings.json"),
home.join(".cursor").join("mcp.json"),
home.join(".config").join("mcp").join("mcp.json"),
]
@@ -313,6 +387,28 @@ fn build_mcp_entry(node_command: &str, index_js: &str, vault_path: &str) -> serd
})
}
fn build_mcp_config_snippet(entry: &serde_json::Value) -> Result<String, String> {
let mut servers = serde_json::Map::new();
servers.insert(MCP_SERVER_NAME.to_string(), entry.clone());
let config = serde_json::json!({ "mcpServers": servers });
serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize MCP config snippet: {e}"))
}
/// Build the exact MCP config JSON users can copy into compatible tools.
pub fn mcp_config_snippet(vault_path: &str) -> Result<String, String> {
let node = find_node().map_err(|e| {
format!("Node.js 18+ is required on PATH before Tolaria can build MCP config: {e}")
})?;
let server_dir = mcp_server_dir()?;
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
let node_command = node.to_string_lossy().into_owned();
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
build_mcp_config_snippet(&entry)
}
/// Write MCP registration to a list of config file paths.
/// Returns "registered" on first registration, "updated" if already present.
fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf]) -> String {
@@ -502,6 +598,20 @@ mod tests {
write_config_json(config_path, serde_json::json!({ "mcpServers": servers }));
}
struct ExpectedMcpServer<'a> {
index_js: &'a str,
vault_path: &'a str,
}
fn assert_registered_tolaria_server(
config: &serde_json::Value,
expected: ExpectedMcpServer<'_>,
) {
let server = &config["mcpServers"][MCP_SERVER_NAME];
assert_eq!(server["args"][0], expected.index_js);
assert_eq!(server["env"]["VAULT_PATH"], expected.vault_path);
}
fn write_index_js(dir: &Path) -> PathBuf {
let index_js = dir.join("index.js");
std::fs::write(&index_js, "console.log('ok');").unwrap();
@@ -519,14 +629,69 @@ mod tests {
}
#[test]
fn first_node_lookup_path_uses_first_non_empty_line() {
fn build_mcp_config_snippet_wraps_tolaria_server_entry() {
let entry = test_mcp_entry("/path/to/index.js", "/my/vault");
let snippet = build_mcp_config_snippet(&entry).unwrap();
let config: serde_json::Value = serde_json::from_str(&snippet).unwrap();
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/path/to/index.js"
);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
"/my/vault"
);
}
#[test]
fn node_lookup_paths_keep_non_empty_lines_in_order() {
let stdout = b"\nC:\\Program Files\\nodejs\\node.exe\r\nC:\\Other\\node.exe\r\n";
assert_eq!(
first_node_lookup_path(stdout).unwrap(),
PathBuf::from("C:\\Program Files\\nodejs\\node.exe")
node_lookup_paths(stdout),
vec![
PathBuf::from("C:\\Program Files\\nodejs\\node.exe"),
PathBuf::from("C:\\Other\\node.exe"),
]
);
}
#[test]
fn first_existing_path_skips_empty_and_missing_lines() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing-node");
let node = dir.path().join("node");
std::fs::write(&node, "#!/bin/sh\n").unwrap();
let stdout = format!("\n{}\n{}\n", missing.display(), node.display());
assert_eq!(first_existing_path(&stdout), Some(node));
}
#[cfg(unix)]
#[test]
fn command_path_from_shell_finds_node_from_login_shell() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let node = dir.path().join("node");
std::fs::write(&node, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o755)).unwrap();
let shell = dir.path().join("shell");
std::fs::write(
&shell,
format!(
"#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n",
node.display()
),
)
.unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(command_path_from_shell(&shell, "node"), Some(node));
}
#[test]
fn node_major_version_accepts_current_node_output() {
assert_eq!(node_major_version("v24.13.1\n"), Some(24));
@@ -534,6 +699,25 @@ mod tests {
assert_eq!(node_major_version("not-node"), None);
}
#[test]
fn node_binary_candidates_include_shell_managed_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = node_binary_candidates_for_home(&home);
let expected = [
home.join(".local/share/mise/shims/node"),
home.join(".asdf/shims/node"),
home.join(".volta/bin/node"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn mcp_server_dir_candidates_prefer_exe_dir_before_macos_resources() {
let dev_path = Path::new("/repo/mcp-server");
@@ -565,13 +749,12 @@ mod tests {
assert!(!was_update);
let config = read_config(&config_path);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/test/index.js"
);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
"/test/vault"
assert_registered_tolaria_server(
&config,
ExpectedMcpServer {
index_js: "/test/index.js",
vault_path: "/test/vault",
},
);
}
@@ -666,6 +849,35 @@ mod tests {
assert!(config["mcpServers"][MCP_SERVER_NAME].is_object());
}
#[test]
fn upsert_preserves_gemini_settings_json_fields() {
let (_tmp, config_path) = temp_config_path("settings.json");
write_config_json(
&config_path,
serde_json::json!({
"theme": "GitHub",
"mcpServers": {
"other": { "command": "example" }
}
}),
);
let entry = test_mcp_entry("/gemini/index.js", "/gemini-vault");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
let config = read_config(&config_path);
assert!(!was_update);
assert_eq!(config["theme"], "GitHub");
assert_eq!(config["mcpServers"]["other"]["command"], "example");
assert_registered_tolaria_server(
&config,
ExpectedMcpServer {
index_js: "/gemini/index.js",
vault_path: "/gemini-vault",
},
);
}
#[test]
fn upsert_creates_parent_dirs() {
let tmp = tempfile::tempdir().unwrap();
@@ -736,6 +948,7 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let claude_user_cfg = tmp.path().join(".claude.json");
let claude_cfg = tmp.path().join("claude").join("mcp.json");
let gemini_cfg = tmp.path().join(".gemini").join("settings.json");
let cursor_cfg = tmp.path().join("cursor").join("mcp.json");
let generic_cfg = tmp.path().join(".config").join("mcp").join("mcp.json");
let entry = test_mcp_entry("/test/index.js", "/vault");
@@ -745,6 +958,7 @@ mod tests {
&[
claude_user_cfg.clone(),
claude_cfg.clone(),
gemini_cfg.clone(),
cursor_cfg.clone(),
generic_cfg.clone(),
],
@@ -752,14 +966,18 @@ mod tests {
assert!(claude_user_cfg.exists());
assert!(claude_cfg.exists());
assert!(gemini_cfg.exists());
assert!(cursor_cfg.exists());
assert!(generic_cfg.exists());
let raw = std::fs::read_to_string(&claude_user_cfg).unwrap();
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/test/index.js"
assert_registered_tolaria_server(
&config,
ExpectedMcpServer {
index_js: "/test/index.js",
vault_path: "/vault",
},
);
}
@@ -773,6 +991,7 @@ mod tests {
vec![
home.join(".claude.json"),
home.join(".claude").join("mcp.json"),
home.join(".gemini").join("settings.json"),
home.join(".cursor").join("mcp.json"),
home.join(".config").join("mcp").join("mcp.json"),
]

View File

@@ -1,23 +1,36 @@
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
use crate::ai_agents::{AiAgentAvailability, AiAgentPermissionMode, AiAgentStreamEvent};
use std::io::BufRead;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct AgentStreamRequest {
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: String,
pub permission_mode: AiAgentPermissionMode,
}
pub fn check_cli() -> AiAgentAvailability {
crate::opencode_discovery::check_cli()
}
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut emit: F) -> Result<String, String>
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = crate::opencode_discovery::find_binary()?;
let mut command = crate::opencode_config::build_command(&binary, &request)?;
run_agent_stream_with_binary(&binary, request, emit)
}
fn run_agent_stream_with_binary<F>(
binary: &Path,
request: AgentStreamRequest,
mut emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let mut command = crate::opencode_config::build_command(binary, &request)?;
let mut child = command
.spawn()
.map_err(|error| format!("Failed to spawn opencode: {error}"))?;
@@ -56,3 +69,88 @@ where
emit(AiAgentStreamEvent::Done);
Ok(session_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let script = dir.join("opencode");
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
script
}
fn request(vault_path: String) -> AgentStreamRequest {
AgentStreamRequest {
message: "Summarize".into(),
system_prompt: None,
vault_path,
permission_mode: AiAgentPermissionMode::Safe,
}
}
#[cfg(unix)]
#[test]
fn run_agent_stream_maps_opencode_json_events() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' '{"type":"session","sessionID":"open_1"}'
printf '%s\n' '{"type":"message","text":"Done"}'
"#,
);
let mut events = Vec::new();
let session_id = run_agent_stream_with_binary(
&binary,
request(vault.path().to_string_lossy().into_owned()),
|event| events.push(event),
)
.unwrap();
assert_eq!(session_id, "open_1");
assert!(matches!(
&events[0],
AiAgentStreamEvent::Init { session_id } if session_id == "open_1"
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::TextDelta { text } if text == "Done"
));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
#[cfg(unix)]
#[test]
fn run_agent_stream_reports_opencode_nonzero_exit_errors() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' '{"type":"session","sessionID":"open_1"}'
printf '%s\n' 'provider login required' >&2
exit 3
"#,
);
let mut events = Vec::new();
let session_id = run_agent_stream_with_binary(
&binary,
request(vault.path().to_string_lossy().into_owned()),
|event| events.push(event),
)
.unwrap();
assert_eq!(session_id, "open_1");
assert!(events.iter().any(|event| matches!(
event,
AiAgentStreamEvent::Error { message } if message.contains("provider configured")
)));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
}

View File

@@ -1,3 +1,4 @@
use crate::ai_agents::AiAgentPermissionMode;
use crate::opencode_cli::AgentStreamRequest;
use std::path::Path;
use std::process::Stdio;
@@ -12,7 +13,7 @@ pub(crate) fn build_command(
.arg(build_prompt(request))
.env(
"OPENCODE_CONFIG_CONTENT",
build_config(&request.vault_path)?,
build_config(&request.vault_path, request.permission_mode)?,
)
.current_dir(&request.vault_path)
.stdin(Stdio::null())
@@ -40,7 +41,10 @@ fn build_prompt(request: &AgentStreamRequest) -> String {
}
}
fn build_config(vault_path: &str) -> Result<String, String> {
fn build_config(
vault_path: &str,
permission_mode: AiAgentPermissionMode,
) -> Result<String, String> {
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
let mcp_server_path = mcp_server
.to_str()
@@ -49,7 +53,7 @@ fn build_config(vault_path: &str) -> Result<String, String> {
serde_json::to_string(&serde_json::json!({
"$schema": "https://opencode.ai/config.json",
"permission": permission_config(),
"permission": permission_config(permission_mode),
"mcp": {
"tolaria": {
"type": "local",
@@ -62,7 +66,12 @@ fn build_config(vault_path: &str) -> Result<String, String> {
.map_err(|error| format!("Failed to serialize opencode config: {error}"))
}
fn permission_config() -> serde_json::Value {
fn permission_config(permission_mode: AiAgentPermissionMode) -> serde_json::Value {
let bash_permission = match permission_mode {
AiAgentPermissionMode::Safe => "deny",
AiAgentPermissionMode::PowerUser => "allow",
};
serde_json::json!({
"read": "allow",
"edit": "allow",
@@ -70,7 +79,7 @@ fn permission_config() -> serde_json::Value {
"grep": "allow",
"list": "allow",
"external_directory": "deny",
"bash": "deny"
"bash": bash_permission
})
}
@@ -85,6 +94,7 @@ mod tests {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe,
}
}
@@ -118,7 +128,9 @@ mod tests {
#[test]
fn config_includes_permissions_and_tolaria_mcp_server() {
if let Ok(config) = build_config("/tmp/vault") {
if let Ok(config) =
build_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe)
{
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
assert_eq!(json["permission"]["edit"], "allow");
assert_eq!(json["permission"]["external_directory"], "deny");
@@ -136,6 +148,18 @@ mod tests {
}
}
#[test]
fn power_user_config_allows_bash_but_keeps_external_directories_denied() {
if let Ok(config) = build_config(
"/tmp/vault",
crate::ai_agents::AiAgentPermissionMode::PowerUser,
) {
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
assert_eq!(json["permission"]["bash"], "allow");
assert_eq!(json["permission"]["external_directory"], "deny");
}
}
#[test]
fn prompt_keeps_system_prompt_first() {
let prompt = build_prompt(&AgentStreamRequest {

View File

@@ -101,12 +101,8 @@ where
F: FnMut(AiAgentStreamEvent),
{
let tool_id = tool_id(json).unwrap_or("tool").to_string();
let tool_name = json["name"]
.as_str()
.or_else(|| json["tool"].as_str())
.unwrap_or("tool")
.to_string();
let input = json.get("input").map(|input| input.to_string());
let tool_name = tool_name(json).unwrap_or("tool").to_string();
let input = tool_input(json);
emit(AiAgentStreamEvent::ToolStart {
tool_name,
@@ -120,10 +116,7 @@ where
F: FnMut(AiAgentStreamEvent),
{
let tool_id = tool_id(json).unwrap_or("tool").to_string();
let output = json["output"]
.as_str()
.map(|output| output.to_string())
.or_else(|| json.get("result").map(|result| result.to_string()));
let output = tool_output(json);
emit(AiAgentStreamEvent::ToolDone { tool_id, output });
}
@@ -140,24 +133,50 @@ where
}
fn tool_id(json: &serde_json::Value) -> Option<&str> {
json["id"]
.as_str()
.or_else(|| json["toolID"].as_str())
.or_else(|| json["tool_id"].as_str())
first_string_field(
json,
&["id", "toolID", "tool_id", "toolCallID", "toolCallId"],
)
}
fn text_value(json: &serde_json::Value) -> Option<&str> {
json["text"]
.as_str()
.or_else(|| json["content"].as_str())
.or_else(|| json["message"].as_str())
first_string_field(json, &["text", "content", "message"])
}
fn message_value(json: &serde_json::Value) -> Option<&str> {
json["message"]
first_string_field(json, &["message", "error", "text"])
}
fn tool_name(json: &serde_json::Value) -> Option<&str> {
first_string_field(json, &["name", "tool", "toolName"])
}
fn tool_input(json: &serde_json::Value) -> Option<String> {
first_json_field(json, &["input", "args"]).map(|input| input.to_string())
}
fn tool_output(json: &serde_json::Value) -> Option<String> {
first_json_field(json, &["output", "result"]).map(display_json_value)
}
fn first_string_field<'a>(json: &'a serde_json::Value, keys: &[&str]) -> Option<&'a str> {
keys.iter()
.find_map(|key| json[*key].as_str().or_else(|| json["part"][*key].as_str()))
}
fn first_json_field<'a>(
json: &'a serde_json::Value,
keys: &[&str],
) -> Option<&'a serde_json::Value> {
keys.iter()
.find_map(|key| json.get(*key).or_else(|| json["part"].get(*key)))
}
fn display_json_value(value: &serde_json::Value) -> String {
value
.as_str()
.or_else(|| json["error"].as_str())
.or_else(|| json["text"].as_str())
.map(|output| output.to_string())
.unwrap_or_else(|| value.to_string())
}
fn is_auth_error(lower: &str) -> bool {

View File

@@ -1,5 +1,61 @@
use super::*;
fn dispatch_events(events: impl IntoIterator<Item = serde_json::Value>) -> Vec<AiAgentStreamEvent> {
let mut mapped = Vec::new();
for event in events {
dispatch_event(&event, &mut |event| mapped.push(event));
}
mapped
}
struct ToolExpectation<'a> {
tool_id: &'a str,
tool_name: &'a str,
input: Option<&'a str>,
output: Option<&'a str>,
}
fn assert_tool_pair(events: &[AiAgentStreamEvent], expected: ToolExpectation<'_>) {
assert!(matches!(
&events[0],
AiAgentStreamEvent::ToolStart {
tool_name: actual_name,
tool_id: actual_id,
input: actual_input,
} if actual_name == expected.tool_name
&& actual_id == expected.tool_id
&& actual_input.as_deref() == expected.input
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::ToolDone {
tool_id: actual_id,
output: actual_output,
} if actual_id == expected.tool_id && actual_output.as_deref() == expected.output
));
}
#[test]
fn parse_line_reports_read_errors_and_skips_blank_or_invalid_lines() {
let mut events = Vec::new();
let read_error = parse_line(Err(std::io::Error::other("broken pipe")), &mut |event| {
events.push(event)
});
let blank = parse_line(Ok(" ".into()), &mut |event| events.push(event));
let invalid = parse_line(Ok("not json".into()), &mut |event| events.push(event));
assert!(read_error.is_none());
assert!(blank.is_none());
assert!(invalid.is_none());
assert!(matches!(
&events[0],
AiAgentStreamEvent::Error { message } if message.contains("broken pipe")
));
}
#[test]
fn dispatch_maps_session_reasoning_and_text() {
let mut events = Vec::new();
@@ -26,28 +82,90 @@ fn dispatch_maps_session_reasoning_and_text() {
}
#[test]
fn dispatch_maps_tool_events() {
fn dispatch_maps_part_backed_reasoning_and_text() {
let mut events = Vec::new();
let tool_start = serde_json::json!({
"type": "tool_use",
"id": "tool_1",
"name": "read",
"input": { "path": "Note.md" }
let reasoning = serde_json::json!({
"type": "reasoning",
"part": { "type": "reasoning", "text": "Checking links" }
});
let text = serde_json::json!({
"type": "text",
"part": { "type": "text", "text": "Done from OpenCode" }
});
let tool_done = serde_json::json!({ "type": "tool_result", "id": "tool_1", "output": "ok" });
dispatch_event(&tool_start, &mut |event| events.push(event));
dispatch_event(&tool_done, &mut |event| events.push(event));
for event in [reasoning, text] {
dispatch_event(&event, &mut |event| events.push(event));
}
assert!(matches!(
&events[0],
AiAgentStreamEvent::ToolStart { tool_name, tool_id, input }
if tool_name == "read" && tool_id == "tool_1" && input.as_deref() == Some(r#"{"path":"Note.md"}"#)
AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links"
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::ToolDone { tool_id, output }
if tool_id == "tool_1" && output.as_deref() == Some("ok")
AiAgentStreamEvent::TextDelta { text } if text == "Done from OpenCode"
));
}
#[test]
fn dispatch_maps_tool_events() {
let direct = dispatch_events([
serde_json::json!({
"type": "tool_use",
"id": "tool_1",
"name": "read",
"input": { "path": "Note.md" }
}),
serde_json::json!({ "type": "tool_result", "id": "tool_1", "output": "ok" }),
]);
let part_backed = dispatch_events([
serde_json::json!({
"type": "tool_use",
"part": {
"id": "prt_tool_1",
"tool": "read",
"input": { "path": "Note.md" }
}
}),
serde_json::json!({
"type": "tool_result",
"part": {
"id": "prt_tool_1",
"output": "ok"
}
}),
]);
assert_tool_pair(
&direct,
ToolExpectation {
tool_id: "tool_1",
tool_name: "read",
input: Some(r#"{"path":"Note.md"}"#),
output: Some("ok"),
},
);
assert_tool_pair(
&part_backed,
ToolExpectation {
tool_id: "prt_tool_1",
tool_name: "read",
input: Some(r#"{"path":"Note.md"}"#),
output: Some("ok"),
},
);
}
#[test]
fn dispatch_maps_error_events() {
let mut events = Vec::new();
let error = serde_json::json!({ "type": "error", "message": "provider failed" });
dispatch_event(&error, &mut |event| events.push(event));
assert!(matches!(
&events[0],
AiAgentStreamEvent::Error { message } if message == "provider failed"
));
}
@@ -61,3 +179,12 @@ fn format_error_explains_missing_auth_or_provider_setup() {
assert!(message.contains("OpenCode CLI is not authenticated"));
assert!(message.contains("opencode auth login"));
}
#[test]
fn format_error_uses_status_or_first_stderr_lines() {
let empty = format_error(String::new(), "exit status: 2".into());
let truncated = format_error("line 1\nline 2\nline 3\nline 4".into(), "ignored".into());
assert_eq!(empty, "opencode exited with status exit status: 2");
assert_eq!(truncated, "line 1\nline 2\nline 3");
}

View File

@@ -1,27 +1,40 @@
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
use crate::ai_agents::{AiAgentAvailability, AiAgentPermissionMode, AiAgentStreamEvent};
use std::io::BufRead;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct AgentStreamRequest {
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: String,
pub permission_mode: AiAgentPermissionMode,
}
pub fn check_cli() -> AiAgentAvailability {
crate::pi_discovery::check_cli()
}
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut emit: F) -> Result<String, String>
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = crate::pi_discovery::find_binary()?;
run_agent_stream_with_binary(&binary, request, emit)
}
fn run_agent_stream_with_binary<F>(
binary: &Path,
request: AgentStreamRequest,
mut emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let agent_dir = tempfile::Builder::new()
.prefix("tolaria-pi-agent-")
.tempdir()
.map_err(|error| format!("Failed to create Pi config directory: {error}"))?;
let mut command = crate::pi_config::build_command(&binary, &request, agent_dir.path())?;
let mut command = crate::pi_config::build_command(binary, &request, agent_dir.path())?;
let mut child = command
.spawn()
.map_err(|error| format!("Failed to spawn pi: {error}"))?;
@@ -60,3 +73,88 @@ where
emit(AiAgentStreamEvent::Done);
Ok(session_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let script = dir.join("pi");
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
script
}
fn request(vault_path: String) -> AgentStreamRequest {
AgentStreamRequest {
message: "Summarize".into(),
system_prompt: None,
vault_path,
permission_mode: AiAgentPermissionMode::Safe,
}
}
#[cfg(unix)]
#[test]
fn run_agent_stream_maps_pi_json_events() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' '{"type":"session","id":"pi_1"}'
printf '%s\n' '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"Done"}}'
"#,
);
let mut events = Vec::new();
let session_id = run_agent_stream_with_binary(
&binary,
request(vault.path().to_string_lossy().into_owned()),
|event| events.push(event),
)
.unwrap();
assert_eq!(session_id, "pi_1");
assert!(matches!(
&events[0],
AiAgentStreamEvent::Init { session_id } if session_id == "pi_1"
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::TextDelta { text } if text == "Done"
));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
#[cfg(unix)]
#[test]
fn run_agent_stream_reports_pi_nonzero_exit_errors() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' '{"type":"session","id":"pi_1"}'
printf '%s\n' 'api key login required' >&2
exit 4
"#,
);
let mut events = Vec::new();
let session_id = run_agent_stream_with_binary(
&binary,
request(vault.path().to_string_lossy().into_owned()),
|event| events.push(event),
)
.unwrap();
assert_eq!(session_id, "pi_1");
assert!(events.iter().any(|event| matches!(
event,
AiAgentStreamEvent::Error { message } if message.contains("not authenticated")
)));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
}

View File

@@ -1,3 +1,4 @@
use crate::ai_agents::AiAgentPermissionMode;
use crate::pi_cli::AgentStreamRequest;
use std::path::Path;
use std::process::Stdio;
@@ -7,7 +8,7 @@ pub(crate) fn build_command(
request: &AgentStreamRequest,
agent_dir: &Path,
) -> Result<std::process::Command, String> {
write_mcp_config(agent_dir, &request.vault_path)?;
write_mcp_config(agent_dir, &request.vault_path, request.permission_mode)?;
let mut command = crate::hidden_command(binary);
command
@@ -46,15 +47,22 @@ fn build_prompt(request: &AgentStreamRequest) -> String {
}
}
fn write_mcp_config(agent_dir: &Path, vault_path: &str) -> Result<(), String> {
fn write_mcp_config(
agent_dir: &Path,
vault_path: &str,
permission_mode: AiAgentPermissionMode,
) -> Result<(), String> {
std::fs::create_dir_all(agent_dir)
.map_err(|error| format!("Failed to create Pi agent directory: {error}"))?;
let config = build_mcp_config(vault_path)?;
let config = build_mcp_config(vault_path, permission_mode)?;
std::fs::write(agent_dir.join("mcp.json"), config)
.map_err(|error| format!("Failed to write Pi MCP config: {error}"))
}
fn build_mcp_config(vault_path: &str) -> Result<String, String> {
fn build_mcp_config(
vault_path: &str,
_permission_mode: AiAgentPermissionMode,
) -> Result<String, String> {
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
let mcp_server_path = mcp_server
.to_str()
@@ -93,6 +101,7 @@ mod tests {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe,
}
}
@@ -129,7 +138,9 @@ mod tests {
#[test]
fn mcp_config_includes_tolaria_server_for_active_vault() {
if let Ok(config) = build_mcp_config("/tmp/vault") {
if let Ok(config) =
build_mcp_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe)
{
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
assert_eq!(json["settings"]["toolPrefix"], "none");
assert_eq!(json["mcpServers"]["tolaria"]["command"], "node");
@@ -147,6 +158,19 @@ mod tests {
}
}
#[test]
fn power_user_mode_uses_the_same_pi_mcp_config_as_safe_mode() {
let safe =
build_mcp_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe).unwrap();
let power = build_mcp_config(
"/tmp/vault",
crate::ai_agents::AiAgentPermissionMode::PowerUser,
)
.unwrap();
assert_eq!(safe, power);
}
#[test]
fn prompt_keeps_system_prompt_first() {
let prompt = build_prompt(&AgentStreamRequest {

View File

@@ -99,13 +99,19 @@ fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
}
fn pi_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| pi_binary_candidates_for_home(&home))
.unwrap_or_default()
let mut candidates = pi_binary_candidates_from_env();
if let Some(home) = dirs::home_dir() {
candidates.extend(pi_binary_candidates_for_home(&home));
}
candidates.extend(pi_global_binary_candidates());
candidates
}
fn pi_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
let mut candidates = pi_nvm_binary_candidates_for_home(home);
candidates.extend([
home.join(".local/bin/pi"),
home.join(".pi/bin/pi"),
home.join(".local/share/mise/shims/pi"),
@@ -113,11 +119,63 @@ fn pi_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
home.join(".npm-global/bin/pi"),
home.join(".npm/bin/pi"),
home.join(".bun/bin/pi"),
]);
candidates
}
fn pi_global_binary_candidates() -> Vec<PathBuf> {
vec![
PathBuf::from("/usr/local/bin/pi"),
PathBuf::from("/opt/homebrew/bin/pi"),
]
}
fn pi_binary_candidates_from_env() -> Vec<PathBuf> {
let nvm_bin = std::env::var_os("NVM_BIN").map(PathBuf::from);
let npm_config_prefix = std::env::var_os("npm_config_prefix").map(PathBuf::from);
let npm_config_prefix_upper = std::env::var_os("NPM_CONFIG_PREFIX").map(PathBuf::from);
pi_binary_candidates_from_prefixes(nvm_bin, npm_config_prefix, npm_config_prefix_upper)
}
fn pi_binary_candidates_from_prefixes(
nvm_bin: Option<PathBuf>,
npm_config_prefix: Option<PathBuf>,
npm_config_prefix_upper: Option<PathBuf>,
) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Some(path) = nvm_bin.filter(|path| !path.as_os_str().is_empty()) {
candidates.push(path.join("pi"));
}
if let Some(path) = npm_config_prefix.filter(|path| !path.as_os_str().is_empty()) {
candidates.push(path.join("bin/pi"));
}
if let Some(path) = npm_config_prefix_upper.filter(|path| !path.as_os_str().is_empty()) {
candidates.push(path.join("bin/pi"));
}
candidates
}
fn pi_nvm_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let versions_dir = home.join(".nvm/versions/node");
let mut version_dirs = match std::fs::read_dir(versions_dir) {
Ok(entries) => entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>(),
Err(_) => return Vec::new(),
};
version_dirs.sort_by(|left, right| right.file_name().cmp(&left.file_name()));
version_dirs
.into_iter()
.map(|version_dir| version_dir.join("bin/pi"))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -133,7 +191,6 @@ mod tests {
home.join(".asdf/shims/pi"),
home.join(".npm-global/bin/pi"),
home.join(".bun/bin/pi"),
PathBuf::from("/opt/homebrew/bin/pi"),
];
for candidate in expected {
@@ -145,6 +202,53 @@ mod tests {
}
}
#[test]
fn binary_candidates_include_nvm_node_version_installs() {
let home = tempfile::tempdir().unwrap();
let pi = home.path().join(".nvm/versions/node/v22.20.0/bin/pi");
std::fs::create_dir_all(pi.parent().unwrap()).unwrap();
std::fs::write(&pi, "#!/bin/sh\n").unwrap();
let candidates = pi_binary_candidates_for_home(home.path());
assert!(
candidates.contains(&pi),
"missing nvm candidate {}",
pi.display()
);
}
#[test]
fn binary_candidates_include_static_global_fallbacks() {
let candidates = pi_global_binary_candidates();
assert!(candidates.contains(&PathBuf::from("/usr/local/bin/pi")));
assert!(candidates.contains(&PathBuf::from("/opt/homebrew/bin/pi")));
}
#[test]
fn binary_candidates_include_env_provided_npm_and_nvm_prefixes() {
let nvm_bin = PathBuf::from("/Users/alex/.nvm/versions/node/v22.20.0/bin");
let npm_config_prefix = PathBuf::from("/Users/alex/.npm-global");
let npm_config_prefix_upper = PathBuf::from("/Users/alex/.npm");
let candidates = pi_binary_candidates_from_prefixes(
Some(nvm_bin.clone()),
Some(npm_config_prefix.clone()),
Some(npm_config_prefix_upper.clone()),
);
assert_eq!(
candidates,
vec![
nvm_bin.join("pi"),
npm_config_prefix.join("bin/pi"),
npm_config_prefix_upper.join("bin/pi"),
]
);
}
#[test]
fn first_existing_path_skips_empty_and_missing_lines() {
let dir = tempfile::tempdir().unwrap();

View File

@@ -1,5 +1,24 @@
use super::*;
#[test]
fn parse_line_reports_read_errors_and_skips_blank_or_invalid_lines() {
let mut events = Vec::new();
let read_error = parse_line(Err(std::io::Error::other("broken pipe")), &mut |event| {
events.push(event)
});
let blank = parse_line(Ok(" ".into()), &mut |event| events.push(event));
let invalid = parse_line(Ok("not json".into()), &mut |event| events.push(event));
assert!(read_error.is_none());
assert!(blank.is_none());
assert!(invalid.is_none());
assert!(matches!(
&events[0],
AiAgentStreamEvent::Error { message } if message.contains("broken pipe")
));
}
#[test]
fn dispatch_maps_session_thinking_and_text() {
let mut events = Vec::new();
@@ -62,6 +81,19 @@ fn dispatch_maps_tool_events() {
));
}
#[test]
fn dispatch_maps_error_events() {
let mut events = Vec::new();
let error = serde_json::json!({ "type": "error", "message": "provider failed" });
dispatch_event(&error, &mut |event| events.push(event));
assert!(matches!(
&events[0],
AiAgentStreamEvent::Error { message } if message == "provider failed"
));
}
#[test]
fn format_error_explains_missing_auth_or_provider_setup() {
let message = format_error(
@@ -72,3 +104,12 @@ fn format_error_explains_missing_auth_or_provider_setup() {
assert!(message.contains("Pi CLI is not authenticated"));
assert!(message.contains("pi /login"));
}
#[test]
fn format_error_uses_status_or_first_stderr_lines() {
let empty = format_error(String::new(), "exit status: 2".into());
let truncated = format_error("line 1\nline 2\nline 3\nline 4".into(), "ignored".into());
assert_eq!(empty, "pi exited with status exit status: 2");
assert_eq!(truncated, "line 1\nline 2\nline 3");
}

View File

@@ -1,5 +1,7 @@
use crate::settings;
use chrono::NaiveDate;
use regex::Regex;
use std::borrow::Cow;
use std::sync::Mutex;
/// Global Sentry guard — must live for the duration of the app.
@@ -44,6 +46,41 @@ fn parse_embedded_sentry_dsn(raw: Option<&str>) -> Option<sentry::types::Dsn> {
normalize_http_like_value(&normalized).parse().ok()
}
fn is_stable_calendar_release(version: &str) -> bool {
let mut parts = version.split('.');
let (Some(year), Some(month), Some(day)) = (parts.next(), parts.next(), parts.next()) else {
return false;
};
if parts.next().is_some() {
return false;
}
let (Ok(year), Ok(month), Ok(day)) = (
year.parse::<i32>(),
month.parse::<u32>(),
day.parse::<u32>(),
) else {
return false;
};
NaiveDate::from_ymd_opt(year, month, day).is_some()
}
fn sentry_release_for_version(version: &'static str) -> Option<Cow<'static, str>> {
is_stable_calendar_release(version).then(|| version.into())
}
fn sentry_release_kind(version: &str) -> &'static str {
if is_stable_calendar_release(version) {
"stable"
} else if version.contains('-') {
"prerelease"
} else {
"internal"
}
}
/// Initialize Sentry if the user has opted in to crash reporting.
/// Returns `true` if Sentry was initialized, `false` if skipped.
pub fn init_sentry_from_settings() -> bool {
@@ -61,9 +98,10 @@ pub fn init_sentry_from_settings() -> bool {
};
let anonymous_id = settings.anonymous_id.unwrap_or_default();
let build_version = env!("CARGO_PKG_VERSION");
let guard = sentry::init(sentry::ClientOptions {
dsn: Some(dsn),
release: Some(env!("CARGO_PKG_VERSION").into()),
release: sentry_release_for_version(build_version),
send_default_pii: false,
before_send: Some(std::sync::Arc::new(|mut event| {
if let Some(ref mut msg) = event.message {
@@ -79,6 +117,8 @@ pub fn init_sentry_from_settings() -> bool {
id: Some(anonymous_id),
..Default::default()
}));
scope.set_tag("tolaria.build_version", build_version);
scope.set_tag("tolaria.release_kind", sentry_release_kind(build_version));
});
*SENTRY_GUARD.lock().unwrap() = Some(guard);
@@ -160,4 +200,26 @@ mod tests {
// Without SENTRY_DSN env var set at compile time, init should return false
assert!(!init_sentry_from_settings());
}
#[test]
fn test_sentry_release_for_stable_calendar_version() {
assert!(sentry_release_for_version("2026.4.28").is_some());
}
#[test]
fn test_sentry_release_for_alpha_version_is_none() {
assert!(sentry_release_for_version("2026.4.28-alpha.7").is_none());
}
#[test]
fn test_sentry_release_for_local_dev_version_is_none() {
assert!(sentry_release_for_version("0.1.0").is_none());
}
#[test]
fn test_sentry_release_kind_classifies_versions() {
assert_eq!(sentry_release_kind("2026.4.28"), "stable");
assert_eq!(sentry_release_kind("2026.4.28-alpha.7"), "prerelease");
assert_eq!(sentry_release_kind("0.1.0"), "internal");
}
}

View File

@@ -58,6 +58,16 @@ _organized: true
This file is only a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
";
const GEMINI_MD_SHIM: &str = "---
type: Note
_organized: true
---
@AGENTS.md
This file is only a Gemini CLI compatibility shim. Keep shared agent instructions in `AGENTS.md`.
";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AiGuidanceFileState {
@@ -71,9 +81,16 @@ pub enum AiGuidanceFileState {
pub struct VaultAiGuidanceStatus {
pub agents_state: AiGuidanceFileState,
pub claude_state: AiGuidanceFileState,
pub gemini_state: AiGuidanceFileState,
pub can_restore: bool,
}
struct GuidancePaths {
agents: PathBuf,
claude: PathBuf,
gemini: PathBuf,
}
#[derive(Debug, Default)]
struct LegacyAgentsMigrationOutcome {
copied_to_root: bool,
@@ -109,6 +126,10 @@ fn matches_claude_shim(content: &str) -> bool {
|| trimmed == CLAUDE_MD_SHIM.trim()
}
fn matches_gemini_shim(content: &str) -> bool {
content.trim() == GEMINI_MD_SHIM.trim()
}
fn claude_shim_can_be_replaced(path: &Path) -> bool {
!path.exists() || {
let content = read_file_or_empty(path);
@@ -116,6 +137,13 @@ fn claude_shim_can_be_replaced(path: &Path) -> bool {
}
}
fn gemini_shim_can_be_replaced(path: &Path) -> bool {
!path.exists() || {
let content = read_file_or_empty(path);
content.trim().is_empty() || matches_gemini_shim(&content)
}
}
fn sync_managed_file(
path: &Path,
content: &str,
@@ -150,9 +178,12 @@ fn classify_guidance_file(
AiGuidanceFileState::Custom
}
fn guidance_paths(vault_path: &Path) -> (PathBuf, PathBuf) {
let vault = vault_path;
(vault.join("AGENTS.md"), vault.join("CLAUDE.md"))
fn guidance_paths(vault_path: &Path) -> GuidancePaths {
GuidancePaths {
agents: vault_path.join("AGENTS.md"),
claude: vault_path.join("CLAUDE.md"),
gemini: vault_path.join("GEMINI.md"),
}
}
fn classify_agents_file(path: &Path) -> AiGuidanceFileState {
@@ -167,6 +198,10 @@ fn classify_claude_file(path: &Path) -> AiGuidanceFileState {
classify_guidance_file(path, matches_claude_shim, claude_shim_can_be_replaced)
}
fn classify_gemini_file(path: &Path) -> AiGuidanceFileState {
classify_guidance_file(path, matches_gemini_shim, gemini_shim_can_be_replaced)
}
fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
matches!(
state,
@@ -175,29 +210,43 @@ fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
}
fn build_ai_guidance_status(vault_path: &Path) -> VaultAiGuidanceStatus {
let (agents_path, claude_path) = guidance_paths(vault_path);
let agents_state = classify_agents_file(&agents_path);
let claude_state = classify_claude_file(&claude_path);
let paths = guidance_paths(vault_path);
let agents_state = classify_agents_file(&paths.agents);
let claude_state = classify_claude_file(&paths.claude);
let gemini_state = classify_gemini_file(&paths.gemini);
VaultAiGuidanceStatus {
agents_state,
claude_state,
gemini_state,
can_restore: guidance_file_needs_restore(agents_state)
|| guidance_file_needs_restore(claude_state),
|| guidance_file_needs_restore(claude_state)
|| guidance_file_needs_restore(gemini_state),
}
}
fn sync_claude_shim_file(vault_path: &Path) -> Result<bool, String> {
let (_, claude_path) = guidance_paths(vault_path);
sync_managed_file(&claude_path, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
let paths = guidance_paths(vault_path);
sync_managed_file(&paths.claude, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
}
fn sync_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
fn sync_gemini_shim_file(vault_path: &Path) -> Result<bool, String> {
let paths = guidance_paths(vault_path);
sync_managed_file(&paths.gemini, GEMINI_MD_SHIM, gemini_shim_can_be_replaced)
}
fn sync_required_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
let wrote_agents = sync_default_agents_file(vault_path)?;
let wrote_claude = sync_claude_shim_file(vault_path)?;
Ok(wrote_agents || wrote_claude)
}
fn sync_all_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
let wrote_required = sync_required_ai_guidance_files(vault_path)?;
let wrote_gemini = sync_gemini_shim_file(vault_path)?;
Ok(wrote_required || wrote_gemini)
}
fn migrate_legacy_agents_file(
root_agents: &Path,
config_agents: &Path,
@@ -241,8 +290,8 @@ fn cleanup_empty_config_dir(vault: &Path) -> Result<bool, String> {
}
pub(super) fn sync_default_agents_file(vault_path: &Path) -> Result<bool, String> {
let (agents_path, _) = guidance_paths(vault_path);
sync_managed_file(&agents_path, AGENTS_MD, root_agents_can_be_replaced)
let paths = guidance_paths(vault_path);
sync_managed_file(&paths.agents, AGENTS_MD, root_agents_can_be_replaced)
}
pub fn get_ai_guidance_status(
@@ -255,7 +304,7 @@ pub fn restore_ai_guidance_files(
vault_path: impl AsRef<str>,
) -> Result<VaultAiGuidanceStatus, String> {
let vault_path = Path::new(vault_path.as_ref());
sync_ai_guidance_files(vault_path)?;
sync_all_ai_guidance_files(vault_path)?;
Ok(build_ai_guidance_status(vault_path))
}
@@ -263,7 +312,7 @@ pub fn restore_ai_guidance_files(
/// Also seeds Tolaria-managed root type definitions used by repair/bootstrap flows.
pub fn seed_config_files(vault_path: impl AsRef<str>) {
let vault_path = Path::new(vault_path.as_ref());
if sync_ai_guidance_files(vault_path).unwrap_or(false) {
if sync_required_ai_guidance_files(vault_path).unwrap_or(false) {
log::info!("Seeded vault AI guidance files at vault root");
}
@@ -307,7 +356,7 @@ pub fn migrate_agents_md(vault_path: impl AsRef<str>) {
log::info!("Removed empty config/ directory");
}
let _ = sync_ai_guidance_files(vault);
let _ = sync_required_ai_guidance_files(vault);
}
/// Repair config files: ensure `AGENTS.md` at vault root and root type definitions.
@@ -320,7 +369,7 @@ pub fn repair_config_files(vault_path: impl AsRef<str>) -> Result<String, String
migrate_legacy_agents_file(&root_agents, &config_agents)?;
let _ = cleanup_empty_config_dir(vault)?;
sync_ai_guidance_files(vault)?;
sync_required_ai_guidance_files(vault)?;
write_if_missing(&vault.join("type.md"), TYPE_TYPE_DEFINITION)?;
write_if_missing(&vault.join("note.md"), NOTE_TYPE_DEFINITION)?;
@@ -355,6 +404,10 @@ mod tests {
fs::write(vault.join("CLAUDE.md"), content).unwrap();
}
fn write_root_gemini(vault: &Path, content: &str) {
fs::write(vault.join("GEMINI.md"), content).unwrap();
}
fn write_legacy_agents(vault: &Path, content: &str) {
fs::write(config_dir(vault).join("agents.md"), content).unwrap();
}
@@ -367,6 +420,10 @@ mod tests {
fs::read_to_string(vault.join("CLAUDE.md")).unwrap()
}
fn read_root_gemini(vault: &Path) -> String {
fs::read_to_string(vault.join("GEMINI.md")).unwrap()
}
type VaultOperation = fn(&Path);
fn run_seed(vault: &Path) {
@@ -454,15 +511,52 @@ mod tests {
assert!(content.contains(expected_root_text));
}
fn assert_required_agents_file_seeded(vault: &Path) {
assert!(vault.join("AGENTS.md").exists());
assert!(read_root_agents(vault).contains("Tolaria Vault"));
}
fn assert_required_guidance_shims_seeded(vault: &Path) {
assert_eq!(read_root_claude(vault), CLAUDE_MD_SHIM);
assert!(!vault.join("GEMINI.md").exists());
}
fn assert_required_guidance_files_seeded(vault: &Path) {
assert_required_agents_file_seeded(vault);
assert_required_guidance_shims_seeded(vault);
}
fn assert_root_type_definitions_seeded(vault: &Path) {
assert!(vault.join("type.md").exists());
assert!(vault.join("note.md").exists());
assert!(!vault.join("config").exists());
}
fn assert_type_definition_content(vault: &Path) {
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert_type_definition_body(&type_content);
assert_note_definition_body(&note_content);
}
fn assert_type_definition_body(type_content: &str) {
assert!(type_content.contains("type: Type"));
assert!(type_content.contains("# Type"));
assert!(type_content.contains("visible: false"));
}
fn assert_note_definition_body(note_content: &str) {
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("# Note"));
}
#[test]
fn test_seed_config_files_creates_guidance_files_at_root() {
let (_dir, vault) = create_vault();
seed_config_files(vault.to_str().unwrap());
assert!(vault.join("AGENTS.md").exists());
assert!(read_root_agents(&vault).contains("Tolaria Vault"));
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
assert_required_guidance_files_seeded(&vault);
}
#[test]
@@ -471,16 +565,8 @@ mod tests {
seed_config_files(vault.to_str().unwrap());
assert!(vault.join("type.md").exists());
assert!(vault.join("note.md").exists());
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert!(type_content.contains("type: Type"));
assert!(type_content.contains("# Type"));
assert!(type_content.contains("visible: false"));
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("# Note"));
assert!(!vault.join("config").exists());
assert_root_type_definitions_seeded(&vault);
assert_type_definition_content(&vault);
}
#[test]
@@ -604,20 +690,12 @@ mod tests {
let msg = repair_config_files(vault.to_str().unwrap()).unwrap();
assert_eq!(msg, "Config files repaired");
assert!(vault.join("AGENTS.md").exists());
assert!(vault.join("CLAUDE.md").exists());
assert!(vault.join("type.md").exists());
assert!(vault.join("note.md").exists());
assert!(!vault.join("config").exists());
let agents = read_root_agents(&vault);
assert!(agents.contains("Tolaria Vault"));
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
assert!(type_content.contains("# Type"));
assert!(type_content.contains("visible: false"));
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("general-purpose document"));
assert_required_guidance_files_seeded(&vault);
assert_root_type_definitions_seeded(&vault);
assert_type_definition_content(&vault);
assert!(fs::read_to_string(vault.join("note.md"))
.unwrap()
.contains("general-purpose document"));
}
#[test]
@@ -656,9 +734,15 @@ mod tests {
write_root_claude(&vault, "");
let status = get_ai_guidance_status(vault.to_str().unwrap()).unwrap();
assert_eq!(status.agents_state, AiGuidanceFileState::Custom);
assert_eq!(status.claude_state, AiGuidanceFileState::Broken);
assert!(status.can_restore);
assert_eq!(
status,
VaultAiGuidanceStatus {
agents_state: AiGuidanceFileState::Custom,
claude_state: AiGuidanceFileState::Broken,
gemini_state: AiGuidanceFileState::Missing,
can_restore: true,
}
);
}
#[test]
@@ -668,11 +752,18 @@ mod tests {
write_root_claude(&vault, "");
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
assert_eq!(status.agents_state, AiGuidanceFileState::Custom);
assert_eq!(status.claude_state, AiGuidanceFileState::Managed);
assert!(!status.can_restore);
assert_eq!(
status,
VaultAiGuidanceStatus {
agents_state: AiGuidanceFileState::Custom,
claude_state: AiGuidanceFileState::Managed,
gemini_state: AiGuidanceFileState::Managed,
can_restore: false,
}
);
assert!(read_root_agents(&vault).contains("Custom Agent Config"));
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
assert_eq!(read_root_gemini(&vault), GEMINI_MD_SHIM);
}
#[test]
@@ -681,8 +772,28 @@ mod tests {
write_root_agents(&vault, "");
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
assert_eq!(status.agents_state, AiGuidanceFileState::Managed);
assert_eq!(status.claude_state, AiGuidanceFileState::Managed);
assert_eq!(
status,
VaultAiGuidanceStatus {
agents_state: AiGuidanceFileState::Managed,
claude_state: AiGuidanceFileState::Managed,
gemini_state: AiGuidanceFileState::Managed,
can_restore: false,
}
);
}
#[test]
fn test_restore_ai_guidance_files_preserves_custom_gemini() {
let (_dir, vault) = create_vault();
write_root_agents(&vault, AGENTS_MD);
write_root_claude(&vault, CLAUDE_MD_SHIM);
write_root_gemini(&vault, "# Custom Gemini instructions\nDo not overwrite\n");
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
assert_eq!(status.gemini_state, AiGuidanceFileState::Custom);
assert!(!status.can_restore);
assert!(read_root_gemini(&vault).contains("Custom Gemini instructions"));
}
}

View File

@@ -1,7 +1,8 @@
use chrono::{DateTime, Duration, Months, NaiveDate, NaiveDateTime, Utc};
use regex::RegexBuilder;
use regex::{Regex, RegexBuilder};
use serde::de::{self, MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::cmp::Ordering;
use std::fmt;
use std::fs;
use std::path::Path;
@@ -15,6 +16,8 @@ pub struct ViewDefinition {
pub icon: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub order: Option<i64>,
#[serde(default)]
pub sort: Option<String>,
#[serde(
@@ -254,10 +257,19 @@ pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
}
}
views.sort_by(|a, b| a.filename.cmp(&b.filename));
views.sort_by(compare_views);
views
}
fn compare_views(left: &ViewFile, right: &ViewFile) -> Ordering {
let order = left
.definition
.order
.unwrap_or(i64::MAX)
.cmp(&right.definition.order.unwrap_or(i64::MAX));
order.then_with(|| left.filename.cmp(&right.filename))
}
/// Save a view definition as YAML to `vault_path/views/{filename}`.
pub fn save_view(
vault_path: &Path,
@@ -422,120 +434,157 @@ fn supports_regex(op: &FilterOp) -> bool {
)
}
enum ConditionField<'a> {
Scalar(Option<String>),
Relationship(&'a [String]),
}
fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
let field = cond.field.as_str();
let mut relationship_values: Option<&[String]> = None;
// Boolean fields
match field {
"archived" => return evaluate_bool_field(entry.archived, &cond.op, &cond.value),
"favorite" => return evaluate_bool_field(entry.favorite, &cond.op, &cond.value),
_ => {}
if let Some(result) = evaluate_condition_bool_field(field, entry, &cond.op, &cond.value) {
return result;
}
// String/option fields
let field_value: Option<String> = match field {
"type" | "isA" => entry.is_a.clone(),
"status" => entry.status.clone(),
"title" => Some(entry.title.clone()),
"body" => Some(entry.snippet.clone()),
_ => {
// Check properties first, then relationships
if let Some(prop) = entry.properties.get(field) {
match prop {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => None,
}
} else if let Some(rels) = entry.relationships.get(field) {
relationship_values = Some(rels);
None
} else {
None
}
}
};
let field_value = resolve_condition_field(field, entry);
let cond_value = cond.value.as_ref().and_then(yaml_value_to_string);
let regex = if cond.regex && supports_regex(&cond.op) {
cond_value.as_deref().and_then(build_regex)
} else {
None
};
let regex = condition_regex(cond, cond_value.as_deref());
if cond.regex && supports_regex(&cond.op) && regex.is_none() {
return false;
}
if let Some(re) = regex.as_ref() {
let matched = if let Some(prop) = field_value.as_deref() {
re.is_match(prop)
} else if let Some(rels) = relationship_values {
rels.iter().any(|item| {
relationship_candidates(item)
.into_iter()
.any(|candidate| re.is_match(&candidate))
})
} else {
false
};
return match cond.op {
FilterOp::Contains | FilterOp::Equals => matched,
FilterOp::NotContains | FilterOp::NotEquals => !matched,
_ => false,
};
return evaluate_regex_condition(&cond.op, &field_value, re);
}
if let Some(rels) = relationship_values {
return evaluate_relationship_op(&cond.op, rels, &cond.value);
match field_value {
ConditionField::Relationship(rels) => evaluate_relationship_op(&cond.op, rels, &cond.value),
ConditionField::Scalar(value) => evaluate_scalar_op(
&cond.op,
value.as_deref(),
cond_value.as_deref(),
&cond.value,
),
}
}
match cond.op {
FilterOp::Equals => match (&field_value, &cond_value) {
fn evaluate_condition_bool_field(
field: &str,
entry: &VaultEntry,
op: &FilterOp,
value: &Option<serde_yaml::Value>,
) -> Option<bool> {
match field {
"archived" => Some(evaluate_bool_field(entry.archived, op, value)),
"favorite" => Some(evaluate_bool_field(entry.favorite, op, value)),
_ => None,
}
}
fn resolve_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionField<'a> {
match field {
"type" | "isA" => ConditionField::Scalar(entry.is_a.clone()),
"status" => ConditionField::Scalar(entry.status.clone()),
"title" => ConditionField::Scalar(Some(entry.title.clone())),
"body" => ConditionField::Scalar(Some(entry.snippet.clone())),
_ => resolve_dynamic_condition_field(field, entry),
}
}
fn resolve_dynamic_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionField<'a> {
if let Some(prop) = entry.properties.get(field) {
return ConditionField::Scalar(json_scalar_to_string(prop));
}
if let Some(relationships) = entry.relationships.get(field) {
return ConditionField::Relationship(relationships);
}
ConditionField::Scalar(None)
}
fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(value) => Some(value.clone()),
serde_json::Value::Number(value) => Some(value.to_string()),
serde_json::Value::Bool(value) => Some(value.to_string()),
_ => None,
}
}
fn condition_regex(cond: &FilterCondition, cond_value: Option<&str>) -> Option<Regex> {
if cond.regex && supports_regex(&cond.op) {
cond_value.and_then(build_regex)
} else {
None
}
}
fn evaluate_regex_condition(op: &FilterOp, field: &ConditionField<'_>, regex: &Regex) -> bool {
let matched = match field {
ConditionField::Scalar(Some(value)) => regex.is_match(value),
ConditionField::Relationship(values) => values.iter().any(|item| {
relationship_candidates(item)
.into_iter()
.any(|candidate| regex.is_match(&candidate))
}),
ConditionField::Scalar(None) => false,
};
match op {
FilterOp::Contains | FilterOp::Equals => matched,
FilterOp::NotContains | FilterOp::NotEquals => !matched,
_ => false,
}
}
fn evaluate_scalar_op(
op: &FilterOp,
field_value: Option<&str>,
cond_value: Option<&str>,
raw_value: &Option<serde_yaml::Value>,
) -> bool {
match op {
FilterOp::Equals => match (field_value, cond_value) {
(Some(f), Some(v)) => f.eq_ignore_ascii_case(v),
(None, None) => true,
_ => false,
},
FilterOp::NotEquals => match (&field_value, &cond_value) {
FilterOp::NotEquals => match (field_value, cond_value) {
(Some(f), Some(v)) => !f.eq_ignore_ascii_case(v),
(None, None) => false,
_ => true,
},
FilterOp::Contains => match (&field_value, &cond_value) {
FilterOp::Contains => match (field_value, cond_value) {
(Some(f), Some(v)) => f.to_lowercase().contains(&v.to_lowercase()),
_ => false,
},
FilterOp::NotContains => match (&field_value, &cond_value) {
FilterOp::NotContains => match (field_value, cond_value) {
(Some(f), Some(v)) => !f.to_lowercase().contains(&v.to_lowercase()),
(None, _) => true,
_ => true,
},
FilterOp::AnyOf => {
let values = cond
.value
let values = raw_value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default();
match &field_value {
match field_value {
Some(f) => values.iter().any(|v| f.eq_ignore_ascii_case(v)),
None => false,
}
}
FilterOp::NoneOf => {
let values = cond
.value
let values = raw_value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default();
match &field_value {
match field_value {
Some(f) => !values.iter().any(|v| f.eq_ignore_ascii_case(v)),
None => true,
}
}
FilterOp::IsEmpty => field_value.as_deref().map_or(true, |s| s.is_empty()),
FilterOp::IsNotEmpty => field_value.as_deref().is_some_and(|s| !s.is_empty()),
FilterOp::Before => match (&field_value, &cond_value) {
FilterOp::IsEmpty => field_value.map_or(true, str::is_empty),
FilterOp::IsNotEmpty => field_value.is_some_and(|s| !s.is_empty()),
FilterOp::Before => match (field_value, cond_value) {
(Some(f), Some(v)) => match (
parse_date_filter_timestamp(f, Utc::now()),
parse_date_filter_timestamp(v, Utc::now()),
@@ -545,7 +594,7 @@ fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
},
_ => false,
},
FilterOp::After => match (&field_value, &cond_value) {
FilterOp::After => match (field_value, cond_value) {
(Some(f), Some(v)) => match (
parse_date_filter_timestamp(f, Utc::now()),
parse_date_filter_timestamp(v, Utc::now()),
@@ -581,80 +630,61 @@ fn evaluate_relationship_op(
) -> bool {
match op {
FilterOp::Contains => {
let target = value.as_ref().and_then(yaml_value_to_string);
match target {
Some(t) => {
let t_stem = wikilink_stem(&t).to_lowercase();
rels.iter()
.any(|r| wikilink_stem(r).to_lowercase() == t_stem)
}
None => false,
}
relationship_target(value).is_some_and(|target| relationship_contains(rels, &target))
}
FilterOp::NotContains => {
let target = value.as_ref().and_then(yaml_value_to_string);
match target {
Some(t) => {
let t_stem = wikilink_stem(&t).to_lowercase();
!rels
.iter()
.any(|r| wikilink_stem(r).to_lowercase() == t_stem)
}
None => true,
}
}
FilterOp::AnyOf => {
let values = value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default();
rels.iter().any(|r| {
let r_stem = wikilink_stem(r).to_lowercase();
values
.iter()
.any(|v| wikilink_stem(v).to_lowercase() == r_stem)
})
}
FilterOp::NoneOf => {
let values = value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default();
!rels.iter().any(|r| {
let r_stem = wikilink_stem(r).to_lowercase();
values
.iter()
.any(|v| wikilink_stem(v).to_lowercase() == r_stem)
})
relationship_target(value).map_or(true, |target| !relationship_contains(rels, &target))
}
FilterOp::AnyOf => relationship_any_of(rels, &relationship_values(value)),
FilterOp::NoneOf => !relationship_any_of(rels, &relationship_values(value)),
FilterOp::IsEmpty => rels.is_empty(),
FilterOp::IsNotEmpty => !rels.is_empty(),
FilterOp::Equals => {
let target = value.as_ref().and_then(yaml_value_to_string);
match target {
Some(t) => {
rels.len() == 1
&& wikilink_stem(&rels[0]).to_lowercase()
== wikilink_stem(&t).to_lowercase()
}
None => rels.is_empty(),
}
}
FilterOp::NotEquals => {
let target = value.as_ref().and_then(yaml_value_to_string);
match target {
Some(t) => {
rels.len() != 1
|| wikilink_stem(&rels[0]).to_lowercase()
!= wikilink_stem(&t).to_lowercase()
}
None => !rels.is_empty(),
}
}
FilterOp::Equals => relationship_target(value).map_or_else(
|| rels.is_empty(),
|target| relationship_equals(rels, &target),
),
FilterOp::NotEquals => relationship_target(value).map_or_else(
|| !rels.is_empty(),
|target| !relationship_equals(rels, &target),
),
_ => false,
}
}
fn relationship_target(value: &Option<serde_yaml::Value>) -> Option<String> {
value.as_ref().and_then(yaml_value_to_string)
}
fn relationship_values(value: &Option<serde_yaml::Value>) -> Vec<String> {
value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default()
}
fn normalized_wikilink_stem(value: &str) -> String {
wikilink_stem(value).to_lowercase()
}
fn relationship_contains(rels: &[String], target: &str) -> bool {
let target_stem = normalized_wikilink_stem(target);
rels.iter()
.any(|relationship| normalized_wikilink_stem(relationship) == target_stem)
}
fn relationship_any_of(rels: &[String], values: &[String]) -> bool {
rels.iter().any(|relationship| {
let relationship_stem = normalized_wikilink_stem(relationship);
values
.iter()
.any(|value| normalized_wikilink_stem(value) == relationship_stem)
})
}
fn relationship_equals(rels: &[String], target: &str) -> bool {
rels.len() == 1 && relationship_contains(rels, target)
}
fn yaml_value_to_string(v: &serde_yaml::Value) -> Option<String> {
match v {
serde_yaml::Value::String(s) => Some(s.clone()),
@@ -686,6 +716,7 @@ mod tests {
name: name.to_string(),
icon: None,
color: None,
order: None,
sort: None,
list_properties_display: Vec::new(),
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
@@ -963,6 +994,34 @@ filters:
assert_eq!(views[1].definition.name, "Beta");
}
#[test]
fn test_scan_views_sorts_by_persisted_order_then_filename() {
let dir = tempfile::TempDir::new().unwrap();
let views_dir = dir.path().join("views");
fs::create_dir_all(&views_dir).unwrap();
let alpha = "name: Alpha\norder: 20\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
let beta = "name: Beta\norder: 10\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
let gamma = "name: Gamma\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
fs::write(views_dir.join("alpha.yml"), alpha).unwrap();
fs::write(views_dir.join("beta.yml"), beta).unwrap();
fs::write(views_dir.join("gamma.yml"), gamma).unwrap();
let views = scan_views(dir.path());
assert_eq!(
views
.iter()
.map(|view| (view.filename.as_str(), view.definition.order))
.collect::<Vec<_>>(),
vec![
("beta.yml", Some(10)),
("alpha.yml", Some(20)),
("gamma.yml", None),
]
);
}
#[test]
fn test_migrate_views_from_old_location() {
let dir = tempfile::TempDir::new().unwrap();

View File

@@ -72,7 +72,9 @@ import { useConflictFlow } from './hooks/useConflictFlow'
import { useAppSave } from './hooks/useAppSave'
import { useNoteRetargetingUi } from './hooks/useNoteRetargetingUi'
import { useVaultBridge } from './hooks/useVaultBridge'
import { useSavedViewOrdering } from './hooks/useSavedViewOrdering'
import { createViewFilename } from './utils/viewFilename'
import { nextViewOrder } from './utils/viewOrdering'
import type { CommitDiffRequest } from './hooks/useDiffMode'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
@@ -453,7 +455,16 @@ function App() {
trackEvent('vault_opened', { has_git: gitRepoState === 'ready' ? 1 : 0, note_count: vault.entries.length })
}
}, [vault.entries.length, gitRepoState, resolvedPath])
const { mcpStatus, connectMcp, disconnectMcp } = useMcpStatus(resolvedPath, setToastMessage)
const {
mcpStatus,
connectMcp,
disconnectMcp,
mcpConfigSnippet,
mcpConfigLoading,
mcpConfigError,
loadMcpConfigSnippet,
copyMcpConfig,
} = useMcpStatus(resolvedPath, setToastMessage)
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
const loadVaultModifiedFiles = vault.loadModifiedFiles
const refreshGitRemoteStatus = gitRemoteStatus.refreshRemoteStatus
@@ -493,6 +504,14 @@ function App() {
}
}, [disconnectMcp])
const handleCopyMcpConfig = useCallback(() => {
void copyMcpConfig()
}, [copyMcpConfig])
const handleLoadMcpConfigSnippet = useCallback(() => {
void loadMcpConfigSnippet().catch(() => undefined)
}, [loadMcpConfigSnippet])
// Detect external file renames on window focus
const [detectedRenames, setDetectedRenames] = useState<DetectedRename[]>([])
useEffect(() => {
@@ -1059,7 +1078,9 @@ function App() {
const filename = editing
? editing.filename
: createViewFilename(definition.name, vault.views.map((view) => view.filename))
const nextDefinition = editing ? { ...editing.definition, ...definition } : definition
const nextDefinition = editing
? { ...editing.definition, ...definition }
: { ...definition, order: nextViewOrder(vault.views) }
const target = isTauri() ? invoke : mockInvoke
try {
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition })
@@ -1302,6 +1323,14 @@ function App() {
? 'Customize All Notes columns'
: 'Customize Inbox columns'
}, [effectiveSelection, vault.views])
const viewOrdering = useSavedViewOrdering({
views: vault.views,
selection: effectiveSelection,
vaultPath: resolvedPath,
reloadViews: vault.reloadViews,
loadModifiedFiles: vault.loadModifiedFiles,
onToast: setToastMessage,
})
const activeNoteModified = useMemo(
() => vault.modifiedFiles.some((file) => file.path === notes.activeTabPath),
[notes.activeTabPath, vault.modifiedFiles],
@@ -1403,6 +1432,11 @@ function App() {
onToggleRawEditor: toggleRawEditorCommand,
noteLayout,
onToggleNoteLayout: toggleNoteLayout,
selectedViewName: viewOrdering.selectedViewName,
onMoveSelectedViewUp: viewOrdering.onMoveSelectedViewUp,
onMoveSelectedViewDown: viewOrdering.onMoveSelectedViewDown,
canMoveSelectedViewUp: viewOrdering.canMoveSelectedViewUp,
canMoveSelectedViewDown: viewOrdering.canMoveSelectedViewDown,
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel,
onSelect: handleSetSelection,
@@ -1553,7 +1587,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} locale={appLocale} />
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onReorderViews={viewOrdering.onReorderViews} onMoveView={viewOrdering.onMoveView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} locale={appLocale} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -1601,6 +1635,7 @@ function App() {
onInitializeProperties={handleInitializeProperties}
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
onCopyMcpConfig={handleCopyMcpConfig}
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
@@ -1685,7 +1720,7 @@ function App() {
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onDisconnect={handleDisconnectMcp} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} manualConfigSnippet={mcpConfigSnippet} manualConfigLoading={mcpConfigLoading} manualConfigError={mcpConfigError} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onCopyManualConfig={handleCopyMcpConfig} onDisconnect={handleDisconnectMcp} onLoadManualConfig={handleLoadMcpConfigSnippet} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
{deleteActions.confirmDelete && (
<ConfirmDeleteDialog

View File

@@ -18,6 +18,7 @@ export interface AiAction {
export interface AiMessageProps {
userMessage: string
references?: NoteReference[]
localMarker?: string
reasoning?: string
reasoningDone?: boolean
actions: AiAction[]
@@ -27,6 +28,18 @@ export interface AiMessageProps {
onNavigateWikilink?: (target: string) => void
}
function LocalMarker({ text }: { text: string }) {
return (
<div
className="mx-auto text-center text-muted-foreground"
style={{ fontSize: 11, margin: '8px 0 16px', maxWidth: '85%' }}
data-testid="ai-local-marker"
>
{text}
</div>
)
}
function ReferencePill({ reference, onClick }: {
reference: NoteReference
onClick?: (path: string) => void
@@ -176,7 +189,15 @@ function StreamingIndicator() {
)
}
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
export function AiMessage(props: AiMessageProps) {
if (props.localMarker) {
return <LocalMarker text={props.localMarker} />
}
return <ConversationMessage {...props} />
}
function ConversationMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
// Manual override: null = follow auto behavior, true/false = user forced
const [userOverride, setUserOverride] = useState(false)
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())

View File

@@ -4,20 +4,27 @@ import { AiPanel } from './AiPanel'
import { UNSUPPORTED_INLINE_PASTE_MESSAGE } from './InlineWikilinkInput'
import type { VaultEntry } from '../types'
import { queueAiPrompt } from '../utils/aiPromptBridge'
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
// Mock the hooks and utils to isolate component tests
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()
const mockAddLocalMarker = vi.fn()
const mockUseCliAiAgent = vi.fn()
vi.mock('../hooks/useCliAiAgent', () => ({
useCliAiAgent: () => ({
messages: mockMessages,
status: mockStatus,
sendMessage: mockSendMessage,
clearConversation: mockClearConversation,
}),
useCliAiAgent: (...args: unknown[]) => {
mockUseCliAiAgent(...args)
return {
messages: mockMessages,
status: mockStatus,
sendMessage: mockSendMessage,
clearConversation: mockClearConversation,
addLocalMarker: mockAddLocalMarker,
}
},
}))
vi.mock('../utils/ai-chat', () => ({
@@ -45,7 +52,18 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: null,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: false,
...overrides,
})
@@ -55,12 +73,78 @@ describe('AiPanel', () => {
mockStatus = 'idle'
mockSendMessage.mockReset()
mockClearConversation.mockReset()
mockAddLocalMarker.mockReset()
mockUseCliAiAgent.mockReset()
resetVaultConfigStore()
bindVaultConfigStore({
zoom: null,
view_mode: null,
editor_mode: null,
note_layout: null,
tag_colors: null,
status_colors: null,
property_display_modes: null,
inbox: null,
allNotes: null,
ai_agent_permission_mode: 'safe',
}, vi.fn())
})
it('renders panel with the default CLI agent header', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('AI Agent')).toBeTruthy()
expect(screen.getByText('Claude Code')).toBeTruthy()
expect(screen.getByText('Claude Code · Safe')).toBeTruthy()
})
it('passes the vault permission mode to the AI agent session', () => {
bindVaultConfigStore({
...getVaultConfig(),
ai_agent_permission_mode: 'power_user',
}, vi.fn())
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('Claude Code · Power User')).toBeTruthy()
expect(mockUseCliAiAgent).toHaveBeenCalledWith(
'/tmp/vault',
undefined,
expect.any(Object),
expect.objectContaining({ permissionMode: 'power_user' }),
)
})
it('persists permission mode changes and records a local transcript marker', () => {
const save = vi.fn()
bindVaultConfigStore({
...getVaultConfig(),
ai_agent_permission_mode: 'safe',
}, save)
mockMessages = [{
userMessage: 'Existing question',
actions: [],
response: 'Existing answer.',
id: 'msg-existing',
}]
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
fireEvent.click(screen.getByRole('button', { name: 'Power User' }))
expect(getVaultConfig().ai_agent_permission_mode).toBe('power_user')
expect(save).toHaveBeenLastCalledWith(expect.objectContaining({
ai_agent_permission_mode: 'power_user',
}))
expect(mockAddLocalMarker).toHaveBeenCalledWith(
'AI permission mode changed to Power User. It will apply to the next message.',
)
})
it('disables permission mode changes while the AI agent is running', () => {
mockStatus = 'thinking'
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByRole('button', { name: 'Vault Safe' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Power User' })).toBeDisabled()
})
it('renders data-testid ai-panel', () => {
@@ -85,6 +169,15 @@ describe('AiPanel', () => {
expect(mockClearConversation).toHaveBeenCalledOnce()
})
it('copies the MCP config from the AI panel header action', () => {
const onCopyMcpConfig = vi.fn()
render(<AiPanel onClose={vi.fn()} onCopyMcpConfig={onCopyMcpConfig} vaultPath="/tmp/vault" />)
fireEvent.click(screen.getByRole('button', { name: 'Copy MCP config' }))
expect(onCopyMcpConfig).toHaveBeenCalledOnce()
})
it('renders empty state without context', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('Open a note, then ask Claude Code about it')).toBeTruthy()

View File

@@ -21,6 +21,7 @@ export type { AiAgentMessage } from '../hooks/useCliAiAgent'
interface AiPanelProps {
onClose: () => void
onCopyMcpConfig?: () => void
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
@@ -42,6 +43,7 @@ interface AiPanelProps {
interface AiPanelViewProps {
controller: AiPanelController
onClose: () => void
onCopyMcpConfig?: () => void
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
@@ -58,6 +60,7 @@ function readinessFromReadyFlag(ready: boolean | undefined): AiAgentReadiness {
export function AiPanelView({
controller,
onClose,
onCopyMcpConfig,
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
@@ -79,8 +82,10 @@ export function AiPanelView({
linkedEntries,
hasContext,
isActive,
permissionMode,
handleSend,
handleNavigateWikilink,
handlePermissionModeChange,
handleNewChat,
} = controller
@@ -112,7 +117,11 @@ export function AiPanelView({
<AiPanelHeader
agentLabel={agentLabel}
agentReadiness={defaultAiAgentReadiness}
permissionMode={permissionMode}
permissionModeDisabled={isActive}
onPermissionModeChange={handlePermissionModeChange}
onClose={onClose}
onCopyMcpConfig={onCopyMcpConfig}
onNewChat={handleNewChat}
/>
{activeEntry && (
@@ -144,6 +153,7 @@ export function AiPanelView({
export function AiPanel({
onClose,
onCopyMcpConfig,
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
@@ -183,6 +193,7 @@ export function AiPanel({
<AiPanelView
controller={controller}
onClose={onClose}
onCopyMcpConfig={onCopyMcpConfig}
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={providedDefaultAiAgent}

View File

@@ -1,8 +1,14 @@
import { useEffect, useRef } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { Copy } from 'lucide-react'
import { AiMessage } from './AiMessage'
import { Button } from '@/components/ui/button'
import { WikilinkChatInput } from './WikilinkChatInput'
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
import {
AI_AGENT_PERMISSION_MODE_LABELS,
type AiAgentPermissionMode,
} from '../lib/aiAgentPermissionMode'
import type { AiAgentMessage } from '../hooks/useCliAiAgent'
import type { AiAgentReadiness } from '../lib/aiAgents'
import type { NoteReference } from '../utils/ai-context'
@@ -11,7 +17,11 @@ import type { VaultEntry } from '../types'
interface AiPanelHeaderProps {
agentLabel: string
agentReadiness: AiAgentReadiness
permissionMode: AiAgentPermissionMode
permissionModeDisabled: boolean
onPermissionModeChange: (mode: AiAgentPermissionMode) => void
onClose: () => void
onCopyMcpConfig?: () => void
onNewChat: () => void
}
@@ -121,40 +131,108 @@ function AiPanelEmptyState({
export function AiPanelHeader({
agentLabel,
agentReadiness,
permissionMode,
permissionModeDisabled,
onPermissionModeChange,
onClose,
onCopyMcpConfig,
onNewChat,
}: AiPanelHeaderProps) {
const modeLabel = AI_AGENT_PERMISSION_MODE_LABELS[permissionMode].short
return (
<div
className="flex shrink-0 items-center border-b border-border"
style={{ height: 52, padding: '0 12px', gap: 8 }}
className="flex shrink-0 flex-col border-b border-border"
style={{ padding: '8px 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 }}>
AI Agent
</span>
<span className="truncate text-[11px] text-muted-foreground">
{agentReadiness === 'checking'
? 'Checking availability'
: `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ''}`}
</span>
<div className="flex items-center" style={{ 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 }}>
AI Agent
</span>
<span className="truncate text-[11px] text-muted-foreground">
{agentReadiness === 'checking'
? 'Checking availability'
: `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ` · ${modeLabel}`}`}
</span>
</div>
{onCopyMcpConfig ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onCopyMcpConfig}
aria-label="Copy MCP config"
title="Copy MCP config"
data-testid="ai-copy-mcp-config"
>
<Copy size={15} />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onNewChat}
aria-label="New AI chat"
title="New AI chat"
>
<Plus size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onClose}
aria-label="Close AI panel"
title="Close AI panel"
>
<X size={16} />
</Button>
</div>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={onNewChat}
aria-label="New AI chat"
title="New AI chat"
>
<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>
<AiPermissionModeToggle
value={permissionMode}
disabled={permissionModeDisabled}
onChange={onPermissionModeChange}
/>
</div>
)
}
function AiPermissionModeToggle({
value,
disabled,
onChange,
}: {
value: AiAgentPermissionMode
disabled: boolean
onChange: (mode: AiAgentPermissionMode) => void
}) {
return (
<div
className="grid rounded-md bg-muted"
style={{ gridTemplateColumns: '1fr 1fr', gap: 2, padding: 2 }}
role="group"
aria-label="AI agent permission mode"
data-testid="ai-permission-mode-toggle"
>
{(['safe', 'power_user'] as const).map((mode) => {
const selected = value === mode
return (
<Button
key={mode}
type="button"
size="xs"
variant={selected ? 'secondary' : 'ghost'}
disabled={disabled}
aria-pressed={selected}
onClick={() => onChange(mode)}
>
{AI_AGENT_PERMISSION_MODE_LABELS[mode].control}
</Button>
)
})}
</div>
)
}
@@ -253,16 +331,20 @@ export function AiPanelComposer({
inputRef={inputRef}
/>
</div>
<button
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
style={sendButtonStyle}
onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
disabled={!canSend}
aria-label="Send message"
title="Send message"
data-testid="agent-send"
>
<PaperPlaneRight size={16} />
</button>
</Button>
</div>
</div>
)

View File

@@ -67,6 +67,7 @@ interface EditorProps {
onInitializeProperties?: (path: string) => void
showAIChat?: boolean
onToggleAIChat?: () => void
onCopyMcpConfig?: () => void
vaultPath?: string
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
@@ -338,6 +339,7 @@ function EditorLayout({
showDiffToggle,
showAIChat,
onToggleAIChat,
onCopyMcpConfig,
inspectorCollapsed,
onToggleInspector,
onNavigateWikilink,
@@ -400,6 +402,7 @@ function EditorLayout({
showDiffToggle: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
onCopyMcpConfig?: () => void
inspectorCollapsed: boolean
onToggleInspector: () => void
onNavigateWikilink: (target: string) => void
@@ -520,6 +523,7 @@ function EditorLayout({
noteListFilter={noteListFilter}
onToggleInspector={onToggleInspector}
onToggleAIChat={onToggleAIChat}
onCopyMcpConfig={onCopyMcpConfig}
onNavigateWikilink={onNavigateWikilink}
onViewCommitDiff={handleViewCommitDiff}
onUpdateFrontmatter={onUpdateFrontmatter}
@@ -551,6 +555,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
onCopyMcpConfig,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile,
onDeleteNote, onArchiveNote, onUnarchiveNote,
@@ -611,6 +616,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
showDiffToggle={showDiffToggle}
showAIChat={showAIChat}
onToggleAIChat={onToggleAIChat}
onCopyMcpConfig={onCopyMcpConfig}
inspectorCollapsed={inspectorCollapsed}
onToggleInspector={onToggleInspector}
onNavigateWikilink={onNavigateWikilink}

View File

@@ -25,6 +25,7 @@ interface EditorRightPanelProps {
noteListFilter?: { type: string | null; query: string }
onToggleInspector: () => void
onToggleAIChat?: () => void
onCopyMcpConfig?: () => void
onNavigateWikilink: (target: string) => void
onViewCommitDiff: (commitHash: string) => Promise<void>
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
@@ -48,6 +49,7 @@ export function EditorRightPanel({
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onCopyMcpConfig,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
onFileCreated, onFileModified, onVaultChanged,
locale,
@@ -87,6 +89,7 @@ export function EditorRightPanel({
<AiPanelView
controller={aiPanelController}
onClose={() => onToggleAIChat?.()}
onCopyMcpConfig={onCopyMcpConfig}
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={defaultAiAgent}

View File

@@ -82,6 +82,17 @@ function isSelectAllShortcut(event: React.KeyboardEvent<HTMLDivElement>) {
return event.key.toLowerCase() === 'a' && (event.metaKey || event.ctrlKey)
}
function isLineBreakShortcut(
event: React.KeyboardEvent<HTMLDivElement>,
isComposing: boolean,
) {
return event.key === 'Enter'
&& event.shiftKey
&& !isComposing
&& !event.nativeEvent.isComposing
&& event.keyCode !== 229
}
export const UNSUPPORTED_INLINE_PASTE_MESSAGE = 'Only text paste is supported in the AI composer right now.'
function hasUnsupportedClipboardPayload(clipboardData: DataTransfer) {
@@ -288,6 +299,12 @@ export function InlineWikilinkInput({
if (!isInsertBeforeInput(nativeEvent)) return
if (nativeEvent.inputType === 'insertLineBreak') {
nativeEvent.preventDefault()
insertTransferText('\n')
return
}
if (isPlainTextBeforeInput(nativeEvent)) {
nativeEvent.preventDefault()
insertTransferText(nativeEvent.data)
@@ -422,6 +439,12 @@ export function InlineWikilinkInput({
const submitValue = () =>
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!disabled && isLineBreakShortcut(event, isComposingRef.current)) {
event.preventDefault()
insertTransferText('\n')
return
}
if (isSelectAllShortcut(event)) {
event.preventDefault()
selectAllContent()

View File

@@ -203,7 +203,7 @@ export function InlineWikilinkEditorField({
contentEditable={!disabled}
suppressContentEditableWarning={true}
role="textbox"
aria-multiline="false"
aria-multiline="true"
aria-disabled={disabled || undefined}
aria-placeholder={placeholder}
data-testid={dataTestId}

View File

@@ -2,6 +2,20 @@ import { describe, it, expect, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { McpSetupDialog } from './McpSetupDialog'
const MANUAL_CONFIG = JSON.stringify({
mcpServers: {
tolaria: {
type: 'stdio',
command: 'node',
args: ['/Applications/Tolaria.app/Contents/Resources/mcp-server/index.js'],
env: {
VAULT_PATH: '/Users/luca/Laputa',
WS_UI_PORT: '9711',
},
},
},
}, null, 2)
describe('McpSetupDialog', () => {
it('renders the explicit setup flow without mutating config by default', () => {
render(
@@ -9,6 +23,7 @@ describe('McpSetupDialog', () => {
open={true}
status="not_installed"
busyAction={null}
manualConfigSnippet={MANUAL_CONFIG}
onClose={vi.fn()}
onConnect={vi.fn()}
onDisconnect={vi.fn()}
@@ -18,13 +33,16 @@ describe('McpSetupDialog', () => {
expect(screen.getByText('Set Up External AI Tools')).toBeInTheDocument()
expect(screen.getByText(/will not touch third-party config files until you confirm here/i)).toBeInTheDocument()
expect(screen.getByText(/requires Node.js 18\+ on PATH/i)).toBeInTheDocument()
expect(screen.getByText(/type: stdio/i)).toBeInTheDocument()
expect(screen.getByText(/VAULT_PATH/i)).toBeInTheDocument()
expect(screen.getByText(/WS_UI_PORT/i)).toBeInTheDocument()
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"type": "stdio"')
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"VAULT_PATH": "/Users/luca/Laputa"')
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"WS_UI_PORT": "9711"')
expect(screen.getAllByText('~/.claude.json')).toHaveLength(2)
expect(screen.getByText('~/.claude/mcp.json')).toBeInTheDocument()
expect(screen.getAllByText('~/.gemini/settings.json')).toHaveLength(2)
expect(screen.getAllByText('~/.config/mcp/mcp.json')).toHaveLength(2)
expect(screen.getByText(/picked up by other MCP-compatible tools/i)).toBeInTheDocument()
expect(screen.getByText(/Gemini CLI needs its own install and sign-in/i)).toBeInTheDocument()
expect(screen.getByText('GEMINI.md')).toBeInTheDocument()
expect(screen.getByTestId('mcp-setup-connect')).toHaveTextContent('Connect External AI Tools')
expect(screen.queryByTestId('mcp-setup-disconnect')).not.toBeInTheDocument()
})
@@ -49,6 +67,7 @@ describe('McpSetupDialog', () => {
it('routes actions through the dialog buttons', () => {
const onClose = vi.fn()
const onConnect = vi.fn()
const onCopyManualConfig = vi.fn()
const onDisconnect = vi.fn()
render(
@@ -58,16 +77,37 @@ describe('McpSetupDialog', () => {
busyAction={null}
onClose={onClose}
onConnect={onConnect}
onCopyManualConfig={onCopyManualConfig}
onDisconnect={onDisconnect}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
fireEvent.click(screen.getByTestId('mcp-copy-config'))
fireEvent.click(screen.getByTestId('mcp-setup-connect'))
fireEvent.click(screen.getByTestId('mcp-setup-disconnect'))
expect(onClose).toHaveBeenCalledOnce()
expect(onCopyManualConfig).toHaveBeenCalledOnce()
expect(onConnect).toHaveBeenCalledOnce()
expect(onDisconnect).toHaveBeenCalledOnce()
})
it('loads exact manual config when opened', () => {
const onLoadManualConfig = vi.fn()
render(
<McpSetupDialog
open={true}
status="not_installed"
busyAction={null}
onClose={vi.fn()}
onConnect={vi.fn()}
onDisconnect={vi.fn()}
onLoadManualConfig={onLoadManualConfig}
/>,
)
expect(onLoadManualConfig).toHaveBeenCalledOnce()
})
})

View File

@@ -1,4 +1,5 @@
import { ShieldCheck } from 'lucide-react'
import { useEffect } from 'react'
import { Copy, ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -14,6 +15,29 @@ interface McpSetupDialogProps {
open: boolean
status: McpStatus
busyAction: 'connect' | 'disconnect' | null
manualConfigError?: string | null
manualConfigLoading?: boolean
manualConfigSnippet?: string | null
onClose: () => void
onConnect: () => void
onCopyManualConfig?: () => void
onDisconnect: () => void
onLoadManualConfig?: () => void
}
interface ManualMcpConfigSectionProps {
error?: string | null
loading: boolean
onCopy?: () => void
snippet?: string | null
}
interface McpSetupActionsProps {
buttonsDisabled: boolean
connectBusy: boolean
disconnectBusy: boolean
primaryLabel: string
secondaryLabel: string | null
onClose: () => void
onConnect: () => void
onDisconnect: () => void
@@ -41,19 +65,100 @@ function actionCopy(status: McpStatus) {
}
}
function manualConfigText({ error, loading, snippet }: ManualMcpConfigSectionProps): string {
if (loading) return 'Loading exact MCP config...'
return error ?? snippet ?? 'Exact config is available after a vault is open.'
}
function ManualMcpConfigSection(props: ManualMcpConfigSectionProps) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="m-0 text-sm font-medium text-foreground">Manual MCP config</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={props.onCopy}
disabled={!props.onCopy || props.loading}
data-testid="mcp-copy-config"
>
<Copy size={14} />
Copy MCP config
</Button>
</div>
<pre
tabIndex={0}
className="max-h-48 overflow-auto rounded-md border border-border bg-background px-3 py-3 font-mono text-xs leading-5 text-foreground"
data-testid="mcp-config-snippet"
>
{manualConfigText(props)}
</pre>
</div>
)
}
function McpSetupActions({
buttonsDisabled,
connectBusy,
disconnectBusy,
primaryLabel,
secondaryLabel,
onClose,
onConnect,
onDisconnect,
}: McpSetupActionsProps) {
return (
<DialogFooter className="flex-row items-center justify-end gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={onClose} disabled={buttonsDisabled}>
Cancel
</Button>
{secondaryLabel ? (
<Button
type="button"
variant="destructive"
onClick={onDisconnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-disconnect"
>
{disconnectBusy ? 'Disconnecting…' : secondaryLabel}
</Button>
) : null}
<Button
type="button"
autoFocus
onClick={onConnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-connect"
>
{connectBusy ? 'Connecting…' : primaryLabel}
</Button>
</DialogFooter>
)
}
export function McpSetupDialog({
open,
status,
busyAction,
manualConfigError,
manualConfigLoading = false,
manualConfigSnippet,
onClose,
onConnect,
onCopyManualConfig,
onDisconnect,
onLoadManualConfig,
}: McpSetupDialogProps) {
const copy = actionCopy(status)
const connectBusy = busyAction === 'connect'
const disconnectBusy = busyAction === 'disconnect'
const buttonsDisabled = busyAction !== null || status === 'checking'
useEffect(() => {
if (open) onLoadManualConfig?.()
}, [open, onLoadManualConfig])
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[520px]" data-testid="mcp-setup-dialog">
@@ -75,46 +180,34 @@ export function McpSetupDialog({
<div className="rounded-md border border-border bg-muted/30 px-3 py-3 font-mono text-xs text-foreground">
<div>~/.claude.json</div>
<div>~/.claude/mcp.json</div>
<div>~/.gemini/settings.json</div>
<div>~/.cursor/mcp.json</div>
<div>~/.config/mcp/mcp.json</div>
</div>
<div className="rounded-md border border-border bg-background px-3 py-3 font-mono text-xs leading-5 text-foreground">
<div>type: stdio</div>
<div>command: node</div>
<div>args: &lt;Tolaria resources&gt;/mcp-server/index.js</div>
<div>VAULT_PATH: active vault</div>
<div>WS_UI_PORT: 9711</div>
</div>
<ManualMcpConfigSection
error={manualConfigError}
loading={manualConfigLoading}
onCopy={onCopyManualConfig}
snippet={manualConfigSnippet}
/>
<p>
Claude Code CLI reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.claude.json</code>, Cursor reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.cursor/mcp.json</code>, and the generic <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.config/mcp/mcp.json</code> path is picked up by other MCP-compatible tools. Cancel leaves all files untouched, reconnect is idempotent, and disconnect removes Tolaria&apos;s entry again.
Claude Code CLI reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.claude.json</code>, Gemini CLI reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.gemini/settings.json</code>, Cursor reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.cursor/mcp.json</code>, and the generic <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.config/mcp/mcp.json</code> path is picked up by other MCP-compatible tools. Cancel leaves all files untouched, reconnect is idempotent, and disconnect removes Tolaria&apos;s entry again.
</p>
<p>
Gemini CLI needs its own install and sign-in. Use Restore Tolaria AI Guidance when you want a vault-root <code className="rounded bg-muted px-1 py-0.5 text-xs">GEMINI.md</code> compatibility shim that points Gemini back to the shared <code className="rounded bg-muted px-1 py-0.5 text-xs">AGENTS.md</code> instructions without overwriting custom guidance.
</p>
</div>
<DialogFooter className="flex-row items-center justify-end gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={onClose} disabled={buttonsDisabled}>
Cancel
</Button>
{copy.secondaryLabel ? (
<Button
type="button"
variant="destructive"
onClick={onDisconnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-disconnect"
>
{disconnectBusy ? 'Disconnecting…' : copy.secondaryLabel}
</Button>
) : null}
<Button
type="button"
autoFocus
onClick={onConnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-connect"
>
{connectBusy ? 'Connecting…' : copy.primaryLabel}
</Button>
</DialogFooter>
<McpSetupActions
buttonsDisabled={buttonsDisabled}
connectBusy={connectBusy}
disconnectBusy={disconnectBusy}
primaryLabel={copy.primaryLabel}
secondaryLabel={copy.secondaryLabel}
onClose={onClose}
onConnect={onConnect}
onDisconnect={onDisconnect}
/>
</DialogContent>
</Dialog>
)

View File

@@ -56,7 +56,7 @@ describe('NoteList virtualized datasets', () => {
expect(screen.getByText('Note 499')).toBeInTheDocument()
})
it('filters large datasets by search query', async () => {
it('filters large datasets by search query', { timeout: 15000 }, async () => {
vi.useFakeTimers()
try {
const entries = [

View File

@@ -54,6 +54,8 @@ describe('SettingsPanel', () => {
vi.clearAllMocks()
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true })
window.localStorage.clear()
document.documentElement.removeAttribute('data-theme')
document.documentElement.classList.remove('dark')
installPointerCapturePolyfill()
})
@@ -201,6 +203,21 @@ describe('SettingsPanel', () => {
}))
})
it('applies the selected dark color mode immediately while settings stays open', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByRole('radio', { name: 'Dark' }))
expect(document.documentElement).toHaveAttribute('data-theme', 'dark')
expect(document.documentElement).toHaveClass('dark')
expect(window.localStorage.getItem(THEME_MODE_STORAGE_KEY)).toBe('dark')
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
theme_mode: 'dark',
}))
})
it('preserves a saved dark color mode until changed', () => {
render(
<SettingsPanel

View File

@@ -28,9 +28,11 @@ import {
type UiLanguagePreference,
} from '../lib/i18n'
import {
applyThemeModeToDocument,
DEFAULT_THEME_MODE,
readStoredThemeMode,
type ThemeMode,
writeStoredThemeMode,
} from '../lib/themeMode'
import { normalizeReleaseChannel, serializeReleaseChannel, type ReleaseChannel } from '../lib/releaseChannel'
import { shouldHideGitignoredFiles } from '../lib/gitignoredVisibility'
@@ -203,6 +205,11 @@ function sanitizePositiveInteger(value: number | null | undefined, fallback: num
return Math.round(value)
}
function applyThemeModeSelection(value: ThemeMode): void {
if (typeof document !== 'undefined') applyThemeModeToDocument(document, value)
if (typeof window !== 'undefined') writeStoredThemeMode(window.localStorage, value)
}
export function SettingsPanel({
open,
settings,
@@ -279,6 +286,12 @@ function SettingsPanelInner({
onSave({ ...settings, hide_gitignored_files: value })
}, [onSave, settings, updateDraft])
const handleThemeModeChange = useCallback((value: ThemeMode) => {
updateDraft('themeMode', value)
applyThemeModeSelection(value)
onSave({ ...settings, theme_mode: value })
}, [onSave, settings, updateDraft])
const handleSave = useCallback(() => {
trackTelemetryConsentChange(settings.analytics_enabled === true, draft.analytics)
onSave(buildSettingsFromDraft(settings, draft))
@@ -344,7 +357,7 @@ function SettingsPanelInner({
releaseChannel={draft.releaseChannel}
setReleaseChannel={(value) => updateDraft('releaseChannel', value)}
themeMode={draft.themeMode}
setThemeMode={(value) => updateDraft('themeMode', value)}
setThemeMode={handleThemeModeChange}
uiLanguage={draft.uiLanguage}
setUiLanguage={(value) => updateDraft('uiLanguage', value)}
initialH1AutoRename={draft.initialH1AutoRename}

View File

@@ -1384,6 +1384,33 @@ describe('Sidebar', () => {
},
]
it('renders keyboard-accessible move buttons for saved views', () => {
const onMoveView = vi.fn()
render(
<Sidebar
entries={mockEntries}
selection={defaultSelection}
onSelect={() => {}}
views={mockViews}
onMoveView={onMoveView}
/>
)
const moveUpButtons = screen.getAllByTitle('Move view up')
const moveDownButtons = screen.getAllByTitle('Move view down')
expect(moveUpButtons[0]).toBeDisabled()
expect(moveUpButtons[1]).not.toBeDisabled()
expect(moveDownButtons[0]).not.toBeDisabled()
expect(moveDownButtons[1]).toBeDisabled()
fireEvent.click(moveUpButtons[1])
expect(onMoveView).toHaveBeenCalledWith('all-topics.yml', 'up')
fireEvent.click(moveDownButtons[0])
expect(onMoveView).toHaveBeenCalledWith('active-projects.yml', 'down')
})
it('shows note count chip for each view matching the filter results', () => {
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} />

View File

@@ -24,6 +24,7 @@ import {
import { useSidebarTypeInteractions } from './sidebar/useSidebarTypeInteractions'
import type { AppLocale } from '../lib/i18n'
import type { FolderFileActions } from '../hooks/useFileActions'
import type { ViewMoveDirection } from '../utils/viewOrdering'
interface SidebarProps {
entries: VaultEntry[]
@@ -43,6 +44,8 @@ interface SidebarProps {
onCreateView?: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
onReorderViews?: (orderedFilenames: string[]) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
folders?: FolderNode[]
onCreateFolder?: (name: string) => Promise<boolean> | boolean
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
@@ -68,6 +71,8 @@ interface SidebarNavigationProps extends Pick<
| 'onCreateView'
| 'onEditView'
| 'onDeleteView'
| 'onReorderViews'
| 'onMoveView'
| 'folders'
| 'onCreateFolder'
| 'onRenameFolder'
@@ -106,6 +111,8 @@ function SidebarNavigation({
onCreateView,
onEditView,
onDeleteView,
onReorderViews,
onMoveView,
folders = [],
onCreateFolder,
onRenameFolder,
@@ -170,6 +177,9 @@ function SidebarNavigation({
onCreateView={onCreateView}
onEditView={onEditView}
onDeleteView={onDeleteView}
onReorderViews={onReorderViews}
onMoveView={onMoveView}
sensors={sensors}
entries={entries}
locale={locale}
/>
@@ -232,6 +242,8 @@ export const Sidebar = memo(function Sidebar({
onCreateView,
onEditView,
onDeleteView,
onReorderViews,
onMoveView,
folders = [],
onCreateFolder,
onRenameFolder,
@@ -268,6 +280,7 @@ export const Sidebar = memo(function Sidebar({
const reordered = computeReorder(sectionIds, active.id as string, over.id as string)
if (reordered) onReorderSections?.(reordered.map((typeName, order) => ({ typeName, order })))
}, [sectionIds, onReorderSections])
const viewActions = { onCreateView, onEditView, onDeleteView, onReorderViews, onMoveView }
const sectionProps: SidebarSectionProps = {
entries,
@@ -291,9 +304,7 @@ export const Sidebar = memo(function Sidebar({
onSelectFavorite={onSelectFavorite}
onReorderFavorites={onReorderFavorites}
views={views}
onCreateView={onCreateView}
onEditView={onEditView}
onDeleteView={onDeleteView}
{...viewActions}
folders={folders}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}

View File

@@ -14,7 +14,9 @@ const state = vi.hoisted(() => ({
hoverGuardMock: vi.fn(),
imageDropState: { isDragOver: false },
linkActivationMock: vi.fn(),
personMentionCandidates: [] as Record<string, unknown>[],
wikilinkEntriesRef: { current: [] as VaultEntry[] },
wikilinkCandidates: [] as Record<string, unknown>[],
}))
vi.mock('@blocknote/react', () => ({
@@ -145,12 +147,12 @@ vi.mock('../utils/typeColors', () => ({
vi.mock('../utils/wikilinkSuggestions', () => ({
MIN_QUERY_LENGTH: 2,
deduplicateByPath: <T,>(items: T[]) => items,
preFilterWikilinks: () => [],
preFilterWikilinks: () => state.wikilinkCandidates,
}))
vi.mock('../utils/personMentionSuggestions', () => ({
PERSON_MENTION_MIN_QUERY: 1,
filterPersonMentions: () => [],
filterPersonMentions: () => state.personMentionCandidates,
}))
vi.mock('../utils/suggestionEnrichment', () => ({
@@ -241,6 +243,7 @@ function createEditor() {
{ id: 'heading-block', type: 'heading', content: [], children: [] },
cursorBlock,
],
domElement: undefined as HTMLElement | undefined,
tryParseMarkdownToBlocks: vi.fn(async () => [
{ type: 'table', content: { type: 'tableContent' } },
]),
@@ -308,7 +311,9 @@ describe('SingleEditorView', () => {
state.capturedBlockNoteOnChange = null
state.capturedMantineGetStyleNonce = null
state.imageDropState.isDragOver = false
state.personMentionCandidates = []
state.wikilinkEntriesRef.current = []
state.wikilinkCandidates = []
mockOpenExternalUrl.mockClear()
document.documentElement.removeAttribute('data-theme')
document.documentElement.classList.remove('dark')
@@ -435,6 +440,60 @@ describe('SingleEditorView', () => {
expect(onMentionItemClick).toHaveBeenCalledOnce()
})
it('ignores stale suggestion item clicks after the editor DOM disconnects', () => {
const editor = createEditor()
editor.domElement = document.createElement('div')
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const staleItemClick = vi.fn(() => {
throw new TypeError('Cannot read properties of undefined (reading isConnected)')
})
expect(() => {
;(state.capturedSuggestionProps['[['].onItemClick as (item: { onItemClick: () => void }) => void)({
onItemClick: staleItemClick,
})
}).not.toThrow()
expect(staleItemClick).not.toHaveBeenCalled()
})
it('guards stale click handlers stored on wikilink suggestion items', async () => {
const editor = createEditor()
editor.domElement = document.createElement('div')
const staleItemClick = vi.fn(() => {
throw new TypeError('Cannot read properties of undefined (reading isConnected)')
})
state.wikilinkCandidates = [{
title: 'Alpha',
path: '/vault/project/alpha.md',
onItemClick: staleItemClick,
}]
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const getItems = state.capturedSuggestionProps['[['].getItems as (
query: string
) => Promise<Array<{ onItemClick: () => void }>>
const items = await getItems('al')
expect(items).toHaveLength(1)
expect(() => items[0].onItemClick()).not.toThrow()
expect(staleItemClick).not.toHaveBeenCalled()
})
it('passes the active document theme to BlockNote', () => {
document.documentElement.setAttribute('data-theme', 'dark')
document.documentElement.classList.add('dark')

View File

@@ -67,6 +67,53 @@ type TestTableBlock = {
type?: string
content?: { type?: string; columnWidths?: Array<number | null> }
}
type SuggestionAction = () => void
type SuggestionItemWithClick = { onItemClick?: SuggestionAction }
function isEditorReadyForSuggestionAction(
editor: ReturnType<typeof useCreateBlockNote>,
container: HTMLElement | null,
) {
if (!container?.isConnected) return false
const editorElement = editor.domElement
if (!(editorElement instanceof HTMLElement)) return true
return editorElement.isConnected && container.contains(editorElement)
}
function runSuggestionActionSafely({
action,
container,
editor,
}: {
action: SuggestionAction
container: HTMLElement | null
editor: ReturnType<typeof useCreateBlockNote>
}) {
if (!isEditorReadyForSuggestionAction(editor, container)) return
try {
action()
} catch (error) {
console.warn('[editor] Ignored stale suggestion menu action:', error)
}
}
function guardSuggestionMenuItems<T extends SuggestionItemWithClick>(
items: T[],
runEditorAction: (action: SuggestionAction) => void,
): T[] {
return items.map((item) => {
if (!item.onItemClick) return item
const onItemClick = item.onItemClick
return {
...item,
onItemClick: () => runEditorAction(onItemClick),
}
})
}
function SharedContextBlockNoteView(props: React.ComponentProps<typeof BlockNoteViewRaw>) {
const { children, className, theme, ...rest } = props
@@ -412,20 +459,26 @@ function buildBaseSuggestionItems(entries: VaultEntry[]) {
})))
}
function useInsertWikilink(editor: ReturnType<typeof useCreateBlockNote>) {
function useInsertWikilink(
editor: ReturnType<typeof useCreateBlockNote>,
runEditorAction: (action: SuggestionAction) => void,
) {
return useCallback((target: string) => {
editor.insertInlineContent([
{ type: 'wikilink' as const, props: { target } },
" ",
], { updateSelection: true })
trackEvent('wikilink_inserted')
}, [editor])
runEditorAction(() => {
editor.insertInlineContent([
{ type: 'wikilink' as const, props: { target } },
" ",
], { updateSelection: true })
trackEvent('wikilink_inserted')
})
}, [editor, runEditorAction])
}
function useSuggestionMenuItems(options: {
baseItems: ReturnType<typeof buildBaseSuggestionItems>
editor: ReturnType<typeof useCreateBlockNote>
insertWikilink: (target: string) => void
runEditorAction: (action: SuggestionAction) => void
typeEntryMap: Record<string, VaultEntry>
vaultPath?: string
}) {
@@ -433,6 +486,7 @@ function useSuggestionMenuItems(options: {
baseItems,
editor,
insertWikilink,
runEditorAction,
typeEntryMap,
vaultPath,
} = options
@@ -447,8 +501,11 @@ function useSuggestionMenuItems(options: {
: filterPersonMentions(baseItems, normalizedQuery)
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
return enrichSuggestionItems(items, normalizedQuery, typeEntryMap)
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
return guardSuggestionMenuItems(
enrichSuggestionItems(items, normalizedQuery, typeEntryMap),
runEditorAction,
)
}, [baseItems, insertWikilink, runEditorAction, typeEntryMap, vaultPath])
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => (
buildItems(query, '[[') ?? []
@@ -458,9 +515,17 @@ function useSuggestionMenuItems(options: {
buildItems(query, '@') ?? []
), [buildItems])
const getSlashMenuItems = useCallback(async (query: string) => (
getTolariaSlashMenuItems(editor, query)
), [editor])
const getSlashMenuItems = useCallback(async (query: string) => {
try {
return guardSuggestionMenuItems(
await Promise.resolve(getTolariaSlashMenuItems(editor, query)),
runEditorAction,
)
} catch (error) {
console.warn('[editor] Ignored stale slash menu query:', error)
return []
}
}, [editor, runEditorAction])
return {
getWikilinkItems,
@@ -513,7 +578,14 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(() => buildBaseSuggestionItems(entries), [entries])
const insertWikilink = useInsertWikilink(editor)
const runEditorAction = useCallback((action: SuggestionAction) => {
runSuggestionActionSafely({
action,
container: containerRef.current,
editor,
})
}, [editor])
const insertWikilink = useInsertWikilink(editor, runEditorAction)
const {
getWikilinkItems,
getPersonMentionItems,
@@ -522,6 +594,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
baseItems,
editor,
insertWikilink,
runEditorAction,
typeEntryMap,
vaultPath,
})
@@ -568,13 +641,13 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
triggerCharacter="[["
getItems={getWikilinkItems}
suggestionMenuComponent={WikilinkSuggestionMenu}
onItemClick={(item: WikilinkSuggestionItem) => item.onItemClick()}
onItemClick={(item: WikilinkSuggestionItem) => runEditorAction(item.onItemClick)}
/>
<SuggestionMenuController
triggerCharacter="@"
getItems={getPersonMentionItems}
suggestionMenuComponent={WikilinkSuggestionMenu}
onItemClick={(item: WikilinkSuggestionItem) => item.onItemClick()}
onItemClick={(item: WikilinkSuggestionItem) => runEditorAction(item.onItemClick)}
/>
</SharedContextBlockNoteView>
</div>

View File

@@ -459,6 +459,59 @@ describe('WikilinkChatInput', () => {
expect(onSend).not.toHaveBeenCalled()
})
it('inserts one controlled newline on Shift+Enter without duplicating the draft', () => {
const onDraftChange = vi.fn()
render(<Controlled onDraftChange={onDraftChange} />)
updateEditorText('first line')
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true })
expect(onDraftChange).toHaveBeenLastCalledWith('first line\n')
expect(screen.getByTestId('agent-input').textContent).toBe('first line\n')
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true })
expect(onDraftChange).toHaveBeenLastCalledWith('first line\n\n')
expect(screen.getByTestId('agent-input').textContent).toBe('first line\n\n')
})
it('inserts one controlled newline from native insertLineBreak beforeinput', () => {
const onDraftChange = vi.fn()
render(<Controlled onDraftChange={onDraftChange} />)
updateEditorText('first line')
const editor = screen.getByTestId('agent-input')
const beforeInputEvent = new Event('beforeinput', {
bubbles: true,
cancelable: true,
})
Object.defineProperty(beforeInputEvent, 'inputType', {
value: 'insertLineBreak',
})
fireEvent(editor, beforeInputEvent)
expect(beforeInputEvent.defaultPrevented).toBe(true)
expect(onDraftChange).toHaveBeenLastCalledWith('first line\n')
expect(screen.getByTestId('agent-input').textContent).toBe('first line\n')
})
it('submits a multi-line draft with normal Enter after Shift+Enter', () => {
const onSend = vi.fn()
render(<Controlled onSend={onSend} />)
updateEditorText('first line')
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true })
const editor = screen.getByTestId('agent-input')
editor.textContent = 'first line\nsecond line'
setSelection(editor, 'first line\nsecond line'.length)
fireEvent.input(editor)
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
expect(onSend).toHaveBeenCalledWith('first line\nsecond line', [])
})
it('marks the editor disabled when disabled is true', () => {
render(<Controlled disabled />)

View File

@@ -48,7 +48,7 @@ describe('inlineWikilinkDom', () => {
nested.textContent = 'Tail'
root.append(nested)
expect(serializeInlineNode(root)).toBe('A B [[Project]]Tail')
expect(serializeInlineNode(root)).toBe('A B\n[[Project]]Tail')
})
it('reads selections inside the editor and falls back to the editor end for outside selections', () => {

View File

@@ -12,5 +12,5 @@ export function normalizeInlineWikilinkValue(value: string): string {
return value
.replace(/\u00A0/g, ' ')
.replace(/\u200B/g, '')
.replace(/\r?\n/g, ' ')
.replace(/\r\n?/g, '\n')
}

View File

@@ -21,8 +21,10 @@ import { TypeCustomizePopover } from '../TypeCustomizePopover'
import { useDragRegion } from '../../hooks/useDragRegion'
import { SidebarGroupHeader } from './SidebarGroupHeader'
import { SidebarViewItem } from './SidebarViewItem'
import { computeReorder } from './sidebarHooks'
import { countByFilter } from '../../utils/noteListHelpers'
import { translate, type AppLocale } from '../../lib/i18n'
import { canMoveView, type ViewMoveDirection } from '../../utils/viewOrdering'
export { SidebarTopNav } from './SidebarTopNav'
export { FavoritesSection } from './FavoritesSection'
@@ -48,6 +50,9 @@ export function ViewsSection({
onCreateView,
onEditView,
onDeleteView,
onReorderViews,
onMoveView,
sensors,
entries,
locale = 'en',
}: {
@@ -59,9 +64,35 @@ export function ViewsSection({
onCreateView?: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
onReorderViews?: (orderedFilenames: string[]) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
sensors: ReturnType<typeof useSensors>
entries: VaultEntry[]
locale?: AppLocale
}) {
const viewIds = views.map((view) => view.filename)
const handleViewDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const reordered = computeReorder(viewIds, active.id as string, over.id as string)
if (reordered) onReorderViews?.(reordered)
}
const renderViewItem = (view: ViewFile) => (
<SidebarViewItem
key={view.filename}
view={view}
isActive={isSelectionActive(selection, { kind: 'view', filename: view.filename })}
onSelect={() => onSelect({ kind: 'view', filename: view.filename })}
onEditView={onEditView}
onDeleteView={onDeleteView}
onMoveView={onMoveView}
canMoveUp={canMoveView(views, view.filename, 'up')}
canMoveDown={canMoveView(views, view.filename, 'down')}
entries={entries}
locale={locale}
/>
)
return (
<div className="border-b border-border" style={{ padding: '0 6px' }}>
<SidebarGroupHeader label={translate(locale, 'sidebar.group.views')} collapsed={collapsed} onToggle={onToggle}>
@@ -81,24 +112,81 @@ export function ViewsSection({
</SidebarGroupHeader>
{!collapsed && (
<div style={{ paddingBottom: 4 }}>
{views.map((view) => (
<SidebarViewItem
key={view.filename}
view={view}
isActive={isSelectionActive(selection, { kind: 'view', filename: view.filename })}
onSelect={() => onSelect({ kind: 'view', filename: view.filename })}
onEditView={onEditView}
onDeleteView={onDeleteView}
entries={entries}
locale={locale}
/>
))}
{onReorderViews ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleViewDragEnd}>
<SortableContext items={viewIds} strategy={verticalListSortingStrategy}>
{views.map((view) => (
<SortableViewItem
key={view.filename}
view={view}
views={views}
selection={selection}
onSelect={onSelect}
onEditView={onEditView}
onDeleteView={onDeleteView}
onMoveView={onMoveView}
entries={entries}
locale={locale}
/>
))}
</SortableContext>
</DndContext>
) : views.map(renderViewItem)}
</div>
)}
</div>
)
}
function SortableViewItem({
view,
views,
selection,
onSelect,
onEditView,
onDeleteView,
onMoveView,
entries,
locale,
}: {
view: ViewFile
views: ViewFile[]
selection: SidebarSelection
onSelect: (selection: SidebarSelection) => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
entries: VaultEntry[]
locale?: AppLocale
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: view.filename })
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}}
>
<SidebarViewItem
view={view}
isActive={isSelectionActive(selection, { kind: 'view', filename: view.filename })}
onSelect={() => onSelect({ kind: 'view', filename: view.filename })}
onEditView={onEditView}
onDeleteView={onDeleteView}
onMoveView={onMoveView}
canMoveUp={canMoveView(views, view.filename, 'up')}
canMoveDown={canMoveView(views, view.filename, 'down')}
dragHandleProps={{ ...attributes, ...listeners }}
entries={entries}
locale={locale}
/>
</div>
)
}
function SortableSection({
group,
sectionProps,

View File

@@ -1,11 +1,14 @@
import { useMemo } from 'react'
import { useMemo, type HTMLAttributes } from 'react'
import type { VaultEntry, ViewFile } from '../../types'
import { evaluateView } from '../../utils/viewFilters'
import { Funnel, PencilSimple, Trash } from '@phosphor-icons/react'
import { ArrowDown, ArrowUp, GripVertical } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { NoteTitleIcon } from '../NoteTitleIcon'
import { SidebarCountPill } from '../SidebarParts'
import { SIDEBAR_ITEM_PADDING } from './sidebarStyles'
import { translate, type AppLocale } from '../../lib/i18n'
import type { ViewMoveDirection } from '../../utils/viewOrdering'
interface SidebarViewItemProps {
view: ViewFile
@@ -13,6 +16,10 @@ interface SidebarViewItemProps {
onSelect: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
canMoveUp?: boolean
canMoveDown?: boolean
dragHandleProps?: HTMLAttributes<HTMLButtonElement>
entries: VaultEntry[]
locale?: AppLocale
}
@@ -23,6 +30,10 @@ export function SidebarViewItem({
onSelect,
onEditView,
onDeleteView,
onMoveView,
canMoveUp = false,
canMoveDown = false,
dragHandleProps,
entries,
locale = 'en',
}: SidebarViewItemProps) {
@@ -39,6 +50,20 @@ export function SidebarViewItem({
style={{ padding: showCount ? SIDEBAR_ITEM_PADDING.withCount : SIDEBAR_ITEM_PADDING.regular, borderRadius: 4 }}
onClick={onSelect}
>
{dragHandleProps && (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="-ml-1 h-5 w-4 min-w-0 cursor-grab rounded p-0 text-muted-foreground hover:bg-transparent hover:text-foreground active:cursor-grabbing"
title={translate(locale, 'sidebar.action.reorderView')}
aria-label={translate(locale, 'sidebar.action.reorderView')}
onClick={(event) => event.stopPropagation()}
{...dragHandleProps}
>
<GripVertical size={12} />
</Button>
)}
{icon}
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{view.definition.name}</span>
{showCount && (
@@ -51,25 +76,59 @@ export function SidebarViewItem({
)}
</div>
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
{onMoveView && (
<>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-5 w-5 min-w-0 rounded p-0 text-muted-foreground hover:bg-transparent hover:text-foreground disabled:opacity-30"
disabled={!canMoveUp}
onClick={(event) => { event.stopPropagation(); onMoveView(view.filename, 'up') }}
title={translate(locale, 'sidebar.action.moveViewUp')}
aria-label={translate(locale, 'sidebar.action.moveViewUp')}
>
<ArrowUp size={12} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-5 w-5 min-w-0 rounded p-0 text-muted-foreground hover:bg-transparent hover:text-foreground disabled:opacity-30"
disabled={!canMoveDown}
onClick={(event) => { event.stopPropagation(); onMoveView(view.filename, 'down') }}
title={translate(locale, 'sidebar.action.moveViewDown')}
aria-label={translate(locale, 'sidebar.action.moveViewDown')}
>
<ArrowDown size={12} />
</Button>
</>
)}
{onEditView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-5 w-5 min-w-0 rounded p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={(event) => { event.stopPropagation(); onEditView(view.filename) }}
title={translate(locale, 'sidebar.action.editView')}
aria-label={translate(locale, 'sidebar.action.editView')}
>
<PencilSimple size={12} />
</button>
</Button>
)}
{onDeleteView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-5 w-5 min-w-0 rounded p-0 text-muted-foreground hover:bg-transparent hover:text-destructive"
onClick={(event) => { event.stopPropagation(); onDeleteView(view.filename) }}
title={translate(locale, 'sidebar.action.deleteView')}
aria-label={translate(locale, 'sidebar.action.deleteView')}
>
<Trash size={12} />
</button>
</Button>
)}
</div>
</div>

View File

@@ -58,6 +58,7 @@ describe('AiAgentsBadge', () => {
guidanceStatus={{
agentsState: 'missing',
claudeState: 'managed',
geminiState: 'managed',
canRestore: true,
}}
defaultAgent="claude_code"
@@ -88,6 +89,7 @@ describe('AiAgentsBadge', () => {
guidanceStatus={{
agentsState: 'managed',
claudeState: 'broken',
geminiState: 'managed',
canRestore: true,
}}
defaultAgent="claude_code"

View File

@@ -6,14 +6,20 @@ import { TolariaSideMenu } from './tolariaBlockNoteSideMenu'
let capturedDragHandleMenu: ComponentType | null = null
vi.mock('@blocknote/react', () => ({
AddBlockButton: () => <button type="button">Add block</button>,
DragHandleMenu: ({ children }: PropsWithChildren) => (
<div data-testid="drag-handle-menu">{children}</div>
),
RemoveBlockItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
SideMenu: ({ dragHandleMenu }: { dragHandleMenu?: ComponentType }) => {
DragHandleButton: ({ dragHandleMenu }: { dragHandleMenu?: ComponentType }) => {
capturedDragHandleMenu = dragHandleMenu ?? null
return <div data-testid="side-menu" />
return (
<button type="button" draggable>
Open block menu
</button>
)
},
RemoveBlockItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
SideMenu: ({ children }: PropsWithChildren) => <div data-testid="side-menu">{children}</div>,
TableColumnHeaderItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
TableRowHeaderItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
useDictionary: () => ({
@@ -32,6 +38,10 @@ describe('TolariaSideMenu', () => {
expect(screen.getByTestId('side-menu')).toBeInTheDocument()
expect(capturedDragHandleMenu).not.toBeNull()
expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual([
'Open block menu',
'Add block',
])
const DragHandleMenuComponent = capturedDragHandleMenu!
render(<DragHandleMenuComponent />)

View File

@@ -1,5 +1,7 @@
import {
AddBlockButton,
DragHandleMenu,
DragHandleButton,
RemoveBlockItem,
SideMenu,
TableColumnHeaderItem,
@@ -21,5 +23,10 @@ function TolariaDragHandleMenu() {
}
export function TolariaSideMenu(props: SideMenuProps) {
return <SideMenu {...props} dragHandleMenu={TolariaDragHandleMenu} />
return (
<SideMenu {...props}>
<DragHandleButton dragHandleMenu={TolariaDragHandleMenu} />
<AddBlockButton />
</SideMenu>
)
}

View File

@@ -305,6 +305,30 @@ describe('tolariaEditorFormatting behavior', () => {
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
})
it('deduplicates floating toolbar store writes during close races', () => {
const editor = createMockEditor('paragraph')
useBlockNoteEditorMock.mockReturnValue(editor)
render(
<TolariaFormattingToolbarController
formattingToolbar={() => <button data-testid="toolbar-action" type="button">Toolbar</button>}
/>,
)
const toolbarWrapper = screen.getByTestId('toolbar-action').parentElement as HTMLElement
const floatingOptions = positionPopoverState.lastProps?.useFloatingOptions as {
onOpenChange: (open: boolean, event: unknown, reason?: string) => void
}
floatingOptions.onOpenChange(true, undefined)
floatingOptions.onOpenChange(false, undefined)
floatingOptions.onOpenChange(false, undefined)
fireEvent.blur(toolbarWrapper, { relatedTarget: document.body })
expect(formattingToolbarStore.setState).toHaveBeenCalledTimes(1)
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
})
it('does not open the floating toolbar when the editor anchor element is unavailable', () => {
const editor = createMockEditor()
editor.domElement = document.createElement('div')

View File

@@ -130,6 +130,27 @@ function useFormattingToolbarCloseGrace({
return { closeGraceActive, clearCloseGrace }
}
type FormattingToolbarStore = {
setState(open: boolean): void
}
function useDeduplicatedFormattingToolbarStore(
store: FormattingToolbarStore,
show: boolean,
) {
const openRef = useRef(show)
useEffect(() => {
openRef.current = show
}, [show])
return useCallback((open: boolean) => {
if (openRef.current === open) return
openRef.current = open
store.setState(open)
}, [store])
}
const TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS = {
bold: {
label: 'Bold',
@@ -503,6 +524,10 @@ export function TolariaFormattingToolbarController(props: {
toolbarHasFocus,
toolbarHovered,
})
const setFormattingToolbarOpen = useDeduplicatedFormattingToolbarStore(
formattingToolbar.store,
show,
)
const isOpen = show || toolbarHasFocus || toolbarHovered || closeGraceActive
const hasFloatingToolbarAnchor = getFormattingToolbarAnchorElement(editor) !== null
@@ -556,7 +581,7 @@ export function TolariaFormattingToolbarController(props: {
useFloatingOptions: {
open: shouldRenderFloatingToolbar,
onOpenChange: (open, _event, reason) => {
formattingToolbar.store.setState(open)
setFormattingToolbarOpen(open)
if (!open) {
setToolbarHasFocus(false)
setToolbarHovered(false)
@@ -579,9 +604,9 @@ export function TolariaFormattingToolbarController(props: {
[
clearCloseGrace,
editor,
formattingToolbar.store,
placement,
props.floatingUIOptions,
setFormattingToolbarOpen,
shouldRenderFloatingToolbar,
],
)
@@ -611,7 +636,7 @@ export function TolariaFormattingToolbarController(props: {
}
setToolbarHasFocus(false)
formattingToolbar.store.setState(false)
setFormattingToolbarOpen(false)
}}
>
<Component />

View File

@@ -1,7 +1,17 @@
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
import type { AiAgentId, AiAgentReadiness } from '../lib/aiAgents'
import {
aiAgentPermissionModeMarker,
normalizeAiAgentPermissionMode,
type AiAgentPermissionMode,
} from '../lib/aiAgentPermissionMode'
import { useCliAiAgent, type AgentFileCallbacks } from '../hooks/useCliAiAgent'
import type { VaultEntry } from '../types'
import {
getVaultConfig,
subscribeVaultConfig,
updateVaultConfigField,
} from '../utils/vaultConfigStore'
import {
type NoteListItem,
type NoteReference,
@@ -32,8 +42,10 @@ export interface AiPanelController {
linkedEntries: ReturnType<typeof useAiPanelContextSnapshot>['linkedEntries']
hasContext: boolean
isActive: boolean
permissionMode: AiAgentPermissionMode
handleSend: (text: string, references: NoteReference[]) => void
handleNavigateWikilink: (target: string) => void
handlePermissionModeChange: (mode: AiAgentPermissionMode) => void
handleNewChat: () => void
}
@@ -44,6 +56,26 @@ function resolveAgentReady(
return (readiness ?? (ready ? 'ready' : 'missing')) === 'ready'
}
function useVaultAiAgentPermissionMode(): AiAgentPermissionMode {
const vaultConfig = useSyncExternalStore(subscribeVaultConfig, getVaultConfig)
return normalizeAiAgentPermissionMode(vaultConfig.ai_agent_permission_mode)
}
function useAgentFileCallbacks({
onFileCreated,
onFileModified,
onVaultChanged,
}: Pick<
UseAiPanelControllerArgs,
'onFileCreated' | 'onFileModified' | 'onVaultChanged'
>): AgentFileCallbacks {
return useMemo<AgentFileCallbacks>(() => ({
onFileCreated,
onFileModified,
onVaultChanged,
}), [onFileCreated, onFileModified, onVaultChanged])
}
export function useAiPanelController({
vaultPath,
defaultAiAgent,
@@ -71,15 +103,13 @@ export function useAiPanelController({
noteListFilter,
})
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
onFileCreated,
onFileModified,
onVaultChanged,
}), [onFileCreated, onFileModified, onVaultChanged])
const fileCallbacks = useAgentFileCallbacks({ onFileCreated, onFileModified, onVaultChanged })
const permissionMode = useVaultAiAgentPermissionMode()
const agent = useCliAiAgent(vaultPath, contextPrompt, fileCallbacks, {
agent: defaultAiAgent,
agentReady: resolveAgentReady(defaultAiAgentReadiness, defaultAiAgentReady),
permissionMode,
})
const hasContext = !!activeEntry
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
@@ -94,6 +124,14 @@ export function useAiPanelController({
onOpenNote?.(target)
}, [onOpenNote])
const handlePermissionModeChange = useCallback((mode: AiAgentPermissionMode) => {
const nextMode = normalizeAiAgentPermissionMode(mode)
if (isActive || nextMode === permissionMode) return
updateVaultConfigField('ai_agent_permission_mode', nextMode)
agent.addLocalMarker(aiAgentPermissionModeMarker(nextMode))
}, [agent, isActive, permissionMode])
const handleNewChat = useCallback(() => {
agent.clearConversation()
setInput('')
@@ -106,8 +144,10 @@ export function useAiPanelController({
linkedEntries,
hasContext,
isActive,
permissionMode,
handleSend,
handleNavigateWikilink,
handlePermissionModeChange,
handleNewChat,
}
}

View File

@@ -9,6 +9,7 @@ describe('buildAiAgentCommands', () => {
vaultAiGuidanceStatus: {
agentsState: 'missing',
claudeState: 'managed',
geminiState: 'managed',
canRestore: true,
},
onRestoreVaultAiGuidance,
@@ -16,6 +17,7 @@ describe('buildAiAgentCommands', () => {
const command = commands.find((item) => item.id === 'restore-vault-ai-guidance')
expect(command).toBeDefined()
expect(command?.keywords).toContain('gemini')
command?.execute()
expect(onRestoreVaultAiGuidance).toHaveBeenCalledOnce()
})
@@ -25,6 +27,7 @@ describe('buildAiAgentCommands', () => {
vaultAiGuidanceStatus: {
agentsState: 'managed',
claudeState: 'managed',
geminiState: 'managed',
canRestore: false,
},
onRestoreVaultAiGuidance: vi.fn(),

View File

@@ -67,7 +67,7 @@ function restoreGuidanceCommands({
id: 'restore-vault-ai-guidance',
label: 'Restore Tolaria AI Guidance',
group: 'Settings',
keywords: aiAgentKeywords('ai', 'agent', 'guidance', 'restore', 'repair', 'agents'),
keywords: aiAgentKeywords('ai', 'agent', 'guidance', 'restore', 'repair', 'agents', 'gemini'),
enabled: true,
execute: () => onRestoreVaultAiGuidance(),
},

View File

@@ -126,4 +126,16 @@ describe('buildSettingsCommands', () => {
expect(listener).toHaveBeenCalledTimes(1)
window.removeEventListener(TOGGLE_GITIGNORED_VISIBILITY_EVENT, listener)
})
it('makes external AI setup discoverable for Gemini CLI', () => {
const onInstallMcp = vi.fn()
const command = findCommand('install-mcp', buildSettingsCommands({
onOpenSettings: vi.fn(),
onInstallMcp,
}))
expect(command?.keywords).toContain('gemini')
command?.execute()
expect(onInstallMcp).toHaveBeenCalledOnce()
})
})

View File

@@ -149,7 +149,7 @@ function buildMaintenanceCommands({
id: 'install-mcp',
label: mcpStatus === 'installed' ? 'Manage External AI Tools…' : 'Set Up External AI Tools…',
group: 'Settings',
keywords: ['mcp', 'ai', 'tools', 'external', 'setup', 'connect', 'disconnect', 'claude', 'codex', 'cursor', 'consent'],
keywords: ['mcp', 'ai', 'tools', 'external', 'setup', 'details', 'copy', 'export', 'manual', 'config', 'connect', 'disconnect', 'claude', 'gemini', 'codex', 'cursor', 'consent'],
enabled: true,
execute: () => onInstallMcp?.(),
},

View File

@@ -28,6 +28,11 @@ interface ViewCommandsConfig {
onCustomizeNoteListColumns?: () => void
canCustomizeNoteListColumns?: boolean
noteListColumnsLabel: string
selectedViewName?: string
onMoveSelectedViewUp?: () => void
onMoveSelectedViewDown?: () => void
canMoveSelectedViewUp?: boolean
canMoveSelectedViewDown?: boolean
}
function buildNoteLayoutCommand(noteLayout: NoteLayout, onToggleNoteLayout?: () => void): CommandAction {
@@ -41,12 +46,32 @@ function buildNoteLayoutCommand(noteLayout: NoteLayout, onToggleNoteLayout?: ()
}
}
function buildMoveSavedViewCommand(
direction: 'Up' | 'Down',
selectedViewName: string | undefined,
onMoveSelectedView: (() => void) | undefined,
canMoveSelectedView: boolean | undefined,
): CommandAction {
const directionKeyword = direction.toLowerCase()
return {
id: `move-view-${directionKeyword}`,
label: selectedViewName ? `Move ${selectedViewName} ${direction}` : `Move View ${direction}`,
group: 'View',
keywords: ['saved view', 'view', 'views', 'order', 'sidebar', 'move', directionKeyword],
enabled: Boolean(onMoveSelectedView && canMoveSelectedView),
execute: onMoveSelectedView ?? noop,
}
}
export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
const {
hasActiveNote, activeNoteModified,
onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, noteLayout = 'centered', onToggleNoteLayout, onToggleAIChat,
zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown,
canMoveSelectedViewUp, canMoveSelectedViewDown,
} = config
return [
@@ -60,6 +85,8 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleAiChat), keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'new-ai-chat', label: 'New AI chat', group: 'View', keywords: ['ai', 'agent', 'chat', 'assistant', 'new', 'fresh', 'conversation', 'reset'], enabled: true, execute: requestNewAiChat },
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
buildMoveSavedViewCommand('Up', selectedViewName, onMoveSelectedViewUp, canMoveSelectedViewUp),
buildMoveSavedViewCommand('Down', selectedViewName, onMoveSelectedViewDown, canMoveSelectedViewDown),
{ id: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewZoomIn), keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewZoomOut), keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },

View File

@@ -0,0 +1,38 @@
let fallbackBlockIdSequence = 0
function createEditorBlockId(): string {
const randomUUID = globalThis.crypto?.randomUUID
if (typeof randomUUID === 'function') return randomUUID.call(globalThis.crypto)
fallbackBlockIdSequence += 1
return `tolaria-block-${fallbackBlockIdSequence}`
}
function isEditorBlockRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasUsableBlockId(block: Record<string, unknown>): boolean {
return typeof block.id === 'string' && block.id.trim().length > 0
}
function repairEditorBlock(block: unknown): unknown {
if (!isEditorBlockRecord(block)) return block
const children = Array.isArray(block.children)
? repairMalformedEditorBlocks(block.children)
: block.children
const missingId = !hasUsableBlockId(block)
if (!missingId && children === block.children) return block
return {
...block,
...(missingId ? { id: createEditorBlockId() } : {}),
...(children === block.children ? {} : { children }),
}
}
export function repairMalformedEditorBlocks(blocks: unknown[]): unknown[] {
return blocks.map(repairEditorBlock)
}

View File

@@ -68,6 +68,10 @@ export function isUntitledPath(path: FilePath): boolean {
return pathStem(path).startsWith('untitled-')
}
export function blankParagraphBlocks(): unknown[] {
return [{ type: 'paragraph', content: [], children: [] }]
}
function isParsedBlock(value: unknown): value is ParsedBlock {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

View File

@@ -0,0 +1,31 @@
type TiptapEditorBridge = {
state?: {
doc?: { content?: { size?: unknown } }
}
commands?: {
setTextSelection?: (position: number) => unknown
}
}
function getTiptapEditorBridge(editor: unknown): TiptapEditorBridge | null {
const editorWithBridge = editor as { _tiptapEditor?: TiptapEditorBridge }
return editorWithBridge._tiptapEditor ?? null
}
function getSafeTextSelectionPosition(tiptapEditor: TiptapEditorBridge): number {
const size = tiptapEditor.state?.doc?.content?.size
if (typeof size !== 'number' || !Number.isFinite(size)) return 0
return size > 0 ? Math.min(1, size) : 0
}
export function resetTextSelectionBeforeContentSwap(editor: unknown): void {
const tiptapEditor = getTiptapEditorBridge(editor)
const setTextSelection = tiptapEditor?.commands?.setTextSelection
if (!tiptapEditor || typeof setTextSelection !== 'function') return
try {
setTextSelection(getSafeTextSelectionPosition(tiptapEditor))
} catch (err) {
console.warn('Failed to reset editor selection before content swap:', err)
}
}

View File

@@ -41,6 +41,11 @@ interface AppCommandsConfig {
onToggleInspector: () => void
onToggleDiff?: () => void
onToggleRawEditor?: () => void
selectedViewName?: string
onMoveSelectedViewUp?: () => void
onMoveSelectedViewDown?: () => void
canMoveSelectedViewUp?: boolean
canMoveSelectedViewDown?: boolean
noteLayout?: NoteLayout
onToggleNoteLayout?: () => void
activeNoteModified: boolean
@@ -155,6 +160,11 @@ type CommandRegistryCoreActions = Pick<
| 'onToggleInspector'
| 'onToggleDiff'
| 'onToggleRawEditor'
| 'selectedViewName'
| 'onMoveSelectedViewUp'
| 'onMoveSelectedViewDown'
| 'canMoveSelectedViewUp'
| 'canMoveSelectedViewDown'
| 'noteLayout'
| 'onToggleNoteLayout'
| 'onToggleAIChat'
@@ -417,6 +427,11 @@ function createCommandRegistryCoreConfig(
onToggleInspector: config.onToggleInspector,
onToggleDiff: config.onToggleDiff,
onToggleRawEditor: config.onToggleRawEditor,
selectedViewName: config.selectedViewName,
onMoveSelectedViewUp: config.onMoveSelectedViewUp,
onMoveSelectedViewDown: config.onMoveSelectedViewDown,
canMoveSelectedViewUp: config.canMoveSelectedViewUp,
canMoveSelectedViewDown: config.canMoveSelectedViewDown,
onFindInNote: config.onFindInNote,
onReplaceInNote: config.onReplaceInNote,
noteLayout: config.noteLayout,

View File

@@ -198,6 +198,81 @@ describe('useAppSave', () => {
consoleSpy.mockRestore()
})
it('pauses manual saves when a stale editor has no active vault', async () => {
vi.mocked(isTauri).mockReturnValue(true)
const path = 'C:\\Users\\Luca\\Notes\\draft.md'
const entry = makeEntry(path, 'Draft', 'draft.md')
const { result } = renderSave({
resolvedPath: '',
tabs: [{ entry, content: '# Draft\n\nBody' }],
activeTabPath: path,
unsavedPaths: new Set([path]),
})
let saved = true
await act(async () => {
saved = await result.current.handleSave()
})
expect(saved).toBe(false)
expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('save_note_content', expect.anything())
expect(deps.setToastMessage).toHaveBeenCalledWith('Select or restore a vault before saving.')
expect(deps.clearUnsaved).not.toHaveBeenCalled()
})
it('pauses stale auto-save timers when the active vault disappears before debounce fires', async () => {
vi.useFakeTimers()
vi.mocked(isTauri).mockReturnValue(true)
const entry = makeEntry('/vault/draft.md', 'Draft', 'draft.md')
const tabs = [{ entry, content: '# Draft' }]
const { result, rerender } = renderHook(
({ vaultPath }: { vaultPath: string }) => useAppSave({
...deps,
resolvedPath: vaultPath,
tabs,
activeTabPath: entry.path,
unsavedPaths: new Set([entry.path]),
}),
{ initialProps: { vaultPath: '/vault' } },
)
act(() => {
result.current.handleContentChange(entry.path, '# Draft\n\nUnsaved')
})
rerender({ vaultPath: '' })
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('save_note_content', expect.anything())
expect(deps.clearUnsaved).not.toHaveBeenCalled()
})
it('does not flush unsaved tab content to disk without an active vault', async () => {
vi.mocked(isTauri).mockReturnValue(true)
const path = 'C:\\Users\\Luca\\Notes\\draft.md'
const entry = makeEntry(path, 'Draft', 'draft.md')
const { result } = renderSave({
resolvedPath: '',
tabs: [{ entry, content: '# Draft\n\nBody' }],
activeTabPath: path,
unsavedPaths: new Set([path]),
})
await act(async () => {
await result.current.flushBeforeAction(path)
})
expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('save_note_content', expect.anything())
expect(deps.setToastMessage).toHaveBeenCalledWith('Select or restore a vault before saving.')
expect(deps.clearUnsaved).not.toHaveBeenCalled()
})
it('handleContentChange is a function', () => {
const { result } = renderSave()
expect(typeof result.current.handleContentChange).toBe('function')

View File

@@ -1,6 +1,7 @@
import { startTransition, useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
import { MISSING_ACTIVE_VAULT_SAVE_MESSAGE } from './useEditorSave'
import { flushEditorContent } from '../utils/autoSave'
import { extractH1TitleFromContent } from '../utils/noteTitle'
import { isTauri } from '../mock-tauri'
@@ -464,6 +465,7 @@ function useAppSaveEffects({
}
function useFlushBeforeAction({
canPersist,
resolveCurrentPath,
savePendingForPath,
tabsRef,
@@ -472,6 +474,7 @@ function useFlushBeforeAction({
setToastMessage,
flushPendingUntitledRename,
}: {
canPersist: boolean
resolveCurrentPath: (path: string) => string
savePendingForPath: (path: string) => Promise<boolean>
tabsRef: MutableRefObject<TabState[]>
@@ -482,6 +485,10 @@ function useFlushBeforeAction({
}) {
return useCallback(async (path: string) => {
const currentPath = resolveCurrentPath(path)
if (!canPersist) {
if (unsavedPathsRef.current.has(currentPath)) setToastMessage(MISSING_ACTIVE_VAULT_SAVE_MESSAGE)
return
}
try {
await flushEditorContent(currentPath, {
savePendingForPath,
@@ -494,7 +501,7 @@ function useFlushBeforeAction({
setToastMessage(`Auto-save failed: ${err}`)
throw err
}
}, [resolveCurrentPath, savePendingForPath, tabsRef, unsavedPathsRef, clearUnsaved, setToastMessage, flushPendingUntitledRename])
}, [canPersist, resolveCurrentPath, savePendingForPath, tabsRef, unsavedPathsRef, clearUnsaved, setToastMessage, flushPendingUntitledRename])
}
async function preparePathForManualRename({
@@ -598,6 +605,7 @@ function useEditorPersistence({
scheduleUntitledRename,
resolveCurrentPath,
resolvePathBeforeSave,
canPersist,
}: {
updateEntry: AppSaveDeps['updateEntry']
setTabs: AppSaveDeps['setTabs']
@@ -610,6 +618,7 @@ function useEditorPersistence({
scheduleUntitledRename: (path: string, content: string) => void
resolveCurrentPath: (path: string) => string
resolvePathBeforeSave: (path: string) => Promise<string>
canPersist: boolean
}) {
const onAfterSave = useCallback(() => {
loadModifiedFiles()
@@ -635,12 +644,14 @@ function useEditorPersistence({
onNotePersisted,
resolvePath: resolveCurrentPath,
resolvePathBeforeSave,
canPersist,
disabledSaveMessage: MISSING_ACTIVE_VAULT_SAVE_MESSAGE,
})
const handleContentChange = useCallback((path: string, content: string) => {
const resolvedPath = resolveCurrentPath(path)
trackUnsaved?.(resolvedPath)
handleContentChangeRaw(resolvedPath, content)
const currentPath = resolveCurrentPath(path)
trackUnsaved?.(currentPath)
handleContentChangeRaw(currentPath, content)
}, [handleContentChangeRaw, resolveCurrentPath, trackUnsaved])
const savePendingForPath = useCallback((path: string) => (
@@ -666,6 +677,7 @@ function useReplaceRenamedEntry({
function useAppSaveHandlers({
contentChangeRef,
handleContentChange,
canPersist,
cancelPendingUntitledRename,
pendingUntitledRenameRef,
activeTabPath,
@@ -687,6 +699,7 @@ function useAppSaveHandlers({
}: {
contentChangeRef: MutableRefObject<(path: string, content: string) => void>
handleContentChange: (path: string, content: string) => void
canPersist: boolean
cancelPendingUntitledRename: (path?: string) => boolean
pendingUntitledRenameRef: MutableRefObject<PendingUntitledRename | null>
activeTabPath: string | null
@@ -715,6 +728,7 @@ function useAppSaveHandlers({
})
const flushBeforeAction = useFlushBeforeAction({
canPersist,
resolveCurrentPath,
savePendingForPath,
tabsRef,
@@ -753,6 +767,7 @@ export function useAppSave({
onInternalVaultWrite,
}: AppSaveDeps) {
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
const canPersist = resolvedPath.trim().length > 0
const { tabsRef, activeTabPathRef, unsavedPathsRef } = useAppSaveStateRefs({ tabs, activeTabPath, unsavedPaths })
const {
pendingUntitledRenameRef, cancelPendingUntitledRename, registerRenamedPath,
@@ -779,11 +794,13 @@ export function useAppSave({
scheduleUntitledRename,
resolveCurrentPath,
resolvePathBeforeSave,
canPersist,
})
const replaceRenamedEntry = useReplaceRenamedEntry({ registerRenamedPath, replaceEntry })
const { handleFilenameRename, handleSave, handleTitleSync, flushBeforeAction } = useAppSaveHandlers({
contentChangeRef,
handleContentChange,
canPersist,
cancelPendingUntitledRename,
pendingUntitledRenameRef,
activeTabPath,

View File

@@ -14,11 +14,15 @@ vi.mock('../utils/ai-agent', () => ({
const mockStreamAiAgent = vi.mocked(streamAiAgent)
const VAULT = '/Users/luca/Laputa'
function renderAgent(contextPrompt: string | undefined = undefined) {
function renderAgent(
contextPrompt: string | undefined = undefined,
permissionMode: 'safe' | 'power_user' = 'safe',
) {
return renderHook(
({ context }) => useCliAiAgent(VAULT, context, undefined, {
agent: 'codex',
agentReady: true,
permissionMode,
}),
{ initialProps: { context: contextPrompt } },
)
@@ -49,6 +53,37 @@ describe('useCliAiAgent', () => {
}))
})
it('forwards the current permission mode to the stream request', async () => {
const { result } = renderAgent(undefined, 'power_user')
await act(async () => {
await result.current.sendMessage('Use the local tools')
})
expect(mockStreamAiAgent).toHaveBeenCalledWith(expect.objectContaining({
permissionMode: 'power_user',
}))
})
it('adds local transcript markers without sending them as chat history', async () => {
const { result } = renderAgent()
act(() => {
result.current.addLocalMarker('AI permission mode changed to Power User. It will apply to the next message.')
})
await act(async () => {
await result.current.sendMessage('Continue')
})
expect(result.current.messages[0]).toEqual(expect.objectContaining({
localMarker: 'AI permission mode changed to Power User. It will apply to the next message.',
}))
expect(mockStreamAiAgent).toHaveBeenCalledWith(expect.objectContaining({
message: 'Continue',
}))
})
it('embeds completed conversation history and clears it for a fresh chat', async () => {
let responseNumber = 0
mockStreamAiAgent.mockImplementation(async ({ callbacks }) => {
@@ -80,6 +115,7 @@ describe('useCliAiAgent', () => {
const { result } = renderHook(() => useCliAiAgent(VAULT, undefined, undefined, {
agent: 'codex',
agentReady: false,
permissionMode: 'safe',
}))
await act(async () => {

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import type { AiAgentId } from '../lib/aiAgents'
import type { AiAgentPermissionMode } from '../lib/aiAgentPermissionMode'
import type { NoteReference } from '../utils/ai-context'
import {
type AgentStatus,
@@ -7,6 +8,7 @@ import {
} from '../lib/aiAgentConversation'
import type { AgentFileCallbacks } from '../lib/aiAgentFileOperations'
import {
addAgentLocalMarker,
clearAgentConversation,
sendAgentMessage,
type AiAgentSessionRuntime,
@@ -20,6 +22,7 @@ export type { AiAgentMessage } from '../lib/aiAgentConversation'
interface UseCliAiAgentOptions {
agent: AiAgentId
agentReady: boolean
permissionMode: AiAgentPermissionMode
}
interface UseCliAiAgentRuntime extends AiAgentSessionRuntime {
@@ -66,6 +69,7 @@ export function useCliAiAgent(
options: UseCliAiAgentOptions,
) {
const { agent, agentReady } = options
const { permissionMode } = options
const runtime = useCliAiAgentRuntime(fileCallbacks)
const { messages, status } = runtime
@@ -76,6 +80,7 @@ export function useCliAiAgent(
agent,
ready: agentReady,
vaultPath,
permissionMode,
systemPromptOverride: contextPrompt,
},
prompt: { text, references },
@@ -86,5 +91,9 @@ export function useCliAiAgent(
clearAgentConversation(runtime)
}
return { messages, status, sendMessage, clearConversation }
function addLocalMarker(text: string): void {
addAgentLocalMarker(runtime, text)
}
return { messages, status, sendMessage, clearConversation, addLocalMarker }
}

View File

@@ -359,6 +359,53 @@ describe('useCommandRegistry', () => {
expect(onToggleNoteLayout).toHaveBeenCalledOnce()
})
it('exposes command palette actions for moving the selected saved view', () => {
const onMoveSelectedViewUp = vi.fn()
const onMoveSelectedViewDown = vi.fn()
const config = makeConfig({
selectedViewName: 'Active Projects',
onMoveSelectedViewUp,
onMoveSelectedViewDown,
canMoveSelectedViewUp: true,
canMoveSelectedViewDown: true,
})
const { result } = renderHook(() => useCommandRegistry(config))
const moveUp = findCommand(result.current, 'move-view-up')
const moveDown = findCommand(result.current, 'move-view-down')
expect(moveUp).toMatchObject({
label: 'Move Active Projects Up',
group: 'View',
enabled: true,
})
expect(moveDown).toMatchObject({
label: 'Move Active Projects Down',
group: 'View',
enabled: true,
})
moveUp!.execute()
moveDown!.execute()
expect(onMoveSelectedViewUp).toHaveBeenCalledOnce()
expect(onMoveSelectedViewDown).toHaveBeenCalledOnce()
})
it('disables saved view move commands at list boundaries', () => {
const config = makeConfig({
selectedViewName: 'Top View',
onMoveSelectedViewUp: vi.fn(),
onMoveSelectedViewDown: vi.fn(),
canMoveSelectedViewUp: false,
canMoveSelectedViewDown: true,
})
const { result } = renderHook(() => useCommandRegistry(config))
expect(findCommand(result.current, 'move-view-up')?.enabled).toBe(false)
expect(findCommand(result.current, 'move-view-down')?.enabled).toBe(true)
})
it('updates note layout command copy when left alignment is active', () => {
const config = makeConfig({ noteLayout: 'left' })
const { result } = renderHook(() => useCommandRegistry(config))

View File

@@ -83,6 +83,11 @@ interface CommandRegistryConfig {
onToggleInspector: () => void
onToggleDiff?: () => void
onToggleRawEditor?: () => void
selectedViewName?: string
onMoveSelectedViewUp?: () => void
onMoveSelectedViewDown?: () => void
canMoveSelectedViewUp?: boolean
canMoveSelectedViewDown?: boolean
onFindInNote?: () => void
onReplaceInNote?: () => void
noteLayout?: NoteLayout
@@ -119,6 +124,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onFindInNote, onReplaceInNote, noteLayout, onToggleNoteLayout, onToggleAIChat, onOpenVault, onCreateEmptyVault,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onRenameFolder, onDeleteFolder, onRevealSelectedFolder, onCopySelectedFolderPath,
@@ -214,11 +220,13 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
}), [
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat,
zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
])
const settingsCommands = useMemo(() => buildSettingsCommands({

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, type MutableRefObject } from 'react'
import type { SetStateAction } from 'react'
import { useSaveNote } from './useSaveNote'
@@ -19,6 +19,9 @@ interface EditorSaveConfig {
resolvePath?: (path: string) => string
/** Wait for an in-flight path change to settle before persisting buffered content. */
resolvePathBeforeSave?: (path: string) => Promise<string>
/** False when editor state is present but no vault is available to receive writes. */
canPersist?: boolean
disabledSaveMessage?: string
}
/**
@@ -29,6 +32,7 @@ const noop = () => {}
const AUTO_SAVE_DEBOUNCE_MS = 500
const INVALID_PATH_SAVE_MESSAGE = 'Save failed: The note path is invalid on this platform. Rename the note or move it to a valid folder, then try again.'
export const MISSING_ACTIVE_VAULT_SAVE_MESSAGE = 'Select or restore a vault before saving.'
interface PendingContent {
path: string
@@ -53,6 +57,14 @@ function formatSaveFailureMessage(error: unknown): string {
return `Save failed: ${message}`
}
function useLatestValueRef<T>(value: T): MutableRefObject<T> {
const ref = useRef(value)
useLayoutEffect(() => {
ref.current = value
}, [value])
return ref
}
function resolveBufferedPath(path: string, resolvePath?: EditorSaveConfig['resolvePath']): string {
return resolvePath?.(path) ?? path
}
@@ -141,16 +153,19 @@ function usePendingContentFlush({
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
canPersistRef,
}: {
pendingContentRef: MutableRefObject<PendingContent | null>
saveNote: (path: string, content: string) => Promise<void>
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
canPersistRef: MutableRefObject<boolean>
}) {
return useCallback(async (pathFilter?: string): Promise<boolean> => {
const pending = pendingContentRef.current
if (!matchesPendingPath(pending, pathFilter, resolvePath)) return false
if (!canPersistRef.current) return false
const { path, content } = pending
await persistResolvedContent({
path,
@@ -162,7 +177,7 @@ function usePendingContentFlush({
})
pendingContentRef.current = null
return true
}, [onNotePersisted, pendingContentRef, resolvePath, resolvePathBeforeSave, saveNote])
}, [canPersistRef, onNotePersisted, pendingContentRef, resolvePath, resolvePathBeforeSave, saveNote])
}
function useCancelAutoSave(autoSaveTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>) {
@@ -201,7 +216,65 @@ async function persistUnsavedFallback({
return true
}
function pausedSaveResult({
canPersistRef,
pendingContentRef,
unsavedFallback,
setToastMessage,
disabledSaveMessage,
}: {
canPersistRef: MutableRefObject<boolean>
pendingContentRef: MutableRefObject<PendingContent | null>
unsavedFallback?: { path: string; content: string }
setToastMessage: EditorSaveConfig['setToastMessage']
disabledSaveMessage: string
}): boolean | null {
if (canPersistRef.current) return null
const hasUnsavedContent = pendingContentRef.current !== null || unsavedFallback !== undefined
setToastMessage(hasUnsavedContent ? disabledSaveMessage : 'Nothing to save')
return !hasUnsavedContent
}
async function persistImmediateSave({
unsavedFallback,
flushPending,
saveNote,
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
setToastMessage,
onAfterSave,
}: {
unsavedFallback?: { path: string; content: string }
flushPending: (pathFilter?: string) => Promise<boolean>
saveNote: (path: string, content: string) => Promise<void>
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
setToastMessage: EditorSaveConfig['setToastMessage']
onAfterSave: () => void
}): Promise<boolean> {
try {
const saved = await flushPending()
const savedFallback = !saved && await persistUnsavedFallback({
unsavedFallback,
saveNote,
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
})
setToastMessage(saved || savedFallback ? 'Saved' : 'Nothing to save')
onAfterSave()
return true
} catch (err) {
console.error('Save failed:', err)
setToastMessage(formatSaveFailureMessage(err))
return false
}
}
function useImmediateSaveCommands({
pendingContentRef,
cancelAutoSave,
flushPending,
setToastMessage,
@@ -210,7 +283,10 @@ function useImmediateSaveCommands({
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
canPersistRef,
disabledSaveMessage,
}: {
pendingContentRef: MutableRefObject<PendingContent | null>
cancelAutoSave: () => void
flushPending: (pathFilter?: string) => Promise<boolean>
setToastMessage: EditorSaveConfig['setToastMessage']
@@ -219,34 +295,43 @@ function useImmediateSaveCommands({
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
canPersistRef: MutableRefObject<boolean>
disabledSaveMessage: string
}) {
const handleSave = useCallback(async (unsavedFallback?: { path: string; content: string }): Promise<boolean> => {
cancelAutoSave()
try {
const saved = await flushPending()
const savedFallback = !saved && await persistUnsavedFallback({
unsavedFallback,
saveNote,
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
})
setToastMessage(saved || savedFallback ? 'Saved' : 'Nothing to save')
onAfterSave()
return true
} catch (err) {
console.error('Save failed:', err)
setToastMessage(formatSaveFailureMessage(err))
return false
}
}, [cancelAutoSave, flushPending, onAfterSave, onNotePersisted, resolvePath, resolvePathBeforeSave, saveNote, setToastMessage])
const pausedResult = pausedSaveResult({
canPersistRef,
pendingContentRef,
unsavedFallback,
setToastMessage,
disabledSaveMessage,
})
if (pausedResult !== null) return pausedResult
return persistImmediateSave({
unsavedFallback,
flushPending,
saveNote,
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
setToastMessage,
onAfterSave,
})
}, [canPersistRef, cancelAutoSave, disabledSaveMessage, flushPending, onAfterSave, onNotePersisted, pendingContentRef, resolvePath, resolvePathBeforeSave, saveNote, setToastMessage])
const savePendingForPath = useCallback(
(path: string): Promise<boolean> => { cancelAutoSave(); return flushPending(path) },
[cancelAutoSave, flushPending],
(path: string): Promise<boolean> => {
cancelAutoSave()
return canPersistRef.current ? flushPending(path) : Promise.resolve(false)
},
[canPersistRef, cancelAutoSave, flushPending],
)
const savePending = useCallback((): Promise<boolean> => { cancelAutoSave(); return flushPending() }, [cancelAutoSave, flushPending])
const savePending = useCallback((): Promise<boolean> => {
cancelAutoSave()
return canPersistRef.current ? flushPending() : Promise.resolve(false)
}, [canPersistRef, cancelAutoSave, flushPending])
return { handleSave, savePendingForPath, savePending }
}
@@ -259,6 +344,7 @@ function useContentChangeCommand({
cancelAutoSave,
flushPending,
onAfterSaveRef,
canPersistRef,
}: {
pendingContentRef: MutableRefObject<PendingContent | null>
autoSaveTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
@@ -267,13 +353,15 @@ function useContentChangeCommand({
cancelAutoSave: () => void
flushPending: () => Promise<boolean>
onAfterSaveRef: MutableRefObject<() => void>
canPersistRef: MutableRefObject<boolean>
}) {
return useCallback((path: string, content: string) => {
pendingContentRef.current = { path, content }
applyTabContent(setTabs, path, content)
cancelAutoSave()
if (!canPersistRef.current) return
scheduleAutoSave({ autoSaveTimerRef, flushPending, onAfterSaveRef, setToastMessage })
}, [autoSaveTimerRef, cancelAutoSave, flushPending, onAfterSaveRef, pendingContentRef, setTabs, setToastMessage])
}, [autoSaveTimerRef, canPersistRef, cancelAutoSave, flushPending, onAfterSaveRef, pendingContentRef, setTabs, setToastMessage])
}
function useEditorSaveCommands({
@@ -287,6 +375,8 @@ function useEditorSaveCommands({
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
canPersistRef,
disabledSaveMessage,
}: {
pendingContentRef: MutableRefObject<PendingContent | null>
autoSaveTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
@@ -298,6 +388,8 @@ function useEditorSaveCommands({
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
canPersistRef: MutableRefObject<boolean>
disabledSaveMessage: string
}) {
const flushPending = usePendingContentFlush({
pendingContentRef,
@@ -305,9 +397,11 @@ function useEditorSaveCommands({
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
canPersistRef,
})
const cancelAutoSave = useCancelAutoSave(autoSaveTimerRef)
const { handleSave, savePendingForPath, savePending } = useImmediateSaveCommands({
pendingContentRef,
cancelAutoSave,
flushPending,
setToastMessage,
@@ -316,6 +410,8 @@ function useEditorSaveCommands({
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
canPersistRef,
disabledSaveMessage,
})
const handleContentChange = useContentChangeCommand({
pendingContentRef,
@@ -325,6 +421,7 @@ function useEditorSaveCommands({
cancelAutoSave,
flushPending: () => flushPending(),
onAfterSaveRef,
canPersistRef,
})
return { handleSave, handleContentChange, savePendingForPath, savePending }
@@ -338,9 +435,12 @@ export function useEditorSave({
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
canPersist = true,
disabledSaveMessage = MISSING_ACTIVE_VAULT_SAVE_MESSAGE,
}: EditorSaveConfig) {
const pendingContentRef = useRef<{ path: string; content: string } | null>(null)
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const canPersistRef = useLatestValueRef(canPersist)
const updateTabAndContent = useCallback((path: string, content: string) => {
updateVaultContent(path, content)
@@ -361,5 +461,7 @@ export function useEditorSave({
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
canPersistRef,
disabledSaveMessage,
})
}

View File

@@ -22,6 +22,8 @@ export function useEditorSaveWithLinks(config: {
onNotePersisted?: (path: string, content: string) => void
resolvePath?: (path: string) => string
resolvePathBeforeSave?: (path: string) => Promise<string>
canPersist?: boolean
disabledSaveMessage?: string
}) {
const { updateEntry } = config
const saveContent = useCallback((path: string, content: string) => {
@@ -32,15 +34,7 @@ export function useEditorSaveWithLinks(config: {
modifiedAt: Math.floor(Date.now() / 1000),
})
}, [updateEntry])
const editor = useEditorSave({
updateVaultContent: saveContent,
setTabs: config.setTabs,
setToastMessage: config.setToastMessage,
onAfterSave: config.onAfterSave,
onNotePersisted: config.onNotePersisted,
resolvePath: config.resolvePath,
resolvePathBeforeSave: config.resolvePathBeforeSave,
})
const editor = useEditorSave({ ...config, updateVaultContent: saveContent })
const { handleContentChange: rawOnChange } = editor
const prevLinksKeyRef = useRef('')
const prevFmKeyRef = useRef(EMPTY_DERIVED_ENTRY_STATE_KEY)

View File

@@ -229,7 +229,13 @@ function makeMockEditor(docRef: { current: unknown[] }) {
blocksToMarkdownLossy: vi.fn(() => ''),
blocksToHTMLLossy: vi.fn(() => ''),
tryParseMarkdownToBlocks: vi.fn(() => blocksA),
_tiptapEditor: { commands: { setContent: vi.fn() } },
_tiptapEditor: {
state: { doc: { content: { size: 8 } } },
commands: {
setContent: vi.fn(),
setTextSelection: vi.fn(),
},
},
_docRef: docRef,
}
Object.defineProperty(editor, 'document', { get: () => docRef.current })
@@ -454,6 +460,24 @@ describe('useEditorTabSwap raw mode sync', () => {
}
})
it('resets stale TipTap selection before applying a switched note', async () => {
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'Note B')
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
setupEditor: (editor) => {
editor._tiptapEditor.state.doc.content.size = 3
},
})
mockEditor._tiptapEditor.commands.setTextSelection.mockClear()
await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })
expect(mockEditor._tiptapEditor.commands.setTextSelection).toHaveBeenCalledWith(1)
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
})
it('re-parses when the active tab content changes without a path change', async () => {
const tabA = makeTab('a.md', 'Note A')
const refreshedTabA = {
@@ -476,6 +500,48 @@ describe('useEditorTabSwap raw mode sync', () => {
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
})
it('repairs parsed blocks with missing ids before applying them to the editor', async () => {
const tabA = makeTab('a.md', 'Note A')
const malformedTab = {
...makeTab('malformed.md', 'Malformed'),
content: '---\ntitle: Malformed\n---\n\n# Malformed\n\n- Parent\n - Child',
}
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
setupEditor: (editor) => {
editor.tryParseMarkdownToBlocks.mockReturnValue([
{
type: 'bulletListItem',
content: [{ type: 'text', text: 'Parent', styles: {} }],
children: [
{
type: 'bulletListItem',
content: [{ type: 'text', text: 'Child', styles: {} }],
children: [],
},
],
},
])
},
})
mockEditor.replaceBlocks.mockClear()
await rerenderWith({ tabs: [malformedTab], activeTabPath: 'malformed.md' })
const appliedBlocks = mockEditor.replaceBlocks.mock.calls[0][1]
expect(appliedBlocks).toEqual([
expect.objectContaining({
id: expect.any(String),
children: [
expect.objectContaining({
id: expect.any(String),
}),
],
}),
])
})
it('ignores editor change events before the pending tab swap applies a new untitled note', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

View File

@@ -7,7 +7,9 @@ import { injectMathInBlocks, preProcessMathMarkdown } from '../utils/mathMarkdow
import { injectMermaidInBlocks, preProcessMermaidMarkdown, serializeMermaidAwareBlocks } from '../utils/mermaidMarkdown'
import { failNoteOpenTrace, finishNoteOpenTrace } from '../utils/noteOpenPerformance'
import { resolveImageUrls, portableImageUrls } from '../utils/vaultImages'
import { repairMalformedEditorBlocks } from './editorBlockRepair'
import {
blankParagraphBlocks,
extractEditorBody,
getH1TextFromBlocks,
isUntitledPath,
@@ -16,6 +18,7 @@ import {
slugifyPathStem,
} from './editorTabContent'
import { clearEditorDomSelection, EDITOR_CONTAINER_SELECTOR } from './editorDomSelection'
import { resetTextSelectionBeforeContentSwap } from './editorTiptapSelection'
export { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './editorTabContent'
interface Tab {
@@ -119,10 +122,6 @@ function extractBodyRemainderAfterEmptyH1(options: { content: string }): string
return [secondLine, ...rest].join('\n').trimStart()
}
function blankParagraphBlocks(): EditorBlocks {
return [{ type: 'paragraph', content: [], children: [] }]
}
async function parseMarkdownBlocks(
editor: ReturnType<typeof useCreateBlockNote>,
preprocessed: string,
@@ -165,13 +164,21 @@ async function resolveBlocksForTarget(
const preprocessed = preProcessEditorMarkdown(body, vaultPath)
const fastPathBlocks = buildFastPathBlocks({ preprocessed })
if (fastPathBlocks) {
const nextState = { blocks: fastPathBlocks, scrollTop: 0, sourceContent: content }
const nextState = {
blocks: repairMalformedEditorBlocks(fastPathBlocks) as EditorBlocks,
scrollTop: 0,
sourceContent: content,
}
cacheEditorState(cache, targetPath, nextState)
return nextState
}
const parsed = normalizeParsedImageBlocks(await parseMarkdownBlocks(editor, preprocessed)) as EditorBlocks
const nextState = { blocks: injectEditorMarkdownBlocks(parsed), scrollTop: 0, sourceContent: content }
const nextState = {
blocks: repairMalformedEditorBlocks(injectEditorMarkdownBlocks(parsed)) as EditorBlocks,
scrollTop: 0,
sourceContent: content,
}
cacheEditorState(cache, targetPath, nextState)
return nextState
}
@@ -182,18 +189,20 @@ function applyBlocksToEditor(
scrollTop: number,
suppressChangeRef: MutableRefObject<boolean>,
) {
const safeBlocks = repairMalformedEditorBlocks(blocks) as EditorBlocks
suppressChangeRef.current = true
try {
resetTextSelectionBeforeContentSwap(editor)
const current = editor.document
if (current.length > 0 && blocks.length > 0) {
editor.replaceBlocks(current, blocks)
} else if (blocks.length > 0) {
editor.insertBlocks(blocks, current[0], 'before')
if (current.length > 0 && safeBlocks.length > 0) {
editor.replaceBlocks(current, safeBlocks)
} else if (safeBlocks.length > 0) {
editor.insertBlocks(safeBlocks, current[0], 'before')
}
} catch (err) {
console.error('applyBlocks failed, trying fallback:', err)
try {
const html = editor.blocksToHTMLLossy(blocks)
const html = editor.blocksToHTMLLossy(safeBlocks)
editor._tiptapEditor.commands.setContent(html)
} catch (err2) {
console.error('Fallback also failed:', err2)
@@ -214,6 +223,7 @@ function applyBlankStateToEditor(
) {
suppressChangeRef.current = true
try {
resetTextSelectionBeforeContentSwap(editor)
editor._tiptapEditor.commands.setContent('<p></p>')
} catch (err) {
console.error('applyBlankStateToEditor failed, falling back to replaceBlocks:', err)
@@ -235,6 +245,7 @@ function applyHtmlStateToEditor(
) {
suppressChangeRef.current = true
try {
resetTextSelectionBeforeContentSwap(editor)
editor._tiptapEditor.commands.setContent(html)
} catch (err) {
console.error('applyHtmlStateToEditor failed:', err)

View File

@@ -24,6 +24,14 @@ function renderSubject(onToast = vi.fn()) {
return renderHook(() => useMcpStatus('/vault', onToast))
}
function mockClipboard(writeText = vi.fn(() => Promise.resolve())) {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
return writeText
}
function mockStatusFlow(
initialStatus: 'installed' | 'not_installed',
overrides: Partial<Record<'register_mcp_tools' | 'remove_mcp_tools', unknown>> = {},
@@ -76,6 +84,7 @@ async function runMutationScenario({
describe('useMcpStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
mockClipboard()
})
it('checks the active vault status without auto-registering on mount', async () => {
@@ -160,4 +169,47 @@ describe('useMcpStatus', () => {
expect(mockInvoke).toHaveBeenCalledWith(overrideKey, commandArgs)
expect(onToast).toHaveBeenCalledWith(expect.stringContaining(toastFragment))
})
it('loads the exact manual MCP config snippet for the active vault', async () => {
const snippet = JSON.stringify({ mcpServers: { tolaria: { type: 'stdio' } } })
mockCommands({
check_mcp_status: 'installed',
get_mcp_config_snippet: snippet,
})
const { result } = renderSubject()
await waitFor(() => {
expect(result.current.mcpStatus).toBe('installed')
})
await act(async () => {
await result.current.loadMcpConfigSnippet()
})
expect(result.current.mcpConfigSnippet).toBe(snippet)
expect(result.current.mcpConfigError).toBeNull()
expect(mockInvoke).toHaveBeenCalledWith('get_mcp_config_snippet', { vaultPath: '/vault' })
})
it('copies the manual MCP config snippet to the clipboard', async () => {
const writeText = mockClipboard()
const onToast = vi.fn()
const snippet = JSON.stringify({ mcpServers: { tolaria: { type: 'stdio' } } })
mockCommands({
check_mcp_status: 'not_installed',
get_mcp_config_snippet: snippet,
})
const { result } = renderSubject(onToast)
await waitFor(() => {
expect(result.current.mcpStatus).toBe('not_installed')
})
await act(async () => {
await expect(result.current.copyMcpConfig()).resolves.toBe(true)
})
expect(writeText).toHaveBeenCalledWith(snippet)
expect(onToast).toHaveBeenCalledWith('Tolaria MCP config copied to clipboard')
})
})

View File

@@ -1,49 +1,136 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
export type McpStatus = 'checking' | 'installed' | 'not_installed'
type ManualConfigSnippet = string
type McpCommand =
| 'check_mcp_status'
| 'get_mcp_config_snippet'
| 'register_mcp_tools'
| 'remove_mcp_tools'
type McpCommandResult = string
type McpStatusResponse = string
type ToastHandler = (msg: ToastMessage) => void
type ToastMessage = string
type VaultPath = string
function tauriCall<T>(command: string, args?: Record<string, unknown>): Promise<T> {
interface ManualMcpConfigState {
error: ToastMessage | null
loading: boolean
snippet: ManualConfigSnippet | null
vaultPath: VaultPath
}
const EMPTY_MANUAL_CONFIG: ManualMcpConfigState = {
error: null,
loading: false,
snippet: null,
vaultPath: '',
}
function tauriCall<T>(command: McpCommand, args?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
function normalizeMcpStatus(value: string | null | undefined): McpStatus {
function normalizeMcpStatus(value: McpStatusResponse | null | undefined): McpStatus {
return value === 'installed' ? 'installed' : 'not_installed'
}
async function fetchMcpStatus(vaultPath: string): Promise<McpStatus> {
async function fetchMcpStatus(vaultPath: VaultPath): Promise<McpStatus> {
try {
const result = await tauriCall<string>('check_mcp_status', { vaultPath })
const result = await tauriCall<McpStatusResponse>('check_mcp_status', { vaultPath })
return normalizeMcpStatus(result)
} catch {
return 'not_installed'
}
}
function connectSuccessToast(result: string): string {
function connectSuccessToast(result: McpCommandResult): ToastMessage {
return result === 'registered'
? 'Tolaria external AI tools connected successfully'
: 'Tolaria external AI tools setup refreshed successfully'
}
function disconnectSuccessToast(result: string): string {
function disconnectSuccessToast(result: McpCommandResult): ToastMessage {
return result === 'removed'
? 'Tolaria external AI tools disconnected successfully'
: 'Tolaria external AI tools were already disconnected'
}
function errorMessage(error: unknown): ToastMessage {
return error instanceof Error ? error.message : String(error)
}
function visibleManualConfig(
state: ManualMcpConfigState,
vaultPath: VaultPath,
): ManualMcpConfigState {
return state.vaultPath === vaultPath ? state : EMPTY_MANUAL_CONFIG
}
async function writeClipboardText(value: ManualConfigSnippet): Promise<void> {
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API is unavailable')
}
await navigator.clipboard.writeText(value)
}
function useManualMcpConfig(
vaultPath: VaultPath,
onToastRef: MutableRefObject<ToastHandler>,
) {
const [manualConfig, setManualConfig] = useState<ManualMcpConfigState>(EMPTY_MANUAL_CONFIG)
const loadMcpConfigSnippet = useCallback(async () => {
setManualConfig({ error: null, loading: true, snippet: null, vaultPath })
try {
const snippet = await tauriCall<ManualConfigSnippet>('get_mcp_config_snippet', { vaultPath })
setManualConfig({ error: null, loading: false, snippet, vaultPath })
return snippet
} catch (error) {
const message = errorMessage(error)
setManualConfig({ error: message, loading: false, snippet: null, vaultPath })
throw error
}
}, [vaultPath])
const copyMcpConfig = useCallback(async () => {
try {
const snippet = await loadMcpConfigSnippet()
await writeClipboardText(snippet)
onToastRef.current('Tolaria MCP config copied to clipboard')
return true
} catch (error) {
onToastRef.current(`Copy MCP config failed: ${errorMessage(error)}`)
return false
}
}, [loadMcpConfigSnippet, onToastRef])
const currentManualConfig = visibleManualConfig(manualConfig, vaultPath)
return {
mcpConfigSnippet: currentManualConfig.snippet,
mcpConfigLoading: currentManualConfig.loading,
mcpConfigError: currentManualConfig.error,
loadMcpConfigSnippet,
copyMcpConfig,
}
}
/**
* Detects whether the active vault is explicitly connected to external MCP
* clients and exposes connect / disconnect actions.
*/
export function useMcpStatus(
vaultPath: string,
onToast: (msg: string) => void,
vaultPath: VaultPath,
onToast: ToastHandler,
) {
const [status, setStatus] = useState<McpStatus>('checking')
const onToastRef = useRef(onToast)
useEffect(() => { onToastRef.current = onToast })
const manualConfigActions = useManualMcpConfig(vaultPath, onToastRef)
const refreshMcpStatus = useCallback(async () => {
const nextStatus = await fetchMcpStatus(vaultPath)
@@ -91,5 +178,11 @@ export function useMcpStatus(
}
}, [refreshMcpStatus])
return { mcpStatus: status, refreshMcpStatus, connectMcp, disconnectMcp }
return {
mcpStatus: status,
refreshMcpStatus,
connectMcp,
disconnectMcp,
...manualConfigActions,
}
}

View File

@@ -74,11 +74,17 @@ describe('useNoteActions hook', () => {
return renderHook(() => useNoteActions(makeConfig(entries)))
}
function createImmediateEntry(type?: string) {
async function flushAsyncWork() {
await Promise.resolve()
await Promise.resolve()
}
async function createImmediateEntry(type?: string) {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderActions()
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate(type)
await flushAsyncWork()
})
const [createdEntry] = addEntry.mock.calls[0]
vi.restoreAllMocks()
@@ -194,25 +200,29 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Property deleted')
})
it('handleCreateNoteImmediate creates note with timestamp-based title', () => {
const createdEntry = createImmediateEntry()
it('handleCreateNoteImmediate creates note with timestamp-based title', async () => {
const createdEntry = await createImmediateEntry()
expect(createdEntry.title).toBe('Untitled Note 1700000000')
expect(createdEntry.filename).toBe('untitled-note-1700000000.md')
expect(createdEntry.isA).toBe('Note')
})
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => {
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', async () => {
vi.useFakeTimers()
let ts = 1700000000000
vi.spyOn(Date, 'now').mockImplementation(() => { ts += 1000; return ts })
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushAsyncWork()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2)
await flushAsyncWork()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2) })
expect(addEntry).toHaveBeenCalledTimes(3)
const filenames = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.filename)
@@ -224,8 +234,8 @@ describe('useNoteActions hook', () => {
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate accepts custom type', () => {
const createdEntry = createImmediateEntry('Project')
it('handleCreateNoteImmediate accepts custom type', async () => {
const createdEntry = await createImmediateEntry('Project')
expect(createdEntry.filename).toMatch(/^untitled-project-\d+\.md$/)
expect(createdEntry.isA).toBe('Project')
})
@@ -255,40 +265,29 @@ describe('useNoteActions hook', () => {
expect(tabContent).toContain('## Steps')
})
it('handleCreateNoteImmediate does not throw for custom types with special characters', () => {
it.each([
['Q&A', (entry: VaultEntry) => { expect(entry.isA).toBe('Q&A') }],
['+++', (entry: VaultEntry) => { expect(entry.filename).not.toBe('.md') }],
])('handleCreateNoteImmediate handles custom type "%s"', async (typeName, assertEntry) => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
expect(() => {
act(() => {
result.current.handleCreateNoteImmediate('Q&A')
})
}).not.toThrow()
const [entry] = addEntry.mock.calls[0]
expect(entry.isA).toBe('Q&A')
expect(entry.path).not.toContain('//')
})
it('handleCreateNoteImmediate does not throw for types that slugify to empty string', () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
expect(() => {
act(() => {
result.current.handleCreateNoteImmediate('+++')
})
}).not.toThrow()
await act(async () => {
expect(() => { result.current.handleCreateNoteImmediate(typeName) }).not.toThrow()
await flushAsyncWork()
})
const [entry] = addEntry.mock.calls[0]
expect(entry.path).not.toContain('//')
expect(entry.filename).not.toBe('.md')
assertEntry(entry)
})
it('handleCreateNoteImmediate uses template for typed notes', () => {
it('handleCreateNoteImmediate uses template for typed notes', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom Template\n\n' })
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate('Project')
await flushAsyncWork()
})
const tabContent = result.current.tabs[0].content
@@ -307,7 +306,15 @@ describe('useNoteActions hook', () => {
})
describe('pending save lifecycle', () => {
it('createAndPersist calls addPendingSave on start (non-Tauri)', async () => {
it.each([
['start', 'Pending Test', 'pending-test.md', 'addPendingSave'],
['completion', 'Persist OK', 'persist-ok.md', 'removePendingSave'],
])('createAndPersist calls pending-save callback on %s (non-Tauri)', async (
_phase,
title,
pathFragment,
callbackName,
) => {
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
const config = makeConfig()
@@ -317,28 +324,12 @@ describe('useNoteActions hook', () => {
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNote('Pending Test', 'Note')
await new Promise((r) => setTimeout(r, 0))
result.current.handleCreateNote(title, 'Note')
await flushAsyncWork()
})
expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('pending-test.md'))
})
it('createAndPersist calls removePendingSave when persist completes (non-Tauri)', async () => {
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
const config = makeConfig()
config.addPendingSave = addPendingSave
config.removePendingSave = removePendingSave
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNote('Persist OK', 'Note')
await new Promise((r) => setTimeout(r, 0))
})
expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('persist-ok.md'))
const callback = callbackName === 'addPendingSave' ? addPendingSave : removePendingSave
expect(callback).toHaveBeenCalledWith(expect.stringContaining(pathFragment))
})
it('createAndPersist calls removePendingSave AND reverts when persist fails (Tauri)', async () => {
@@ -363,22 +354,34 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
})
it('handleCreateNoteImmediate calls trackUnsaved and markContentPending (no disk write)', async () => {
const trackUnsaved = vi.fn()
const markContentPending = vi.fn()
it('handleCreateNoteImmediate creates the backing file before opening the note', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockResolvedValueOnce(undefined)
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
const onNewNotePersisted = vi.fn()
const config = makeConfig()
config.trackUnsaved = trackUnsaved
config.markContentPending = markContentPending
config.addPendingSave = addPendingSave
config.removePendingSave = removePendingSave
config.onNewNotePersisted = onNewNotePersisted
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNoteImmediate()
await new Promise((r) => setTimeout(r, 0))
await flushAsyncWork()
})
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/))
expect(markContentPending).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/), expect.stringContaining('type: Note'))
const createdPath = expect.stringMatching(/untitled-note-\d+\.md$/)
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
path: createdPath,
content: expect.stringContaining('type: Note'),
})
expect(addPendingSave).toHaveBeenCalledWith(createdPath)
expect(removePendingSave).toHaveBeenCalledWith(createdPath)
expect(onNewNotePersisted).toHaveBeenCalledOnce()
expect(addEntry).toHaveBeenCalledTimes(1)
expect(result.current.tabs[0].entry.path).toMatch(/untitled-note-\d+\.md$/)
})
it('calls onNewNotePersisted after successful disk write (non-Tauri)', async () => {
@@ -454,20 +457,24 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).not.toHaveBeenCalled()
})
it('handleCreateNoteImmediate does not call invoke (no disk write)', async () => {
it('handleCreateNoteImmediate writes each rapid note before opening it', async () => {
vi.useFakeTimers()
vi.mocked(invoke).mockResolvedValue(undefined)
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushAsyncWork()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2)
await flushAsyncWork()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2) })
expect(addEntry).toHaveBeenCalledTimes(3)
// No disk writes for immediate creation — notes are unsaved/in-memory
expect(invoke).not.toHaveBeenCalled()
expect(vi.mocked(invoke).mock.calls.filter(([command]) => command === 'create_note_content')).toHaveLength(3)
expect(removeEntry).not.toHaveBeenCalled()
})

View File

@@ -211,6 +211,10 @@ describe('useNoteCreation hook', () => {
})
const tabDeps = { openTabWithContent }
const flushImmediateCreate = async () => {
await Promise.resolve()
await Promise.resolve()
}
beforeEach(() => {
vi.clearAllMocks()
@@ -230,10 +234,13 @@ describe('useNoteCreation hook', () => {
expect(openTabWithContent.mock.calls[0][1]).toBe('---\ntitle: Test Note\ntype: Note\n---\n')
})
it('handleCreateNoteImmediate generates timestamp-based title', () => {
it('handleCreateNoteImmediate generates timestamp-based title', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateNoteImmediate() })
await act(async () => {
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
expect(addEntry).toHaveBeenCalledTimes(1)
expect(addEntry.mock.calls[0][0].title).toBe('Untitled Note 1700000000')
expect(addEntry.mock.calls[0][0].filename).toBe('untitled-note-1700000000.md')
@@ -242,17 +249,21 @@ describe('useNoteCreation hook', () => {
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => {
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', async () => {
vi.useFakeTimers()
let ts = 1700000000000
vi.spyOn(Date, 'now').mockImplementation(() => { ts += 1000; return ts })
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2)
await flushImmediateCreate()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2) })
const filenames = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.filename)
// Each call consumes Date.now() multiple times (filename + buildNewEntry), so just verify uniqueness
expect(new Set(filenames).size).toBe(3)
@@ -262,16 +273,20 @@ describe('useNoteCreation hook', () => {
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate avoids filename collisions when called twice in the same second', () => {
it('handleCreateNoteImmediate avoids filename collisions when called twice in the same second', async () => {
vi.useFakeTimers()
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushImmediateCreate()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS) })
const filenames = addEntry.mock.calls.map(([entry]: [VaultEntry]) => entry.filename)
expect(filenames).toEqual([
@@ -282,66 +297,117 @@ describe('useNoteCreation hook', () => {
vi.restoreAllMocks()
})
it('serializes rapid immediate-create bursts after the first note', () => {
it('serializes rapid immediate-create bursts after the first note', async () => {
vi.useFakeTimers()
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
expect(addEntry).toHaveBeenCalledTimes(1)
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS) })
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushImmediateCreate()
})
expect(addEntry).toHaveBeenCalledTimes(2)
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS) })
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushImmediateCreate()
})
expect(addEntry).toHaveBeenCalledTimes(3)
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate accepts custom type', () => {
it('handleCreateNoteImmediate accepts custom type', async () => {
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateNoteImmediate('Project') })
await act(async () => {
result.current.handleCreateNoteImmediate('Project')
await flushImmediateCreate()
})
expect(addEntry.mock.calls[0][0].isA).toBe('Project')
expect(addEntry.mock.calls[0][0].status).toBeNull()
expect(openTabWithContent.mock.calls[0][1]).toBe('---\ntype: Project\n---\n\n# \n\n## Objective\n\n\n\n## Key Results\n\n\n\n## Notes\n\n')
})
it('handleCreateNoteImmediate slugifies custom type names for filenames', () => {
it('handleCreateNoteImmediate slugifies custom type names for filenames', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate('Q&A / Ops')
await flushImmediateCreate()
})
expect(addEntry.mock.calls[0][0].filename).toBe('untitled-q-a-ops-1700000000.md')
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate tracks unsaved state', async () => {
const trackUnsaved = vi.fn()
const markContentPending = vi.fn()
const config = makeConfig()
config.trackUnsaved = trackUnsaved
config.markContentPending = markContentPending
it('handleCreateNoteImmediate creates the backing file before opening the note', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockResolvedValueOnce(undefined)
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
const onNewNotePersisted = vi.fn()
const config = {
...makeConfig(),
addPendingSave,
removePendingSave,
onNewNotePersisted,
}
const { result } = renderHook(() => useNoteCreation(config, tabDeps))
await act(async () => { result.current.handleCreateNoteImmediate() })
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/))
expect(markContentPending).toHaveBeenCalled()
await act(async () => {
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
const createdPath = expect.stringMatching(/untitled-note-\d+\.md$/)
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
path: createdPath,
content: expect.stringContaining('type: Note'),
})
expect(addPendingSave).toHaveBeenCalledWith(createdPath)
expect(removePendingSave).toHaveBeenCalledWith(createdPath)
expect(onNewNotePersisted).toHaveBeenCalledOnce()
expect(addEntry).toHaveBeenCalledTimes(1)
expect(openTabWithContent).toHaveBeenCalledTimes(1)
expect(vi.mocked(invoke).mock.invocationCallOrder[0]).toBeLessThan(
openTabWithContent.mock.invocationCallOrder[0],
)
})
it('handleCreateNoteImmediate requests editor focus for the new path', () => {
it('handleCreateNoteImmediate does not open an optimistic note when disk creation fails', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockRejectedValueOnce(new Error('disk full'))
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
await act(async () => {
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
expect(addEntry).not.toHaveBeenCalled()
expect(openTabWithContent).not.toHaveBeenCalled()
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
})
it('handleCreateNoteImmediate requests editor focus for the new path', async () => {
const focusListener = vi.fn()
window.addEventListener('laputa:focus-editor', focusListener)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateNoteImmediate() })
await act(async () => {
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
expect(focusListener).toHaveBeenCalledTimes(1)
const event = focusListener.mock.calls[0][0] as CustomEvent

View File

@@ -1,10 +1,11 @@
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, addMockEntry, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import { slugifyNoteStem as slugify } from '../utils/noteSlug'
import { resolveEntry } from '../utils/wikilink'
import { trackEvent } from '../lib/telemetry'
import { cacheNoteContent } from './useTabManagement'
export interface NewEntryParams {
path: string
@@ -370,13 +371,15 @@ async function createTypeSilently({
}
interface ImmediateCreateDeps {
addPendingSave?: (path: string) => void
entries: VaultEntry[]
vaultPath: string
pendingSlugs: Set<string>
openTabWithContent: (entry: VaultEntry, content: string) => void
addEntry: (entry: VaultEntry) => void
trackUnsaved?: (path: string) => void
markContentPending?: (path: string, content: string) => void
onNewNotePersisted?: () => void
removePendingSave?: (path: string) => void
setToastMessage: (msg: string | null) => void
}
interface ImmediateCreateRequest {
@@ -384,12 +387,14 @@ interface ImmediateCreateRequest {
}
interface ImmediateCreateQueueConfig {
addPendingSave?: (path: string) => void
entries: VaultEntry[]
vaultPath: string
addEntry: (entry: VaultEntry) => void
openTabWithContent: (entry: VaultEntry, content: string) => void
trackUnsaved?: (path: string) => void
markContentPending?: (path: string, content: string) => void
onNewNotePersisted?: () => void
removePendingSave?: (path: string) => void
setToastMessage: (msg: string | null) => void
}
/** Generate a unique untitled filename using a timestamp. */
@@ -410,8 +415,26 @@ function generateUntitledFilename(entries: VaultEntry[], type: string, pendingSl
return candidate
}
/** Create an untitled note without persisting to disk (deferred save). */
function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
async function persistImmediateEntry(
deps: ImmediateCreateDeps,
entry: VaultEntry,
content: string,
): Promise<boolean> {
try {
await persistOptimistic(entry.path, content, {
onStart: deps.addPendingSave,
onEnd: deps.removePendingSave,
onPersisted: deps.onNewNotePersisted,
})
return true
} catch (error) {
deps.setToastMessage(createPersistFailureMessage(entry, error))
return false
}
}
/** Create an untitled note and write its backing file before opening it. */
async function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): Promise<boolean> {
const noteType = type || 'Note'
const slug = generateUntitledFilename(deps.entries, noteType, deps.pendingSlugs)
const title = slug_to_title(slug)
@@ -419,11 +442,68 @@ function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
const status = null
const entry = buildNewEntry({ path: `${deps.vaultPath}/${slug}.md`, slug, title, type: noteType, status })
const content = buildNoteContent({ title: null, type: noteType, status, template, initialEmptyHeading: true })
const didPersist = await persistImmediateEntry(deps, entry, content)
if (!didPersist) return false
cacheNoteContent(entry.path, content)
deps.openTabWithContent(entry, content)
addEntryWithMock(entry, content, deps.addEntry)
deps.trackUnsaved?.(entry.path)
deps.markContentPending?.(entry.path, content)
signalFocusEditor({ path: entry.path, selectTitle: true })
return true
}
function trackImmediateCreate(request: ImmediateCreateRequest, didCreate: boolean): void {
if (!didCreate) return
trackEvent('note_created', {
has_type: request.type ? 1 : 0,
creation_path: request.type ? 'type_section' : 'cmd_n',
})
}
function useLatestImmediateCreateDeps(
config: ImmediateCreateQueueConfig,
pendingSlugsRef: MutableRefObject<Set<string>>,
) {
const {
entries,
vaultPath,
openTabWithContent,
addEntry,
addPendingSave,
onNewNotePersisted,
removePendingSave,
setToastMessage,
} = config
const latestDepsRef = useRef<ImmediateCreateDeps | null>(null)
const syncDeps = useCallback(() => {
latestDepsRef.current = {
entries,
vaultPath,
pendingSlugs: pendingSlugsRef.current,
openTabWithContent,
addEntry,
addPendingSave,
onNewNotePersisted,
removePendingSave,
setToastMessage,
}
}, [
entries,
vaultPath,
openTabWithContent,
addEntry,
addPendingSave,
onNewNotePersisted,
removePendingSave,
setToastMessage,
pendingSlugsRef,
])
useEffect(() => {
syncDeps()
}, [syncDeps])
return { latestDepsRef, syncDeps }
}
function useImmediateCreateQueue(config: ImmediateCreateQueueConfig): (type?: string) => void {
@@ -431,40 +511,13 @@ function useImmediateCreateQueue(config: ImmediateCreateQueueConfig): (type?: st
const queuedImmediateCreatesRef = useRef<ImmediateCreateRequest[]>([])
const immediateCreateLockedRef = useRef(false)
const immediateCreateTimerRef = useRef<number | null>(null)
const latestDepsRef = useRef<ImmediateCreateDeps | null>(null)
const syncDeps = useCallback(() => {
latestDepsRef.current = {
entries: config.entries,
vaultPath: config.vaultPath,
pendingSlugs: pendingSlugsRef.current,
openTabWithContent: config.openTabWithContent,
addEntry: config.addEntry,
trackUnsaved: config.trackUnsaved,
markContentPending: config.markContentPending,
}
}, [
config.entries,
config.vaultPath,
config.openTabWithContent,
config.addEntry,
config.trackUnsaved,
config.markContentPending,
])
useEffect(() => {
syncDeps()
}, [syncDeps])
const { latestDepsRef, syncDeps } = useLatestImmediateCreateDeps(config, pendingSlugsRef)
const executeRequest = useCallback((request: ImmediateCreateRequest) => {
const deps = latestDepsRef.current
if (!deps) return
createNoteImmediate(deps, request.type)
trackEvent('note_created', {
has_type: request.type ? 1 : 0,
creation_path: request.type ? 'type_section' : 'cmd_n',
})
}, [])
void createNoteImmediate(deps, request.type).then((didCreate) => trackImmediateCreate(request, didCreate))
}, [latestDepsRef])
const scheduleQueuedBurst = useCallback(function scheduleQueuedBurst() {
if (immediateCreateTimerRef.current !== null) return
@@ -577,9 +630,11 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
entries,
vaultPath,
addEntry,
addPendingSave,
openTabWithContent,
trackUnsaved: config.trackUnsaved,
markContentPending: config.markContentPending,
onNewNotePersisted,
removePendingSave,
setToastMessage,
})
return {

View File

@@ -0,0 +1,99 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SidebarSelection, ViewFile } from '../types'
import { useSavedViewOrdering } from './useSavedViewOrdering'
const mockInvokeFn = vi.fn()
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
function makeView(filename: string, name: string, order: number): ViewFile {
return {
filename,
definition: {
name,
icon: null,
color: null,
order,
sort: null,
filters: { all: [] },
},
}
}
function renderOrdering(selection: SidebarSelection = { kind: 'view', filename: 'beta.yml' }) {
const reloadViews = vi.fn().mockResolvedValue([])
const loadModifiedFiles = vi.fn().mockResolvedValue(undefined)
const onToast = vi.fn()
const views = [
makeView('alpha.yml', 'Alpha', 0),
makeView('beta.yml', 'Beta', 1),
makeView('gamma.yml', 'Gamma', 2),
]
const hook = renderHook(() => useSavedViewOrdering({
views,
selection,
vaultPath: '/vault',
reloadViews,
loadModifiedFiles,
onToast,
}))
return { ...hook, reloadViews, loadModifiedFiles, onToast }
}
describe('useSavedViewOrdering', () => {
beforeEach(() => {
mockInvokeFn.mockReset()
mockInvokeFn.mockResolvedValue(null)
})
it('persists a dense order when moving a saved view', async () => {
const { result, reloadViews, loadModifiedFiles, onToast } = renderOrdering()
await act(async () => {
await result.current.onMoveView('beta.yml', 'up')
})
expect(mockInvokeFn).toHaveBeenCalledWith('save_view_cmd', {
vaultPath: '/vault',
filename: 'beta.yml',
definition: expect.objectContaining({ name: 'Beta', order: 0 }),
})
expect(mockInvokeFn).toHaveBeenCalledWith('save_view_cmd', {
vaultPath: '/vault',
filename: 'alpha.yml',
definition: expect.objectContaining({ name: 'Alpha', order: 1 }),
})
expect(reloadViews).toHaveBeenCalledOnce()
expect(loadModifiedFiles).toHaveBeenCalledOnce()
expect(onToast).toHaveBeenCalledWith('Views reordered')
})
it('exposes command state for the selected saved view', () => {
const { result } = renderOrdering()
expect(result.current.selectedViewName).toBe('Beta')
expect(result.current.selectedViewFilename).toBe('beta.yml')
expect(result.current.canMoveSelectedViewUp).toBe(true)
expect(result.current.canMoveSelectedViewDown).toBe(true)
})
it('ignores invalid drag orders without writing view files', async () => {
const { result, reloadViews } = renderOrdering()
await act(async () => {
await result.current.onReorderViews(['alpha.yml', 'alpha.yml', 'gamma.yml'])
})
expect(mockInvokeFn).not.toHaveBeenCalled()
expect(reloadViews).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,96 @@
import { useCallback, useMemo } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { SidebarSelection, ViewFile } from '../types'
import {
buildViewOrderUpdates,
canMoveView,
moveView,
orderViewsByFilename,
type ViewMoveDirection,
type ViewMoveHandler,
type ViewReorderHandler,
} from '../utils/viewOrdering'
interface SavedViewOrderingConfig {
views: ViewFile[]
selection: SidebarSelection
vaultPath: string
reloadViews: () => Promise<unknown>
loadModifiedFiles: () => Promise<void>
onToast: (message: string) => void
}
interface SavedViewOrdering {
onReorderViews: ViewReorderHandler
onMoveView: ViewMoveHandler
selectedViewName?: string
selectedViewFilename: string | null
onMoveSelectedViewUp?: () => void
onMoveSelectedViewDown?: () => void
canMoveSelectedViewUp: boolean
canMoveSelectedViewDown: boolean
}
function selectedSavedView(views: ViewFile[], selection: SidebarSelection): ViewFile | null {
if (selection.kind !== 'view') return null
return views.find((view) => view.filename === selection.filename) ?? null
}
function viewMoveCommand(
view: ViewFile | null,
direction: ViewMoveDirection,
onMoveView: ViewMoveHandler,
): (() => void) | undefined {
if (!view) return undefined
const { filename } = view
return () => { void onMoveView(filename, direction) }
}
export function useSavedViewOrdering({
views,
selection,
vaultPath,
reloadViews,
loadModifiedFiles,
onToast,
}: SavedViewOrderingConfig): SavedViewOrdering {
const persistViewOrder = useCallback(async (orderedViews: ViewFile[]) => {
const target = isTauri() ? invoke : mockInvoke
await Promise.all(buildViewOrderUpdates(orderedViews).map(({ filename, definition }) => (
target('save_view_cmd', { vaultPath, filename, definition })
)))
await reloadViews()
await loadModifiedFiles()
onToast('Views reordered')
}, [loadModifiedFiles, onToast, reloadViews, vaultPath])
const onReorderViews = useCallback<ViewReorderHandler>(async (orderedFilenames) => {
const orderedViews = orderViewsByFilename(views, orderedFilenames)
if (!orderedViews) return
await persistViewOrder(orderedViews)
}, [persistViewOrder, views])
const onMoveView = useCallback<ViewMoveHandler>(async (filename, direction) => {
const orderedViews = moveView(views, filename, direction)
if (!orderedViews) return
await persistViewOrder(orderedViews)
}, [persistViewOrder, views])
const selectedView = useMemo(
() => selectedSavedView(views, selection),
[selection, views],
)
const selectedViewFilename = selectedView?.filename ?? null
return {
onReorderViews,
onMoveView,
selectedViewName: selectedView?.definition.name,
selectedViewFilename,
onMoveSelectedViewUp: viewMoveCommand(selectedView, 'up', onMoveView),
onMoveSelectedViewDown: viewMoveCommand(selectedView, 'down', onMoveView),
canMoveSelectedViewUp: selectedViewFilename ? canMoveView(views, selectedViewFilename, 'up') : false,
canMoveSelectedViewDown: selectedViewFilename ? canMoveView(views, selectedViewFilename, 'down') : false,
}
}

View File

@@ -22,6 +22,7 @@ describe('useVaultAiGuidanceStatus', () => {
mockInvoke.mockResolvedValue({
agents_state: 'managed',
claude_state: 'broken',
gemini_state: 'missing',
can_restore: true,
})
@@ -29,11 +30,13 @@ describe('useVaultAiGuidanceStatus', () => {
expect(result.current.status.agentsState).toBe('checking')
expect(result.current.status.claudeState).toBe('checking')
expect(result.current.status.geminiState).toBe('checking')
await waitFor(() => {
expect(result.current.status).toEqual({
agentsState: 'managed',
claudeState: 'broken',
geminiState: 'missing',
canRestore: true,
})
})
@@ -44,11 +47,13 @@ describe('useVaultAiGuidanceStatus', () => {
.mockResolvedValueOnce({
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
})
.mockResolvedValueOnce({
agents_state: 'managed',
claude_state: 'broken',
gemini_state: 'managed',
can_restore: true,
})
@@ -65,6 +70,7 @@ describe('useVaultAiGuidanceStatus', () => {
expect(result.current.status).toEqual({
agentsState: 'managed',
claudeState: 'broken',
geminiState: 'managed',
canRestore: true,
})
})

View File

@@ -11,6 +11,7 @@ import {
type RawVaultAiGuidanceStatus = Partial<{
agents_state: VaultAiGuidanceFileState | null
claude_state: VaultAiGuidanceFileState | null
gemini_state: VaultAiGuidanceFileState | null
can_restore: boolean | null
}>

View File

@@ -11,6 +11,7 @@ import {
subscribeVaultConfig,
} from '../utils/vaultConfigStore'
import { migrateLocalStorageToVaultConfig } from '../utils/configMigration'
import { DEFAULT_AI_AGENT_PERMISSION_MODE } from '../lib/aiAgentPermissionMode'
const STORAGE_PREFIX = 'laputa:vault-config:'
@@ -20,9 +21,10 @@ function storageKey(vaultPath: string): string {
function loadFromStorage(vaultPath: string): VaultConfig {
const DEFAULT: VaultConfig = {
zoom: null, view_mode: null, editor_mode: null,
zoom: null, view_mode: null, editor_mode: null, note_layout: null,
ai_agent_permission_mode: DEFAULT_AI_AGENT_PERMISSION_MODE,
tag_colors: null, status_colors: null, property_display_modes: null,
inbox: null,
inbox: null, allNotes: null,
}
try {
const raw = localStorage.getItem(storageKey(vaultPath))

View File

@@ -11,6 +11,8 @@ const mockEntries: VaultEntry[] = [
archived: false,
modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
sidebarLabel: null, view: null, visible: null, organized: false, favorite: false, favoriteIndex: null,
listPropertiesDisplay: [], properties: {}, hasH1: false,
},
]
@@ -152,6 +154,55 @@ describe('useVaultLoader', () => {
expect(result.current.entries[0].title).toBe('Hello')
})
it('normalizes missing entry and view string metadata from vault load', async () => {
backendInvokeFn.mockImplementation(((cmd: string) => {
if (isVaultLoadCommand(cmd)) {
return Promise.resolve([
{
path: '/vault/note/missing-title.md',
filename: undefined,
title: undefined,
aliases: undefined,
outgoingLinks: undefined,
relationships: undefined,
properties: undefined,
},
])
}
if (cmd === 'list_views') return Promise.resolve([{ filename: undefined, definition: {} }])
if (cmd === 'get_modified_files') return Promise.resolve([])
if (cmd === 'list_vault_folders') return Promise.resolve([])
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitForEntries(result)
await waitFor(() => {
expect(result.current.views).toHaveLength(1)
})
expect(result.current.entries[0]).toMatchObject({
path: '/vault/note/missing-title.md',
filename: 'missing-title.md',
title: 'missing-title',
aliases: [],
outgoingLinks: [],
relationships: {},
properties: {},
})
expect(result.current.views[0]).toMatchObject({
filename: 'view-1.yml',
definition: {
name: 'View 1',
icon: null,
color: null,
sort: null,
filters: { all: [] },
},
})
})
it('reports initial vault loading until the note scan resolves', async () => {
const entriesLoad = createDeferred<VaultEntry[]>()
backendInvokeFn.mockImplementation(((cmd: string) => {

View File

@@ -1,6 +1,4 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry, FolderNode, GitCommit, ModifiedFile, NoteStatus, GitPushResult, ViewFile } from '../types'
import {
GITIGNORED_VISIBILITY_CHANGED_EVENT,
@@ -8,46 +6,16 @@ import {
type GitignoredVisibilityChangedEvent,
} from '../lib/gitignoredVisibilityEvents'
import { clearPrefetchCache } from './useTabManagement'
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
}
function hasVaultPath(vaultPath: string): boolean {
return vaultPath.trim().length > 0
}
function loadVaultEntries(vaultPath: string): Promise<VaultEntry[]> {
const command = isTauri() ? 'reload_vault' : 'list_vault'
return tauriCall<VaultEntry[]>(command, { path: vaultPath })
}
async function loadVaultData(vaultPath: string) {
if (!isTauri()) console.info('[mock] Using mock Tauri data for browser testing')
const entries = await loadVaultEntries(vaultPath)
console.log(`Vault scan complete: ${entries.length} entries found`)
return { entries }
}
function loadVaultFolders(vaultPath: string): Promise<FolderNode[]> {
return tauriCall<FolderNode[]>('list_vault_folders', { path: vaultPath })
}
function loadVaultViews(vaultPath: string): Promise<ViewFile[]> {
return tauriCall<ViewFile[]>('list_views', { vaultPath })
}
async function loadVaultChrome(vaultPath: string) {
const [folders, views] = await Promise.all([
loadVaultFolders(vaultPath).catch(() => [] as FolderNode[]),
loadVaultViews(vaultPath).catch(() => [] as ViewFile[]),
])
return {
folders: folders ?? [],
views: views ?? [],
}
}
import {
commitWithPush,
hasVaultPath,
loadVaultChrome,
loadVaultData,
loadVaultFolders,
loadVaultViews,
reloadVaultEntries,
tauriCall,
} from './vaultLoaderCommands'
function resetVaultState(options: {
clearNewPaths: () => void
@@ -78,11 +46,11 @@ async function loadInitialVaultState(options: {
setViews: (views: ViewFile[]) => void
}) {
const { path, isCurrentVaultPath, setEntries, setFolders, setIsLoading, setViews } = options
const chromeLoad = loadVaultChrome(path)
const chromeLoad = loadVaultChrome({ vaultPath: path })
setIsLoading(true)
try {
const { entries } = await loadVaultData(path)
const { entries } = await loadVaultData({ vaultPath: path })
if (isCurrentVaultPath(path)) setEntries(entries)
} catch (err) {
console.warn('Vault scan failed:', err)
@@ -106,15 +74,6 @@ function useCurrentVaultPathGuard(vaultPath: string) {
return useCallback((path: string) => currentPathRef.current === path, [])
}
async function commitWithPush(vaultPath: string, message: string): Promise<GitPushResult> {
if (!isTauri()) {
await mockInvoke<string>('git_commit', { message })
return mockInvoke<GitPushResult>('git_push', {})
}
await invoke<string>('git_commit', { vaultPath, message })
return invoke<GitPushResult>('git_push', { vaultPath })
}
function useNewNoteTracker() {
const [newPaths, setNewPaths] = useState<Set<string>>(new Set())
@@ -242,7 +201,7 @@ function useInitialVaultLoad({
})
resetReloading()
if (!hasVaultPath(path)) return
if (!hasVaultPath({ vaultPath: path })) return
let cancelled = false
void loadInitialVaultState({
@@ -277,13 +236,17 @@ function useModifiedFilesLoader(vaultPath: string, isCurrentVaultPath: (path: st
const path = vaultPath
setModifiedFilesError(null)
if (!hasVaultPath(path)) {
if (!hasVaultPath({ vaultPath: path })) {
setModifiedFiles([])
return
}
try {
const files = await tauriCall<ModifiedFile[]>('get_modified_files', { vaultPath: path }, {})
const files = await tauriCall<ModifiedFile[]>({
command: 'get_modified_files',
tauriArgs: { vaultPath: path },
mockArgs: {},
})
if (isCurrentVaultPath(path)) setModifiedFiles(files)
} catch (err) {
if (!isCurrentVaultPath(path)) return
@@ -347,18 +310,32 @@ function useEntryMutations(
function useGitLoaders(vaultPath: string) {
const loadGitHistory = useCallback(async (path: string): Promise<GitCommit[]> => {
try { return await tauriCall<GitCommit[]>('get_file_history', { vaultPath, path }, { path }) }
try {
return await tauriCall<GitCommit[]>({
command: 'get_file_history',
tauriArgs: { vaultPath, path },
mockArgs: { path },
})
}
catch (err) { console.warn('Failed to load git history:', err); return [] }
}, [vaultPath])
const loadDiffAtCommit = useCallback((path: string, commitHash: string): Promise<string> =>
tauriCall<string>('get_file_diff_at_commit', { vaultPath, path, commitHash }, { path, commitHash }), [vaultPath])
tauriCall<string>({
command: 'get_file_diff_at_commit',
tauriArgs: { vaultPath, path, commitHash },
mockArgs: { path, commitHash },
}), [vaultPath])
const loadDiff = useCallback((path: string): Promise<string> =>
tauriCall<string>('get_file_diff', { vaultPath, path }, { path }), [vaultPath])
tauriCall<string>({
command: 'get_file_diff',
tauriArgs: { vaultPath, path },
mockArgs: { path },
}), [vaultPath])
const commitAndPush = useCallback((message: string): Promise<GitPushResult> =>
commitWithPush(vaultPath, message), [vaultPath])
commitWithPush({ vaultPath, message }), [vaultPath])
return { loadGitHistory, loadDiffAtCommit, loadDiff, commitAndPush }
}
@@ -386,9 +363,9 @@ function useVaultReloads({
const reloadFolders = useCallback(async () => {
const path = vaultPath
if (!hasVaultPath(path)) return [] as FolderNode[]
if (!hasVaultPath({ vaultPath: path })) return [] as FolderNode[]
try {
const folders = await loadVaultFolders(path)
const folders = await loadVaultFolders({ vaultPath: path })
if (!isCurrentVaultPath(path)) return [] as FolderNode[]
const nextFolders = folders ?? []
setFolders(nextFolders)
@@ -400,11 +377,11 @@ function useVaultReloads({
const reloadVault = useCallback(async () => {
const path = vaultPath
if (!hasVaultPath(path)) return [] as VaultEntry[]
if (!hasVaultPath({ vaultPath: path })) return [] as VaultEntry[]
clearPrefetchCache()
beginReload()
try {
const entries = await tauriCall<VaultEntry[]>('reload_vault', { path })
const entries = await reloadVaultEntries({ vaultPath: path })
if (!isCurrentVaultPath(path)) return [] as VaultEntry[]
setEntries(entries)
void loadModifiedFiles()
@@ -419,9 +396,9 @@ function useVaultReloads({
const reloadViews = useCallback(async () => {
const path = vaultPath
if (!hasVaultPath(path)) return []
if (!hasVaultPath({ vaultPath: path })) return []
try {
const nextViews = await loadVaultViews(path)
const nextViews = await loadVaultViews({ vaultPath: path })
if (!isCurrentVaultPath(path)) return []
const resolvedViews = nextViews ?? []
setViews(resolvedViews)
@@ -462,7 +439,7 @@ function useGitignoredVisibilityReloads(
export function useVaultLoader(vaultPath: string) {
const [entries, setEntries] = useState<VaultEntry[]>([])
const [folders, setFolders] = useState<FolderNode[]>([])
const [isLoading, setIsLoading] = useState(() => hasVaultPath(vaultPath))
const [isLoading, setIsLoading] = useState(() => hasVaultPath({ vaultPath }))
const [views, setViews] = useState<ViewFile[]>([])
const tracker = useNewNoteTracker()
const pendingSave = usePendingSaveTracker()

View File

@@ -0,0 +1,86 @@
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { FolderNode, GitPushResult, VaultEntry, ViewFile } from '../types'
import { normalizeVaultEntries, normalizeViewFiles } from '../utils/vaultMetadataNormalization'
interface TauriCallOptions {
command: string
tauriArgs: Record<string, unknown>
mockArgs?: Record<string, unknown>
}
interface VaultPathOptions {
vaultPath: string
}
interface CommitWithPushOptions extends VaultPathOptions {
message: string
}
interface LoadedVaultData {
entries: VaultEntry[]
}
interface LoadedVaultChrome {
folders: FolderNode[]
views: ViewFile[]
}
export function hasVaultPath({ vaultPath }: VaultPathOptions): boolean {
return vaultPath.trim().length > 0
}
export function tauriCall<T>({ command, tauriArgs, mockArgs }: TauriCallOptions): Promise<T> {
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
}
function loadVaultEntriesWithCommand({ vaultPath, command }: VaultPathOptions & { command: string }): Promise<VaultEntry[]> {
return tauriCall<unknown>({ command, tauriArgs: { path: vaultPath } })
.then((entries) => normalizeVaultEntries(entries, vaultPath))
}
function loadVaultEntries({ vaultPath }: VaultPathOptions): Promise<VaultEntry[]> {
const command = isTauri() ? 'reload_vault' : 'list_vault'
return loadVaultEntriesWithCommand({ vaultPath, command })
}
export function reloadVaultEntries({ vaultPath }: VaultPathOptions): Promise<VaultEntry[]> {
return loadVaultEntriesWithCommand({ vaultPath, command: 'reload_vault' })
}
export function loadVaultFolders({ vaultPath }: VaultPathOptions): Promise<FolderNode[]> {
return tauriCall<FolderNode[]>({ command: 'list_vault_folders', tauriArgs: { path: vaultPath } })
}
export function loadVaultViews({ vaultPath }: VaultPathOptions): Promise<ViewFile[]> {
return tauriCall<unknown>({ command: 'list_views', tauriArgs: { vaultPath } })
.then(normalizeViewFiles)
}
export async function loadVaultData({ vaultPath }: VaultPathOptions): Promise<LoadedVaultData> {
if (!isTauri()) console.info('[mock] Using mock Tauri data for browser testing')
const entries = await loadVaultEntries({ vaultPath })
console.log(`Vault scan complete: ${entries.length} entries found`)
return { entries }
}
export async function loadVaultChrome({ vaultPath }: VaultPathOptions): Promise<LoadedVaultChrome> {
const [folders, views] = await Promise.all([
loadVaultFolders({ vaultPath }).catch(() => [] as FolderNode[]),
loadVaultViews({ vaultPath }).catch(() => [] as ViewFile[]),
])
return {
folders: folders ?? [],
views: views ?? [],
}
}
export async function commitWithPush({ vaultPath, message }: CommitWithPushOptions): Promise<GitPushResult> {
if (!isTauri()) {
await mockInvoke<string>('git_commit', { message })
return mockInvoke<GitPushResult>('git_push', {})
}
await invoke<string>('git_commit', { vaultPath, message })
return invoke<GitPushResult>('git_push', { vaultPath })
}

View File

@@ -11,10 +11,12 @@ import {
import type { NoteReference } from '../utils/ai-context'
import type { AiAgentId } from './aiAgents'
import { getAiAgentDefinition } from './aiAgents'
import type { AiAgentPermissionMode } from './aiAgentPermissionMode'
export interface AiAgentMessage {
userMessage: string
references?: NoteReference[]
localMarker?: string
reasoning?: string
reasoningDone?: boolean
actions: AiAction[]
@@ -29,6 +31,7 @@ export interface AgentExecutionContext {
agent: AiAgentId
ready: boolean
vaultPath: string
permissionMode: AiAgentPermissionMode
systemPromptOverride?: string
}
@@ -38,7 +41,7 @@ export interface PendingUserPrompt {
}
function toChatHistory(messages: AiAgentMessage[]): ChatMessage[] {
return messages.flatMap((message) => {
return messages.filter((message) => !message.localMarker).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` })
@@ -47,6 +50,21 @@ function toChatHistory(messages: AiAgentMessage[]): ChatMessage[] {
})
}
export function appendLocalMarker(
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>,
text: string,
): void {
setMessages((current) => [
...current,
{
userMessage: '',
localMarker: text,
actions: [],
id: nextMessageId(),
},
])
}
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.`

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import {
AI_AGENT_PERMISSION_MODE_LABELS,
DEFAULT_AI_AGENT_PERMISSION_MODE,
aiAgentPermissionModeMarker,
normalizeAiAgentPermissionMode,
} from './aiAgentPermissionMode'
describe('aiAgentPermissionMode', () => {
it('defaults missing, null, and unknown values to vault safe mode', () => {
expect(DEFAULT_AI_AGENT_PERMISSION_MODE).toBe('safe')
expect(normalizeAiAgentPermissionMode(undefined)).toBe('safe')
expect(normalizeAiAgentPermissionMode(null)).toBe('safe')
expect(normalizeAiAgentPermissionMode('danger')).toBe('safe')
})
it('preserves known permission modes and exposes compact labels', () => {
expect(normalizeAiAgentPermissionMode('safe')).toBe('safe')
expect(normalizeAiAgentPermissionMode('power_user')).toBe('power_user')
expect(AI_AGENT_PERMISSION_MODE_LABELS.safe.short).toBe('Safe')
expect(AI_AGENT_PERMISSION_MODE_LABELS.safe.control).toBe('Vault Safe')
expect(AI_AGENT_PERMISSION_MODE_LABELS.power_user.short).toBe('Power User')
})
it('formats a local transcript marker for mode changes', () => {
expect(aiAgentPermissionModeMarker('power_user')).toBe(
'AI permission mode changed to Power User. It will apply to the next message.',
)
})
})

View File

@@ -0,0 +1,26 @@
export type AiAgentPermissionMode = 'safe' | 'power_user'
export const DEFAULT_AI_AGENT_PERMISSION_MODE: AiAgentPermissionMode = 'safe'
export const AI_AGENT_PERMISSION_MODE_LABELS: Record<
AiAgentPermissionMode,
{ short: string; control: string }
> = {
safe: {
short: 'Safe',
control: 'Vault Safe',
},
power_user: {
short: 'Power User',
control: 'Power User',
},
}
export function normalizeAiAgentPermissionMode(value: unknown): AiAgentPermissionMode {
return value === 'power_user' ? 'power_user' : DEFAULT_AI_AGENT_PERMISSION_MODE
}
export function aiAgentPermissionModeMarker(mode: AiAgentPermissionMode): string {
const label = AI_AGENT_PERMISSION_MODE_LABELS[mode].short
return `AI permission mode changed to ${label}. It will apply to the next message.`
}

View File

@@ -1,6 +1,7 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import {
appendLocalResponse,
appendLocalMarker,
appendStreamingMessage,
buildFormattedMessage,
createMissingAgentResponse,
@@ -80,6 +81,7 @@ export async function sendAgentMessage({
message: formattedMessage,
systemPrompt,
vaultPath: context.vaultPath,
permissionMode: context.permissionMode,
callbacks: createStreamCallbacks({
agent: context.agent,
messageId,
@@ -94,6 +96,13 @@ export async function sendAgentMessage({
})
}
export function addAgentLocalMarker(
runtime: Pick<AiAgentSessionRuntime, 'setMessages'>,
text: string,
): void {
appendLocalMarker(runtime.setMessages, text)
}
export function clearAgentConversation(runtime: Pick<AiAgentSessionRuntime, 'abortRef' | 'responseAccRef' | 'toolInputMapRef' | 'setMessages' | 'setStatus'>): void {
runtime.abortRef.current.aborted = true
runtime.responseAccRef.current = ''

View File

@@ -153,6 +153,9 @@
"sidebar.action.createView": "Create view",
"sidebar.action.editView": "Edit view",
"sidebar.action.deleteView": "Delete view",
"sidebar.action.reorderView": "Reorder view",
"sidebar.action.moveViewUp": "Move view up",
"sidebar.action.moveViewDown": "Move view down",
"sidebar.action.customizeSections": "Customize sections",
"sidebar.action.renameSection": "Rename section…",
"sidebar.action.customizeIconColor": "Customize icon & color…",

View File

@@ -3,6 +3,7 @@ import { afterEach, describe, it, expect, vi } from 'vitest'
const sentryMocks = vi.hoisted(() => ({
close: vi.fn(),
init: vi.fn(),
setTag: vi.fn(),
setUser: vi.fn(),
}))
@@ -22,6 +23,7 @@ afterEach(() => {
vi.unstubAllEnvs()
sentryMocks.close.mockClear()
sentryMocks.init.mockClear()
sentryMocks.setTag.mockClear()
sentryMocks.setUser.mockClear()
})
@@ -69,17 +71,23 @@ describe('trackEvent', () => {
})
describe('initSentry', () => {
it('passes the configured build release to Sentry', () => {
it.each([
['stable builds', '2026.4.23', '2026.4.23', 'stable'],
['alpha builds', '2026.4.28-alpha.7', undefined, 'prerelease'],
['local builds', '0.1.0', undefined, 'internal'],
])('sets release metadata for %s', (_name, buildVersion, sentryRelease, releaseKind) => {
vi.stubEnv('VITE_SENTRY_DSN', 'https://public@example.ingest.sentry.io/123456')
vi.stubEnv('VITE_SENTRY_RELEASE', '2026.4.23')
vi.stubEnv('VITE_SENTRY_RELEASE', buildVersion)
initSentry('anonymous-user')
expect(sentryMocks.init).toHaveBeenCalledWith(expect.objectContaining({
dsn: 'https://public@example.ingest.sentry.io/123456',
release: '2026.4.23',
release: sentryRelease,
}))
expect(sentryMocks.setUser).toHaveBeenCalledWith({ id: 'anonymous-user' })
expect(sentryMocks.setTag).toHaveBeenCalledWith('tolaria.build_version', buildVersion)
expect(sentryMocks.setTag).toHaveBeenCalledWith('tolaria.release_kind', releaseKind)
})
})

View File

@@ -25,7 +25,7 @@ let posthogInstance: typeof import('posthog-js').default | null = null
export function initSentry(anonymousId: string): void {
if (sentryInitialized) return
const { sentryDsn, sentryRelease } = resolveFrontendTelemetryConfig()
const { sentryDsn, sentryBuildVersion, sentryRelease } = resolveFrontendTelemetryConfig()
if (!sentryDsn) return
Sentry.init({
@@ -35,6 +35,14 @@ export function initSentry(anonymousId: string): void {
beforeSend: scrubSentryEvent,
})
Sentry.setUser({ id: anonymousId })
if (sentryBuildVersion) {
const releaseKind = sentryRelease
? 'stable'
: sentryBuildVersion.includes('-') ? 'prerelease' : 'internal'
Sentry.setTag('tolaria.build_version', sentryBuildVersion)
Sentry.setTag('tolaria.release_kind', releaseKind)
}
sentryInitialized = true
}

View File

@@ -48,6 +48,7 @@ describe('resolveFrontendTelemetryConfig', () => {
},
expected: {
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: 'https://eu.i.posthog.com',
@@ -62,6 +63,7 @@ describe('resolveFrontendTelemetryConfig', () => {
},
expected: {
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: 'https://eu.i.posthog.com',
@@ -83,6 +85,24 @@ describe('resolveFrontendTelemetryConfig', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: 'false' }).sentryRelease).toBe('')
})
it('keeps stable calendar versions as Sentry releases', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: '2026.4.28' }).sentryRelease).toBe('2026.4.28')
})
it('drops prerelease versions from the Sentry release field', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: '2026.4.28-alpha.7' })).toMatchObject({
sentryBuildVersion: '2026.4.28-alpha.7',
sentryRelease: '',
})
})
it('drops local development versions from the Sentry release field', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: '0.1.0' })).toMatchObject({
sentryBuildVersion: '0.1.0',
sentryRelease: '',
})
})
it('drops invalid PostHog hosts instead of loading scripts from them', () => {
expect(resolveConfig({ VITE_POSTHOG_HOST: 'not a url' }).posthogHost).toBeNull()
})
@@ -93,6 +113,7 @@ describe('resolveFrontendTelemetryConfig', () => {
VITE_POSTHOG_HOST: 'false',
})).toEqual({
sentryDsn: '',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: null,
@@ -105,6 +126,7 @@ describe('resolveFrontendTelemetryConfig', () => {
VITE_POSTHOG_HOST: 'https://le',
})).toEqual({
sentryDsn: '',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: null,
@@ -115,6 +137,7 @@ describe('resolveFrontendTelemetryConfig', () => {
VITE_POSTHOG_HOST: 'http://localhost:8010',
})).toEqual({
sentryDsn: 'http://public@localhost:9000/123456',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: 'http://localhost:8010',

View File

@@ -17,6 +17,7 @@ type TelemetryEnv = {
export type FrontendTelemetryConfig = {
sentryDsn: string
sentryBuildVersion: string
sentryRelease: string
posthogKey: string
posthogHost: string | null
@@ -87,8 +88,18 @@ function normalizeSentryDsn(value: string): string {
}
function normalizeSentryRelease(value: string): string {
if (!value) return ''
return DISALLOWED_TELEMETRY_VALUES.has(value.toLowerCase()) ? '' : value
const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})$/.exec(value)
if (!match) return ''
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const date = new Date(Date.UTC(year, month - 1, day))
const validDate = date.getUTCFullYear() === year
&& date.getUTCMonth() === month - 1
&& date.getUTCDate() === day
return validDate ? value : ''
}
function normalizePostHogHost(value: string): string | null {
@@ -103,15 +114,17 @@ export function resolveFrontendTelemetryConfig(
const sentryDsn = normalizeSentryDsn(
sanitizeTelemetryEnvValue(env.VITE_SENTRY_DSN),
)
const sentryRelease = normalizeSentryRelease(
sanitizeTelemetryEnvValue(env.VITE_SENTRY_RELEASE),
)
const sanitizedSentryVersion = sanitizeTelemetryEnvValue(env.VITE_SENTRY_RELEASE)
const sentryBuildVersion = DISALLOWED_TELEMETRY_VALUES.has(sanitizedSentryVersion.toLowerCase())
? ''
: sanitizedSentryVersion
const sentryRelease = normalizeSentryRelease(sentryBuildVersion)
const posthogKey = sanitizeTelemetryEnvValue(env.VITE_POSTHOG_KEY)
const posthogHost = normalizePostHogHost(
sanitizeTelemetryEnvValue(env.VITE_POSTHOG_HOST),
)
return { sentryDsn, sentryRelease, posthogKey, posthogHost }
return { sentryDsn, sentryBuildVersion, sentryRelease, posthogKey, posthogHost }
}
export { DEFAULT_POSTHOG_HOST as _defaultPostHogHostForTest }

View File

@@ -51,6 +51,7 @@ describe('vaultAiGuidance helpers', () => {
expect(createCheckingVaultAiGuidanceStatus()).toEqual({
agentsState: 'checking',
claudeState: 'checking',
geminiState: 'checking',
canRestore: false,
})
})
@@ -59,10 +60,12 @@ describe('vaultAiGuidance helpers', () => {
expect(normalizeVaultAiGuidanceStatus({
agents_state: 'managed',
claude_state: 'broken',
gemini_state: 'missing',
can_restore: true,
})).toEqual({
agentsState: 'managed',
claudeState: 'broken',
geminiState: 'missing',
canRestore: true,
})
})
@@ -71,11 +74,13 @@ describe('vaultAiGuidance helpers', () => {
const restoreable = normalizeVaultAiGuidanceStatus({
agents_state: 'missing',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: true,
})
const custom = normalizeVaultAiGuidanceStatus({
agents_state: 'custom',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
})
@@ -85,13 +90,34 @@ describe('vaultAiGuidance helpers', () => {
expect(getVaultAiGuidanceSummary(custom)).toBe('Using custom AGENTS.md')
})
it('summarizes optional Gemini guidance states', () => {
const missing = normalizeVaultAiGuidanceStatus({
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'missing',
can_restore: true,
})
const custom = normalizeVaultAiGuidanceStatus({
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'custom',
can_restore: false,
})
expect(vaultAiGuidanceNeedsRestore(missing)).toBe(true)
expect(getVaultAiGuidanceSummary(missing)).toBe('Gemini guidance can be created')
expect(vaultAiGuidanceUsesCustomFiles(custom)).toBe(true)
expect(getVaultAiGuidanceSummary(custom)).toBe('Using custom GEMINI.md')
})
it('builds a refresh key from AGENTS and CLAUDE entries only', () => {
const key = buildVaultAiGuidanceRefreshKey([
makeEntry('alpha.md'),
makeEntry('CLAUDE.md', { modifiedAt: 20, fileSize: 30 }),
makeEntry('GEMINI.md', { modifiedAt: 25, fileSize: 35 }),
makeEntry('AGENTS.md', { modifiedAt: 15, fileSize: 40 }),
])
expect(key).toBe('/vault/AGENTS.md:15:40|/vault/CLAUDE.md:20:30')
expect(key).toBe('/vault/AGENTS.md:15:40|/vault/CLAUDE.md:20:30|/vault/GEMINI.md:25:35')
})
})

View File

@@ -5,21 +5,24 @@ export type VaultAiGuidanceFileState = 'checking' | 'managed' | 'missing' | 'bro
export interface VaultAiGuidanceStatus {
agentsState: VaultAiGuidanceFileState
claudeState: VaultAiGuidanceFileState
geminiState: VaultAiGuidanceFileState
canRestore: boolean
}
type RawVaultAiGuidanceStatus = Partial<{
agents_state: VaultAiGuidanceFileState | null
claude_state: VaultAiGuidanceFileState | null
gemini_state: VaultAiGuidanceFileState | null
can_restore: boolean | null
}>
const GUIDANCE_FILENAMES = new Set(['AGENTS.md', 'CLAUDE.md'])
const GUIDANCE_FILENAMES = new Set(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'])
export function createCheckingVaultAiGuidanceStatus(): VaultAiGuidanceStatus {
return {
agentsState: 'checking',
claudeState: 'checking',
geminiState: 'checking',
canRestore: false,
}
}
@@ -42,12 +45,15 @@ export function normalizeVaultAiGuidanceStatus(
return {
agentsState: normalizeFileState(payload?.agents_state),
claudeState: normalizeFileState(payload?.claude_state),
geminiState: normalizeFileState(payload?.gemini_state),
canRestore: payload?.can_restore === true,
}
}
export function isVaultAiGuidanceStatusChecking(status: VaultAiGuidanceStatus): boolean {
return status.agentsState === 'checking' || status.claudeState === 'checking'
return status.agentsState === 'checking'
|| status.claudeState === 'checking'
|| status.geminiState === 'checking'
}
export function vaultAiGuidanceNeedsRestore(status: VaultAiGuidanceStatus): boolean {
@@ -56,16 +62,26 @@ export function vaultAiGuidanceNeedsRestore(status: VaultAiGuidanceStatus): bool
|| status.agentsState === 'broken'
|| status.claudeState === 'missing'
|| status.claudeState === 'broken'
|| status.geminiState === 'missing'
|| status.geminiState === 'broken'
}
export function vaultAiGuidanceUsesCustomFiles(status: VaultAiGuidanceStatus): boolean {
return status.agentsState === 'custom' || status.claudeState === 'custom'
return status.agentsState === 'custom'
|| status.claudeState === 'custom'
|| status.geminiState === 'custom'
}
function isMissingOrBroken(state: VaultAiGuidanceFileState): boolean {
return state === 'missing' || state === 'broken'
}
function formatGuidanceFileList(names: string[]): string {
if (names.length < 2) return names.join('')
if (names.length === 2) return `${names[0]} and ${names[1]}`
return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`
}
function getBrokenGuidanceSummary(status: VaultAiGuidanceStatus): string | null {
if (isMissingOrBroken(status.agentsState)) {
return 'Tolaria guidance missing or broken'
@@ -73,15 +89,27 @@ function getBrokenGuidanceSummary(status: VaultAiGuidanceStatus): string | null
if (isMissingOrBroken(status.claudeState)) {
return 'Claude compatibility shim missing or broken'
}
if (status.geminiState === 'missing') {
return 'Gemini guidance can be created'
}
if (status.geminiState === 'broken') {
return 'Gemini guidance missing or broken'
}
return null
}
function getCustomGuidanceSummary(status: VaultAiGuidanceStatus): string | null {
if (status.agentsState === 'custom' && status.claudeState === 'custom') {
return 'Custom AGENTS.md and CLAUDE.md active'
const customNames = [
status.agentsState === 'custom' ? 'AGENTS.md' : null,
status.claudeState === 'custom' ? 'CLAUDE.md' : null,
status.geminiState === 'custom' ? 'GEMINI.md' : null,
].filter((name): name is string => name !== null)
if (customNames.length > 1) {
return `Custom ${formatGuidanceFileList(customNames)} active`
}
if (status.agentsState === 'custom') return 'Using custom AGENTS.md'
if (status.claudeState === 'custom') return 'Using custom CLAUDE.md'
if (status.geminiState === 'custom') return 'Using custom GEMINI.md'
return null
}

Some files were not shown because too many files have changed in this diff Show More