Compare commits

...

209 Commits

Author SHA1 Message Date
lucaronin
d204e95460 fix: expose desktop menu in window chrome 2026-05-24 13:33:14 +02:00
lucaronin
b5d462b3cd fix: sync active git vaults together 2026-05-24 13:11:29 +02:00
lucaronin
c77d2af537 fix: ignore canceled external URL opens 2026-05-24 12:44:51 +02:00
lucaronin
7d6ba934c6 feat: switch app icon with theme 2026-05-24 12:28:29 +02:00
lucaronin
9ca05c1665 fix: stack narrow status bar footer 2026-05-24 11:04:18 +02:00
lucaronin
194cebdfe0 fix: clamp selection toolbar to viewport 2026-05-24 10:48:14 +02:00
lucaronin
d8e0524613 test: cover immediate new note editing 2026-05-24 10:30:16 +02:00
lucaronin
e4afa7cfa5 fix: ratchet codescene thresholds 2026-05-24 10:08:24 +02:00
lucaronin
c6faf005b5 fix: prefer Windows OpenCode cmd shims 2026-05-24 00:06:53 +02:00
lucaronin
2e9f07bab4 fix: use custom chrome on Windows 2026-05-23 23:36:22 +02:00
lucaronin
39159b9b1e fix: detect installed Codex on Windows 2026-05-23 23:25:38 +02:00
lucaronin
770fdee881 fix: preserve AI session when panel closes 2026-05-23 22:39:02 +02:00
lucaronin
0979c0664c fix: derive AI agent targets from registry 2026-05-23 22:09:39 +02:00
lucaronin
ce237039c9 fix: preserve editor focus during active note refresh 2026-05-23 21:53:21 +02:00
lucaronin
1c8207eeb5 refactor: split vault loader workspace tests 2026-05-23 21:26:03 +02:00
lucaronin
35d258c0ba fix: keep active vault folder roots visible 2026-05-23 21:01:36 +02:00
lucaronin
2df076f834 fix: bound code-block clipboard copies 2026-05-23 19:59:13 +02:00
lucaronin
49008eb79c fix: recover empty startup vault reloads 2026-05-23 19:10:10 +02:00
lucaronin
454181456d fix: insert emoji shortcode suggestions 2026-05-23 19:06:07 +02:00
lucaronin
e07dab2310 docs: record app preferences context ADR 2026-05-23 19:06:03 +02:00
lucaronin
ae529f64cd fix: persist property-only frontmatter edits 2026-05-23 18:12:58 +02:00
lucaronin
2a3ecf7270 fix: recover invalid editor block joins 2026-05-23 17:49:00 +02:00
lucaronin
12646d4e98 fix: recover stale editor block references 2026-05-23 16:53:43 +02:00
lucaronin
f0fb7b7418 fix: recover invalid insertion depth transforms 2026-05-23 16:11:20 +02:00
lucaronin
5bb3fe0550 fix: recover stale table editor transforms 2026-05-23 15:28:35 +02:00
lucaronin
7709ad1002 fix: recover missing note frontmatter writes 2026-05-23 14:41:11 +02:00
lucaronin
948b1501bf fix: route sync to selected git repository 2026-05-23 14:07:56 +02:00
lucaronin
806df2c586 feat: create notes from quick open 2026-05-21 19:03:13 +02:00
Luca Rossi
3bed1f9235 Merge pull request #717 from mvanhorn/fix/710-numeric-property-rendered-as-ipv4
fix: do not treat pure-numeric property values as bare-domain URLs (#710)
2026-05-21 16:20:10 +02:00
Luca Rossi
07248fb868 Merge pull request #711 from mvanhorn/fix/705-mcp-node-discovery-linuxbrew-fallback
fix: include linuxbrew node path in MCP runtime fallbacks (#705)
2026-05-21 16:19:46 +02:00
github-actions[bot]
8f948d4c41 Merge branch 'main' into fix/705-mcp-node-discovery-linuxbrew-fallback 2026-05-21 14:19:12 +00:00
github-actions[bot]
cfb8e0aa4f Merge branch 'main' into fix/710-numeric-property-rendered-as-ipv4 2026-05-21 14:19:08 +00:00
lucaronin
fa9de47729 Merge remote-tracking branch 'refs/remotes/origin/pr-711' 2026-05-21 16:04:56 +02:00
lucaronin
fe84ed91ac Merge remote-tracking branch 'refs/remotes/origin/pr-716' 2026-05-21 16:00:58 +02:00
lucaronin
b5e0c9d890 Merge remote-tracking branch 'refs/remotes/origin/pr-717' 2026-05-21 15:56:29 +02:00
github-actions[bot]
5d9deb3028 Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-21 13:30:12 +00:00
lucaronin
600ddccff4 docs: align stable release notes filename 2026-05-21 15:29:37 +02:00
github-actions[bot]
e3e7981d8a Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-21 13:25:11 +00:00
lucaronin
bf1feb204b docs: add stable release notes 2026-05-21 15:24:35 +02:00
github-actions[bot]
2f4d52e53a Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-21 12:08:49 +00:00
lucaronin
2c9b897430 docs: add Portent templates page 2026-05-21 13:57:23 +02:00
github-actions[bot]
e1486959bb Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-21 10:13:52 +00:00
lucaronin
74dac301d3 fix: speed up note window startup 2026-05-21 12:01:14 +02:00
github-actions[bot]
2011acf1e2 Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-21 08:33:24 +00:00
github-actions[bot]
50c46f9924 Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-21 08:22:15 +00:00
lucaronin
ecfc76af59 fix: restore note window opening 2026-05-21 10:19:49 +02:00
lucaronin
b2a94b482e chore: tighten eslint pipeline 2026-05-21 10:08:15 +02:00
github-actions[bot]
425393b4a2 Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-21 08:03:06 +00:00
lucaronin
ac196bec58 feat: expand note list context menu
Co-authored-by: Brian Lee <engbrianlee@gmail.com>
2026-05-21 09:49:38 +02:00
github-actions[bot]
4e3dfaad55 Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-20 21:32:53 +00:00
lucaronin
d5b2773436 fix: satisfy release project build 2026-05-20 23:19:52 +02:00
github-actions[bot]
695c8423f5 Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-20 18:02:43 +00:00
lucaronin
c7c53cafa2 feat: add note item context menu 2026-05-20 19:50:42 +02:00
github-actions[bot]
6c29a01a69 Merge branch 'main' into fix/705-mcp-node-discovery-linuxbrew-fallback 2026-05-20 17:42:41 +00:00
github-actions[bot]
7a72df1c2f Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-20 17:42:38 +00:00
lucaronin
5f87debecb fix: support go code block highlighting 2026-05-20 19:30:32 +02:00
github-actions[bot]
57c61a1ddd Merge branch 'main' into fix/705-mcp-node-discovery-linuxbrew-fallback 2026-05-20 17:27:45 +00:00
github-actions[bot]
08abcd5eff Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-20 17:27:42 +00:00
github-actions[bot]
bb47cf875d Merge branch 'main' into fix/710-numeric-property-rendered-as-ipv4 2026-05-20 17:27:39 +00:00
lucaronin
0a36915903 fix: detect shell-managed pi installs 2026-05-20 19:14:14 +02:00
github-actions[bot]
df1e2913c0 Merge branch 'main' into fix/705-mcp-node-discovery-linuxbrew-fallback 2026-05-20 17:09:24 +00:00
github-actions[bot]
ecf4df2d11 Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-20 17:09:21 +00:00
github-actions[bot]
54595ab174 Merge branch 'main' into fix/710-numeric-property-rendered-as-ipv4 2026-05-20 17:09:18 +00:00
lucaronin
73742495b0 fix: unzoom fullscreen mermaid diagrams 2026-05-20 18:57:08 +02:00
github-actions[bot]
b93e25ddb1 Merge branch 'main' into fix/705-mcp-node-discovery-linuxbrew-fallback 2026-05-20 16:51:53 +00:00
github-actions[bot]
5c5da8a670 Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-20 16:51:50 +00:00
github-actions[bot]
3a7ce6bb5f Merge branch 'main' into fix/710-numeric-property-rendered-as-ipv4 2026-05-20 16:51:46 +00:00
lucaronin
ae6bd8ec73 fix: resolve non-markdown wikilink paths 2026-05-20 18:39:44 +02:00
github-actions[bot]
c7f957775e Merge branch 'main' into fix/705-mcp-node-discovery-linuxbrew-fallback 2026-05-20 16:36:03 +00:00
github-actions[bot]
9ea4c367bd Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-20 16:36:00 +00:00
github-actions[bot]
55c0153692 Merge branch 'main' into fix/710-numeric-property-rendered-as-ipv4 2026-05-20 16:35:57 +00:00
lucaronin
67007e75ff fix: refresh active note after external edits 2026-05-20 18:21:16 +02:00
Matt Van Horn
57c6a4a2a4 fix: do not treat pure-numeric property values as bare-domain URLs
Fixes #710

A number property like "Year: 2026" rendered as "0.0.7.234" in the
note list column because the WHATWG URL parser canonicalizes integer-only
hosts to IPv4 form: new URL('https://2026').hostname === '0.0.7.234'.
formatChipLabel called isUrlValue('2026'), which returned true via the
bare-domain branch of normalizeExternalUrl, and then asked the URL parser
for the hostname.

Add a single guard in normalizeExternalUrl: when the input contains no '.',
reject it before the bare-domain candidate is constructed. The
parseHttpUrl(trimmed) branch above already handles inputs with an explicit
scheme (https://...), so the guard only narrows the bare-domain path -
the path that produced the bug. Existing cases ('example.com', 'localhost')
remain unaffected.

Two new vitest cases cover the regression ('2026' and '0') plus a parity
case for https://example.com to make sure the scheme branch is untouched.
2026-05-19 23:30:59 -07:00
Matt Van Horn
663a7d9a3f fix: prefer .cmd shim over POSIX wrapper for claude/codex on Windows
Fixes #695

`where claude` on Windows returns both the npm-generated POSIX shell
wrapper (extensionless) and the Windows batch shim (`claude.cmd`)
side-by-side. The existing `path_from_successful_output` helpers in
`claude_cli.rs` and `codex_cli.rs` picked the first existing line,
which gave the POSIX wrapper - Windows tried to load it as a PE32,
`CreateProcessW` returned ERROR_BAD_EXE_FORMAT (193), and the AI chat
panel surfaced "%1 is not a valid Win32 application."

Mirror the fix that landed for Gemini in `gemini_discovery.rs`: when
`cfg!(windows)`, filter candidates through the existing shared utility
`cli_agent_runtime::has_windows_cli_extension` so .bat/.cmd/.com/.exe
win over extensionless POSIX scripts. Non-Windows behavior is unchanged.

Added a regression test in each file matching the
`windows_path_lookup_prefers_cmd_shim_over_extensionless_npm_script`
pattern from gemini_discovery.rs.
2026-05-19 23:28:16 -07:00
Matt Van Horn
0abf7b492a fix: include linuxbrew node path in MCP runtime fallbacks
Fixes #705

GUI launches of the AppImage on Linux do not inherit the shell PATH, so
`find_on_path` and `find_in_user_shell` cannot reach Homebrew-on-Linux's
Node install. Add the canonical Linuxbrew paths to `fallback_node_paths`
and to `node_binary_candidates_for_home`; `.is_file()` filtering keeps
machines without Linuxbrew unaffected.
2026-05-19 23:23:58 -07:00
lucaronin
6cf77f9b94 fix: restore autogit multi-vault pushes 2026-05-19 15:46:29 +02:00
lucaronin
2c1fe9f308 fix: reduce retained editor memory 2026-05-19 15:34:46 +02:00
lucaronin
efd3cd4f27 Merge pull request #572 from oksusucha/feat/add-kiro-cli-agent
feat: add Kiro CLI as AI agent

Co-authored-by: oksusucha <sol4877@gmail.com>
2026-05-18 13:55:43 +02:00
lucaronin
3429474fc5 fix: finish pr 412 693 integration 2026-05-18 13:22:09 +02:00
lucaronin
c6ce5f421b fix: save new views in default workspace 2026-05-18 13:17:11 +02:00
lucaronin
7c307e8baf Merge pull request #693 from kzadorozhny/feat/support-bun-runtime
feat: support Bun as MCP server runtime
2026-05-18 13:09:06 +02:00
lucaronin
9d447741f3 Merge pull request #412 from kossoy/main
fix: vault watcher reload loop on iCloud .git symlink vaults
2026-05-18 13:09:00 +02:00
github-actions[bot]
e9079df63c Merge branch 'main' into feat/support-bun-runtime 2026-05-18 10:31:34 +00:00
lucaronin
48fb6710e1 Merge pull request #583 from alroniks/main
feat: add Belarusian basic and latin locales
2026-05-18 12:18:41 +02:00
github-actions[bot]
ae72a43a43 Merge branch 'main' into feat/support-bun-runtime 2026-05-18 09:17:41 +00:00
lucaronin
49935db529 docs: add release notes for v2026-05-18 2026-05-18 11:17:08 +02:00
github-actions[bot]
cd4d3dece2 Merge branch 'main' into feat/support-bun-runtime 2026-05-18 08:43:27 +00:00
lucaronin
0ca0f4a6cf fix: point update banner to release notes 2026-05-18 10:33:50 +02:00
Konstantin Zadorozhny
c2a0988368 docs: note Bun runtime alongside Node for MCP server
Mention `find_mcp_runtime` (Node.js 18+ preferred, Bun 1+ fallback) wherever
the docs previously said Tolaria "verifies Node.js" before writing MCP config,
update the mcp-server directory description, and broaden the AppImage subprocess
sanitisation note to cover both runtimes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:46:59 -07:00
Konstantin Zadorozhny
ab123a1428 feat: support Bun as MCP server runtime
Tolaria's MCP server is pure ESM with only standard `node:fs|path|http|child_process`
imports and pure-JS dependencies, so Bun can execute it identically to Node.
Until now `find_node()` was the single entry point for spawning the WebSocket
bridge and writing external AI tool config — users with Bun but no Node would
hit "node not found in PATH or common install locations" and lose access to MCP
tools entirely.

Introduce `find_mcp_runtime()` which returns the first verifying runtime,
preferring Node 18+ when present and falling back to Bun 1+. The generic
PATH and login-shell lookup helpers (`find_on_path`, `find_in_user_shell`,
`lookup_command`, `lookup_paths`) are now parameterised by command name so
both runtimes share the same machinery. Bun candidates cover `~/.bun/bin/bun`,
mise/asdf/proto shims, Homebrew, and `%USERPROFILE%\.bun\bin\bun.exe` on
Windows. The Codex CLI and Windows .cmd-shim resolution paths keep using the
strict `find_node()` since they specifically need Node.

`spawn_ws_bridge_with_paths`, `mcp_config_snippet`, and `register_mcp` now
resolve through `find_mcp_runtime` so the runtime that gets registered into
`~/.claude.json`, `~/.gemini/settings.json`, `~/.cursor/mcp.json`, etc.
matches the one actually present on the machine.

Locale copy updated from "nodeRequirement" to "runtimeRequirement" across
all 15 catalogs to reflect "Node.js 18+ or Bun 1+".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:42:31 -07:00
lucaronin
c1a5042fb9 test: cover image toolbar hover access 2026-05-17 20:06:16 +02:00
lucaronin
9d331ffda8 fix: respect rtl direction in editor blocks 2026-05-17 19:46:18 +02:00
lucaronin
41992506f1 fix: align raw editor line numbers 2026-05-17 19:34:18 +02:00
lucaronin
761f232647 fix: address clawpatch findings 2026-05-17 19:31:12 +02:00
lucaronin
c09b25d3c5 fix: restore release build type safety 2026-05-17 16:34:09 +02:00
lucaronin
4061d5e88e fix: remove built-in note body templates 2026-05-17 16:31:20 +02:00
lucaronin
0983e4e288 test: cover attachment preservation during tab switch 2026-05-17 04:09:47 +02:00
lucaronin
247c3eefb9 fix: repair rich editor invalid list content 2026-05-17 03:06:49 +02:00
lucaronin
28e2072f9b fix: stabilize mermaid diagram reload clicks 2026-05-17 00:24:17 +02:00
lucaronin
7adecaea60 fix: preserve embedded attachment paths 2026-05-16 19:25:48 +02:00
lucaronin
2616f87185 fix: prevent editor file drop navigation 2026-05-16 18:54:19 +02:00
lucaronin
d81c696742 fix: resolve relative dates in view filters 2026-05-16 18:34:41 +02:00
lucaronin
678cc8f7ba fix: preserve aliased wikilinks in markdown tables 2026-05-16 17:58:44 +02:00
lucaronin
35176d6eff feat: expose rpm download option 2026-05-16 13:44:11 +02:00
lucaronin
42130e116d feat: add note-list search clear action 2026-05-16 13:13:16 +02:00
lucaronin
1040d95eec feat: add granular git controls 2026-05-16 12:28:56 +02:00
lucaronin
480876558a fix: preserve new note edits during empty heading swap 2026-05-16 11:34:59 +02:00
lucaronin
ce04a49bae fix: keep default workspace views mounted 2026-05-15 11:33:55 +02:00
lucaronin
3695b6f3fe fix: mark MCP tools approval-safe 2026-05-15 11:19:07 +02:00
lucaronin
7e50da7816 fix: match scalar array properties in views 2026-05-15 08:33:17 +02:00
lucaronin
17a938f8cf docs: add/update ADR for AppImage media preview fallback (guard — from commit 9b6a43c) 2026-05-15 08:03:35 +02:00
lucaronin
3d2efcedeb fix: bound parsed note-list preload warmups 2026-05-15 03:16:38 +02:00
lucaronin
c7b7b97022 fix: suppress app-owned frontmatter reloads 2026-05-15 02:42:51 +02:00
lucaronin
0c8be62831 refactor: consolidate vault ordering 2026-05-15 02:29:39 +02:00
lucaronin
9b28f60be0 chore: ratchet codescene thresholds 2026-05-15 01:36:10 +02:00
lucaronin
9b6a43cec7 fix: guard linux appimage media previews 2026-05-15 01:22:56 +02:00
lucaronin
384c752c33 docs: add release notes for v2026-05-14 2026-05-14 18:12:17 +02:00
lucaronin
e844cfa0bc feat: support vault reordering 2026-05-14 17:30:13 +02:00
lucaronin
7174061b2a fix: warm parsed blocks for large notes 2026-05-14 17:20:49 +02:00
lucaronin
cf03c01b75 fix: use stock appimage packaging in release 2026-05-14 17:04:36 +02:00
lucaronin
8b83b2c691 fix: preserve appimage plugin name 2026-05-14 16:44:46 +02:00
lucaronin
d445c72edb fix: extract appimage tools in linux release 2026-05-14 16:33:56 +02:00
lucaronin
9a8aacad40 fix: install fuse for linux appimage builds 2026-05-14 16:27:21 +02:00
lucaronin
416131aa8f fix: restore TypeScript build 2026-05-14 16:09:54 +02:00
lucaronin
8b1d2bbed8 fix: support relationship equality filters 2026-05-14 16:01:34 +02:00
github-actions[bot]
9a7a31c47b Merge branch 'main' into fix/view_filters 2026-05-14 13:27:51 +00:00
lucaronin
62e62790c1 feat: stabilize external mcp appimage registration
Co-authored-by: Domenico Lupinetti <domenico@translated.net>
2026-05-14 15:22:47 +02:00
lucaronin
3b2f675264 fix: create notes in selected vault 2026-05-14 15:12:56 +02:00
github-actions[bot]
9706ee8ec2 Merge branch 'main' into fix/view_filters 2026-05-14 11:57:37 +00:00
lucaronin
c9a4b273d6 refactor: simplify file attachment serialization 2026-05-14 13:43:14 +02:00
github-actions[bot]
8fe02f8c26 Merge branch 'main' into fix/view_filters 2026-05-14 11:38:23 +00:00
lucaronin
18f91af77f fix: preserve file attachment raw toggles 2026-05-14 13:29:33 +02:00
github-actions[bot]
1c047079f0 Merge branch 'main' into fix/view_filters 2026-05-14 11:16:48 +00:00
lucaronin
a8273ad7a9 feat: make external mcp vault resolution dynamic
Port the useful dynamic-vault MCP direction from Domenico Lupinetti's proposal into the current mounted-workspace model.

Durable external MCP registration is now vault-neutral, Node MCP resolves active mounted vaults from explicit env or Tolaria vaults.json, and vault context includes root AGENTS.md instructions per active vault.

Based-on: https://github.com/refactoringhq/tolaria/pull/603

Co-authored-by: Domenico Lupinetti <domenico@translated.net>
2026-05-14 13:06:43 +02:00
github-actions[bot]
133b2601a2 Merge branch 'main' into fix/view_filters 2026-05-14 11:06:39 +00:00
lucaronin
21d4577079 fix: apply webkit safeguards on linux wayland 2026-05-14 12:55:09 +02:00
github-actions[bot]
c19003cf39 Merge branch 'main' into fix/view_filters 2026-05-14 10:42:50 +00:00
lucaronin
d99da92b43 fix: preserve windows fullscreen navigation 2026-05-14 12:32:42 +02:00
github-actions[bot]
8b9c4592f1 Merge branch 'main' into fix/view_filters 2026-05-14 10:23:26 +00:00
lucaronin
832feb7df1 fix: standardize icons on phosphor 2026-05-14 12:14:16 +02:00
github-actions[bot]
506e8d0eac Merge branch 'main' into fix/view_filters 2026-05-14 10:11:02 +00:00
lucaronin
7cd346bde0 fix: prevent duplicate vault folder pickers 2026-05-14 11:59:20 +02:00
Andrius Miasnikovas
47f5da1798 Fix equals and not equals filters not working in views 2026-05-14 11:08:48 +03:00
lucaronin
ff4939fb29 fix: recover procedure editor schema errors 2026-05-14 09:55:20 +02:00
lucaronin
3fa1d34c32 fix: stabilize table handle controls after reload 2026-05-14 09:22:44 +02:00
lucaronin
2ef4d95bb5 fix: retarget externally moved inbox notes 2026-05-14 08:15:49 +02:00
lucaronin
ce715ed5aa docs: add/update ADR for entry-scoped note windows (guard — from commit 6bb9c7f) 2026-05-14 08:05:28 +02:00
lucaronin
95dab219b8 fix: preserve cjk code block copy text 2026-05-14 07:48:04 +02:00
lucaronin
2fcea0624d test: stabilize type-derived smoke setup 2026-05-14 06:57:25 +02:00
lucaronin
7f8aad0686 fix: clean up invalid mermaid renders 2026-05-14 06:43:50 +02:00
lucaronin
2eae7a0899 fix: preserve focused editor after pulls 2026-05-14 05:53:37 +02:00
lucaronin
6bb9c7fbc9 fix: avoid note-window vault scans 2026-05-14 05:05:33 +02:00
lucaronin
9d9681345d ci: keep linux check off push lane 2026-05-14 04:05:05 +02:00
lucaronin
201aefca05 fix: bundle fcitx gtk module in appimage 2026-05-13 16:44:38 +02:00
lucaronin
41f0e00cfb fix: support symlinked appimage launches 2026-05-13 16:06:57 +02:00
github-actions[bot]
c571b06293 Merge branch 'main' into main 2026-05-07 16:00:03 +00:00
Ivan Klimchuk
14bf0dc32f fix: resolve pnpm patchedDependencies routing for tests 2026-05-07 18:44:28 +03:00
Ivan Klimchuk
238b4df55f feat: add Belarusian basic and latin locales 2026-05-07 18:44:27 +03:00
oksusucha
881f860504 refactor: extract shared binary discovery into cli_agent_runtime
Move common shell lookup logic (PATH check, login shell probe,
first_existing_path) into cli_agent_runtime as find_cli_binary()
and check_cli_availability(). Rewrite kiro_discovery to use these
shared utilities, reducing duplication from 100 lines to ~20.

Other discovery modules (pi, gemini, opencode) can adopt the same
pattern in a follow-up.
2026-05-07 17:15:27 +09:00
oksusucha
596d2b93bb refactor: reduce kiro_cli complexity by extracting helper functions
Split run_agent_stream into focused helpers:
- spawn_kiro_process: command setup and spawn
- write_prompt_async: threaded stdin write
- generate_session_id: unique ID generation
- stream_stdout: line-by-line output processing
- collect_stderr: stderr collection

Reduces per-function cyclomatic complexity from ~18 to ~6.
2026-05-07 17:13:16 +09:00
oksusucha
16ce8b105c feat: add Kiro CLI as AI agent (rebuilt on upstream architecture)
- Add kiro_discovery.rs: binary detection (PATH, login shell, known paths)
- Add kiro_cli.rs: streaming via stdin, ANSI stripping, MCP config merge
- Register Kiro in ai_agents.rs enum, status, and dispatcher
- Add 'kiro' to settings normalization
- Add Kiro to frontend agent definitions and helpers
- Update test for agent cycling

Kiro uses plain text streaming (not JSON) so it has its own stream
handler rather than using cli_agent_runtime::run_ai_agent_json_stream.
MCP config merges into existing .kiro/settings/mcp.json to preserve
user-configured servers. Prompt is passed via stdin to avoid E2BIG.
2026-05-07 17:08:44 +09:00
github-actions[bot]
e7d74a7880 Merge branch 'main' into main 2026-04-29 19:58:07 +00:00
github-actions[bot]
8cb5d0fd88 Merge branch 'main' into main 2026-04-29 19:42:54 +00:00
github-actions[bot]
cebcd17db0 Merge branch 'main' into main 2026-04-29 19:40:29 +00:00
github-actions[bot]
4276c1c3ac Merge branch 'main' into main 2026-04-29 19:39:54 +00:00
github-actions[bot]
fa3cf3f7f3 Merge branch 'main' into main 2026-04-29 19:27:18 +00:00
github-actions[bot]
8d761751e7 Merge branch 'main' into main 2026-04-29 19:21:47 +00:00
github-actions[bot]
eabab20453 Merge branch 'main' into main 2026-04-29 19:18:49 +00:00
github-actions[bot]
fdd6a64c72 Merge branch 'main' into main 2026-04-29 18:55:44 +00:00
github-actions[bot]
2db83d0810 Merge branch 'main' into main 2026-04-29 18:23:25 +00:00
github-actions[bot]
cdb303d98c Merge branch 'main' into main 2026-04-29 18:05:55 +00:00
github-actions[bot]
fe2a35b09e Merge branch 'main' into main 2026-04-29 17:40:28 +00:00
github-actions[bot]
816145ea0f Merge branch 'main' into main 2026-04-29 16:32:08 +00:00
github-actions[bot]
5d3661995c Merge branch 'main' into main 2026-04-29 15:25:20 +00:00
github-actions[bot]
c06b673e62 Merge branch 'main' into main 2026-04-29 12:34:15 +00:00
github-actions[bot]
45e7416267 Merge branch 'main' into main 2026-04-29 12:24:43 +00:00
github-actions[bot]
743b259fb5 Merge branch 'main' into main 2026-04-29 11:59:15 +00:00
github-actions[bot]
154e6f68be Merge branch 'main' into main 2026-04-29 11:46:39 +00:00
github-actions[bot]
053404afee Merge branch 'main' into main 2026-04-29 11:29:05 +00:00
github-actions[bot]
459eae1334 Merge branch 'main' into main 2026-04-29 10:22:51 +00:00
github-actions[bot]
22b366d6f9 Merge branch 'main' into main 2026-04-29 10:00:02 +00:00
github-actions[bot]
9540a4e9dc Merge branch 'main' into main 2026-04-29 09:28:10 +00:00
github-actions[bot]
de1cad448b Merge branch 'main' into main 2026-04-29 08:54:27 +00:00
github-actions[bot]
1133b8ee6f Merge branch 'main' into main 2026-04-29 08:42:58 +00:00
github-actions[bot]
ae4dbefe83 Merge branch 'main' into main 2026-04-29 08:31:25 +00:00
github-actions[bot]
2f1ffeffcb Merge branch 'main' into main 2026-04-29 08:17:58 +00:00
github-actions[bot]
a3a89ef483 Merge branch 'main' into main 2026-04-29 06:19:49 +00:00
github-actions[bot]
6c533b7055 Merge branch 'main' into main 2026-04-29 05:56:55 +00:00
github-actions[bot]
d54467cbfe Merge branch 'main' into main 2026-04-29 05:09:38 +00:00
github-actions[bot]
359103b0d9 Merge branch 'main' into main 2026-04-29 04:50:54 +00:00
github-actions[bot]
2302594c5c Merge branch 'main' into main 2026-04-29 04:21:53 +00:00
github-actions[bot]
e226804947 Merge branch 'main' into main 2026-04-29 03:41:19 +00:00
github-actions[bot]
165697f678 Merge branch 'main' into main 2026-04-29 03:17:43 +00:00
github-actions[bot]
a6a8146975 Merge branch 'main' into main 2026-04-29 03:00:21 +00:00
github-actions[bot]
ec03ffc7a9 Merge branch 'main' into main 2026-04-29 02:47:15 +00:00
github-actions[bot]
beeecb1845 Merge branch 'main' into main 2026-04-29 02:11:15 +00:00
github-actions[bot]
f7a54593ac Merge branch 'main' into main 2026-04-29 01:43:08 +00:00
github-actions[bot]
6209b0baa5 Merge branch 'main' into main 2026-04-29 01:16:17 +00:00
github-actions[bot]
8739605572 Merge branch 'main' into main 2026-04-29 00:48:11 +00:00
github-actions[bot]
f18a309469 Merge branch 'main' into main 2026-04-29 00:36:54 +00:00
github-actions[bot]
aa1d54f4c9 Merge branch 'main' into main 2026-04-28 23:54:31 +00:00
github-actions[bot]
f816be5bbe Merge branch 'main' into main 2026-04-28 23:28:37 +00:00
github-actions[bot]
101423c977 Merge branch 'main' into main 2026-04-28 22:27:31 +00:00
github-actions[bot]
e66c81d896 Merge branch 'main' into main 2026-04-28 22:04:32 +00:00
github-actions[bot]
e399d4c1fe Merge branch 'main' into main 2026-04-28 21:50:18 +00:00
github-actions[bot]
63c5c5e6b1 Merge branch 'main' into main 2026-04-28 21:39:27 +00:00
github-actions[bot]
d39fa1aa9d Merge branch 'main' into main 2026-04-28 21:09:09 +00:00
Oleg Kossoy
52aae9ed96 fix: tolerate non-JS pnpm binary in coverage runner
`run-vitest-coverage.mjs` reused `process.env.npm_execpath` with
`process.execPath` (node) for a fast path that skips the pnpm wrapper.
That assumes `npm_execpath` is a JS file, but standalone pnpm installs
ship a native Mach-O / ELF binary, so node tries to load the binary as
a CJS module and crashes with `SyntaxError: Invalid or unexpected
token`, taking the pre-push hook down with it.

Only take the fast path when the path actually looks like a JS module
(`.js`/`.mjs`/`.cjs`); otherwise fall back to invoking `pnpm` from
PATH, which is what the script already does when `npm_execpath` is
unset.
2026-04-28 23:49:40 +03:00
Oleg Kossoy
f72b7d3361 fix: ignore .git symlink targets in vault watcher
Vaults stored on iCloud / Dropbox commonly use a `.git -> .git.nosync`
symlink so the cloud sync engine ignores git internals. Background
`git fetch` calls (auto-pull, remote-status, gitstatusd) wrote to the
real `.git.nosync/` directory, which the watcher's `.git` component
filter did not match — every fetch fired a `vault-changed` event and
triggered `reloadVault`, flickering the "Reload vault" status badge
multiple times per second.

Resolve the real git directory at watcher start (symlink, regular dir,
or `gitdir:` pointer for worktrees/submodules) and reject any path
inside it. Also filter `.gitstatus.*` cache dirs and `*.icloud`
placeholders, which are noisy on the same setups.
2026-04-28 23:49:40 +03:00
341 changed files with 16135 additions and 2974 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=10.0
AVERAGE_THRESHOLD=9.93
AVERAGE_THRESHOLD=9.95

View File

@@ -195,6 +195,11 @@ jobs:
linux-build:
name: Linux build verification
# Keep the normal push CI lane under the 10-minute target. The release
# workflows already perform the full Linux/AppImage build after main
# pushes, so this slower compatibility check stays available for PRs and
# manual diagnostics without blocking every direct push.
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-22.04
steps:
@@ -209,6 +214,7 @@ jobs:
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
libfuse2 \
librsvg2-dev \
patchelf \
build-essential \

View File

@@ -283,6 +283,8 @@ jobs:
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
fcitx5-frontend-gtk3 \
libfuse2 \
librsvg2-dev \
curl \
wget \
@@ -333,6 +335,7 @@ jobs:
- name: Build Tauri app (Linux bundles)
env:
APPIMAGE_EXTRACT_AND_RUN: 1
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
@@ -346,8 +349,11 @@ jobs:
- name: Validate Linux bundles
run: |
shopt -s nullglob
installers=(
appimages=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
)
installers=(
"${appimages[@]}"
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
)
@@ -356,6 +362,10 @@ jobs:
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
)
if [ ${#appimages[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage bundle."
exit 1
fi
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
exit 1

View File

@@ -339,6 +339,8 @@ jobs:
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
fcitx5-frontend-gtk3 \
libfuse2 \
librsvg2-dev \
curl \
wget \
@@ -389,6 +391,7 @@ jobs:
- name: Build Tauri app (Linux bundles)
env:
APPIMAGE_EXTRACT_AND_RUN: 1
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
@@ -402,8 +405,11 @@ jobs:
- name: Validate Linux bundles
run: |
shopt -s nullglob
installers=(
appimages=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
)
installers=(
"${appimages[@]}"
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
)
@@ -412,6 +418,10 @@ jobs:
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
)
if [ ${#appimages[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage bundle."
exit 1
fi
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
exit 1

View File

@@ -152,41 +152,44 @@ if [ "$APP_CHANGED" = false ]; then
exit 0
fi
# ── 0. TypeScript + Vite build ──────────────────────────────────────────
# ── 0. Frontend lint ───────────────────────────────────────────────────
echo ""
echo "📦 [0/5] TypeScript + Vite build..."
pnpm exec tsc --noEmit
# TypeScript is checked explicitly above; run Vite directly here to avoid
# paying for the package build script's duplicate `tsc -b` pass.
pnpm exec vite build
echo "🔎 [0/6] Frontend lint..."
pnpm lint
echo " ✅ Lint OK"
# ── 1. TypeScript + Vite build ──────────────────────────────────────────
echo ""
echo "📦 [1/6] TypeScript + Vite build..."
pnpm build
echo " ✅ Build OK"
# ── 1. Frontend coverage (≥70%) — includes all unit tests ───────────────
# ── 2. Frontend coverage (≥70%) — includes all unit tests ───────────────
echo ""
echo "📊 [1/5] Frontend tests + coverage (≥70%)..."
echo "📊 [2/6] Frontend tests + coverage (≥70%)..."
pnpm test:coverage --silent
echo " ✅ Frontend coverage OK"
# ── 2. Rust lint (clippy + fmt) — fast, run before coverage ─────────────
# ── 3. Rust lint (clippy + fmt) — fast, run before coverage ─────────────
echo ""
if [ "$RUST_CHANGED" = true ]; then
echo "🔧 [2/5] Clippy + rustfmt..."
echo "🔧 [3/6] Clippy + rustfmt..."
cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings
cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check
echo " ✅ Rust lint OK"
else
echo "⏭️ [2/5] Rust lint — skipped (no src-tauri/ changes)"
echo "⏭️ [3/6] Rust lint — skipped (no src-tauri/ changes)"
fi
# ── 3. Rust coverage (≥85% lines) ──────────────────────────────────────
# ── 4. Rust coverage (≥85% lines) ──────────────────────────────────────
echo ""
if [ "$RUST_CHANGED" = true ]; then
LLVM_COV_FLAGS="--no-clean"
if [ "${LAPUTA_FULL_COVERAGE:-0}" = "1" ]; then
LLVM_COV_FLAGS=""
echo "🦀 [3/5] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
echo "🦀 [4/6] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
else
echo "🦀 [3/5] Rust coverage (≥85%, incremental)..."
echo "🦀 [4/6] Rust coverage (≥85%, incremental)..."
fi
# Unset GIT_DIR so git tests create isolated repos without inheriting hook context
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
@@ -199,24 +202,24 @@ if [ "$RUST_CHANGED" = true ]; then
-- --test-threads=1
echo " ✅ Rust coverage OK"
else
echo "⏭️ [3/5] Rust coverage — skipped (no src-tauri/ changes)"
echo "⏭️ [4/6] Rust coverage — skipped (no src-tauri/ changes)"
fi
# ── 4. Playwright core smoke lane (if any exist) ──────────────────────
# ── 5. Playwright core smoke lane (if any exist) ──────────────────────
echo ""
SMOKE_FILES=$(find tests/smoke tests/integration -name '*.spec.ts' 2>/dev/null | head -1)
if [ -n "$SMOKE_FILES" ]; then
echo "🎭 [4/5] Playwright core smoke tests..."
echo "🎭 [5/6] Playwright core smoke tests..."
if ! pnpm playwright:smoke; then
echo " ❌ Core smoke tests FAILED"
exit 1
fi
echo " ✅ Core smoke tests OK"
else
echo "⏭️ [4/5] Playwright core smoke tests — skipped (no tests/**/*.spec.ts)"
echo "⏭️ [5/6] Playwright core smoke tests — skipped (no tests/**/*.spec.ts)"
fi
# ── 5. CodeScene code health gate (ratchet) ──────────────────────────────
# ── 6. CodeScene code health gate (ratchet) ──────────────────────────────
# Thresholds live in .codescene-thresholds and only ever go UP (ratchet).
# If remote scores improved, the hook updates the file and stops so the new
# floor is committed with normal verified hooks before the next push.
@@ -232,7 +235,7 @@ if [ -f "$THRESHOLDS_FILE" ]; then
fi
echo ""
echo "🏥 [5/5] CodeScene code health (Hotspot ≥${HOTSPOT_MIN} + Average ≥${AVERAGE_MIN})..."
echo "🏥 [6/6] CodeScene code health (Hotspot ≥${HOTSPOT_MIN} + Average ≥${AVERAGE_MIN})..."
if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then
echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping"
else

View File

@@ -17,5 +17,5 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"iconLibrary": "phosphor"
}

View File

@@ -67,7 +67,7 @@ Git is a per-vault capability, not a prerequisite for the document model. A vaul
| Git-backed | The vault path contains a Git repository | History, changes, commits, sync, conflict resolution, remotes, AutoGit, and auto-sync are available according to remote/config state |
| Non-git | The vault path is a plain folder | Markdown scanning, editing, search, and navigation work; Git-dependent status-bar controls and command-palette entries are replaced by `Git disabled` + `Initialize Git for Current Vault` |
Plain folders become Git-backed only when the user explicitly runs Git initialization from the setup dialog, status bar, or command palette. Features that depend on Git must check this capability instead of assuming every vault has `.git`.
Plain folders become Git-backed only when the user explicitly runs Git initialization from the setup dialog, status bar, or command palette. The setup dialog supports "not now" for a one-time dismissal and "never for this vault" for a local per-vault opt-out from future automatic prompts. Features that depend on Git must check both the vault capability and the installation-local `git_enabled` setting instead of assuming every vault has `.git` or that Git chrome is globally visible.
Git initialization is intentionally scoped to dedicated vault folders. When the current non-git folder looks like a broad personal root such as Documents, Desktop, or Downloads and does not already carry Tolaria-managed vault markers, `init_git_repo` refuses to run Git and asks the user to select or create a dedicated subfolder instead.
@@ -97,7 +97,7 @@ classDiagram
+WorkspaceIdentity? workspace
+Boolean trashed ⚠ legacy
+Number? trashedAt ⚠ legacy
+Record~string,string~ properties
+Record~string,VaultPropertyValue~ properties
}
class TypeDocument {
@@ -149,7 +149,7 @@ interface VaultEntry {
archived: boolean // Archived flag
trashed: boolean // Kept for backward compatibility (Trash system removed — delete is permanent)
trashedAt: number | null // Kept for backward compatibility (Trash system removed)
properties: Record<string, string> // Scalar frontmatter fields (custom properties)
properties: Record<string, VaultPropertyValue> // Scalar and scalar-array custom properties
fileKind?: 'markdown' | 'text' | 'binary' // Controls editor/raw/preview behavior
}
```
@@ -189,7 +189,7 @@ Git-facing renderer code must pass an explicit repository path instead of assumi
| `text` | UTF-8 editable formats such as `.yml`, `.json`, `.ts`, `.py`, `.sh` | Opens through the raw editor without Markdown note semantics |
| `binary` | Images, audio, video, PDFs, archives, other non-text files | Stays a normal vault file; previewable media and PDFs open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `<img>`, supported audio/video render through native HTML media controls, and supported PDFs render through the webview's PDF object renderer, all backed by Tauri asset URLs. Runtime asset access is accumulated only for vault roots Tolaria has loaded in the current app session, because Tauri directory forbids cannot be safely reversed after a vault switch. The "open in default app" action re-enters the active-vault command boundary through `open_vault_file_external` before delegating to the native opener. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `<img>`, supported audio/video render through native HTML media controls, and supported PDFs render through the webview's PDF object renderer, all backed by Tauri asset URLs. On Linux AppImage builds, `should_use_external_media_preview` can disable in-webview audio/video rendering so the same file blocks show filename/external-open fallback controls instead of triggering unstable WebKitGTK media playback. Runtime asset access is accumulated only for vault roots Tolaria has loaded in the current app session, because Tauri directory forbids cannot be safely reversed after a vault switch. The "open in default app" action re-enters the active-vault command boundary through `open_vault_file_external` before delegating to the native opener. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
### Note Content Freshness
@@ -250,7 +250,7 @@ Each entity type can have a corresponding **type document**: any markdown note w
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[project]]`. This makes the type navigable from the Inspector panel while keeping location as an implementation detail.
**Instance schema/defaults**: Custom scalar properties and relationship fields on a type document define the expected shape for notes of that type. Existing instances do not get mutated when a type changes; the Inspector enriches their real frontmatter with gray placeholders for missing type-defined properties/relationships. Valued type fields are copied into frontmatter only when Tolaria creates a new instance of that type. Blank type fields stay as placeholders.
**Instance schema/defaults**: Custom scalar/scalar-array properties and relationship fields on a type document define the expected shape for notes of that type. Existing instances do not get mutated when a type changes; the Inspector enriches their real frontmatter with gray placeholders for missing type-defined properties/relationships. Valued type fields are copied into frontmatter only when Tolaria creates a new instance of that type. Blank type fields stay as placeholders.
**UI behavior**:
- Clicking a section group header pins the type document at the top of the NoteList if it exists
@@ -282,6 +282,8 @@ Supported value types (defined in `src-tauri/src/frontmatter/yaml.rs` as `Frontm
- **List**: Multi-line ` - item` or inline `[item1, item2]`
- **Null**: `owner:` (empty value)
Custom frontmatter fields with scalar values are exposed through `VaultEntry.properties`. Custom fields with scalar arrays are also exposed there, unless any array value contains a wikilink; wikilink-bearing fields belong to `VaultEntry.relationships`. Single-item scalar arrays continue to normalize to their scalar value for compatibility, while multi-item scalar arrays remain arrays so saved view filters can match exact elements.
### Custom Relationships
The Rust parser scans all frontmatter keys for fields containing `[[wikilinks]]`. Any non-standard field with wikilink values is captured in the `relationships` HashMap:
@@ -353,7 +355,7 @@ type SidebarSelection =
### 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 `sort` value accepts built-in sort forms such as `"modified:desc"` and custom-property forms such as `"property:Priority:asc"` or bare `"Priority:asc"`; the renderer keeps configured custom-property sorts visible even when the current result set has no populated values for that property. 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.
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 `sort` value accepts built-in sort forms such as `"modified:desc"` and custom-property forms such as `"property:Priority:asc"` or bare `"Priority:asc"`; the renderer keeps configured custom-property sorts visible even when the current result set has no populated values for that property. Filter conditions on scalar-array custom properties, such as `tags: [blues, chicago]`, evaluate `contains`, `any_of`, and related set operators against exact array elements rather than substrings. 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.
In a mounted-workspace graph, each loaded `ViewFile` carries optional renderer-owned `rootPath` and `workspace` provenance. `SidebarSelection.kind === 'view'` can include that `rootPath`, and view identity is `(rootPath, filename)` rather than filename alone. This lets two vaults both expose `views/focus.yml` without colliding in sidebar selection, note-list filtering, counts, sort/column persistence, edit, or delete flows. A saved View with `rootPath` filters only entries from its own workspace and persists changes through `save_view_cmd` / `delete_view_cmd` against that source vault.
@@ -374,9 +376,9 @@ The renderer uses `viewOrdering` helpers to convert drag or command-palette move
## Command Surface
`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, Linux titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, register the Windows main-window menu event bridge, and toggle state-dependent menu items from manifest groups.
`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, custom titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, and toggle state-dependent menu items from manifest groups.
Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the Linux fallback menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation.
Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the custom desktop titlebar menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation.
## File System Integration
@@ -411,7 +413,7 @@ Renderer attachment paths are normalized through `src/utils/vaultAttachments.ts`
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. Plain-text paste reads the desktop clipboard through `read_text_from_clipboard` in Tauri so macOS WKWebView clipboard permissions do not block the command; browser/mock mode falls back to the Web Clipboard API or mock handlers. 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. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same packaged `mcp-server/` resolver as registration and app-managed AI agents, including Windows executable-adjacent installs under `%LOCALAPPDATA%\Tolaria`, so the copied snippet remains scoped to the active vault without writing third-party config files. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
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 the active mounted workspace set in `VAULT_PATHS`, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints accept explicit `VAULT_PATH`/`VAULT_PATHS` for app-owned or legacy launches; durable external registrations omit vault env and resolve the current mounted workspace set from Tolaria's `vaults.json` at tool-call time. Manual MCP config export uses the same packaged `mcp-server/` resolver as registration and app-managed AI agents, including Windows executable-adjacent installs under `%LOCALAPPDATA%\Tolaria`, so the copied snippet stays durable across active-workspace changes without writing third-party config files. Vault context checks each active workspace root for `AGENTS.md` and returns those instructions alongside note counts, folders, and recent notes. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
### Vault Caching
@@ -504,15 +506,16 @@ interface PulseCommit {
### Auto-Sync
`useAutoSync` hook handles automatic git sync:
`useAutoSync` hook handles automatic git sync across every active Git repository:
- Configurable interval (from app settings: `auto_pull_interval_minutes`)
- Pulls on interval, pushes after commits
- Awaits the post-pull vault refresh so toasts land after note-list state is fresh
- Pulls the active repository set concurrently on launch, focus, interval, and manual sync
- Pushes the active repository set during divergence recovery
- Awaits the post-pull vault refreshes so toasts land after note-list state is fresh
- Reopens the clean active tab from disk only when the pull changed that active note, so unrelated updates do not remount the editor
- Detects merge conflicts → opens `ConflictResolverModal`
- Tracks remote status (branch, ahead/behind via `git_remote_status`)
- Tracks aggregate remote status (ahead/behind via `git_remote_status`)
- Handles push rejection (divergence) → sets `pull_required` status
- `pullAndPush()`: pulls then auto-pushes for divergence recovery
- `pullAndPush()`: pulls then auto-pushes each active repository for divergence recovery
- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs)
### External Vault Refresh
@@ -545,7 +548,7 @@ External vault mutations are any disk writes Tolaria did not just perform throug
- **No remote indicator**: Neutral chip in the bottom bar when `GitRemoteStatus.hasRemote === false`
- **Pulse view**: Activity feed when Pulse filter is selected
- **Pull command**: Cmd+K → "Pull from Remote", also in Vault menu
- **Git status popup**: Click sync badge → shows branch, ahead/behind, Pull button
- **Git status popup**: Click sync badge → shows aggregate ahead/behind and a Pull button for the active repository set
- **Conflict banner**: Inline banner in editor with Keep mine / Keep theirs for conflicted notes
## BlockNote Customization
@@ -632,7 +635,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
```mermaid
flowchart LR
A["📄 Raw markdown\n(from disk)"] --> B["splitFrontmatter()\n→ yaml + body"]
B --> C["preProcessDurableEditorMarkdown(body)\nmermaid/tldraw fences → tokens"]
B --> C["preProcessDurableEditorMarkdown(body)\nmermaid/tldraw fences + file links → tokens"]
C --> D["preProcessWikilinks(body)\n[[target]] → token"]
D --> E["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
E --> F["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
@@ -643,7 +646,7 @@ flowchart LR
style H fill:#d4edda,stroke:#28a745,color:#000
```
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math, Mermaid, and tldraw placeholder tokens use ASCII sentinels with URI-encoded payloads.
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math, Mermaid, tldraw, and standalone file-attachment link placeholders use ASCII sentinels with URI-encoded payloads.
### BlockNote-to-Markdown Pipeline (Save)
@@ -658,7 +661,7 @@ flowchart LR
style E fill:#d4edda,stroke:#28a745,color:#000
```
Rich-editor change events are coalesced before this serialization runs. `useEditorTabSwap` keeps the latest BlockNote state in the editor, schedules one Markdown serialization for a short idle window, and exposes an explicit flush hook for save, note switch, raw-mode entry, and destructive note actions. `src/utils/richEditorMarkdown.ts` is the shared BlockNote-to-Markdown owner for autosave/tab-swap and raw-mode entry, so wikilink restoration, durable schema-node serialization, frontmatter preservation, and portable attachment paths cannot drift between editor modes. This keeps long notes from paying full-document Markdown serialization on every keystroke while preserving the disk-first save path.
Rich-editor change events are coalesced before this serialization runs. `useEditorTabSwap` keeps the latest BlockNote state in the editor, schedules one Markdown serialization for a short idle window, and exposes an explicit flush hook for save, note switch, raw-mode entry, and destructive note actions. `src/utils/richEditorMarkdown.ts` is the shared BlockNote-to-Markdown owner for autosave/tab-swap and raw-mode entry, so wikilink restoration, durable schema-node serialization, frontmatter preservation, file-attachment block round-tripping, and portable attachment paths cannot drift between editor modes. This keeps long notes from paying full-document Markdown serialization on every keystroke while preserving the disk-first save path.
Autosave then waits for a 1.5s idle window before invoking `save_note_content`. If an older save resolves after the user has already typed newer content, the older save is treated as stale and cannot clear the newer pending buffer or repaint tab state over it; the latest pending content remains scheduled for its own save.
@@ -774,7 +777,7 @@ Mounted workspaces are loaded together by `useVaultLoader` for note-list, quick-
Per-vault settings stored locally and scoped by vault path:
- Managed by `useVaultConfig` hook and `vaultConfigStore`
- Settings: zoom, view mode, editor mode, 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`)
- Settings: zoom, view mode, editor mode, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, Git setup prompt preference, 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`)
@@ -811,7 +814,7 @@ Vault guidance is intentionally short and vault-specific. General Tolaria produc
`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 nvm-managed Node installs plus Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources
- Uses `get_ai_agents_status`, whose backend checks Claude Code, Codex, OpenCode, Pi, Gemini, and Kiro by treating 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
@@ -820,8 +823,9 @@ Tolaria delegates remote auth to the user's system git setup:
- `CloneVaultModal` captures a remote URL and local destination
- `clone_git_repo` and `create_getting_started_vault` both run system git clone work in blocking Tokio tasks so clone UIs stay responsive
- On macOS, system-git commands prefer the user's login-shell `git` and `PATH`, and `git_add_remote` preflights HTTPS remotes through `git credential fill` so Keychain can prompt/grant access before the first fetch or push
- On Linux AppImage launches, every system-git command and MCP Node subprocess removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` before spawning, so helpers like `git-remote-https` and system `node` bind against the host library stack instead of Tolaria's bundled WebKit/AppImage libraries
- On Linux AppImage Wayland launches, startup environment safeguards also set `GTK_IM_MODULE=fcitx` when common input-method variables already indicate fcitx and the user has not explicitly chosen a GTK IM module, allowing WebKitGTK editor input to reach fcitx5 on compositors such as niri without overriding deliberate `GTK_IM_MODULE` choices.
- On Linux AppImage launches, every system-git command and MCP runtime subprocess (Node.js or Bun) removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` before spawning, so helpers like `git-remote-https` and the system MCP runtime bind against the host library stack instead of Tolaria's bundled WebKit/AppImage libraries
- On native Linux Wayland launches and Linux AppImage launches, startup environment safeguards set `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` unless the user already provided either variable, keeping WebKitGTK rendering crashes out of the app startup path while leaving native X11 launches unchanged.
- On Linux AppImage launches, release packaging bundles the GTK3 fcitx immodule into the AppImage and startup environment safeguards write a cache-local `GTK_IM_MODULE_FILE` that points GTK at the mounted module whenever fcitx is configured. If the user has not explicitly chosen a GTK IM module, Tolaria also sets `GTK_IM_MODULE=fcitx`, allowing WebKitGTK editor input to reach fcitx5 on both Wayland and X11 fallback launches without relying on host GTK module cache paths.
- `git_add_remote` uses the same system git path and refuses remotes whose history is unrelated or ahead of the local vault
- Existing `git_pull` / `git_push` commands keep surfacing raw git errors, and clone commands fail fast when git wants interactive terminal input
- No provider-specific token or username is stored in app settings
@@ -847,7 +851,8 @@ interface Settings {
note_width_mode: 'normal' | 'wide' | null
sidebar_type_pluralization_enabled: boolean | null // null = default true
ai_features_enabled: boolean | null // null = default true
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | null
git_enabled: boolean | null // null = default true
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | 'kiro' | null
default_ai_target: string | null // "agent:codex" or "model:<provider>/<model>"
ai_model_providers: AiModelProvider[] | null
hide_gitignored_files: boolean | null // null = default true
@@ -857,7 +862,7 @@ interface Settings {
}
```
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `git_enabled` is also installation-local and defaults to `true`; when false, Tolaria hides Git status-bar entries and command-palette actions, disables AutoGit controls, and avoids background Git refresh/sync work while leaving Settings as the re-enable path. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
## Telemetry
@@ -905,6 +910,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. The docs/release Pages job reads the stable manifest from the latest stable release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so the public updater JSON changes as part of the release workflow. 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, Linux x86_64 `.deb` / AppImage artifacts, and a static public download page that starts the selected installer without replacing the page with a blank download navigation. The Pages job reads the alpha manifest from the latest alpha release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so stable and alpha manifests stay fresh. 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.
- **`.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. The Linux job uses Tauri's stock linuxdeploy AppImage output plugin and validates that installer and updater-signature artifacts exist before upload. The docs/release Pages job reads the stable manifest from the latest stable release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so the public updater JSON changes as part of the release workflow. 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, Linux x86_64 `.deb` / `.rpm` / AppImage artifacts, and a static public download page that starts the selected installer without replacing the page with a blank download navigation. Linux visitors default to the AppImage target while the page exposes RPM as a manual Linux package option when the stable release includes one. The Linux job uses the same stock Tauri/linuxdeploy AppImage packaging and artifact validation as alpha releases. The Pages job reads the alpha manifest from the latest alpha release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so stable and alpha manifests stay fresh. 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

@@ -26,7 +26,9 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
| Property display order | Window size / position |
| Per-note `_width` rich-editor width override | Default rich-editor note width |
| Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files |
| N/A | Whether this installation shows Git features |
| Per-vault All Notes note-list column overrides | All Notes PDF/image/unsupported file visibility |
| N/A | Per-vault Git setup prompt opt-out |
| Type `_sidebar_label` overrides | Whether this installation auto-pluralizes type labels |
| N/A | Registered workspace labels, aliases, mount state, and default new-note destination |
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
@@ -135,12 +137,12 @@ The note list opportunistically preloads visible and adjacent markdown/text entr
| Raw editor | CodeMirror 6 | - |
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
| UI primitives | Radix UI + shadcn/ui | - |
| Icons | Phosphor Icons + Lucide | - |
| Icons | Phosphor Icons | - |
| Build | Vite | 7.3.1 |
| Backend language | Rust (edition 2021) | 1.77.2 |
| Frontmatter parsing | gray_matter | 0.2 |
| Filesystem watcher | notify | 6.1 |
| AI (agent panel) | CLI agent adapters (Claude Code + Codex + OpenCode + Pi + Gemini) | - |
| AI (agent panel) | CLI agent adapters (Claude Code + Codex + OpenCode + Pi + Gemini + Kiro) | - |
| Search | Keyword (walkdir-based file scan) | - |
| Localization | App-owned runtime + JSON catalogs (`src/lib/i18n.ts`, `src/lib/locales/*.json`, `lara.yaml`) | English fallback + Lara CLI sync |
| MCP | @modelcontextprotocol/sdk | 1.0 |
@@ -179,7 +181,7 @@ flowchart TD
end
subgraph EXT["External Services"]
CCLI["Claude / Codex / OpenCode / Pi / Gemini CLI\n(agent subprocesses)"]
CCLI["Claude / Codex / OpenCode / Pi / Gemini / Kiro CLI\n(agent subprocesses)"]
MCP["MCP Server\n(ws://9710, 9711)"]
GCLI["git CLI\n(system executable)"]
REMOTE["Git remotes\n(GitHub/GitLab/Gitea/etc.)"]
@@ -222,19 +224,19 @@ flowchart TD
- **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; 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 for single-vault lists: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. In multiple-vault mode, saved View rows are keyed by source vault plus filename so duplicate filenames do not collide, and edits/deletes route to the owning vault. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, 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: Type` document; new type documents created by Tolaria are written at the vault root.
- **Note List / Pulse View** (220-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. Inbox organization auto-advance is coordinated by `useInboxOrganizeAdvance`, which only opens the next visible Inbox note when the organized note is still the active requested tab after the write finishes. Folder-backed lists also show non-Markdown files: previewable media and PDF binaries get file indicators 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 filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, 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 rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `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.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, 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 rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. Linux AppImage builds ask the native runtime whether audio/video should fall back to external-open controls before mounting webview media elements. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `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.
- **Right side panels** (200-500px or hidden): Properties, Table of Contents, and AI Agent are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation; AI Agent keeps the selected CLI/API target controller mounted for tool execution and chat state. The breadcrumb bar toggles Table of Contents, AI, and Properties actions, and opening one replaces the others. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
Panels are separated by `ResizeHandle` components that support drag-to-resize. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`.
The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds the current sidebar / note-list / expanded-inspector widths on top with minimum floors, and calls the native `update_current_window_min_size` command whenever view mode, inspector visibility, or restored pane widths change. That same native command can grow the current window back out when a wider pane combination is restored, but Windows keeps grow-to-fit disabled so opening or closing the Properties panel does not unmaximize or reposition the main window. Note windows skip this path and keep their dedicated 800×700 initial sizing.
The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds the current sidebar / note-list / expanded-inspector widths on top with minimum floors, and calls the native `update_current_window_min_size` command whenever view mode, inspector visibility, or restored pane widths change. That same native command can grow the current window back out when a wider pane combination is restored, but Windows keeps grow-to-fit disabled and skips min-size mutation while fullscreen or maximized so navigation/sidebar interactions do not unfullscreen or reposition the main window. Note windows skip this path and keep their dedicated 800×700 initial sizing.
The main Tauri window also persists its last normal size and screen position in the app config directory as `window-state.json`. The state stores logical window points, while `window_state.rs` migrates older physical-pixel state on read so Retina and non-Retina launches restore the same user-facing bounds. On startup, the restored frame applies only to the main window and clamps to the currently available monitor work areas, so stale coordinates from a disconnected display fall back to a visible placement. Maximized, fullscreen, minimized, and detached note-window frames are not written as the restore baseline.
Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell.
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu keeps macOS-only Services/Hide entries off Windows and Linux, registers the macOS Window submenu with Tauri's reserved `WINDOW_SUBMENU_ID` so NSApp can add system window-management and window-list items, registers a window-scoped menu event handler on Windows where Tauri delivers menu clicks through the main `WebviewWindow`, and cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback.
When Tolaria is launched from a Linux AppImage, `run()` also applies AppImage-only WebKitGTK startup safeguards without changing native package installs. It injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, and on Wayland sessions it re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. If an AppImage Wayland session advertises fcitx through common input-method environment hints and the user has not already chosen `GTK_IM_MODULE`, Tolaria sets `GTK_IM_MODULE=fcitx` before WebKit starts so GTK/WebKit input contexts reach fcitx5 on compositors that do not reliably provide the Wayland input method frontend. The same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep AppImage WebViews from blanking after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, the fcitx GTK fallback preserves Chinese/Japanese/Korean IME composition for affected AppImage Wayland users, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available.
Linux and Windows use custom React-rendered window chrome instead of the native Tauri menu bar. `setup_custom_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu is macOS-only so Services/Hide/Quit and the reserved `WINDOW_SUBMENU_ID` keep behaving like normal NSApp menu items, while cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback from the renderer menu.
On Linux, `run()` applies WebKitGTK startup safeguards before Tauri creates the webview. Native Wayland launches and AppImage launches inject `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, covering compositor-specific WebKit crashes without changing native X11 launches. AppImage launches keep the additional AppImage-only safeguards: on Wayland sessions Tolaria re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. Runtime startup writes a mount-path-specific `GTK_IM_MODULE_FILE` cache when fcitx is configured via `GTK_IM_MODULE=fcitx` or common fcitx environment hints; release packaging currently uses Tauri's stock linuxdeploy AppImage output plugin instead of Tolaria's experimental output-plugin shim. If the user has not already chosen `GTK_IM_MODULE`, Tolaria sets `GTK_IM_MODULE=fcitx` before WebKit starts. The same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep WebViews from blanking or crashing after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available.
## Multi-Window (Note Windows)
@@ -263,8 +265,8 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts` + `aiTargets.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-target selection, bundled-docs prompt injection, and the per-vault Safe / Power User permission mode shown in the panel header for coding agents
2. **Backend orchestration** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters
3. **Shared runtime scaffold** (`cli_agent_runtime.rs`) — owns the common request shape, prompt wrapping, JSON-line subprocess lifecycle, normalized error/done handling, version probing, and Tolaria MCP server path resolution used by app-managed CLI agents
4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. 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 and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. Codex, OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. Pi seeds its transient agent directory from the user's Pi agent directory before merging Tolaria MCP, so app-managed runs keep standalone Pi provider/auth settings. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides using Tolaria's resolved Node path plus `VAULT_PATH` and `WS_UI_PORT`, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter` after copying and merging the user's Pi agent config, and Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous permission-bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. 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 and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. Kiro runs through `kiro-cli chat --no-interactive --trust-all-tools`, streams line-oriented stdout, drains stderr concurrently, and writes prompt content through stdin to avoid OS argument length limits. Codex, OpenCode, Pi, Gemini, and Kiro all launch from the active vault cwd with transient MCP config. Pi seeds its transient agent directory from the user's Pi agent directory before merging Tolaria MCP, so app-managed runs keep standalone Pi provider/auth settings. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides using Tolaria's resolved Node path plus `VAULT_PATH` and `WS_UI_PORT`, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter` after copying and merging the user's Pi agent config, Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`, and Kiro receives it through `.kiro/settings/mcp.json` in the active vault.
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. App-managed CLI spawns also expand the active vault path before using it as the subprocess working directory, then extend the child process `PATH` with the resolved binary directory plus those common toolchain directories, which lets GUI-launched macOS sessions run Homebrew/npm shims and their `node`-backed MCP subprocesses even when Finder/Dock did not inherit a terminal shell path.
@@ -283,11 +285,11 @@ sequenceDiagram
U->>FE: sendMessage(text, references)
FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs)
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath, permissionMode})
R->>R: pick adapter for claude_code, codex, opencode, or pi
R->>R: pick adapter for claude_code, codex, opencode, pi, gemini, or kiro
R->>C: spawn agent with MCP-enabled config
loop Normalized stream
C-->>R: Claude NDJSON, Codex JSONL, OpenCode JSON, Pi JSON, or Gemini JSONL events
C-->>R: Claude NDJSON, Codex JSONL, OpenCode JSON, Pi JSON, Gemini JSONL, or Kiro text events
R-->>FE: emit("ai-agent-stream", event)
alt TextDelta
FE->>FE: accumulate response (revealed on Done)
@@ -341,7 +343,7 @@ Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existi
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)
### Tool Surface
| Tool | Params | Description |
|------|--------|-------------|
@@ -349,12 +351,13 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
| `read_note` | `path` | Read note content (alias for `open_note`) |
| `create_note` | `path, title, [type]` | Create new note with title and optional type frontmatter |
| `search_notes` | `query, [limit]` | Search notes by title or content substring |
| `list_vaults` | — | List active mounted vaults and whether each has root `AGENTS.md` instructions |
| `append_to_note` | `path, text` | Append text to end of existing note |
| `edit_note_frontmatter` | `path, patch` | Merge key-value patch into YAML frontmatter |
| `delete_note` | `path` | Delete a note file from the vault |
| `link_notes` | `source_path, property, target_title` | Add a target to an array property in frontmatter |
| `list_notes` | `[type_filter], [sort]` | List all notes, optionally filtered by type |
| `vault_context` | — | Get vault summary: entity types + 20 recent notes + configFiles |
| `vault_context` / `get_vault_context` | `[vaultPath]` | Get mounted-vault summary: entity types, folders, recent notes, and root `AGENTS.md` instructions |
| `ui_open_note` | `path` | Open a note in the Tolaria UI editor |
| `ui_open_tab` | `path` | Open a note in a new UI tab |
| `ui_highlight` | `element, [path]` | Highlight a UI element (editor, tab, properties, notelist) |
@@ -374,27 +377,28 @@ Tolaria can register itself as an MCP server in:
- `~/.gemini/settings.json` (Gemini CLI)
- `~/.cursor/mcp.json` (Cursor)
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
- `~/.config/opencode/opencode.json` (OpenCode, using its `mcp` config key)
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. In the desktop app, `useMcpStatus` copies that snippet through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria`, `/usr/lib/tolaria`, and `/usr/lib/tolaria/resources`, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background.
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/OpenCode settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria resolves an MCP runtime (Node.js 18+ preferred, Bun 1+ as fallback) before writing config so external clients are not left pointing at a missing binary, writes a vault-neutral `type: "stdio"` entry for standard MCP clients, writes OpenCode's vault-neutral `type: "local"` entry, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Durable external MCP processes resolve active workspaces at tool-call time: explicit `VAULT_PATH`/`VAULT_PATHS` env still wins for app-owned and legacy launches, otherwise the MCP server reads Tolaria's `vaults.json`, uses `active_vault` first, and includes every workspace not marked `mounted: false`. Vault context checks each active workspace root for `AGENTS.md` and includes those instructions in the returned context. 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. In the desktop app, `useMcpStatus` copies that snippet through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria`, `/usr/lib/tolaria`, and `/usr/lib/tolaria/resources`, and AppImage paths. Linux AppImage startup extracts the bundled `mcp-server/` to `~/.local/share/tolaria/mcp-server/` with a `.tolaria-version` marker, so durable external registrations use a stable path instead of the changing AppImage mount point. The `useMcpStatus` hook tracks whether Tolaria's durable MCP entry is connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the runtime process instead of keeping it alive in the background.
### Architecture
```mermaid
flowchart TD
subgraph MCP["MCP Server (Node.js) — selected-vault scoped"]
subgraph MCP["MCP Server (Node.js or Bun) — mounted-workspace scoped"]
IDX["index.js"]
VAULT["vault.js\n(findMarkdownFiles, readNote, createNote,\nsearchNotes, appendToNote, editNoteFrontmatter,\ndeleteNote, linkNotes, listNotes, vaultContext)"]
WSB["ws-bridge.js"]
IDX -->|"stdio transport"| STDIO["Claude Code / Cursor"]
IDX -->|"stdio transport"| STDIO["Claude Code / Cursor / Gemini / OpenCode"]
IDX --> VAULT
IDX --> WSB
WSB -->|"port 9710 — tool bridge"| AI["AI Clients\n(Claude Code, external)"]
WSB -->|"port 9711 — UI bridge"| FE["Frontend\n(useAiActivity)"]
end
TAURI["Tauri bridge lifecycle"] -->|"start/stop/restart with active VAULT_PATH"| MCP
UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude.json\n~/.claude/mcp.json\n~/.cursor/mcp.json\n~/.config/mcp/mcp.json"]
TAURI["Tauri bridge lifecycle"] -->|"start/stop/restart with VAULT_PATHS"| MCP
UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude.json\n~/.claude/mcp.json\n~/.cursor/mcp.json\n~/.config/mcp/mcp.json\n~/.config/opencode/opencode.json"]
```
### WebSocket Bridge
@@ -420,11 +424,12 @@ flowchart LR
| Function | Purpose |
|----------|---------|
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with `VAULT_PATH`/`VAULT_PATHS` 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, 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 |
| `extract_mcp_server_to_stable_dir(app_version)` | On Linux AppImage launches, copies bundled MCP files to `~/.local/share/tolaria/mcp-server/` with version-gated replacement so external clients can keep a stable `index.js` path |
| `register_mcp(vault_path)` | Resolves an MCP runtime (Node.js 18+ preferred, Bun 1+ fallback), resolves the packaged or stable extracted `mcp-server/`, and writes Tolaria's vault-neutral entry to Claude Code, Gemini CLI, Cursor, OpenCode, and generic MCP configs on user request |
| `mcp_config_snippet(vault_path)` | Builds the exact vault-neutral `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, OpenCode, 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 plus waited on app exit via the `RunEvent::Exit` handler. The same desktop layer keeps Tauri asset protocol access limited to vault roots loaded during the current app session; command calls remain active-vault scoped for reads, writes, and external opens.
@@ -446,7 +451,7 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin
### Cache File
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is normalized through `vault/path_identity.rs` before hashing, so macOS `/tmp` aliases and separator variants share the same cache identity. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v13 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem.
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is normalized through `vault/path_identity.rs` before hashing, so macOS `/tmp` aliases and separator variants share the same cache identity. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v14 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem.
`<vault>/.tolaria-rename-txn/` — hidden, scan-ignored staging directory for crash-safe note renames. Tolaria stores temporary backup files plus one manifest per in-flight rename here. On the next vault scan, unfinished transactions are recovered before entries are listed so users do not see a missing note or a visible duplicate after a crash.
@@ -509,6 +514,7 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
- `inbox.noteListProperties`: Optional Inbox-only property chip override for the note list
- `allNotes.noteListProperties`: Optional All Notes-only property chip override for the note list
- `inbox.explicitOrganization`: When `false`, hide Inbox and the organized toggle so the vault behaves like a plain note collection
- `git_setup_preference`: `"never"` when the user has opted out of future automatic Git setup prompts for that vault
### Getting Started Vault
@@ -519,11 +525,11 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
If the selected vault disappears after startup, `useVaultLoader` re-checks `check_vault_exists` when reloads or vault-derived surfaces fail. A confirmed missing path clears cached entries, folders, views, modified-file state, and prefetched note content, then `App` reuses the `vault-missing` `WelcomeScreen` state so note and view actions cannot keep targeting the stale active vault.
When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action.
When an opened folder is not yet a git repo, Tolaria can show a Git setup dialog with Initialize, Not now, and Never for this vault actions. The Never choice stores a local per-vault `git_setup_preference` so the automatic dialog does not return for that vault, while manual initialization remains reachable from Git commands when global Git features are enabled. Markdown scanning, note browsing, note editing, and search continue normally in plain folders. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git.
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup, remote-connection, manual/automatic, and conflict-resolution commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria <vault@tolaria.md>` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code, Codex, OpenCode, Pi, and Gemini CLI are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code, Codex, OpenCode, Pi, Gemini, and Kiro CLI are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
@@ -573,7 +579,7 @@ sequenceDiagram
participant MCP as MCP Server
participant U as User
T->>T: apply Linux AppImage WebKit env/preload safeguards<br/>(AppImage only)
T->>T: apply Linux WebKit env safeguards<br/>(Wayland/AppImage)
T->>T: start background legacy vault housekeeping<br/>(does not block setup)
T->>MCP: start background initial ws-bridge sync<br/>(if active vault exists)
T->>A: App mounts
@@ -654,7 +660,9 @@ flowchart TD
`useGitRemoteStatus` re-checks `git_remote_status` for the default repository, and `useCommitFlow` can resolve remote status for an explicit selected repository when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps that repository's flow local-only: the status bar shows a neutral `No remote` chip for the default repository, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted.
If the current vault is not a Git repository, Tolaria treats Git as disabled instead of degraded. The status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup. Command registration follows the same state: only `Initialize Git for Current Vault` is available in the Git group, while pull, commit, changes, conflict, and remote commands are hidden. `useAutoSync` is disabled for non-git vaults so the app does not run background Git commands against plain folders.
If the current vault is not a Git repository, Tolaria treats Git as unavailable instead of degraded. With global Git features enabled, the status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup unless the user has chosen not to be prompted automatically for that vault. Command registration follows the same state: only `Initialize Git for Current Vault` is available in the Git group, while pull, commit, changes, conflict, and remote commands are hidden. `useAutoSync` is disabled for non-git vaults so the app does not run background Git commands against plain folders.
The installation-local `git_enabled` setting is a broader visibility switch. When it is `false`, Tolaria hides Git status-bar entries and Git command-palette actions completely, disables AutoGit controls in Settings, and prevents background Git refresh/sync work even for repositories that are otherwise Git-backed. Settings remains the re-enable path.
The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command ensures the local author identity, adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds.
@@ -785,12 +793,12 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|---------|-------------|
| `stream_claude_chat` | Claude CLI chat mode (streaming) |
| `check_claude_cli` | Check if Claude CLI is available |
| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi + Gemini availability |
| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi + Gemini + Kiro availability |
| `get_agent_docs_path` | Resolve the bundled local Tolaria docs folder used in AI-agent system prompts |
| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, Pi, or Gemini through the normalized agent event layer |
| `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 |
| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, Pi, Gemini, or Kiro through the normalized agent event layer |
| `register_mcp_tools` | Register vault-neutral MCP in Claude/Gemini/Cursor/OpenCode/generic config |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Gemini/Cursor/OpenCode/generic config |
| `check_mcp_status` | Check whether Tolaria's durable MCP entry is registered in Claude/Gemini/Cursor/OpenCode/generic config |
| `get_mcp_config_snippet` | Return the exact manual MCP JSON snippet for the active vault |
| `copy_text_to_clipboard` | Copy setup snippets through the native desktop clipboard command path |
| `read_text_from_clipboard` | Read current desktop clipboard text for command-driven plain-text paste |
@@ -862,8 +870,8 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `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, date display format, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility, All Notes file visibility) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences, AI permission mode | Vault-specific config |
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, date display format, auto-sync interval, Git visibility, AutoGit thresholds, default AI agent, Gitignored-content visibility, All Notes file visibility) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences, Git setup prompt preference, AI permission mode | Vault-specific config |
| `appCommandDispatcher` | Manifest-backed 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`.
@@ -896,7 +904,7 @@ Shortcut routing is explicit:
- macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- `Cmd+Shift+V` uses the same command path for "Paste without Formatting"; `plainTextPaste.ts` reads text through the native clipboard command in Tauri and inserts it through the active rich/raw editor target or the focused browser text control
- `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions; on Windows, `menu.rs` also listens to main-window menu events because Tauri attaches the native menu to the `WebviewWindow`
- `menu.rs`, `useMenuEvents`, and the custom titlebar `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
- Deterministic QA uses two explicit proof paths from the shared manifest:
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`
@@ -922,6 +930,11 @@ push to main
→ build-windows job:
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
→ build-linux job:
→ pnpm install, stamp version
→ tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
→ verify Linux installer and updater-signature artifacts exist
→ upload .deb, .rpm, .AppImage, and signed Linux updater bundles
→ release job:
→ generate alpha-latest.json with darwin-aarch64, darwin-x86_64, Linux, and Windows updater URLs
→ publish GitHub prerelease alpha-vYYYY.M.D-alpha.NNNN named Tolaria Alpha YYYY.M.D.N
@@ -944,8 +957,10 @@ push stable-vYYYY.M.D tag
→ pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig and .dmg artifacts named Tolaria_<version>_macOS_Silicon and Tolaria_<version>_macOS_Intel
→ build-linux job:
→ pnpm install, stamp version, tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
upload .deb, .AppImage, and signed Linux updater bundles
→ pnpm install, stamp version
tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
→ verify Linux installer and updater-signature artifacts exist
→ upload .deb, .rpm, .AppImage, and signed Linux updater bundles
→ build-windows job:
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
@@ -956,11 +971,13 @@ push stable-vYYYY.M.D tag
→ build VitePress public docs into the GitHub Pages root
→ build static HTML release history page at /releases/
→ publish stable/latest.json
→ publish stable/download/ and download/ as permanent download pages that keep the browser page visible while the platform installer starts
→ publish stable/download/ and download/ as permanent download pages that keep the browser page visible while the platform installer starts, default Linux visitors to AppImage, and expose RPM as a manual Linux option when the stable release includes one
→ preserve alpha/latest.json
→ deploy to gh-pages
```
Linux AppImage release jobs use Tauri's stock linuxdeploy AppImage output plugin. `scripts/appimage-launcher-tools.mjs` remains available for local experiments with symlink-safe AppRun patching and fcitx module bundling, but release packaging does not pre-seed that shim because linuxdeploy currently exits before sealing the AppImage when the shim replaces the stock output plugin in Tauri's tools cache.
### Versioning
- Stable promotions use git tags in the form `stable-vYYYY.M.D` and stamp the technical version `YYYY.M.D`.
@@ -1075,7 +1092,7 @@ Desktop-only modules gated at the crate level:
Desktop-only features gated at the function level in `commands/`:
- Git operations (commit, pull, push, status, history, diff, conflicts)
- Clone-by-URL via system git (`clone_repo`)
- CLI AI agent streaming (Claude, Codex, OpenCode, Pi, Gemini)
- CLI AI agent streaming (Claude, Codex, OpenCode, Pi, Gemini, Kiro)
- MCP registration and status
- Menu state updates

View File

@@ -37,7 +37,7 @@ On some Wayland systems, the Linux AppImage may fail to launch with:
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
```
Recent Tolaria AppImages automatically disable unstable WebKitGTK AppImage rendering paths and retry startup with an architecture-matching system Wayland client library when they detect this class of AppImage + Wayland environment. If you are running an older build, use this workaround:
Recent Tolaria Linux builds automatically disable unstable WebKitGTK rendering paths on Wayland and AppImage launches. AppImage launches also retry startup with an architecture-matching system Wayland client library when they detect this class of AppImage + Wayland environment. If you are running an older build, use this workaround:
```bash
WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 LD_PRELOAD=/usr/lib64/libwayland-client.so.0 ./Tolaria*.AppImage
@@ -45,6 +45,16 @@ WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 LD_PRELOAD=/u
If your distribution stores the 64-bit library elsewhere, use that path instead, for example `/usr/lib/x86_64-linux-gnu/libwayland-client.so.0`. On 64-bit Fedora, avoid `/usr/lib/libwayland-client.so.0`; that path can point at a 32-bit library and be ignored by the loader with a wrong ELF class warning.
### Linux AppImage packaging checks
Linux release CI currently uses Tauri's stock linuxdeploy AppImage output plugin:
```bash
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
```
Release validation verifies that the Linux job produced an AppImage, at least one installer bundle, and updater signature artifacts. The experimental AppImage output-plugin shim in `scripts/appimage-launcher-tools.mjs` is retained for local investigation, but it is not wired into release packaging because linuxdeploy currently exits before sealing the AppImage when the shim is pre-seeded in Tauri's tools cache.
## Quick Start
```bash
@@ -75,7 +85,7 @@ Linux AppImage builds still use the user's system `git` and `node`. Before Tolar
The `settings.multi_workspace_enabled` flag turns the registered vault list into a unified graph. When enabled, `useVaultLoader` loads every available mounted vault, annotates entries with workspace provenance, and lets note lists, quick open, keyword search, backlinks, and wikilink navigation span those vaults.
The selected/default vault remains the write and repository focus. New notes and Type documents use `defaultWorkspacePath` when it points at an available mounted vault, while Git status, commits, sync, folder tree, repair, and watcher behavior stay scoped to explicit repository roots. Saved Views are listed from every mounted vault with source-vault identity, so duplicate view filenames remain separate and edits persist back to the view's owning vault.
The selected/default vault remains the write target for new notes and Type documents when `defaultWorkspacePath` points at an available mounted vault. Git status, changes, AutoGit checkpointing, and sync operate across the active mounted repository set, while history, diff, repair, and file operations still resolve explicit repository roots from the selected surface or entry provenance. Saved Views are listed from every mounted vault with source-vault identity, so duplicate view filenames remain separate and edits persist back to the view's owning vault.
The bottom-left `VaultMenu` exposes quick include/exclude controls and a `Manage vaults` entry. The Vaults settings section owns the full identity controls: display name, short label, read-only alias, accent color, removal, and default destination for new notes.
@@ -115,7 +125,7 @@ tolaria/
│ │ ├── CommandPalette.tsx # Cmd+K command launcher
│ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions
│ │ ├── WelcomeScreen.tsx # Onboarding screen
│ │ ├── LinuxTitlebar.tsx # Linux-only custom window chrome + controls
│ │ ├── LinuxTitlebar.tsx # Linux/Windows custom window chrome + controls
│ │ ├── LinuxMenuButton.tsx # Linux titlebar menu mirroring app commands
│ │ ├── CloneVaultModal.tsx # Clone a vault from any git URL
│ │ ├── AddRemoteModal.tsx # Connect a local-only vault to a remote later
@@ -141,7 +151,7 @@ tolaria/
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline
│ │ ├── aiAgentPermissionMode.ts # Safe/Power User mode normalization + labels
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi/Gemini availability polling
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi/Gemini/Kiro availability polling
│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling
│ │ ├── useAiActivity.ts # MCP UI bridge listener
│ │ ├── useAutoSync.ts # Auto git pull/push
@@ -221,6 +231,7 @@ tolaria/
│ │ ├── claude_cli.rs # Claude CLI adapter
│ │ ├── codex_cli.rs # Codex CLI adapter
│ │ ├── pi_cli.rs # Pi CLI adapter
│ │ ├── kiro_cli.rs # Kiro CLI adapter
│ │ ├── mcp.rs # MCP server lifecycle + explicit config registration/removal
│ │ ├── app_updater.rs # Alpha/stable updater metadata resolution
│ │ ├── settings.rs # App settings persistence
@@ -229,7 +240,7 @@ tolaria/
│ │ └── menu.rs # Native macOS menu bar
│ └── icons/ # App icons
├── mcp-server/ # MCP bridge (Node.js)
├── mcp-server/ # MCP bridge (Node.js or Bun)
│ ├── index.js # MCP server entry (stdio, 14 tools)
│ ├── vault.js # Vault file operations
│ ├── ws-bridge.js # WebSocket bridge (ports 9710, 9711)
@@ -297,7 +308,7 @@ tolaria/
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
| `src-tauri/src/ai_agents.rs` | CLI-agent request normalization, availability aggregation, adapter dispatch, and Claude event mapping. |
| `src-tauri/src/cli_agent_runtime.rs` | Shared CLI-agent request shape, prompt wrapping, JSON subprocess lifecycle, version probing, and MCP path helpers. |
| `src-tauri/src/claude_cli.rs`, `src-tauri/src/codex_cli.rs`, `src-tauri/src/opencode_cli.rs`, `src-tauri/src/pi_cli.rs`, `src-tauri/src/gemini_cli.rs` | Per-agent command, config, discovery, and JSON event adapters. |
| `src-tauri/src/claude_cli.rs`, `src-tauri/src/codex_cli.rs`, `src-tauri/src/opencode_cli.rs`, `src-tauri/src/pi_cli.rs`, `src-tauri/src/gemini_cli.rs`, `src-tauri/src/kiro_cli.rs` | Per-agent command, config, discovery, and event adapters. |
| `src-tauri/src/app_updater.rs` | Desktop updater bridge — resolves alpha/stable manifests and streams install progress. |
### Editor
@@ -335,12 +346,12 @@ tolaria/
| File | Why it matters |
|------|---------------|
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, UI language, date display format, auto-sync interval, default note width, sidebar type pluralization, default AI agent). |
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, UI language, date display format, Git visibility, auto-sync interval, default note width, sidebar type pluralization, default AI agent). |
| `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). |
| `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. |
| `src/hooks/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, AI permission mode). |
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, content display preferences, default AI agent, and the vault-level explicit organization toggle. |
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow, Git setup prompt preference, AI permission mode). |
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, Git visibility, sync interval, UI language, content display preferences, default AI agent, and the vault-level explicit organization toggle. |
| `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. |
## Architecture Patterns
@@ -376,7 +387,7 @@ type SidebarSelection =
### Command Registry
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the Light/Dark/System theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Windows, native menu clicks arrive from the main `WebviewWindow`, so `src-tauri/src/menu.rs` must keep its window-scoped menu event handler in addition to the app-level handler. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the Light/Dark/System theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux and Windows, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because those builds use Tolaria's custom chrome instead of the native desktop menu bar. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
@@ -457,12 +468,13 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
4. **Permission-mode UI and request plumbing**: Edit `src/lib/aiAgentPermissionMode.ts`, `src/components/AiPanel*.tsx`, `src/hooks/useCliAiAgent.ts`, and `src/utils/streamAiAgent.ts`
5. **Shared CLI runtime behavior**: Edit `src-tauri/src/cli_agent_runtime.rs` for process lifecycle, prompt wrapping, version probing, and common Tolaria MCP path handling.
6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `gemini_*`). Keep Codex Safe on `read-only` + `untrusted` and Codex Power User on active-vault `workspace-write` + `never`, keep Pi and Gemini on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode. Pi's transient agent directory must be seeded from the user's existing Pi agent directory before Tolaria MCP is merged so standalone provider/auth setup keeps working. Gemini Power User intentionally uses Gemini's `yolo` mode per ADR-0103.
6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `gemini_*`, `kiro_*`). Keep Codex Safe on `read-only` + `untrusted` and Codex Power User on active-vault `workspace-write` + `never`, keep Pi, Gemini, and Kiro on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode. Pi's transient agent directory must be seeded from the user's existing Pi agent directory before Tolaria MCP is merged so standalone provider/auth setup keeps working. Gemini Power User intentionally uses Gemini's `yolo` mode per ADR-0103. Kiro receives prompt content over stdin and writes Tolaria MCP config into `.kiro/settings/mcp.json` in the active vault.
### Work with external MCP setup
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows executable-adjacent installs such as `%LOCALAPPDATA%\Tolaria`, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`, `/usr/lib/tolaria/resources`), 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
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs` and its `src-tauri/src/mcp/` helpers; registration and manual config generation must resolve an MCP runtime via `find_mcp_runtime` (Node.js 18+ preferred, Bun 1+ fallback) first, resolve the packaged `mcp-server/` for macOS, Windows executable-adjacent installs such as `%LOCALAPPDATA%\Tolaria`, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`, `/usr/lib/tolaria/resources`), and AppImage installs, and use a vault-neutral entry with `WS_UI_PORT=9711`. Linux AppImage startup must extract `mcp-server/` to `~/.local/share/tolaria/mcp-server/` before durable registration uses that stable path. App-owned bridge launches still pass `VAULT_PATH`/`VAULT_PATHS`; durable external registrations rely on the MCP server reading `vaults.json` at tool-call time.
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the runtime prerequisite (Node.js 18+ or Bun 1+), 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`; app-managed Gemini sessions still require the user to install and sign in to Gemini CLI, but Tolaria supplies transient MCP settings when Gemini is selected as the default AI agent
5. **Process lifecycle**: Stdio MCP servers in `mcp-server/index.js` must exit when their external client closes stdin, and the desktop-owned `ws-bridge.js` child must be stopped on vault deselection, vault switch, and app exit
5. **OpenCode compatibility**: Keep `~/.config/opencode/opencode.json` in durable registration. OpenCode uses the top-level `mcp` key, `command` as an array, `environment` for env vars, `type: "local"`, and `enabled: true`; it must remain vault-neutral like the standard `mcpServers` entry.
6. **Process lifecycle and vault guidance**: Stdio MCP servers in `mcp-server/index.js` must exit when their external client closes stdin, and the desktop-owned `ws-bridge.js` child must be stopped on vault deselection, vault switch, and app exit. MCP context must include root `AGENTS.md` instructions for every active mounted workspace when those files exist.

View File

@@ -2,8 +2,9 @@
type: ADR
id: "0026"
title: "Props-down callbacks-up (no global state management)"
status: active
status: superseded
date: 2026-02-15
superseded_by: "0115"
---
## Context

View File

@@ -2,9 +2,10 @@
type: ADR
id: "0110"
title: "In-app media and PDF previews for binary vault files"
status: active
status: superseded
date: 2026-05-05
supersedes: "0098"
superseded_by: "0121"
---
## Context

View File

@@ -0,0 +1,28 @@
---
type: ADR
id: "0115"
title: "Scoped React Context for shared UI preferences"
status: active
date: 2026-05-12
---
## Context
Laputa has relied on props-down callbacks-up state flow since ADR-0026 because most renderer state is orchestrated in `App.tsx` and the component tree stays understandable. Today's `date_display_format` refactor exposed a narrow exception: the same installation-local rendering preference now needs to reach note rows, property chips and cells, inspector surfaces, table-of-contents metadata, search subtitles, and date-editing controls across multiple branches of the tree. Continuing to thread that value through intermediate components would add noisy prop plumbing to components that do not conceptually own the preference.
## Decision
**Use a scoped React context for shared UI preferences that are read in many renderer leaves but still sourced from `App.tsx`. `AppPreferencesProvider` publishes the current installation-local preference values, and leaf components consume them through focused hooks such as `useDateDisplayFormat`; writes still flow through the existing settings/update path rather than through context mutations.**
## Alternatives considered
- **Scoped app-preferences context** (chosen): removes prop forwarding for cross-cutting rendering preferences while keeping the source of truth in `App.tsx` and avoiding a general-purpose global store.
- **Continue prop drilling from `App.tsx`**: preserves the old rule literally, but keeps widening component signatures and couples intermediate components to preferences they do not use.
- **Adopt a broader global state/store solution**: centralizes access, but introduces more indirection and policy surface than this renderer-only preference case needs.
## Consequences
Leaf components can read shared formatting preferences directly, so `date_display_format` stays consistent across note-list, inspector, search, and metadata surfaces without forwarding props through unrelated layers.
This narrows ADR-0026's blanket "no Context for data" rule. The replacement rule is: mutable application/domain state still lives in `App.tsx` plus focused hooks, while React context is allowed only for tightly scoped, cross-cutting UI preferences whose canonical value still originates from that same top-level state.
Future additions to `AppPreferencesProvider` should stay small, renderer-local, and read-focused. If Laputa starts moving writable domain state, async workflows, or large derived objects into context, that needs a new ADR rather than quietly expanding this pattern.

View File

@@ -0,0 +1,24 @@
# ADR-0117: Bundle the fcitx GTK3 frontend in Linux AppImages
## Status
Accepted
## Context
Linux AppImages run WebKitGTK through the GTK3 input-method stack. Users with fcitx5 can export `GTK_IM_MODULE=fcitx`, `QT_IM_MODULE=fcitx`, and `XMODIFIERS=@im=fcitx`, but the AppImage still cannot load the host GTK immodule reliably because the GTK module cache and library paths are isolated from the mounted AppDir.
The previous AppImage startup fallback set `GTK_IM_MODULE=fcitx` when fcitx was detected, but it did not make the `im-fcitx5.so` module available inside the AppImage. That left Chinese/Japanese/Korean input dependent on host paths that GTK may not search from a sealed AppImage.
## Decision
Linux release jobs install `fcitx5-frontend-gtk3` and the AppImage output-plugin shim copies the GTK3 fcitx immodule plus its client library into the AppDir before the AppImage is sealed. At runtime, AppImage startup writes a mount-path-specific `GTK_IM_MODULE_FILE` cache that points GTK at the bundled module whenever fcitx is configured explicitly or through common fcitx environment hints.
The sealed AppImage validation step extracts every produced AppImage and fails the release if the symlink-safe AppRun resolver, the bundled fcitx immodule, or the fcitx client library is missing.
## Consequences
- fcitx5 input works in AppImage launches without relying on the host GTK immodule cache path.
- X11 fallback launches with explicit `GTK_IM_MODULE=fcitx` use the same bundled module path as Wayland launches.
- Linux AppImage builds now depend on the distro package that provides the GTK3 fcitx frontend.
- If the Ubuntu package path changes, the AppImage validation step fails before publishing a broken bundle.

View File

@@ -0,0 +1,35 @@
---
type: ADR
id: "0118"
title: "Entry-scoped note windows without vault index scans"
status: active
date: 2026-05-14
---
## Context
ADR-0031 kept secondary note windows on the full `App` shell so they would inherit the same editor capabilities as the primary window. That decision also accepted a full vault load per secondary window as the simpler trade-off.
In practice, repeated note-window opens were paying that full-vault scan cost even when the window only needed one known note path. The startup path loaded the vault index and passed related entries into the editor even though note-window mode renders a single-note surface. That extra work increased window-open cost and made every secondary window depend on repository-wide entry hydration for a workflow that is intentionally scoped to one note.
Tolaria still wants note windows to reuse the main App architecture rather than reviving a separate `NoteWindow` shell. The missing decision was how far the shared vault loader should go when the window contract is already narrowed to a single entry.
## Decision
**Secondary note windows continue to render the full `App` shell, but they no longer load the full vault index during startup.** In note-window mode, Tolaria skips the shared vault-entry scan, reloads only the requested note entry, and scopes editor entry props to that active note instead of passing repository-wide visible entries.
This keeps the architectural benefit of ADR-0031 (one window architecture, one editor surface) while changing the data-loading contract for secondary windows from vault-scoped to entry-scoped.
## Alternatives considered
- **Full `App` shell with entry-scoped loading** (chosen): preserves feature parity in the shared shell while removing unnecessary full-vault scans for a one-note window. Trade-off: note windows should not assume vault-index-derived context is available by default.
- **Full `App` shell with full vault scan**: simplest continuation of ADR-0031, but repeats avoidable repository-wide I/O every time a note window opens.
- **Dedicated `NoteWindow` shell**: could be lighter still, but reintroduces the architectural drift and duplicated feature work that ADR-0031 intentionally removed.
## Consequences
- Opening a secondary note window no longer requires `list_vault`/full entry hydration before the editor can render the requested note.
- Repeated note-window opens avoid redundant vault scans and stay aligned with the product contract that these windows are single-note work surfaces.
- Features inside note-window mode must treat vault-index-derived data as opt-in; they cannot assume related entries are already loaded just because the full `App` shell is mounted.
- ADR-0031 remains directionally valid for shared window architecture, but its original "full vault load per secondary window" trade-off is no longer the operating model.
- Re-evaluate if note windows later need immediate repository-wide browsing context, or if future profiling shows the remaining single-entry reload path is still too expensive.

View File

@@ -0,0 +1,35 @@
# 0119. Vault-Neutral MCP Registration With Mounted Workspace Guidance
Status: active
Date: 2026-05-14
## Context
Tolaria used to register external MCP clients with a durable `VAULT_PATH` environment variable. That made the copied config easy to inspect, but it also meant Claude Code, Cursor, Gemini CLI, and generic MCP clients stayed pinned to whichever vault was active at setup time.
Domenico Lupinetti's dynamic-vault MCP proposal in PR #603 pointed in the right direction: MCP clients should follow Tolaria's current workspace state instead of requiring users to reconnect after each vault change. The current app model has also moved from one selected vault toward mounted workspaces, so the MCP server needs to operate on every active mounted vault and load local agent guidance from each vault.
## Decision
Durable external MCP registration is vault-neutral. Tolaria still writes an explicit stdio MCP entry, but that entry contains only the Node command, `mcp-server/index.js`, and `WS_UI_PORT=9711`. It no longer writes `VAULT_PATH`.
The Node MCP entrypoints resolve vaults at tool-call time:
- Explicit `VAULT_PATH` and `VAULT_PATHS` environment variables continue to win for app-owned bridge launches and legacy/manual launches.
- When those env vars are absent, the MCP server reads Tolaria's `vaults.json`.
- `active_vault` is returned first.
- Every workspace in `vaults[]` is included unless it is explicitly marked `mounted: false`.
- Paths are deduplicated and blank paths are ignored.
Vault context now checks each active mounted workspace root for `AGENTS.md` and returns those instructions with the vault summary. The MCP server also exposes `list_vaults` so agents can discover the active workspace set and whether each vault has root guidance.
We are not adding a session-local `switch_vault` tool. A switch tool would create a second source of truth inside the MCP process, while Tolaria already owns mounted workspace state.
## Consequences
External MCP config survives vault switches and mounted-workspace changes without reconnecting.
Agents can work across all active mounted vaults and receive the per-vault `AGENTS.md` instructions needed to respect local rules.
Manual users can still override the resolved workspace set with `VAULT_PATH` or `VAULT_PATHS` when they intentionally want a static or scripted MCP session.

View File

@@ -0,0 +1,37 @@
# 0120. Stable AppImage MCP Server Path With OpenCode Registration
Status: active
Date: 2026-05-14
## Context
Domenico Lupinetti's PR #600 identified two gaps in durable external MCP setup:
- Linux AppImage launches expose bundled resources through a mount path that can change between app starts, so external clients can keep a stale `mcp-server/index.js` path.
- OpenCode uses `~/.config/opencode/opencode.json` with a different MCP schema from Claude Code, Cursor, Gemini, and generic `mcpServers` clients.
ADR-0119 made durable MCP registration vault-neutral, so PR #600 could not be merged directly: its registered entries still carried `VAULT_PATH`. The stable-path and OpenCode work is still valid, but it has to preserve the current mounted-workspace resolution model.
## Decision
On Linux AppImage startup, Tolaria extracts the bundled `mcp-server/` directory to `~/.local/share/tolaria/mcp-server/`. The extracted directory is version-gated by a `.tolaria-version` marker. Extraction runs on first launch or after an app version change, uses a staging directory plus rename, and uses a process lock so concurrent app launches do not write the stable directory at the same time.
Durable external registration prefers the stable extracted server directory when it is ready. Otherwise it falls back to the packaged resource resolver.
OpenCode is added to durable MCP registration and removal. Tolaria writes an OpenCode-specific entry under the top-level `mcp` key using:
- `type: "local"`
- `command: [node, index.js]`
- `enabled: true`
- `environment.WS_UI_PORT = "9711"`
OpenCode registration remains vault-neutral. It does not write `VAULT_PATH`; the Node MCP server resolves active mounted workspaces from Tolaria state per ADR-0119.
## Consequences
Linux AppImage users can register external MCP clients once and keep a valid `index.js` path across restarts and updates.
OpenCode participates in the same connect, disconnect, and status flow as Claude Code, Cursor, Gemini, and generic MCP clients while preserving its own config schema.
The stable path fixes the packaging lifecycle without reintroducing static vault pinning.

View File

@@ -0,0 +1,34 @@
---
type: ADR
id: "0121"
title: "AppImage external fallback for audio and video previews"
status: active
date: 2026-05-15
supersedes: "0110"
---
## Context
ADR-0110 standardized in-app previews for image, audio, video, and PDF vault files through the shared `FilePreview` surface and Tauri asset URLs. In practice, Linux AppImage builds run audio and video playback through WebKitGTK, and that runtime has proven unstable enough that mounting the same in-webview media controls is not a reliable default for packaged Linux releases.
Tolaria still needs one binary-preview model across platforms: previewability should remain renderer-inferred from filename extensions, binary files should remain ordinary vault entries, and external-open actions must continue to re-enter the active-vault command boundary before the OS opens a file.
## Decision
**Tolaria keeps in-app image and PDF previews everywhere, but Linux AppImage builds fall back to external-open controls for audio and video instead of mounting in-webview media playback.**
- `FilePreview` remains the single renderer-owned surface for supported binary vault files.
- The preview policy is runtime-owned: the renderer asks the native runtime whether external media fallback is required before rendering audio or video elements.
- Linux AppImage builds return `true` for that runtime check and suppress in-webview audio/video previews; other targets keep the existing native HTML media controls.
- Editor-embedded BlockNote audio/video blocks follow the same runtime gate so binary preview behavior stays consistent between note bodies and file previews.
## Alternatives considered
- **Runtime-gated external fallback on Linux AppImage** (chosen): keeps one preview architecture while containing a platform-specific runtime instability. Cons: AppImage users lose inline playback for audio/video.
- **Keep in-app audio/video previews on every platform**: preserves feature parity, but continues shipping a known unstable playback path on AppImage.
- **Disable all binary previews on Linux**: simpler policy, but unnecessarily removes stable image/PDF previews and weakens the file-first editor experience.
## Consequences
Tolaria now treats audio/video preview as a runtime capability decision rather than a universal guarantee of the binary preview system. Linux AppImage users see explicit external-open fallback controls for audio and video, while other platforms keep the richer in-app playback path.
This keeps the filesystem-first binary model, scoped asset access, and active-vault validation boundary intact without introducing persisted media types or a separate media subsystem. Re-evaluate this decision if AppImage media playback becomes stable enough to restore inline playback without special handling, or if other packaged runtimes need their own preview capability gates.

View File

@@ -0,0 +1,33 @@
# 0122. Scalar Array Frontmatter Properties
Status: active
Date: 2026-05-15
## Context
Saved Views filter against `VaultEntry.properties` in the renderer and in the Rust view evaluator. Before this decision, Tolaria preserved custom scalar frontmatter values as properties but dropped multi-element non-wikilink arrays during a full vault scan. That made a view such as `tags / contains / blues` unstable: optimistic renderer state could see a changed array for a while, but reload, view switch, or restart rebuilt the entry without the array-backed property.
Relationship arrays already have separate semantics because wikilink-bearing fields are stored in `VaultEntry.relationships`. Plain scalar arrays need to stay queryable as custom properties without being treated as relationship fields.
## Decision
**Tolaria preserves custom scalar-array frontmatter fields in `VaultEntry.properties`, while wikilink-bearing arrays remain relationships.** Single-item scalar arrays continue to normalize to a scalar value for compatibility; multi-item scalar arrays remain arrays.
Saved View filters evaluate scalar-array properties with set semantics. `contains` and `any_of` match exact case-insensitive elements, not substrings inside an element. Scalar properties keep their existing case-insensitive text matching behavior.
The vault cache version is bumped so existing caches that dropped array properties are rebuilt from disk.
## Options considered
- **Option A (chosen): Preserve scalar arrays as properties** - keeps YAML frontmatter expressive, fixes reload/restart behavior, and avoids hardcoded fields such as `tags`. The cost is widening `VaultEntry.properties` from scalar-only to scalar-or-array.
- **Option B: Flatten arrays to comma-delimited strings** - keeps the old property type, but cannot distinguish exact elements from substrings and makes filters ambiguous.
- **Option C: Treat every array as a relationship** - reuses existing relationship matching, but non-wikilink values such as tags are not graph edges and should not appear as relationships.
## Consequences
Views can filter custom scalar arrays consistently across save, reload, view switches, and app restart.
Property chips and sorting must tolerate property arrays. The note-list chip resolver already expands array values; custom-property sorting falls back to string comparison for arrays.
Any future custom-property logic must handle `VaultPropertyValue` rather than assuming every property is a scalar.

View File

@@ -81,7 +81,7 @@ proposed → active → superseded
| [0023](0023-repair-vault-auto-bootstrap.md) | Repair Vault auto-bootstrap pattern | active |
| [0024](0024-cache-outside-vault.md) | Vault cache stored outside vault directory | active |
| [0025](0025-type-field-canonical.md) | type: as canonical field (replacing Is A:) | active |
| [0026](0026-props-down-no-global-state.md) | Props-down callbacks-up (no global state) | active |
| [0026](0026-props-down-no-global-state.md) | Props-down callbacks-up (no global state) | superseded → [0115](0115-scoped-react-context-for-shared-ui-preferences.md) |
| [0027](0027-dual-ai-architecture.md) | Dual AI architecture (API chat + CLI agent) | superseded |
| [0028](0028-cli-agent-only-no-api-key.md) | CLI agent only — no direct Anthropic API key | active |
| [0029](0029-domain-command-builder-pattern.md) | Domain command builder pattern for useCommandRegistry | active |
@@ -163,9 +163,15 @@ proposed → active → superseded
| [0107](0107-pointer-owned-editor-block-reordering.md) | Pointer-owned editor block reordering | active |
| [0108](0108-sanitized-rendered-markup-and-safe-regex.md) | Sanitized rendered markup and safe user regex | active |
| [0109](0109-debounced-worker-derived-editor-indexes.md) | Debounced worker-derived editor indexes | active |
| [0110](0110-in-app-media-and-pdf-file-previews.md) | In-app media and PDF previews for binary vault files | active |
| [0110](0110-in-app-media-and-pdf-file-previews.md) | In-app media and PDF previews for binary vault files | superseded → [0121](0121-appimage-external-fallback-for-audio-and-video-previews.md) |
| [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) | Path-aware external vault refresh with focused-editor preservation | active |
| [0112](0112-system-theme-mode.md) | System theme mode | active |
| [0113](0113-shared-renderer-attachment-path-normalization.md) | Shared renderer attachment path normalization | active |
| [0114](0114-mounted-workspaces-unified-graph.md) | Mounted workspaces unified graph | active |
| [0115](0115-scoped-react-context-for-shared-ui-preferences.md) | Scoped React Context for shared UI preferences | active |
| [0116](0116-rich-raw-transition-and-serialization-ownership.md) | Rich/raw transition and serialization ownership | active |
| [0118](0118-entry-scoped-note-windows-without-vault-index-scans.md) | Entry-scoped note windows without vault index scans | active |
| [0119](0119-vault-neutral-mcp-registration-with-mounted-workspace-guidance.md) | Vault-neutral MCP registration with mounted workspace guidance | active |
| [0120](0120-stable-appimage-mcp-server-path-with-opencode-registration.md) | Stable AppImage MCP server path with OpenCode registration | active |
| [0121](0121-appimage-external-fallback-for-audio-and-video-previews.md) | AppImage external fallback for audio and video previews | active |
| [0122](0122-scalar-array-frontmatter-properties.md) | Scalar array frontmatter properties | active |

View File

@@ -59,6 +59,7 @@ files:
command.git.commitPush: 8cb5faa4019716f43dd69c41496b1d46
command.git.addRemote: 7eef951b3f909340a68dc9811be83e9e
command.git.pull: 3988d61f4a4546381f61a4eaf61315b4
command.git.pullRepository: 931ad25176586fd4332dd671574d3185
command.git.resolveConflicts: 871ac36968cfca3d2297a89b28b25dee
command.git.viewChanges: b2699edf69d8e1031afce2b2f94e5c8d
git.repository.select: 33fcf2b3ec4686d9cd06051c726d0ba2
@@ -127,6 +128,8 @@ files:
settings.workspaces.labelTooltip: b1b87a2affcd504e54bf97100e3d9c34
settings.workspaces.slugTooltip: daa690f5855e35d1cfa9dbffe1870d40
settings.workspaces.color: cb5feb1b7314637725a2e73bdc9f7295
settings.workspaces.moveUpAria: 2ade43872ef9432d14af83056304c6d7
settings.workspaces.moveDownAria: ed1ef4a310797ac84894a224bf4c77a6
settings.workspaces.removeAria: a5d2ceeeb76bfcb99015fc780ced07c2
settings.appearance.title: a1c58e94227389415de133efdf78ea6e
settings.appearance.description: ca7b75a4c10ab8a540707e3a96cd3592
@@ -139,9 +142,12 @@ files:
settings.language.label: 244c7b77926f077b863c33ff1244e1ec
settings.language.system: 41e2252344aa441e925589ddcb762e6d
settings.language.summary: edc83f1f48ce29bc80bf2f2f90e24997
settings.autogit.title: e59f720605ec1cfa42b3b97d89d7e883
settings.autogit.title: 0bcc70105ad279503e31fe7b3f47b665
settings.git.enable: c40503e1b0f483eecf3aff2fd01665c4
settings.git.enableDescription: a96a43dc4dbf1490d665eef437807b07
settings.autogit.description.enabled: e65bec70ca4d7921be56f84d10836018
settings.autogit.description.disabled: 1143ef6ee00614816785e3c6fc299d51
settings.autogit.description.gitDisabled: 55a1dd2d7f797062cde1374930221649
settings.autogit.enable: 9b205a4ae0e3ad4e6708155880ce9c75
settings.autogit.enableDescription: a7424a8f90c0b7f290a8144d12374554
settings.autogit.idleThreshold: 28016cc6fccd951a904ee3020cd733b6
@@ -152,7 +158,7 @@ files:
settings.titles.description: b94aeeca78dd376cc19b9aa019e53f6c
settings.titles.autoRename: 5383db8e790ebf5f956d9c59cb4c4f67
settings.titles.autoRenameDescription: cc28c28d8817736e658082a2261d9a6e
settings.vaultContent.title: 78b883ce9acb9c25e611158a518d9185
settings.vaultContent.title: f15c1cae7882448b3fb0404682e17e61
settings.vaultContent.description: a53487bf1c7898a1459dc1510e216f7d
settings.dateDisplay.default: 915e8ca934abe6625e17695ee7ae266a
settings.dateDisplay.defaultDescription: 502ed7ff7b9c0fe5971e8cb0576e91f6
@@ -310,7 +316,7 @@ files:
mcp.setup.disconnect: 42ae25231906c83927831e0ef7c317ac
mcp.setup.connecting: 182e7eda4e721ad00737460b88701dee
mcp.setup.disconnecting: 11de134aefe51cb4a5c545b5a057a871
mcp.setup.nodeRequirement: ac58a35cf21d2204fed548c227f77594
mcp.setup.runtimeRequirement: 1d7a8c99b2bc875b3601fa57860432fb
mcp.setup.writeEntryDescription: 370aa4403d2ffc37f574503bc3cd00e6
mcp.setup.clientPathsDescription: 94565852412051c799e78d2f4525ff53
mcp.setup.geminiGuidanceDescription: 182cf5b84b2f56cb1cf41dcdb5e093d8
@@ -381,6 +387,8 @@ files:
noteList.title.notes: f4c6f851b00d5518bf888815de279aba
noteList.searchPlaceholder: 4857cd2689a0a40eb4a77cdc745c518e
noteList.searchAction: 5012120fc480c67686dd22ee2f9493cd
noteList.clearSearch: 9983381c21069a3d6e4432e0aaea0079
noteList.quickOpenCreate: a4e964a3bc5b25e7ae87163624731e05
noteList.createNote: f1484bc40bd3fe3430c13fb89e2ce1af
noteList.empty.changesError: 4fca500c4b70f61f7d9413c57c34bdc4
noteList.empty.noChanges: ad82ac153532f5625760c29c176c5d5f
@@ -655,3 +663,5 @@ files:
locale.koKR: d0bdb3cde477d82e766da05ebda50ccb
locale.vi: 7b80fae85640c16cdb0261bef0c27636
locale.plPL: c730389bc8d99e59c867766babdd48b5
locale.beBY: 0a6de4460095f398e8a45ed512a7538f
locale.beLatn: 9c454dccccd6aaac0121a45b923dc5ad

View File

@@ -23,6 +23,7 @@ locales:
- ko-KR
- vi
- pl-PL
- be-BY
files:
json:

View File

@@ -0,0 +1,23 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { vaultContext } from './vault.js'
export async function readAgentInstructions(vaultPath) {
const instructionsPath = path.join(vaultPath, 'AGENTS.md')
try {
return {
path: instructionsPath,
content: await readFile(instructionsPath, 'utf8'),
}
} catch (error) {
if (error?.code === 'ENOENT') return null
throw error
}
}
export async function vaultContextWithInstructions(vaultPath) {
return {
...(await vaultContext(vaultPath)),
agentInstructions: await readAgentInstructions(vaultPath),
}
}

View File

@@ -19,14 +19,19 @@ import {
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import WebSocket from 'ws'
import { searchNotes, getNote, vaultContext } from './vault.js'
import { searchNotes, getNote } from './vault.js'
import { requireVaultPaths } from './vault-path.js'
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
import path from 'node:path'
const VAULT_PATHS = requireVaultPaths()
const PRIMARY_VAULT_PATH = VAULT_PATHS[0]
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
const LOCAL_READ_ONLY_TOOL_ANNOTATIONS = Object.freeze({
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
})
// Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js).
// The bridge relays messages to all other clients (the React frontend).
@@ -35,6 +40,10 @@ let reconnectTimer = null
let shutdownStarted = false
const RECONNECT_INTERVAL_MS = 3000
function activeVaultPaths() {
return requireVaultPaths()
}
function connectUiBridge() {
if (shutdownStarted) return
@@ -104,6 +113,7 @@ const TOOLS = [
{
name: 'search_notes',
description: 'Full-text search across vault notes by title or content. Returns matching paths, titles, and snippets.',
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@@ -115,7 +125,8 @@ const TOOLS = [
},
{
name: 'get_vault_context',
description: 'Get vault orientation for the active Tolaria vaults: entity types, note count, folders, and recent notes.',
description: 'Get vault orientation for the active Tolaria vaults: entity types, AGENTS.md instructions, note count, folders, and recent notes.',
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@@ -123,9 +134,19 @@ const TOOLS = [
},
},
},
{
name: 'list_vaults',
description: 'List the current active Tolaria vaults available to MCP tools, including whether each vault has AGENTS.md instructions.',
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'get_note',
description: 'Read a note with parsed YAML frontmatter and markdown content. Returns {path, frontmatter, content}.',
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@@ -138,6 +159,7 @@ const TOOLS = [
{
name: 'open_note',
description: 'Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.',
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@@ -150,6 +172,7 @@ const TOOLS = [
{
name: 'highlight_editor',
description: 'Visually highlight a UI element in Tolaria (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@@ -162,6 +185,7 @@ const TOOLS = [
{
name: 'refresh_vault',
description: 'Trigger a vault rescan so new or modified files appear immediately in the Tolaria note list.',
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@@ -175,7 +199,7 @@ const TOOLS = [
function requestedVaultPath(args = {}) {
const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : ''
if (!requested) return null
if (!VAULT_PATHS.includes(requested)) {
if (!activeVaultPaths().includes(requested)) {
throw new Error(`Vault is not active in Tolaria: ${requested}`)
}
return requested
@@ -194,7 +218,7 @@ function withVaultMetadata(note, vaultPath) {
}
async function getNoteFromActiveVaults(notePath, vaultPath = null) {
const candidates = vaultPath ? [vaultPath] : VAULT_PATHS
const candidates = vaultPath ? [vaultPath] : activeVaultPaths()
const matches = []
const errors = []
@@ -217,7 +241,7 @@ async function searchActiveVaults(query, limit = 10) {
const requestedLimit = Number.isFinite(limit) && limit > 0 ? limit : 10
const results = []
for (const vaultPath of VAULT_PATHS) {
for (const vaultPath of activeVaultPaths()) {
const vaultResults = await searchNotes(vaultPath, query, requestedLimit)
results.push(...vaultResults.map((result) => withVaultMetadata(result, vaultPath)))
if (results.length >= requestedLimit) break
@@ -227,18 +251,20 @@ async function searchActiveVaults(query, limit = 10) {
}
async function activeVaultContext(targetVaultPath = null) {
if (targetVaultPath) return vaultContext(targetVaultPath)
if (VAULT_PATHS.length === 1) return vaultContext(PRIMARY_VAULT_PATH)
const roots = activeVaultPaths()
if (targetVaultPath) return vaultContextWithInstructions(targetVaultPath)
if (roots.length === 1) return vaultContextWithInstructions(roots[0])
return {
vaults: await Promise.all(VAULT_PATHS.map(vaultContext)),
vaults: await Promise.all(roots.map(vaultContextWithInstructions)),
}
}
function uiPath(args = {}) {
const notePath = typeof args.path === 'string' ? args.path : ''
if (path.isAbsolute(notePath)) return notePath
const vaultPath = requestedVaultPath(args) ?? (VAULT_PATHS.length === 1 ? PRIMARY_VAULT_PATH : '')
const roots = activeVaultPaths()
const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '')
return vaultPath ? path.join(vaultPath, notePath) : notePath
}
@@ -255,6 +281,20 @@ async function handleVaultContext(args = {}) {
return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] }
}
async function handleListVaults() {
const vaults = await Promise.all(activeVaultPaths().map(async (vaultPath) => {
const agentInstructions = await readAgentInstructions(vaultPath)
return {
path: vaultPath,
label: vaultLabel(vaultPath),
agentInstructionsPath: agentInstructions?.path ?? null,
hasAgentInstructions: agentInstructions !== null,
}
}))
return { content: [{ type: 'text', text: JSON.stringify({ vaults }, null, 2) }] }
}
async function handleGetNote(args) {
const note = await getNoteFromActiveVaults(args.path, requestedVaultPath(args))
return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] }
@@ -279,23 +319,20 @@ function handleRefreshVault(args) {
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
}
const TOOL_HANDLERS = new Map([
['search_notes', handleSearchNotes],
['get_vault_context', handleVaultContext],
['list_vaults', handleListVaults],
['get_note', handleGetNote],
['open_note', handleOpenNote],
['highlight_editor', handleHighlightEditor],
['refresh_vault', handleRefreshVault],
])
function callToolHandler(name, args) {
switch (name) {
case 'search_notes':
return handleSearchNotes(args)
case 'get_vault_context':
return handleVaultContext(args)
case 'get_note':
return handleGetNote(args)
case 'open_note':
return handleOpenNote(args)
case 'highlight_editor':
return handleHighlightEditor(args)
case 'refresh_vault':
return handleRefreshVault(args)
default:
throw new Error(`Unknown tool: ${name}`)
}
const handler = TOOL_HANDLERS.get(name)
if (!handler) throw new Error(`Unknown tool: ${name}`)
return handler(args)
}
// --- Server setup ---
@@ -358,7 +395,7 @@ async function main() {
connectUiBridge()
await server.connect(transport)
console.error(`Tolaria MCP server running (vaults: ${VAULT_PATHS.join(', ')})`)
console.error('Tolaria MCP server running (vaults resolved per call)')
}
main().catch((error) => {

View File

@@ -2,17 +2,20 @@ import { describe, it, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import {
mkdtemp, mkdir, open, rm,
mkdtemp, mkdir, open, rm, writeFile,
} from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { clearTimeout, setTimeout } from 'node:timers'
import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import {
findMarkdownFiles, getNote, searchNotes, vaultContext,
} from './vault.js'
import { requireVaultPath, requireVaultPaths } from './vault-path.js'
import { vaultContextWithInstructions } from './agent-instructions.js'
import { evaluateBridgeRequest } from './ws-bridge.js'
let tmpDir
@@ -191,6 +194,26 @@ describe('vaultContext', () => {
const ctx = await vaultContext(tmpDir)
assert.equal(ctx.noteCount, 4)
})
it('includes root AGENTS.md instructions when present', async () => {
const agentsPath = path.join(tmpDir, 'AGENTS.md')
await writeFile(agentsPath, '# Vault Rules\n\nUse this vault carefully.\n', 'utf-8')
try {
const ctx = await vaultContextWithInstructions(tmpDir)
assert.deepEqual(ctx.agentInstructions, {
path: agentsPath,
content: '# Vault Rules\n\nUse this vault carefully.\n',
})
} finally {
await rm(agentsPath, { force: true })
}
})
it('reports null agent instructions when AGENTS.md is absent', async () => {
const ctx = await vaultContextWithInstructions(tmpDir)
assert.equal(ctx.agentInstructions, null)
})
})
describe('evaluateBridgeRequest', () => {
@@ -236,11 +259,13 @@ describe('requireVaultPath', () => {
)
})
it('rejects missing vault paths instead of falling back to ~/Laputa', () => {
it('rejects missing vault paths instead of falling back to ~/Laputa', async () => {
const configDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-empty-config-'))
assert.throws(
() => requireVaultPath({}),
() => requireVaultPaths({}, { configDir }),
/VAULT_PATH is required/,
)
await rm(configDir, { recursive: true, force: true })
})
it('returns all configured active vault paths with the primary vault first', () => {
@@ -252,9 +277,64 @@ describe('requireVaultPath', () => {
['/tmp/Default Vault', '/tmp/Second Vault'],
)
})
it('loads active mounted vault paths from Tolaria config when env is vault-neutral', async () => {
const configDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-config-'))
const primaryVault = path.join(configDir, 'Primary Vault')
const secondaryVault = path.join(configDir, 'Secondary Vault')
const hiddenVault = path.join(configDir, 'Hidden Vault')
const configPath = path.join(configDir, 'com.tolaria.app', 'vaults.json')
await mkdir(path.dirname(configPath), { recursive: true })
await writeFile(configPath, JSON.stringify({
active_vault: primaryVault,
vaults: [
{ label: 'Secondary', path: secondaryVault, mounted: true },
{ label: 'Hidden', path: hiddenVault, mounted: false },
{ label: 'Primary', path: primaryVault, mounted: true },
],
}), 'utf-8')
try {
assert.deepEqual(
requireVaultPaths({}, { configDir }),
[primaryVault, secondaryVault],
)
} finally {
await rm(configDir, { recursive: true, force: true })
}
})
})
describe('stdio process lifecycle', () => {
it('advertises local vault tools as approval-safe for MCP clients', async () => {
const { client, stderr } = await connectMcpClient()
try {
const { tools } = await client.listTools()
const toolsByName = new Map(tools.map(tool => [tool.name, tool]))
const safeReadTools = [
'search_notes',
'get_vault_context',
'list_vaults',
'get_note',
'open_note',
'highlight_editor',
'refresh_vault',
]
for (const name of safeReadTools) {
const tool = toolsByName.get(name)
assert.ok(tool, `Missing MCP tool: ${name}`)
assert.equal(tool.annotations?.readOnlyHint, true, `${name} should not require destructive approval`)
assert.equal(tool.annotations?.destructiveHint, false, `${name} should not be treated as destructive`)
assert.equal(tool.annotations?.openWorldHint, false, `${name} should stay scoped to local active vaults`)
}
} finally {
await closeMcpClient(client, stderr)
}
})
it('exits when the MCP client closes stdin', async () => {
const child = spawn(process.execPath, ['index.js'], {
cwd: MCP_SERVER_DIR,
@@ -282,6 +362,41 @@ describe('stdio process lifecycle', () => {
})
})
async function connectMcpClient() {
const transport = new StdioClientTransport({
command: process.execPath,
args: ['index.js'],
cwd: MCP_SERVER_DIR,
env: { ...process.env, VAULT_PATH: tmpDir, WS_UI_PORT: '65534' },
stderr: 'pipe',
})
const stderr = collectTransportStderr(transport)
const client = new Client(
{ name: 'tolaria-mcp-test-client', version: '0.0.0' },
{ capabilities: {} },
)
await client.connect(transport)
return { client, stderr }
}
function collectTransportStderr(transport) {
const chunks = []
transport.stderr?.setEncoding('utf8')
transport.stderr?.on('data', chunk => {
chunks.push(chunk)
})
return () => chunks.join('')
}
async function closeMcpClient(client, stderr) {
try {
await client.close()
} catch (error) {
assert.fail(`Failed to close MCP test client: ${error.message}\n${stderr()}`)
}
}
async function assertRejectsOutsideVault(prefix, resolveNotePath) {
const outsideDir = await mkdtemp(path.join(os.tmpdir(), prefix))
const outsideNote = path.join(outsideDir, 'outside.md')

View File

@@ -1,3 +1,10 @@
import { existsSync, readFileSync } from 'node:fs'
import { homedir, platform } from 'node:os'
import { join } from 'node:path'
const APP_CONFIG_DIR = 'com.tolaria.app'
const LEGACY_APP_CONFIG_DIR = 'com.laputa.app'
function parseVaultPathList(rawValue) {
if (!rawValue?.trim()) return []
@@ -24,17 +31,58 @@ function uniqueVaultPaths(paths) {
return unique
}
export function requireVaultPaths(env = process.env) {
function appConfigBaseDir(env = process.env) {
if (platform() === 'darwin') return join(homedir(), 'Library', 'Application Support')
if (platform() === 'win32') return env.APPDATA || join(homedir(), 'AppData', 'Roaming')
return env.XDG_CONFIG_HOME || join(homedir(), '.config')
}
export function vaultsJsonPath({ configDir = appConfigBaseDir() } = {}) {
const preferred = join(configDir, APP_CONFIG_DIR, 'vaults.json')
if (existsSync(preferred)) return preferred
const legacy = join(configDir, LEGACY_APP_CONFIG_DIR, 'vaults.json')
return existsSync(legacy) ? legacy : preferred
}
function pushUniquePath(paths, value) {
const path = typeof value === 'string' ? value.trim() : ''
if (!path || paths.includes(path)) return
paths.push(path)
}
function activeVaultPathsFromList(list) {
const paths = []
pushUniquePath(paths, list?.active_vault)
for (const vault of list?.vaults ?? []) {
if (vault?.mounted === false) continue
pushUniquePath(paths, vault?.path)
}
return paths
}
export function configuredVaultPaths({ configDir } = {}) {
const filePath = vaultsJsonPath({ configDir })
if (!existsSync(filePath)) return []
return activeVaultPathsFromList(JSON.parse(readFileSync(filePath, 'utf-8')))
}
export function requireVaultPaths(env = process.env, options = {}) {
const vaultPaths = uniqueVaultPaths([
env.VAULT_PATH?.trim() ?? '',
...parseVaultPathList(env.VAULT_PATHS),
])
if (vaultPaths.length === 0) {
const configuredPaths = configuredVaultPaths(options)
if (configuredPaths.length > 0) return configuredPaths
throw new Error('VAULT_PATH is required. Open a vault in Tolaria before starting MCP tools.')
}
return vaultPaths
}
export function requireVaultPath(env = process.env) {
return requireVaultPaths(env)[0]
export function requireVaultPath(env = process.env, options = {}) {
return requireVaultPaths(env, options)[0]
}

View File

@@ -22,9 +22,10 @@
import { createServer } from 'node:http'
import { WebSocketServer } from 'ws'
import {
getNote, searchNotes, vaultContext,
getNote, searchNotes,
} from './vault.js'
import { requireVaultPaths } from './vault-path.js'
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
import path from 'node:path'
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
@@ -38,16 +39,10 @@ const TRUSTED_UI_ORIGINS = new Set([
/** @type {WebSocketServer | null} */
let uiBridge = null
let vaultPaths = null
const UNKNOWN_TOOL = Symbol('unknown tool')
function activeVaultPaths() {
vaultPaths ??= requireVaultPaths()
return vaultPaths
}
function primaryVaultPath() {
return activeVaultPaths()[0]
return requireVaultPaths()
}
function requestedVaultPath(args = {}) {
@@ -63,7 +58,7 @@ function uiPath(args = {}) {
const notePath = typeof args.path === 'string' ? args.path : ''
if (path.isAbsolute(notePath)) return notePath
const roots = activeVaultPaths()
const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? primaryVaultPath() : '')
const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '')
return vaultPath ? path.join(vaultPath, notePath) : notePath
}
@@ -102,8 +97,8 @@ async function searchActiveVaults(query, limit = 10) {
async function activeVaultContext() {
const roots = activeVaultPaths()
if (roots.length === 1) return vaultContext(roots[0])
return { vaults: await Promise.all(roots.map(vaultContext)) }
if (roots.length === 1) return vaultContextWithInstructions(roots[0])
return { vaults: await Promise.all(roots.map(vaultContextWithInstructions)) }
}
function broadcastUiAction(action, payload) {
@@ -149,11 +144,26 @@ function refreshVaultTool(args) {
return { ok: true }
}
async function listVaultsTool() {
return {
vaults: await Promise.all(activeVaultPaths().map(async (vaultPath) => {
const agentInstructions = await readAgentInstructions(vaultPath)
return {
path: vaultPath,
label: path.basename(vaultPath) || vaultPath,
agentInstructionsPath: agentInstructions?.path ?? null,
hasAgentInstructions: agentInstructions !== null,
}
})),
}
}
const TOOL_EXECUTORS = [
['open_note', readNoteTool],
['read_note', readNoteTool],
['search_notes', (args) => searchActiveVaults(args.query, args.limit)],
['vault_context', () => activeVaultContext()],
['list_vaults', () => listVaultsTool()],
['ui_open_note', uiOpenNoteTool],
['ui_open_tab', uiOpenTabTool],
['ui_highlight', highlightTool],

View File

@@ -12,7 +12,7 @@
"docs:dev": "vitepress dev site --host 127.0.0.1",
"docs:build": "pnpm agent-docs && vitepress build site",
"docs:preview": "vitepress preview site --host 127.0.0.1",
"lint": "eslint .",
"lint": "eslint . --max-warnings=0",
"l10n:translate": "lara-cli translate",
"l10n:translate:force": "lara-cli translate --force",
"l10n:validate": "node scripts/validate-locales.mjs",
@@ -21,7 +21,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.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/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/note-history-edit-loop.spec.ts tests/smoke/save-before-note-switch.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/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/type-derived-properties.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.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/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/note-history-edit-loop.spec.ts tests/smoke/quick-open-create-note.spec.ts tests/smoke/save-before-note-switch.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/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/type-derived-properties.spec.ts tests/smoke/vault-loading-skeleton.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",
@@ -65,7 +65,6 @@
"date-fns": "^4.1.0",
"dompurify": "3.4.2",
"katex": "^0.16.28",
"lucide-react": "^0.564.0",
"mermaid": "^11.14.0",
"posthog-js": "^1.363.5",
"radix-ui": "^1.4.3",
@@ -124,6 +123,13 @@
"postcss": "8.5.10",
"protobufjs": "7.5.6",
"rollup": "4.59.0"
},
"patchedDependencies": {
"@blocknote/core@0.46.2": "patches/@blocknote__core@0.46.2.patch",
"@blocknote/react@0.46.2": "patches/@blocknote__react@0.46.2.patch",
"@tiptap/extension-link@3.19.0": "patches/@tiptap__extension-link@3.19.0.patch",
"prosemirror-tables@1.8.5": "patches/prosemirror-tables@1.8.5.patch",
"@blocknote/code-block@0.46.2": "patches/@blocknote__code-block@0.46.2.patch"
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -241,7 +241,7 @@ index 3ac6fe16aa9ae605a59916f8343b8440befda89e..ee009c533fa3826819df0b83a12755b6
e.state.draggingState = void 0, e.emitUpdate(), n.transact((o) => o.setMeta(D, null)), !n.headless && Ut(n.prosemirrorView.root);
},
/**
@@ -1918,30 +1961,37 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
@@ -1918,30 +1961,38 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
setCellSelection(o, r, s = r) {
if (!e)
throw new Error("Table handles view not initialized");
@@ -292,8 +292,9 @@ index 3ac6fe16aa9ae605a59916f8343b8440befda89e..ee009c533fa3826819df0b83a12755b6
+ return e == null || e.hideHandles({ resetCell: !0 }), !1;
return r.orientation === "row" ? r.side === "above" ? pt(a, i) : ft(a, i) : r.side === "left" ? gt(a, i) : wt(a, i);
});
+ e == null || e.hideHandles({ resetCell: !0, resetDragging: !0 });
},
@@ -1949,17 +1999,23 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
@@ -1949,17 +2000,23 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
* Removes a row or column from the table using prosemirror-table commands
*/
removeRowOrColumn(o, r) {
@@ -722,7 +723,7 @@ index 029103600a98bf8ccd3054a5c43ffa2fd9115c06..d1678ed4761baca0b3495dafc8d75152
destroy() {
diff --git a/src/extensions/TableHandles/TableHandles.ts b/src/extensions/TableHandles/TableHandles.ts
index 30637742d517bcf136ca562a09a1544d7b03a20d..25b298e6a2685f3667ee53b95175912a4c805e27 100644
index 3063774..9813bed 100644
--- a/src/extensions/TableHandles/TableHandles.ts
+++ b/src/extensions/TableHandles/TableHandles.ts
@@ -99,6 +99,46 @@ function getChildIndex(node: Element) {
@@ -1035,7 +1036,15 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..25b298e6a2685f3667ee53b95175912a
if (direction.orientation === "row") {
if (direction.side === "above") {
return addRowBefore(state, dispatch);
@@ -1006,12 +1081,19 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
@@ -997,6 +1072,7 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
}
}
});
+ view?.hideHandles({ resetCell: true, resetDragging: true });
},
/**
@@ -1006,12 +1082,19 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
index: RelativeCellIndices["row"] | RelativeCellIndices["col"],
direction: "row" | "column",
) {
@@ -1055,7 +1064,7 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..25b298e6a2685f3667ee53b95175912a
return deleteRow(state, dispatch);
});
} else {
@@ -1020,6 +1102,9 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
@@ -1020,6 +1103,9 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
row: 0,
col: index,
});
@@ -1065,7 +1074,7 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..25b298e6a2685f3667ee53b95175912a
return deleteColumn(state, dispatch);
});
}
@@ -1041,6 +1126,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
@@ -1041,6 +1127,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
)
: beforeState;
@@ -1076,7 +1085,7 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..25b298e6a2685f3667ee53b95175912a
return mergeCells(state, dispatch);
});
},
@@ -1055,6 +1144,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
@@ -1055,6 +1145,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
? this.setCellSelection(beforeState, relativeCellToSplit)
: beforeState;

View File

@@ -1,5 +1,5 @@
diff --git a/dist/blocknote-react.js b/dist/blocknote-react.js
index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf6319df91544 100644
index d4d36cd..79dd4f0 100644
--- a/dist/blocknote-react.js
+++ b/dist/blocknote-react.js
@@ -154,8 +154,26 @@ const co = (e) => {
@@ -31,7 +31,15 @@ index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf631
}
const z = (e) => {
var h, b, p;
@@ -216,9 +234,7 @@ const z = (e) => {
@@ -193,6 +211,7 @@ const z = (e) => {
...e.elementProps,
style: {
display: "flex",
+ pointerEvents: c === "close" ? "none" : void 0,
...(h = e.elementProps) == null ? void 0 : h.style,
zIndex: `calc(var(--bn-ui-base-z-index) + ${((p = (b = e.elementProps) == null ? void 0 : b.style) == null ? void 0 : p.zIndex) || 0})`,
...n,
@@ -216,9 +235,7 @@ const z = (e) => {
const s = Ue(t, c.doc);
if (!s)
return;
@@ -42,7 +50,7 @@ index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf631
if (a instanceof Element)
return {
element: a
@@ -2499,7 +2515,14 @@ function Kr(e) {
@@ -2499,7 +2516,14 @@ function Kr(e) {
columns: d
} = e, m = H(
(C) => {
@@ -58,7 +66,7 @@ index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf631
},
[u, a, s]
), { items: g, usedQuery: f, loadingState: h } = Yt(
@@ -2734,7 +2757,14 @@ function ti(e) {
@@ -2734,7 +2758,14 @@ function ti(e) {
onItemClick: u
} = e, d = H(
(p) => {
@@ -74,7 +82,7 @@ index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf631
},
[u, a, s]
), { items: m, usedQuery: g, loadingState: f } = Yt(
@@ -3306,14 +3336,15 @@ const ii = (e, t = 0.3) => {
@@ -3306,14 +3337,15 @@ const ii = (e, t = 0.3) => {
);
if (!m)
return {};
@@ -93,7 +101,7 @@ index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf631
return p instanceof Element ? (d.cellReference = { element: p }, d.rowReference = {
element: f,
getBoundingClientRect: () => {
@@ -4371,7 +4402,7 @@ const zi = (e) => {
@@ -4371,7 +4403,7 @@ const zi = (e) => {
const a = Ue(t, c.prosemirrorState.doc);
if (!a)
return;

39
pnpm-lock.yaml generated
View File

@@ -18,11 +18,14 @@ overrides:
rollup: 4.59.0
patchedDependencies:
'@blocknote/code-block@0.46.2':
hash: c006cad64c22a2efc10299d85bf72407150fd29a1f16d497b8db0faa071535f8
path: patches/@blocknote__code-block@0.46.2.patch
'@blocknote/core@0.46.2':
hash: e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0
hash: 9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2
path: patches/@blocknote__core@0.46.2.patch
'@blocknote/react@0.46.2':
hash: 9721936e806f31cf440cc94ead3ec03764ae968870c167d086a8e60d8f6d4a08
hash: 67ee7211dff79c50d821c3145b0c96b42bb9a614d508edafd65492b6b5ccb41e
path: patches/@blocknote__react@0.46.2.patch
'@tiptap/extension-link@3.19.0':
hash: fbe59b1b8b798ba3fefa371c0729b5aa3458e1ea70a4b86cd5267f9f62529284
@@ -40,16 +43,16 @@ importers:
version: 0.78.0(zod@4.3.6)
'@blocknote/code-block':
specifier: ^0.46.2
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
version: 0.46.2(patch_hash=c006cad64c22a2efc10299d85bf72407150fd29a1f16d497b8db0faa071535f8)(@blocknote/core@0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
'@blocknote/core':
specifier: ^0.46.2
version: 0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
version: 0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/mantine':
specifier: ^0.46.2
version: 0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@blocknote/react':
specifier: ^0.46.2
version: 0.46.2(patch_hash=9721936e806f31cf440cc94ead3ec03764ae968870c167d086a8e60d8f6d4a08)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
version: 0.46.2(patch_hash=67ee7211dff79c50d821c3145b0c96b42bb9a614d508edafd65492b6b5ccb41e)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@codemirror/commands':
specifier: ^6.10.2
version: 6.10.2
@@ -146,9 +149,6 @@ importers:
katex:
specifier: ^0.16.28
version: 0.16.28
lucide-react:
specifier: ^0.564.0
version: 0.564.0(react@19.2.4)
mermaid:
specifier: ^11.14.0
version: 11.14.0
@@ -4561,11 +4561,6 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.564.0:
resolution: {integrity: sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -6219,9 +6214,9 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
'@blocknote/code-block@0.46.2(patch_hash=c006cad64c22a2efc10299d85bf72407150fd29a1f16d497b8db0faa071535f8)(@blocknote/core@0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@shikijs/core': 3.23.0
'@shikijs/engine-javascript': 3.23.0
'@shikijs/langs': 3.23.0
@@ -6229,7 +6224,7 @@ snapshots:
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.22.0
'@blocknote/core@0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
'@blocknote/core@0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
dependencies:
'@emoji-mart/data': 1.2.1
'@handlewithcare/prosemirror-inputrules': 0.1.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)
@@ -6281,8 +6276,8 @@ snapshots:
'@blocknote/mantine@0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/react': 0.46.2(patch_hash=9721936e806f31cf440cc94ead3ec03764ae968870c167d086a8e60d8f6d4a08)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@blocknote/core': 0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/react': 0.46.2(patch_hash=67ee7211dff79c50d821c3145b0c96b42bb9a614d508edafd65492b6b5ccb41e)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/core': 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/hooks': 8.3.14(react@19.2.4)
'@mantine/utils': 6.0.22(react@19.2.4)
@@ -6301,9 +6296,9 @@ snapshots:
- sugar-high
- supports-color
'@blocknote/react@0.46.2(patch_hash=9721936e806f31cf440cc94ead3ec03764ae968870c167d086a8e60d8f6d4a08)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
'@blocknote/react@0.46.2(patch_hash=67ee7211dff79c50d821c3145b0c96b42bb9a614d508edafd65492b6b5ccb41e)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@emoji-mart/data': 1.2.1
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.10
@@ -10519,10 +10514,6 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.564.0(react@19.2.4):
dependencies:
react: 19.2.4
lz-string@1.5.0: {}
magic-string@0.30.21:

View File

@@ -0,0 +1,17 @@
## New Features
- 🗂️ **Custom Vault Order** — Reorder your vaults so the switcher and workspace controls match the way you actually work.
- 🔌 **Smarter External MCP Setup** — Register Tolarias external MCP server with dynamic vault resolution, including mounted-workspace guidance for AppImage users.
## Improvements
-**Faster Large-Note Opens** — Warm parsed editor blocks ahead of note switches so large notes feel more responsive.
- 🧭 **Clearer Vault and Status Controls** — Use cleaner status-bar sections, vault menus, and workspace settings rows when managing multiple vaults.
- 🐧 **Stronger Linux AppImage Support** — Improve AppImage launch, packaging, FUSE, fcitx input, and MCP subprocess handling across Linux setups.
- 🎨 **More Consistent Interface Icons** — Standardize icons on the Phosphor set for a cleaner and more coherent UI.
## Stability and Fixes
- This release fixes relationship equality filters, externally moved inbox notes, selected-vault note creation, and duplicate vault folder pickers.
- Editor reliability is improved around pulled changes, file attachments, CJK code-block copying, table handles after reload, Mermaid errors, and procedure editor schema recovery.
- Whiteboard dialogs, native commit messages, AutoGit author failures, fullscreen navigation, Linux Wayland safeguards, and release history links were hardened.

View File

@@ -0,0 +1,18 @@
## New Features
- 🔎 **Faster Note-List Search Controls** — Clear note-list searches directly from the list header while keeping keyboard focus and loading feedback predictable.
- 🧰 **More Granular Git Controls** — Use clearer Git actions for local commit, pull, push, and sync workflows without collapsing everything into one path.
- 🐧 **Linux RPM Download Option** — Stable download pages can expose RPM packages alongside AppImage and other platform installers when release artifacts include them.
## Improvements
- 🧭 **Cleaner Update Release Notes Link** — The in-app update banner now opens the public release notes page instead of the old GitHub Pages root.
- 📝 **More Reliable Editor Direction and Spacing** — Mixed right-to-left and left-to-right notes resolve text direction per block, and raw-editor line numbers align more cleanly with content.
- 🧩 **Better View Filtering for Array Properties** — Saved views now match scalar array properties such as tags more consistently after reloads and across renderer/Rust evaluation.
- 🖼️ **Safer Linux AppImage Media Handling** — AppImage builds avoid unstable in-webview audio/video preview paths while preserving stable external-open fallbacks.
## Stability and Fixes
- Rich-editor recovery was hardened for invalid list content, embedded attachment paths, file drops, Mermaid reload clicks, aliased wikilinks in Markdown tables, and empty-heading note edits.
- Vault and workspace behavior is steadier around mounted default workspaces, app-owned frontmatter reloads, parsed note-list preload warmups, and built-in note body template removal.
- Release build type safety, CodeScene thresholds, image-toolbar hover access, and multiple patch-review findings were addressed before promotion.

View File

@@ -0,0 +1,19 @@
## New Features
- 🧭 **Expanded Note Context Menus** — Right-click notes for faster everyday actions, including opening in a new window, favorites, organization state, Finder reveal, and file path copy.
- 🤖 **More Local AI Agent Options** — Use Kiro as a local AI agent, run Tolaria's MCP server through Bun as well as Node, and launch Pi more reliably from shell-managed installs.
- 📚 **Portent Knowledge-Base Template** — Tolaria's docs now include Portent as a reference template for structuring durable, agent-friendly knowledge bases.
- 🌍 **Belarusian Language Support** — Use Tolaria with new Belarusian interface translations.
## Improvements
-**Much Faster Note Windows** — Opening a note in a separate window now uses a lighter startup path, avoiding the full main-window load and reducing app-wide stalls.
- 🪟 **More Complete Note Actions** — Note-list actions are now available from the context menu as well as existing command paths, making common file and organization tasks easier to reach.
- 🧩 **Cleaner CLI Agent Runtime Detection** — Shared binary discovery was consolidated so local agent launches handle shell-managed runtimes more consistently.
## Stability and Fixes
- Note windows now open reliably from all entry points and no longer stall the whole application during startup.
- Editor reliability is improved around Go code-block highlighting, Mermaid fullscreen zoom, non-Markdown wikilink targets, active-note refresh after external edits, and retained editor memory.
- Vault and workspace behavior is steadier around AutoGit multi-vault pushes, new views in the default workspace, and vault watcher Git symlinks.
- Release build type safety, pnpm patched dependency handling, CodeScene thresholds, and multiple patch-review findings were addressed before promotion.

View File

@@ -0,0 +1,329 @@
import { spawnSync } from 'node:child_process'
import { error as logError, log } from 'node:console'
import { existsSync } from 'node:fs'
import {
chmod,
mkdir,
mkdtemp,
readFile,
rename,
rm,
writeFile,
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
export const BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE =
'this_dir="$(readlink -f "$(dirname "$0")")"'
export const FIXED_LINUXDEPLOY_APPRUN_DIR_LINE =
'this_dir="$(dirname "$(readlink -f "$0")")"'
export const APPIMAGE_PLUGIN_WRAPPER_NAME = 'linuxdeploy-plugin-appimage.AppImage'
export const REAL_APPIMAGE_PLUGIN_NAME =
'tolaria-real-linuxdeploy-plugin-appimage/linuxdeploy-plugin-appimage.AppImage'
export const APPIMAGE_FCITX_GTK3_IM_MODULE_PATH =
'usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so'
export const APPIMAGE_FCITX_GCLIENT_LIBRARY_PATH =
'usr/lib/x86_64-linux-gnu/libFcitx5GClient.so.2'
export const DEFAULT_APPIMAGE_PLUGIN_URL =
'https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-x86_64.AppImage'
const WRAPPER_MARKER = 'Tolaria AppImage symlink launcher shim'
const REQUIRED_APPIMAGE_PATHS = [
'AppRun',
APPIMAGE_FCITX_GTK3_IM_MODULE_PATH,
APPIMAGE_FCITX_GCLIENT_LIBRARY_PATH,
]
export function tauriToolsCacheDir(env = process.env) {
if (env.TOLARIA_TAURI_TOOLS_DIR) {
return resolve(env.TOLARIA_TAURI_TOOLS_DIR)
}
if (env.XDG_CACHE_HOME) {
return resolve(env.XDG_CACHE_HOME, 'tauri')
}
if (!env.HOME) {
throw new Error('HOME or XDG_CACHE_HOME is required to locate the Tauri tools cache')
}
return resolve(env.HOME, '.cache', 'tauri')
}
export function patchAppRunText(text) {
if (text.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE)) {
return {
changed: true,
text: text.replaceAll(
BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE,
FIXED_LINUXDEPLOY_APPRUN_DIR_LINE,
),
}
}
return { changed: false, text }
}
export function assertSymlinkSafeAppRunText(text, label = 'AppRun') {
if (text.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE)) {
throw new Error(`${label} still resolves dirname before following AppRun symlinks`)
}
if (!text.includes(FIXED_LINUXDEPLOY_APPRUN_DIR_LINE)) {
throw new Error(`${label} is missing the symlink-safe AppRun directory resolver`)
}
}
export function appImagePluginWrapperSource({
pluginUrl = DEFAULT_APPIMAGE_PLUGIN_URL,
} = {}) {
return `#!/usr/bin/env bash
set -euo pipefail
# ${WRAPPER_MARKER}
PLUGIN_URL="\${TOLARIA_APPIMAGE_PLUGIN_URL:-${pluginUrl}}"
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
REAL_PLUGIN="\${TOLARIA_APPIMAGE_REAL_PLUGIN:-"$SCRIPT_DIR/${REAL_APPIMAGE_PLUGIN_NAME}"}"
FCITX_GTK3_IM_MODULE="\${TOLARIA_FCITX_GTK3_IM_MODULE:-/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so}"
FCITX_LIBRARY_DIR="\${TOLARIA_FCITX_LIBRARY_DIR:-/usr/lib/x86_64-linux-gnu}"
appdir_from_args() {
local previous=""
for arg in "$@"; do
if [ "$previous" = "--appdir" ]; then
printf '%s\\n' "$arg"
return 0
fi
case "$arg" in
--appdir=*)
printf '%s\\n' "\${arg#--appdir=}"
return 0
;;
esac
previous="$arg"
done
}
download_real_plugin() {
if [ -x "$REAL_PLUGIN" ]; then
return 0
fi
local tmp_plugin="$REAL_PLUGIN.tmp.$$"
rm -f "$tmp_plugin"
mkdir -p "$(dirname -- "$REAL_PLUGIN")"
if command -v curl >/dev/null 2>&1; then
curl -fsSL -o "$tmp_plugin" "$PLUGIN_URL"
elif command -v wget >/dev/null 2>&1; then
wget -q -O "$tmp_plugin" "$PLUGIN_URL"
else
echo "curl or wget is required to fetch the real linuxdeploy AppImage output plugin" >&2
return 1
fi
chmod +x "$tmp_plugin"
mv "$tmp_plugin" "$REAL_PLUGIN"
}
patch_apprun() {
local appdir="\${APPDIR:-}"
if [ -z "$appdir" ]; then
appdir="$(appdir_from_args "$@" || true)"
fi
if [ -z "$appdir" ] || [ ! -f "$appdir/AppRun" ]; then
return 0
fi
python3 - "$appdir/AppRun" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
broken = 'this_dir="$(readlink -f "$(dirname "$0")")"'
fixed = 'this_dir="$(dirname "$(readlink -f "$0")")"'
text = path.read_text(encoding="utf-8")
if broken in text:
path.write_text(text.replace(broken, fixed), encoding="utf-8")
print(f"Patched linuxdeploy AppRun symlink resolution in {path}", file=sys.stderr)
elif fixed in text:
pass
elif "autogenerated by linuxdeploy" in text and "AppRun.wrapped" in text:
raise SystemExit(f"{path} is a linuxdeploy wrapper but does not contain the expected AppRun resolver")
PY
}
bundle_fcitx_gtk3_module() {
local appdir="\${APPDIR:-}"
if [ -z "$appdir" ]; then
appdir="$(appdir_from_args "$@" || true)"
fi
if [ -z "$appdir" ] || [ ! -d "$appdir" ] || [ ! -f "$FCITX_GTK3_IM_MODULE" ]; then
return 0
fi
local module_dest="$appdir/${APPIMAGE_FCITX_GTK3_IM_MODULE_PATH}"
local library_dest_dir="$appdir/usr/lib/x86_64-linux-gnu"
mkdir -p "$(dirname -- "$module_dest")" "$library_dest_dir"
cp -a "$FCITX_GTK3_IM_MODULE" "$module_dest"
local copied_library=0
shopt -s nullglob
for lib in "$FCITX_LIBRARY_DIR"/libFcitx5GClient.so* "$FCITX_LIBRARY_DIR"/libFcitx5Utils.so*; do
cp -a "$lib" "$library_dest_dir/"
copied_library=1
done
shopt -u nullglob
if [ "$copied_library" -eq 0 ]; then
echo "No fcitx GTK client libraries found in $FCITX_LIBRARY_DIR" >&2
fi
}
download_real_plugin
patch_apprun "$@"
bundle_fcitx_gtk3_module "$@"
exec "$REAL_PLUGIN" "$@"
`
}
export async function preparePluginWrapper({
env = process.env,
toolsDir = tauriToolsCacheDir(env),
} = {}) {
await mkdir(toolsDir, { recursive: true })
const wrapperPath = join(toolsDir, APPIMAGE_PLUGIN_WRAPPER_NAME)
const realPluginPath = join(toolsDir, REAL_APPIMAGE_PLUGIN_NAME)
if (existsSync(wrapperPath) && !existsSync(realPluginPath)) {
const existing = await readFile(wrapperPath, 'utf8').catch(() => '')
if (!existing.includes(WRAPPER_MARKER)) {
await mkdir(dirname(realPluginPath), { recursive: true })
await rename(wrapperPath, realPluginPath)
}
}
await writeFile(wrapperPath, appImagePluginWrapperSource(), 'utf8')
await chmod(wrapperPath, 0o755)
return { realPluginPath, wrapperPath }
}
export async function validateAppRunFile(path) {
const text = await readFile(path, 'utf8')
assertSymlinkSafeAppRunText(text, path)
}
function extractAppImagePath(appImage, requiredPath, tempDir) {
const result = spawnSync(appImage, ['--appimage-extract', requiredPath], {
cwd: tempDir,
encoding: 'utf8',
})
if (result.status === 0) {
return
}
throw new Error(
[
`Failed to extract ${requiredPath} from ${appImage}`,
result.stdout.trim(),
result.stderr.trim(),
]
.filter(Boolean)
.join('\n'),
)
}
function assertAppImagePathsExtracted(appImage, tempDir, requiredPaths) {
for (const requiredPath of requiredPaths) {
const extractedPath = join(tempDir, 'squashfs-root', requiredPath)
if (!existsSync(extractedPath)) {
throw new Error(`${appImage} is missing ${requiredPath}`)
}
}
}
async function validateExtractedAppImage(appImage, tempDir) {
for (const requiredPath of REQUIRED_APPIMAGE_PATHS) {
extractAppImagePath(appImage, requiredPath, tempDir)
}
await validateAppRunFile(join(tempDir, 'squashfs-root', 'AppRun'))
assertAppImagePathsExtracted(appImage, tempDir, REQUIRED_APPIMAGE_PATHS.slice(1))
}
export async function validateAppImages(paths) {
if (paths.length === 0) {
throw new Error('At least one AppImage path is required for launcher validation')
}
for (const appImage of paths.map((path) => resolve(path))) {
const tempDir = await mkdtemp(join(tmpdir(), 'tolaria-appimage-'))
try {
await validateExtractedAppImage(appImage, tempDir)
} finally {
await rm(tempDir, { recursive: true, force: true })
}
}
}
async function preparePluginCommand() {
const { wrapperPath, realPluginPath } = await preparePluginWrapper()
log(`Prepared ${wrapperPath}`)
log(`Real plugin cache: ${realPluginPath}`)
}
async function validateAppRunFilesCommand(paths) {
for (const path of paths) {
await validateAppRunFile(path)
log(`Validated ${path}`)
}
}
async function validateAppImagesCommand(paths) {
await validateAppImages(paths)
for (const path of paths) {
log(`Validated AppImage launcher in ${path}`)
}
}
const COMMANDS = new Map([
['prepare-plugin', preparePluginCommand],
['validate-apprun-file', validateAppRunFilesCommand],
['validate-appimages', validateAppImagesCommand],
])
function usage() {
return 'Usage: node scripts/appimage-launcher-tools.mjs prepare-plugin | validate-apprun-file <AppRun...> | validate-appimages <AppImage...>'
}
async function main() {
const [command, ...args] = process.argv.slice(2)
const handler = COMMANDS.get(command)
if (!handler) {
throw new Error(usage())
}
await handler(args)
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
logError(error.message)
process.exit(1)
})
}

View File

@@ -0,0 +1,182 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { existsSync, realpathSync } from 'node:fs'
import {
chmod,
mkdir,
mkdtemp,
readFile,
symlink,
writeFile,
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import process from 'node:process'
import test from 'node:test'
import {
BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE,
FIXED_LINUXDEPLOY_APPRUN_DIR_LINE,
REAL_APPIMAGE_PLUGIN_NAME,
appImagePluginWrapperSource,
patchAppRunText,
preparePluginWrapper,
} from './appimage-launcher-tools.mjs'
function brokenResolverDir(invokedPath) {
return realpathSync(dirname(invokedPath))
}
function fixedResolverDir(invokedPath) {
return dirname(realpathSync(invokedPath))
}
test('patches linuxdeploy AppRun wrapper to resolve the invoked path before dirname', () => {
const original = [
'#! /usr/bin/env bash',
'# autogenerated by linuxdeploy',
BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE,
'exec "$this_dir"/AppRun.wrapped "$@"',
].join('\n')
const patched = patchAppRunText(original)
assert.equal(patched.changed, true)
assert.equal(patched.text.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE), false)
assert.equal(patched.text.includes(FIXED_LINUXDEPLOY_APPRUN_DIR_LINE), true)
})
test('fixed resolver follows absolute and relative symlinks before choosing AppDir', async () => {
const root = await mkdtemp(join(tmpdir(), 'tolaria-apprun-resolver-'))
const appDir = join(root, 'Tolaria.AppDir')
const binDir = join(root, 'bin')
const relativeDir = join(root, 'relative-bin')
const appRun = join(appDir, 'AppRun')
await mkdir(appDir)
await mkdir(binDir)
await mkdir(relativeDir)
await writeFile(appRun, '#! /usr/bin/env bash\n', 'utf8')
const absoluteSymlink = join(binDir, 'tolaria')
const relativeSymlink = join(relativeDir, 'tolaria')
await symlink(appRun, absoluteSymlink)
await symlink(`../${basename(appDir)}/AppRun`, relativeSymlink)
assert.equal(brokenResolverDir(absoluteSymlink), realpathSync(binDir))
assert.equal(fixedResolverDir(absoluteSymlink), realpathSync(appDir))
assert.equal(brokenResolverDir(relativeSymlink), realpathSync(relativeDir))
assert.equal(fixedResolverDir(relativeSymlink), realpathSync(appDir))
})
test('plugin wrapper patches AppRun before delegating to the real output plugin', async () => {
const root = await mkdtemp(join(tmpdir(), 'tolaria-appimage-plugin-'))
const appDir = join(root, 'Tolaria.AppDir')
const appRun = join(appDir, 'AppRun')
const wrapper = join(root, 'linuxdeploy-plugin-appimage.AppImage')
const realPlugin = join(root, 'linuxdeploy-plugin-appimage.real.AppImage')
const pluginMarker = join(root, 'plugin-ran')
await mkdir(appDir)
await writeFile(
appRun,
[
'#! /usr/bin/env bash',
'# autogenerated by linuxdeploy',
BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE,
'exec "$this_dir"/AppRun.wrapped "$@"',
].join('\n'),
'utf8',
)
await writeFile(wrapper, appImagePluginWrapperSource(), 'utf8')
await chmod(wrapper, 0o755)
await writeFile(
realPlugin,
`#!/usr/bin/env bash\nset -euo pipefail\ntouch "${pluginMarker}"\n`,
'utf8',
)
await chmod(realPlugin, 0o755)
const result = spawnSync(wrapper, [], {
encoding: 'utf8',
env: {
...process.env,
APPDIR: appDir,
TOLARIA_APPIMAGE_REAL_PLUGIN: realPlugin,
},
})
assert.equal(result.status, 0, result.stderr)
assert.equal(existsSync(pluginMarker), true)
const patched = await readFile(appRun, 'utf8')
assert.equal(patched.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE), false)
assert.equal(patched.includes(FIXED_LINUXDEPLOY_APPRUN_DIR_LINE), true)
})
test('plugin wrapper keeps the delegated appimage plugin basename canonical', async () => {
const root = await mkdtemp(join(tmpdir(), 'tolaria-appimage-tools-'))
const { realPluginPath, wrapperPath } = await preparePluginWrapper({
toolsDir: root,
})
assert.equal(basename(wrapperPath), 'linuxdeploy-plugin-appimage.AppImage')
assert.equal(
realPluginPath,
join(root, 'tolaria-real-linuxdeploy-plugin-appimage', 'linuxdeploy-plugin-appimage.AppImage'),
)
assert.equal(REAL_APPIMAGE_PLUGIN_NAME.endsWith('/linuxdeploy-plugin-appimage.AppImage'), true)
const wrapper = await readFile(wrapperPath, 'utf8')
assert.equal(wrapper.includes('linuxdeploy-plugin-appimage.real.AppImage'), false)
})
test('plugin wrapper bundles fcitx GTK3 input module before sealing AppImage', async () => {
const root = await mkdtemp(join(tmpdir(), 'tolaria-appimage-fcitx-'))
const appDir = join(root, 'Tolaria.AppDir')
const wrapper = join(root, 'linuxdeploy-plugin-appimage.AppImage')
const realPlugin = join(root, 'linuxdeploy-plugin-appimage.real.AppImage')
const pluginMarker = join(root, 'plugin-ran')
const hostModule = join(root, 'host', 'im-fcitx5.so')
const hostLibraryDir = join(root, 'host-lib')
const hostLibrary = join(hostLibraryDir, 'libFcitx5GClient.so.2')
await mkdir(appDir)
await mkdir(dirname(hostModule), { recursive: true })
await mkdir(hostLibraryDir)
await writeFile(hostModule, 'fake fcitx gtk module', 'utf8')
await writeFile(hostLibrary, 'fake fcitx client library', 'utf8')
await writeFile(wrapper, appImagePluginWrapperSource(), 'utf8')
await chmod(wrapper, 0o755)
await writeFile(
realPlugin,
`#!/usr/bin/env bash\nset -euo pipefail\ntouch "${pluginMarker}"\n`,
'utf8',
)
await chmod(realPlugin, 0o755)
const result = spawnSync(wrapper, [], {
encoding: 'utf8',
env: {
...process.env,
APPDIR: appDir,
TOLARIA_APPIMAGE_REAL_PLUGIN: realPlugin,
TOLARIA_FCITX_GTK3_IM_MODULE: hostModule,
TOLARIA_FCITX_LIBRARY_DIR: hostLibraryDir,
},
})
assert.equal(result.status, 0, result.stderr)
assert.equal(existsSync(pluginMarker), true)
assert.equal(
existsSync(
join(
appDir,
'usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so',
),
),
true,
)
assert.equal(existsSync(join(appDir, 'usr/lib/x86_64-linux-gnu/libFcitx5GClient.so.2')), true)
})

View File

@@ -6,7 +6,7 @@ const repoRoot = path.resolve(import.meta.dirname, '..')
const siteRoot = path.join(repoRoot, 'site')
const outputRoot = path.join(repoRoot, 'src-tauri', 'resources', 'agent-docs')
const sectionOrder = ['start', 'concepts', 'guides', 'reference', 'troubleshooting', 'download', 'releases']
const sectionOrder = ['start', 'concepts', 'guides', 'templates', 'reference', 'troubleshooting', 'download', 'releases']
const ignoredDirs = new Set(['.vitepress', 'public', 'node_modules', '.DS_Store'])
function titleFromSlug(slug) {
@@ -90,6 +90,8 @@ function buildIndex(docs) {
'',
'Start here, then use `rg` over this folder for specific Tolaria concepts and workflows.',
'',
'When users ask how to improve a knowledge base, structure notes, choose types, model relationships, or make a vault easier for humans and agents to use, treat Portent as Tolaria\'s default best-practice model. Read `pages/templates/portent.md` and combine it with the Tolaria concepts for types, relationships, properties, Inbox, archive, and custom views.',
'',
]
for (const section of ['home', ...sectionOrder]) {
@@ -120,11 +122,13 @@ This folder contains local, generated Tolaria product docs for AI agents.
Use these docs when a user asks how Tolaria works, when you need product behavior, or before making Tolaria-specific assumptions.
Portent is the default best-practice model for structuring Tolaria knowledge bases. When a user asks how to improve a knowledge base, make it better organized, choose better types, model relationships, or make the vault easier for humans and agents to use, read \`pages/templates/portent.md\` and combine it with Tolaria's docs for types, relationships, properties, Inbox, archive, and custom views.
Recommended lookup flow:
1. Read the active vault's AGENTS.md for vault-specific conventions.
2. Read this folder's index.md for the docs map.
3. Use \`rg\` over this folder for advanced concepts, workflows, shortcuts, Git, AutoGit, AI, types, properties, relationships, and troubleshooting.
3. Use \`rg\` over this folder for advanced concepts, workflows, shortcuts, Git, AutoGit, AI, Portent, types, properties, relationships, and troubleshooting.
Vault-specific AGENTS.md wins for local conventions. These bundled docs win for Tolaria product behavior.
`

View File

@@ -17,12 +17,16 @@ const hasMaxWorkersOverride = forwardedArgs.some((arg) =>
)
const maxAttempts = 2
// Standalone pnpm installs ship a native binary, so npm_execpath points at
// a Mach-O/ELF executable. Only reuse process.execPath (node) when the path
// is something node can actually load as a module.
const packageManagerExec = process.env.npm_execpath
const command = packageManagerExec ? process.execPath : 'pnpm'
const baseCommandArgs = packageManagerExec
const isJsExecpath = packageManagerExec && /\.[mc]?js$/i.test(packageManagerExec)
const command = isJsExecpath ? process.execPath : 'pnpm'
const baseCommandArgs = isJsExecpath
? [packageManagerExec, 'exec', 'vitest', 'run', '--coverage']
: ['exec', 'vitest', 'run', '--coverage']
const clearCacheCommandArgs = packageManagerExec
const clearCacheCommandArgs = isJsExecpath
? [packageManagerExec, 'exec', 'vitest', '--clearCache']
: ['exec', 'vitest', '--clearCache']

View File

@@ -27,6 +27,7 @@ export default defineConfig({
{ text: "Start", link: "/start/install" },
{ text: "Concepts", link: "/concepts/vaults" },
{ text: "Guides", link: "/guides/capture-a-note" },
{ text: "Templates", link: "/templates/portent" },
{ text: "Downloads", link: "https://tolaria.md/download/", target: "_self", noIcon: true },
],
search: {
@@ -75,6 +76,12 @@ export default defineConfig({
{ text: "Use The Command Palette", link: "/guides/use-command-palette" },
],
},
{
text: "Templates",
items: [
{ text: "Portent", link: "/templates/portent" },
],
},
{
text: "Reference",
items: [

View File

@@ -6,7 +6,7 @@ Tolaria is a desktop app built with Tauri. Releases currently target macOS, Wind
| --- | --- | --- |
| macOS | Primary | Main development and QA target. Apple Silicon and Intel artifacts are published. |
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Menu, shell-path, and credential-helper behavior receive platform-specific fixes as they appear. |
| Linux | Supported, early | AppImage and deb artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
## Support Policy

View File

@@ -24,7 +24,7 @@ brew install --cask tolaria
| --- | --- | --- |
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
| Linux | Supported, early | AppImage and deb artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
See [Supported Platforms](/reference/supported-platforms) for the current support policy.

72
site/templates/portent.md Normal file
View File

@@ -0,0 +1,72 @@
# Portent
[Portent](https://portent.md) is an open specification and template for work and personal knowledge bases.
It gives a Tolaria vault a small set of defaults for organizing information: clear types, generic graph-like relationships, and a simple lifecycle for captured knowledge. The goal is to make a knowledge base useful to humans and AI agents without forcing every person or team to design a private ontology first.
## Core Questions
Portent favors convention over configuration. Instead of asking "where should this go?", it asks:
- What is this?
- What is it useful for?
- Is it captured, organized, or archived?
Those questions map naturally to Tolaria's type documents, relationship fields, Inbox, organized state, archive behavior, and custom views.
## Types
Portent defines eight default types.
PORT types are actionable:
- Project
- Operation
- Responsibility
- Task
ENTP types are non-actionable knowledge records:
- Event
- Note
- Topic
- Person
These defaults are meant to cover the common shape of personal and work knowledge with almost no setup. You can add custom types later, but Portent works best when the default vocabulary comes first.
## Relationships
Portent models knowledge as a graph. The two default relationships are:
- `belongs_to`: primary ownership, composition, or context.
- `related_to`: a looser semantic connection.
In Tolaria, these relationships can live in YAML frontmatter and point to other notes with wikilinks. That keeps the graph portable, searchable, and readable outside the app.
## Lifecycle
Portent separates capture from organization:
1. Capture information quickly so it is not lost.
2. Organize it by assigning a type and useful relationships.
3. Archive it when it has served its purpose.
Tolaria supports that lifecycle directly: the Inbox holds captured notes, organizing a note marks it ready for normal views, and archiving hides old or obsolete notes from active surfaces while keeping them available.
## Why Use It
A blank vault is flexible, but it also asks you to make structural decisions before you have momentum. Portent gives you enough structure to start capturing, organizing, and retrieving notes immediately.
Because Portent is file-friendly and portable, the same model can work across local Markdown vaults, note apps, docs tools, and agent-readable knowledge bases. Tolaria is the first intended implementation, but the spec is not tied to Tolaria internals.
## Start From The Template
The fastest starting point is the Portent template vault:
- [refactoringhq/portent-vault-template](https://github.com/refactoringhq/portent-vault-template)
Use it as-is, rename pieces to match your language, or treat it as a reference model for your own Tolaria setup.
## Learn More
Visit [portent.md](https://portent.md) for the full spec, examples, and implementation notes.

60
src-tauri/Cargo.lock generated
View File

@@ -470,6 +470,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -1934,7 +1940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
dependencies = [
"byteorder",
"png",
"png 0.17.16",
]
[[package]]
@@ -2051,6 +2057,19 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2469,6 +2488,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.17.1"
@@ -2484,7 +2513,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-foundation",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
@@ -3250,6 +3279,19 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.11.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -3392,6 +3434,12 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -4742,6 +4790,7 @@ dependencies = [
"heck 0.5.0",
"http",
"http-range",
"image",
"jni",
"libc",
"log",
@@ -4809,7 +4858,7 @@ dependencies = [
"ico",
"json-patch",
"plist",
"png",
"png 0.17.16",
"proc-macro2",
"quote",
"semver",
@@ -5290,6 +5339,9 @@ dependencies = [
"gray_matter",
"log",
"notify",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"regex",
"reqwest 0.12.28",
"sentry",
@@ -5517,7 +5569,7 @@ dependencies = [
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",

View File

@@ -21,7 +21,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
log = "0.4"
notify = "6.1"
tauri = { version = "2.10.0", features = ["protocol-asset", "devtools"] }
tauri = { version = "2.10.0", features = ["protocol-asset", "devtools", "image-png"] }
tauri-plugin-log = "2"
gray_matter = "0.2"
walkdir = "2"
@@ -41,4 +41,9 @@ uuid = { version = "1", features = ["v4"] }
tempfile = "3"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSImage"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSData", "NSThread"] }
[dev-dependencies]

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -0,0 +1,41 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_110_75)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M924 356.627C924 346.845 924.004 337.062 923.944 327.279C923.895 319.038 923.8 310.799 923.576 302.562C923.092 284.609 922.033 266.502 918.84 248.749C915.602 230.741 910.314 213.98 901.981 197.617C893.789 181.534 883.088 166.817 870.32 154.058C857.555 141.299 842.834 130.605 826.746 122.418C810.366 114.083 793.587 108.797 775.558 105.56C757.803 102.372 739.691 101.315 721.738 100.83C713.495 100.607 705.253 100.513 697.008 100.462C687.22 100.402 677.432 100.407 667.644 100.407L553.997 100H468.997L357.361 100.407C347.554 100.407 337.747 100.402 327.94 100.462C319.678 100.513 311.42 100.607 303.161 100.83C285.167 101.315 267.014 102.373 249.217 105.565C231.164 108.801 214.36 114.085 197.958 122.414C181.835 130.602 167.083 141.297 154.291 154.058C141.501 166.816 130.78 181.53 122.573 197.61C114.217 213.981 108.919 230.752 105.673 248.77C102.477 266.516 101.418 284.617 100.931 302.562C100.709 310.8 100.613 319.039 100.563 327.279C100.503 337.063 100 349.216 100 358.999L100.003 469.089L100 554.998L100.508 667.427C100.508 677.223 100.504 687.019 100.563 696.815C100.613 705.067 100.709 713.317 100.932 721.566C101.418 739.542 102.479 757.675 105.678 775.452C108.923 793.484 114.22 810.269 122.569 826.653C130.777 842.759 141.5 857.495 154.291 870.272C167.082 883.049 181.83 893.757 197.95 901.956C214.362 910.302 231.174 915.595 249.238 918.836C267.027 922.029 285.174 923.088 303.161 923.573C311.42 923.796 319.679 923.891 327.941 923.941C337.748 924.001 347.554 923.997 357.361 923.997L470.006 924H555.217L667.644 923.996C677.432 923.996 687.22 924.001 697.008 923.941C705.253 923.891 713.495 923.796 721.738 923.573C739.698 923.087 757.816 922.027 775.579 918.832C793.597 915.591 810.368 910.3 826.739 901.959C842.831 893.761 857.554 883.051 870.32 870.272C883.086 857.497 893.786 842.763 901.978 826.66C910.316 810.268 915.604 793.475 918.844 775.431C922.034 757.661 923.092 739.535 923.577 721.566C923.8 713.316 923.895 705.066 923.944 696.815C924.005 687.019 924 677.223 924 667.427C924 667.427 923.994 556.983 923.994 554.998V468.999C923.994 467.533 924 356.627 924 356.627Z" fill="#151923"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M924 356.627C924 346.845 924.004 337.062 923.944 327.279C923.895 319.038 923.8 310.799 923.576 302.562C923.092 284.609 922.033 266.502 918.84 248.749C915.602 230.741 910.314 213.98 901.981 197.617C893.789 181.534 883.088 166.817 870.32 154.058C857.555 141.299 842.834 130.605 826.746 122.418C810.366 114.083 793.587 108.797 775.558 105.56C757.803 102.372 739.691 101.315 721.738 100.83C713.495 100.607 705.253 100.513 697.008 100.462C687.22 100.402 677.432 100.407 667.644 100.407L553.997 100H468.997L357.361 100.407C347.554 100.407 337.747 100.402 327.94 100.462C319.678 100.513 311.42 100.607 303.161 100.83C285.167 101.315 267.014 102.373 249.217 105.565C231.164 108.801 214.36 114.085 197.958 122.414C181.835 130.602 167.083 141.297 154.291 154.058C141.501 166.816 130.78 181.53 122.573 197.61C114.217 213.981 108.919 230.752 105.673 248.77C102.477 266.516 101.418 284.617 100.931 302.562C100.709 310.8 100.613 319.039 100.563 327.279C100.503 337.063 100 349.216 100 358.999L100.003 469.089L100 554.998L100.508 667.427C100.508 677.223 100.504 687.019 100.563 696.815C100.613 705.067 100.709 713.317 100.932 721.566C101.418 739.542 102.479 757.675 105.678 775.452C108.923 793.484 114.22 810.269 122.569 826.653C130.777 842.759 141.5 857.495 154.291 870.272C167.082 883.049 181.83 893.757 197.95 901.956C214.362 910.302 231.174 915.595 249.238 918.836C267.027 922.029 285.174 923.088 303.161 923.573C311.42 923.796 319.679 923.891 327.941 923.941C337.748 924.001 347.554 923.997 357.361 923.997L470.006 924H555.217L667.644 923.996C677.432 923.996 687.22 924.001 697.008 923.941C705.253 923.891 713.495 923.796 721.738 923.573C739.698 923.087 757.816 922.027 775.579 918.832C793.597 915.591 810.368 910.3 826.739 901.959C842.831 893.761 857.554 883.051 870.32 870.272C883.086 857.497 893.786 842.763 901.978 826.66C910.316 810.268 915.604 793.475 918.844 775.431C922.034 757.661 923.092 739.535 923.577 721.566C923.8 713.316 923.895 705.066 923.944 696.815C924.005 687.019 924 677.223 924 667.427C924 667.427 923.994 556.983 923.994 554.998V468.999C923.994 467.533 924 356.627 924 356.627Z" fill="url(#paint0_linear_110_75)" fill-opacity="0.42"/>
</g>
<mask id="mask0_110_75" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="100" y="100" width="824" height="824">
<path fill-rule="evenodd" clip-rule="evenodd" d="M924 356.627C924 346.845 924.004 337.062 923.944 327.279C923.895 319.038 923.8 310.799 923.576 302.562C923.092 284.609 922.033 266.502 918.84 248.749C915.602 230.741 910.314 213.98 901.981 197.617C893.789 181.534 883.088 166.817 870.32 154.058C857.555 141.299 842.834 130.605 826.746 122.418C810.366 114.083 793.587 108.797 775.558 105.56C757.803 102.372 739.691 101.315 721.738 100.83C713.495 100.607 705.253 100.513 697.008 100.462C687.22 100.402 677.432 100.407 667.644 100.407L553.997 100H468.997L357.361 100.407C347.554 100.407 337.747 100.402 327.94 100.462C319.678 100.513 311.42 100.607 303.161 100.83C285.167 101.315 267.014 102.373 249.217 105.565C231.164 108.801 214.36 114.085 197.958 122.414C181.835 130.602 167.083 141.297 154.291 154.058C141.501 166.816 130.78 181.53 122.573 197.61C114.217 213.981 108.919 230.752 105.673 248.77C102.477 266.516 101.418 284.617 100.931 302.562C100.709 310.8 100.613 319.039 100.563 327.279C100.503 337.063 100 349.216 100 358.999L100.003 469.089L100 554.998L100.508 667.427C100.508 677.223 100.504 687.019 100.563 696.815C100.613 705.067 100.709 713.317 100.932 721.566C101.418 739.542 102.479 757.675 105.678 775.452C108.923 793.484 114.22 810.269 122.569 826.653C130.777 842.759 141.5 857.495 154.291 870.272C167.082 883.049 181.83 893.757 197.95 901.956C214.362 910.302 231.174 915.595 249.238 918.836C267.027 922.029 285.174 923.088 303.161 923.573C311.42 923.796 319.679 923.891 327.941 923.941C337.748 924.001 347.554 923.997 357.361 923.997L470.006 924H555.217L667.644 923.996C677.432 923.996 687.22 924.001 697.008 923.941C705.253 923.891 713.495 923.796 721.738 923.573C739.698 923.087 757.816 922.027 775.579 918.832C793.597 915.591 810.368 910.3 826.739 901.959C842.831 893.761 857.554 883.051 870.32 870.272C883.086 857.497 893.786 842.763 901.978 826.66C910.316 810.268 915.604 793.475 918.844 775.431C922.034 757.661 923.092 739.535 923.577 721.566C923.8 713.316 923.895 705.066 923.944 696.815C924.005 687.019 924 677.223 924 667.427C924 667.427 923.994 556.983 923.994 554.998V468.999C923.994 467.533 924 356.627 924 356.627Z" fill="white"/>
</mask>
<g mask="url(#mask0_110_75)">
</g>
<g opacity="0.4" filter="url(#filter1_f_110_75)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M491.45 246.005C502.143 235.332 519.52 235.332 530.213 246.005C581.007 299.371 712 460.802 712 584.878C712 695.612 622.443 785 511.5 785C400.557 785 311 695.612 311 584.878C311 460.802 441.993 299.371 491.45 246.005ZM400.972 603.993C398.809 591.733 387.083 583.189 374.74 584.878C362.397 586.568 354.168 597.843 356.331 610.103C367.894 675.638 420.114 728.676 486.27 742.031C488.878 742.56 491.491 742.603 493.947 742.267C503.142 741.008 510.721 734.157 512.354 724.625C514.445 712.509 506.134 700.696 493.807 698.205C446.539 688.645 409.226 650.772 400.972 603.993Z" fill="#155DFF"/>
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M400.972 603.993C398.809 591.733 387.083 583.189 374.74 584.878C362.397 586.568 354.168 597.843 356.331 610.103C367.894 675.638 420.114 728.676 486.27 742.031C488.878 742.56 491.491 742.603 493.947 742.267C503.142 741.008 510.721 734.157 512.354 724.625C514.445 712.509 506.134 700.696 493.807 698.205C446.539 688.645 409.226 650.772 400.972 603.993Z" fill="#C4E4FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M491.45 246.005C502.143 235.332 519.52 235.332 530.213 246.005C581.007 299.371 712 460.802 712 584.878C712 695.612 622.443 785 511.5 785C400.557 785 311 695.612 311 584.878C311 460.802 441.993 299.371 491.45 246.005ZM400.972 603.993C398.809 591.733 387.083 583.189 374.74 584.878C362.397 586.568 354.168 597.843 356.331 610.103C367.894 675.638 420.114 728.676 486.27 742.031C488.878 742.56 491.491 742.603 493.947 742.267C503.142 741.008 510.721 734.157 512.354 724.625C514.445 712.509 506.134 700.696 493.807 698.205C446.539 688.645 409.226 650.772 400.972 603.993Z" fill="#155DFF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M491.45 246.005C502.143 235.332 519.52 235.332 530.213 246.005C581.007 299.371 712 460.802 712 584.878C712 695.612 622.443 785 511.5 785C400.557 785 311 695.612 311 584.878C311 460.802 441.993 299.371 491.45 246.005ZM400.972 603.993C398.809 591.733 387.083 583.189 374.74 584.878C362.397 586.568 354.168 597.843 356.331 610.103C367.894 675.638 420.114 728.676 486.27 742.031C488.878 742.56 491.491 742.603 493.947 742.267C503.142 741.008 510.721 734.157 512.354 724.625C514.445 712.509 506.134 700.696 493.807 698.205C446.539 688.645 409.226 650.772 400.972 603.993Z" fill="url(#paint1_linear_110_75)"/>
<defs>
<filter id="filter0_d_110_75" x="75" y="80" width="884" height="884" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="5" dy="10"/>
<feGaussianBlur stdDeviation="15"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_110_75"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_110_75" result="shape"/>
</filter>
<filter id="filter1_f_110_75" x="281" y="208" width="461" height="607" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="15" result="effect1_foregroundBlur_110_75"/>
</filter>
<linearGradient id="paint0_linear_110_75" x1="233" y1="138.5" x2="784.5" y2="1320.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#2D3546"/>
<stop offset="1" stop-color="#070A10"/>
</linearGradient>
<linearGradient id="paint1_linear_110_75" x1="126" y1="-35.5" x2="723" y2="744" gradientUnits="userSpaceOnUse">
<stop stop-color="#00AEFF"/>
<stop offset="1" stop-color="#155DFF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -4,10 +4,12 @@ This folder contains local, generated Tolaria product docs for AI agents.
Use these docs when a user asks how Tolaria works, when you need product behavior, or before making Tolaria-specific assumptions.
Portent is the default best-practice model for structuring Tolaria knowledge bases. When a user asks how to improve a knowledge base, make it better organized, choose better types, model relationships, or make the vault easier for humans and agents to use, read `pages/templates/portent.md` and combine it with Tolaria's docs for types, relationships, properties, Inbox, archive, and custom views.
Recommended lookup flow:
1. Read the active vault's AGENTS.md for vault-specific conventions.
2. Read this folder's index.md for the docs map.
3. Use `rg` over this folder for advanced concepts, workflows, shortcuts, Git, AutoGit, AI, types, properties, relationships, and troubleshooting.
3. Use `rg` over this folder for advanced concepts, workflows, shortcuts, Git, AutoGit, AI, Portent, types, properties, relationships, and troubleshooting.
Vault-specific AGENTS.md wins for local conventions. These bundled docs win for Tolaria product behavior.

View File

@@ -121,7 +121,7 @@ brew install --cask tolaria
| --- | --- | --- |
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
| Linux | Supported, early | AppImage and deb artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
@@ -1023,6 +1023,86 @@ Prefer clear note titles and filenames. Tolaria's wikilink autocomplete helps yo
---
# Portent
Source: templates/portent.md
URL: /templates/portent
# Portent
[Portent](https://portent.md) is an open specification and template for work and personal knowledge bases.
It gives a Tolaria vault a small set of defaults for organizing information: clear types, generic graph-like relationships, and a simple lifecycle for captured knowledge. The goal is to make a knowledge base useful to humans and AI agents without forcing every person or team to design a private ontology first.
## Core Questions
Portent favors convention over configuration. Instead of asking "where should this go?", it asks:
- What is this?
- What is it useful for?
- Is it captured, organized, or archived?
Those questions map naturally to Tolaria's type documents, relationship fields, Inbox, organized state, archive behavior, and custom views.
## Types
Portent defines eight default types.
PORT types are actionable:
- Project
- Operation
- Responsibility
- Task
ENTP types are non-actionable knowledge records:
- Event
- Note
- Topic
- Person
These defaults are meant to cover the common shape of personal and work knowledge with almost no setup. You can add custom types later, but Portent works best when the default vocabulary comes first.
## Relationships
Portent models knowledge as a graph. The two default relationships are:
- `belongs_to`: primary ownership, composition, or context.
- `related_to`: a looser semantic connection.
In Tolaria, these relationships can live in YAML frontmatter and point to other notes with wikilinks. That keeps the graph portable, searchable, and readable outside the app.
## Lifecycle
Portent separates capture from organization:
1. Capture information quickly so it is not lost.
2. Organize it by assigning a type and useful relationships.
3. Archive it when it has served its purpose.
Tolaria supports that lifecycle directly: the Inbox holds captured notes, organizing a note marks it ready for normal views, and archiving hides old or obsolete notes from active surfaces while keeping them available.
## Why Use It
A blank vault is flexible, but it also asks you to make structural decisions before you have momentum. Portent gives you enough structure to start capturing, organizing, and retrieving notes immediately.
Because Portent is file-friendly and portable, the same model can work across local Markdown vaults, note apps, docs tools, and agent-readable knowledge bases. Tolaria is the first intended implementation, but the spec is not tied to Tolaria internals.
## Start From The Template
The fastest starting point is the Portent template vault:
- [refactoringhq/portent-vault-template](https://github.com/refactoringhq/portent-vault-template)
Use it as-is, rename pieces to match your language, or treat it as a reference model for your own Tolaria setup.
## Learn More
Visit [portent.md](https://portent.md) for the full spec, examples, and implementation notes.
---
# Contribute
Source: reference/contribute.md
@@ -1272,7 +1352,7 @@ Tolaria is a desktop app built with Tauri. Releases currently target macOS, Wind
| --- | --- | --- |
| macOS | Primary | Main development and QA target. Apple Silicon and Intel artifacts are published. |
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Menu, shell-path, and credential-helper behavior receive platform-specific fixes as they appear. |
| Linux | Supported, early | AppImage and deb artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
## Support Policy

View File

@@ -4,6 +4,8 @@ These docs are generated from the public Tolaria documentation for local AI agen
Start here, then use `rg` over this folder for specific Tolaria concepts and workflows.
When users ask how to improve a knowledge base, structure notes, choose types, model relationships, or make a vault easier for humans and agents to use, treat Portent as Tolaria's default best-practice model. Read `pages/templates/portent.md` and combine it with the Tolaria concepts for types, relationships, properties, Inbox, archive, and custom views.
## Home
- [Index](pages/index.md)
@@ -44,6 +46,10 @@ Start here, then use `rg` over this folder for specific Tolaria concepts and wor
- [Use The Table Of Contents](pages/guides/use-table-of-contents.md)
- [Use Wikilinks](pages/guides/use-wikilinks.md)
## Templates
- [Portent](pages/templates/portent.md)
## Reference
- [Contribute](pages/reference/contribute.md)

View File

@@ -11,7 +11,7 @@ Tolaria is a desktop app built with Tauri. Releases currently target macOS, Wind
| --- | --- | --- |
| macOS | Primary | Main development and QA target. Apple Silicon and Intel artifacts are published. |
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Menu, shell-path, and credential-helper behavior receive platform-specific fixes as they appear. |
| Linux | Supported, early | AppImage and deb artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
## Support Policy

View File

@@ -29,7 +29,7 @@ brew install --cask tolaria
| --- | --- | --- |
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
| Linux | Supported, early | AppImage and deb artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
See [Supported Platforms](/reference/supported-platforms) for the current support policy.

View File

@@ -0,0 +1,77 @@
# Portent
Source: templates/portent.md
URL: /templates/portent
# Portent
[Portent](https://portent.md) is an open specification and template for work and personal knowledge bases.
It gives a Tolaria vault a small set of defaults for organizing information: clear types, generic graph-like relationships, and a simple lifecycle for captured knowledge. The goal is to make a knowledge base useful to humans and AI agents without forcing every person or team to design a private ontology first.
## Core Questions
Portent favors convention over configuration. Instead of asking "where should this go?", it asks:
- What is this?
- What is it useful for?
- Is it captured, organized, or archived?
Those questions map naturally to Tolaria's type documents, relationship fields, Inbox, organized state, archive behavior, and custom views.
## Types
Portent defines eight default types.
PORT types are actionable:
- Project
- Operation
- Responsibility
- Task
ENTP types are non-actionable knowledge records:
- Event
- Note
- Topic
- Person
These defaults are meant to cover the common shape of personal and work knowledge with almost no setup. You can add custom types later, but Portent works best when the default vocabulary comes first.
## Relationships
Portent models knowledge as a graph. The two default relationships are:
- `belongs_to`: primary ownership, composition, or context.
- `related_to`: a looser semantic connection.
In Tolaria, these relationships can live in YAML frontmatter and point to other notes with wikilinks. That keeps the graph portable, searchable, and readable outside the app.
## Lifecycle
Portent separates capture from organization:
1. Capture information quickly so it is not lost.
2. Organize it by assigning a type and useful relationships.
3. Archive it when it has served its purpose.
Tolaria supports that lifecycle directly: the Inbox holds captured notes, organizing a note marks it ready for normal views, and archiving hides old or obsolete notes from active surfaces while keeping them available.
## Why Use It
A blank vault is flexible, but it also asks you to make structural decisions before you have momentum. Portent gives you enough structure to start capturing, organizing, and retrieving notes immediately.
Because Portent is file-friendly and portable, the same model can work across local Markdown vaults, note apps, docs tools, and agent-readable knowledge bases. Tolaria is the first intended implementation, but the spec is not tied to Tolaria internals.
## Start From The Template
The fastest starting point is the Portent template vault:
- [refactoringhq/portent-vault-template](https://github.com/refactoringhq/portent-vault-template)
Use it as-is, rename pieces to match your language, or treat it as a reference model for your own Tolaria setup.
## Learn More
Visit [portent.md](https://portent.md) for the full spec, examples, and implementation notes.

View File

@@ -247,7 +247,7 @@ Tolaria is a desktop app built with Tauri. Releases currently target macOS, Wind
| --- | --- | --- |
| macOS | Primary | Main development and QA target. Apple Silicon and Intel artifacts are published. |
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Menu, shell-path, and credential-helper behavior receive platform-specific fixes as they appear. |
| Linux | Supported, early | AppImage and deb artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
## Support Policy

View File

@@ -323,6 +323,21 @@
"Keep Links Stable"
]
},
{
"title": "Portent",
"path": "pages/templates/portent.md",
"url": "/templates/portent",
"section": "templates",
"headings": [
"Core Questions",
"Types",
"Relationships",
"Lifecycle",
"Why Use It",
"Start From The Template",
"Learn More"
]
},
{
"title": "Contribute",
"path": "pages/reference/contribute.md",

View File

@@ -112,7 +112,7 @@ brew install --cask tolaria
| --- | --- | --- |
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
| Linux | Supported, early | AppImage and deb artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
See [Supported Platforms](/reference/supported-platforms) for the current support policy.

View File

@@ -0,0 +1,77 @@
# Portent
Source: templates/portent.md
URL: /templates/portent
# Portent
[Portent](https://portent.md) is an open specification and template for work and personal knowledge bases.
It gives a Tolaria vault a small set of defaults for organizing information: clear types, generic graph-like relationships, and a simple lifecycle for captured knowledge. The goal is to make a knowledge base useful to humans and AI agents without forcing every person or team to design a private ontology first.
## Core Questions
Portent favors convention over configuration. Instead of asking "where should this go?", it asks:
- What is this?
- What is it useful for?
- Is it captured, organized, or archived?
Those questions map naturally to Tolaria's type documents, relationship fields, Inbox, organized state, archive behavior, and custom views.
## Types
Portent defines eight default types.
PORT types are actionable:
- Project
- Operation
- Responsibility
- Task
ENTP types are non-actionable knowledge records:
- Event
- Note
- Topic
- Person
These defaults are meant to cover the common shape of personal and work knowledge with almost no setup. You can add custom types later, but Portent works best when the default vocabulary comes first.
## Relationships
Portent models knowledge as a graph. The two default relationships are:
- `belongs_to`: primary ownership, composition, or context.
- `related_to`: a looser semantic connection.
In Tolaria, these relationships can live in YAML frontmatter and point to other notes with wikilinks. That keeps the graph portable, searchable, and readable outside the app.
## Lifecycle
Portent separates capture from organization:
1. Capture information quickly so it is not lost.
2. Organize it by assigning a type and useful relationships.
3. Archive it when it has served its purpose.
Tolaria supports that lifecycle directly: the Inbox holds captured notes, organizing a note marks it ready for normal views, and archiving hides old or obsolete notes from active surfaces while keeping them available.
## Why Use It
A blank vault is flexible, but it also asks you to make structural decisions before you have momentum. Portent gives you enough structure to start capturing, organizing, and retrieving notes immediately.
Because Portent is file-friendly and portable, the same model can work across local Markdown vaults, note apps, docs tools, and agent-readable knowledge bases. Tolaria is the first intended implementation, but the spec is not tied to Tolaria internals.
## Start From The Template
The fastest starting point is the Portent template vault:
- [refactoringhq/portent-vault-template](https://github.com/refactoringhq/portent-vault-template)
Use it as-is, rename pieces to match your language, or treat it as a reference model for your own Tolaria setup.
## Learn More
Visit [portent.md](https://portent.md) for the full spec, examples, and implementation notes.

View File

@@ -8,6 +8,7 @@ pub enum AiAgentId {
Opencode,
Pi,
Gemini,
Kiro,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
@@ -31,6 +32,7 @@ pub struct AiAgentsStatus {
pub opencode: AiAgentAvailability,
pub pi: AiAgentAvailability,
pub gemini: AiAgentAvailability,
pub kiro: AiAgentAvailability,
}
#[derive(Debug, Clone, Serialize)]
@@ -86,6 +88,7 @@ pub fn get_ai_agents_status() -> AiAgentsStatus {
opencode: crate::opencode_cli::check_cli(),
pi: crate::pi_cli::check_cli(),
gemini: crate::gemini_cli::check_cli(),
kiro: crate::kiro_cli::check_cli(),
}
}
@@ -149,9 +152,28 @@ where
};
crate::gemini_cli::run_agent_stream(mapped, emit)
}
AiAgentId::Kiro => run_kiro_agent_stream(request, permission_mode, emit),
}
}
fn run_kiro_agent_stream<F>(
request: AiAgentStreamRequest,
permission_mode: AiAgentPermissionMode,
emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let mapped = crate::cli_agent_runtime::AgentStreamRequest {
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
vault_paths: request.vault_paths,
permission_mode,
};
crate::kiro_cli::run_agent_stream(mapped, emit)
}
fn availability_from_claude() -> AiAgentAvailability {
let status = crate::claude_cli::check_cli();
AiAgentAvailability {

97
src-tauri/src/app_icon.rs Normal file
View File

@@ -0,0 +1,97 @@
use tauri::Manager;
const LIGHT_ICON_BYTES: &[u8] = include_bytes!("../icons/512x512.png");
const DARK_ICON_BYTES: &[u8] = include_bytes!("../icons/512x512-dark.png");
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AppIconMode {
Light,
Dark,
}
impl AppIconMode {
fn parse(value: &str) -> Result<Self, String> {
match value {
"light" => Ok(Self::Light),
"dark" => Ok(Self::Dark),
_ => Err(format!("Unsupported app icon theme mode: {value}")),
}
}
fn png_bytes(self) -> &'static [u8] {
match self {
Self::Light => LIGHT_ICON_BYTES,
Self::Dark => DARK_ICON_BYTES,
}
}
}
pub fn update_app_icon_for_theme(
app_handle: &tauri::AppHandle,
theme_mode: &str,
) -> Result<(), String> {
let icon_bytes = AppIconMode::parse(theme_mode)?.png_bytes();
let image = tauri::image::Image::from_bytes(icon_bytes)
.map_err(|err| format!("Failed to decode app icon: {err}"))?;
for window in app_handle.webview_windows().into_values() {
window
.set_icon(image.clone())
.map_err(|err| format!("Failed to update window icon: {err}"))?;
}
apply_platform_app_icon(app_handle, icon_bytes)
}
#[cfg(target_os = "macos")]
fn apply_platform_app_icon(
app_handle: &tauri::AppHandle,
icon_bytes: &'static [u8],
) -> Result<(), String> {
app_handle
.run_on_main_thread(move || {
if let Err(err) = set_macos_application_icon(icon_bytes) {
log::warn!("Failed to update macOS application icon: {err}");
}
})
.map_err(|err| format!("Failed to schedule macOS app icon update: {err}"))
}
#[cfg(target_os = "macos")]
fn set_macos_application_icon(icon_bytes: &[u8]) -> Result<(), String> {
use objc2::AllocAnyThread;
use objc2_app_kit::{NSApplication, NSImage};
use objc2_foundation::{MainThreadMarker, NSData};
let marker = unsafe { MainThreadMarker::new_unchecked() };
let app = NSApplication::sharedApplication(marker);
let data = NSData::with_bytes(icon_bytes);
let app_icon = NSImage::initWithData(NSImage::alloc(), &data)
.ok_or_else(|| "Failed to create macOS app icon image".to_string())?;
unsafe { app.setApplicationIconImage(Some(&app_icon)) };
Ok(())
}
#[cfg(not(target_os = "macos"))]
fn apply_platform_app_icon(
_app_handle: &tauri::AppHandle,
_icon_bytes: &'static [u8],
) -> Result<(), String> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::AppIconMode;
#[test]
fn parses_supported_icon_modes() {
assert_eq!(AppIconMode::parse("light"), Ok(AppIconMode::Light));
assert_eq!(AppIconMode::parse("dark"), Ok(AppIconMode::Dark));
}
#[test]
fn rejects_unknown_icon_modes() {
assert!(AppIconMode::parse("system").is_err());
}
}

View File

@@ -118,20 +118,33 @@ fn claude_path_from_shell(shell: &Path) -> Option<PathBuf> {
}
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
if !output.status.success() {
return None;
if output.status.success() {
first_existing_path(&String::from_utf8_lossy(&output.stdout))
} else {
None
}
}
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
first_existing_path_for_platform(stdout, cfg!(windows))
}
fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option<PathBuf> {
let mut paths = stdout.lines().filter_map(existing_path);
if windows {
return paths.find(|path| crate::cli_agent_runtime::has_windows_cli_extension(path));
}
String::from_utf8_lossy(&output.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)
})
paths.next()
}
fn existing_path(line: &str) -> Option<PathBuf> {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
}
fn claude_binary_candidates() -> Vec<PathBuf> {
@@ -710,6 +723,22 @@ mod tests {
assert!(candidates.contains(&claude), "missing {}", claude.display());
}
#[test]
fn windows_path_lookup_prefers_cmd_shim_over_extensionless_npm_script() {
let dir = tempfile::tempdir().unwrap();
let shell_script = dir.path().join("claude");
let cmd_shim = dir.path().join("claude.cmd");
std::fs::write(&shell_script, "#!/bin/sh\n").unwrap();
std::fs::write(&cmd_shim, "@ECHO off\n").unwrap();
let stdout = format!("{}\n{}\n", shell_script.display(), cmd_shim.display());
assert_eq!(
first_existing_path_for_platform(&stdout, true),
Some(cmd_shim)
);
}
#[test]
fn unsupported_claude_flag_errors_are_detected() {
assert!(is_unsupported_claude_flag_error(ClaudeStderr(

View File

@@ -455,6 +455,97 @@ where
Ok(run.session_id)
}
/// Shared binary discovery: look up a CLI command by name using PATH, login shell, then candidates.
pub(crate) fn find_cli_binary(
name: &str,
candidates: Vec<PathBuf>,
label: &str,
install_hint: &str,
) -> Result<PathBuf, String> {
if let Some(binary) = find_binary_on_path(name) {
return Ok(binary);
}
if let Some(binary) = find_binary_in_user_shell(name) {
return Ok(binary);
}
if let Some(binary) = find_executable_binary_candidate(candidates, label)? {
return Ok(binary);
}
Err(format!("{label} not found. Install it: {install_hint}"))
}
fn find_binary_on_path(name: &str) -> Option<PathBuf> {
let cmd = if cfg!(windows) { "where" } else { "which" };
crate::hidden_command(cmd)
.arg(name)
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn find_binary_in_user_shell(name: &str) -> Option<PathBuf> {
shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, name))
}
fn 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
}
}
pub(crate) 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)
})
}
/// Shared check_cli pattern for CLI agents.
pub(crate) fn check_cli_availability(
find_binary: impl FnOnce() -> Result<PathBuf, String>,
) -> crate::ai_agents::AiAgentAvailability {
match find_binary() {
Ok(binary) => crate::ai_agents::AiAgentAvailability {
installed: true,
version: version_for_binary(&binary),
},
Err(_) => crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -4,20 +4,7 @@ use std::path::{Path, PathBuf};
use std::process::Stdio;
pub fn check_cli() -> AiAgentAvailability {
let binary = match find_codex_binary() {
Ok(binary) => binary,
Err(_) => {
return AiAgentAvailability {
installed: false,
version: None,
}
}
};
AiAgentAvailability {
installed: true,
version: crate::cli_agent_runtime::version_for_binary(&binary),
}
codex_availability_from_binary_result(find_codex_binary())
}
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
@@ -29,15 +16,37 @@ where
}
fn find_codex_binary() -> Result<PathBuf, String> {
find_codex_binary_on_path()
.filter(|binary| is_usable_codex_binary(binary))
.or_else(|| {
find_codex_binary_in_user_shell().filter(|binary| is_usable_codex_binary(binary))
})
.or_else(|| find_usable_codex_binary(codex_binary_candidates()))
.ok_or_else(|| {
"Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into()
})
if let Some(binary) = find_codex_binary_on_path() {
return Ok(binary);
}
if let Some(binary) = find_codex_binary_in_user_shell() {
return Ok(binary);
}
if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate(
codex_binary_candidates(),
"Codex CLI",
)? {
return Ok(binary);
}
Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
}
fn codex_availability_from_binary_result(
binary_result: Result<PathBuf, String>,
) -> AiAgentAvailability {
match binary_result {
Ok(binary) => AiAgentAvailability {
installed: true,
version: crate::cli_agent_runtime::version_for_binary(&binary),
},
Err(_) => AiAgentAvailability {
installed: false,
version: None,
},
}
}
fn find_codex_binary_on_path() -> Option<PathBuf> {
@@ -60,7 +69,7 @@ fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
user_shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, "codex"))
.find_map(|shell| codex_path_from_shell(&shell))
}
fn user_shell_candidates() -> Vec<PathBuf> {
@@ -75,10 +84,10 @@ fn user_shell_candidates() -> Vec<PathBuf> {
shells
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
fn codex_path_from_shell(shell: &Path) -> Option<PathBuf> {
crate::hidden_command(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.arg("command -v codex")
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
@@ -93,14 +102,25 @@ fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf>
}
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)
})
first_existing_path_for_platform(stdout, cfg!(windows))
}
fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option<PathBuf> {
let mut paths = stdout.lines().filter_map(existing_path);
if windows {
return paths.find(|path| crate::cli_agent_runtime::has_windows_cli_extension(path));
}
paths.next()
}
fn existing_path(line: &str) -> Option<PathBuf> {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
}
fn codex_binary_candidates() -> Vec<PathBuf> {
@@ -113,12 +133,16 @@ fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let mut candidates = vec![
home.join(".local/bin/codex"),
home.join(".local/bin/codex.exe"),
home.join(".local/bin/codex.cmd"),
home.join(".codex/bin/codex"),
home.join(".codex/bin/codex.exe"),
home.join(".codex/bin/codex.cmd"),
home.join(".local/share/mise/shims/codex"),
home.join(".local/share/mise/shims/codex.exe"),
home.join(".local/share/mise/shims/codex.cmd"),
home.join(".asdf/shims/codex"),
home.join(".asdf/shims/codex.exe"),
home.join(".asdf/shims/codex.cmd"),
home.join(".npm-global/bin/codex"),
home.join(".npm-global/bin/codex.cmd"),
home.join(".npm-global/bin/codex.exe"),
@@ -127,22 +151,24 @@ fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
home.join(".npm/bin/codex.exe"),
home.join(".bun/bin/codex"),
home.join(".bun/bin/codex.exe"),
home.join(".bun/bin/codex.cmd"),
home.join(".linuxbrew/bin/codex"),
home.join("AppData/Roaming/npm/codex.cmd"),
home.join("AppData/Roaming/npm/codex.exe"),
home.join("AppData/Local/pnpm/codex.cmd"),
home.join("AppData/Local/pnpm/codex.exe"),
home.join("scoop/shims/codex.cmd"),
home.join("scoop/shims/codex.exe"),
PathBuf::from("/home/linuxbrew/.linuxbrew/bin/codex"),
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.extend(nvm_codex_binary_candidates_for_home(home));
candidates
}
fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<PathBuf> {
fn nvm_codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
return Vec::new();
};
@@ -151,22 +177,12 @@ fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<Pa
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.map(|path| path.join("bin").join(binary_name))
.map(|path| path.join("bin").join("codex"))
.collect::<Vec<_>>();
candidates.sort();
candidates
}
fn find_usable_codex_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates
.into_iter()
.find(|binary| is_usable_codex_binary(binary))
}
fn is_usable_codex_binary(binary: &Path) -> bool {
crate::cli_agent_runtime::version_for_binary(binary).is_some()
}
fn run_agent_stream_with_binary<F>(
binary: &Path,
request: AgentStreamRequest,
@@ -191,7 +207,12 @@ where
emit,
codex_session_id,
dispatch_codex_event,
format_codex_error,
|stderr_output, status| {
format_codex_error(CodexProcessError {
stderr_output,
status,
})
},
)
}
@@ -265,7 +286,7 @@ fn build_codex_args(
"-c".into(),
codex_config_string_list("mcp_servers.tolaria.args", &[mcp_server_path.as_str()]),
"-c".into(),
codex_mcp_env_config(&request.vault_path, &request.vault_paths),
codex_mcp_env_config(request),
];
if let Some(path) = last_message_path {
@@ -289,11 +310,14 @@ fn codex_config_string_list(key: &str, values: &[&str]) -> String {
format!("{key}=[{values}]")
}
fn codex_mcp_env_config(vault_path: &str, vault_paths: &[String]) -> String {
let vault_paths = crate::cli_agent_runtime::active_vault_paths_json(vault_path, vault_paths);
fn codex_mcp_env_config(request: &AgentStreamRequest) -> String {
let vault_paths = crate::cli_agent_runtime::active_vault_paths_json(
&request.vault_path,
&request.vault_paths,
);
format!(
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}",VAULT_PATHS="{}",WS_UI_PORT="9711"}}"#,
toml_escape(vault_path),
toml_escape(&request.vault_path),
toml_escape(&vault_paths)
)
}
@@ -431,8 +455,13 @@ fn read_codex_last_message(path: &Path) -> Option<String> {
.filter(|text| !text.is_empty())
}
fn format_codex_error(stderr_output: String, status: String) -> String {
let lower = stderr_output.to_ascii_lowercase();
struct CodexProcessError {
stderr_output: String,
status: String,
}
fn format_codex_error(error: CodexProcessError) -> String {
let lower = error.stderr_output.to_ascii_lowercase();
if is_codex_auth_error(&lower) {
return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into();
}
@@ -441,10 +470,15 @@ fn format_codex_error(stderr_output: String, status: String) -> String {
return "Codex could not write to the active vault. Vault Safe uses a read-only Codex sandbox; switch to Power User for shell-backed local writes, or verify the selected vault folder is writable and retry. Writes outside the active vault remain blocked.".into();
}
if stderr_output.trim().is_empty() {
format!("codex exited with status {status}")
if error.stderr_output.trim().is_empty() {
format!("codex exited with status {}", error.status)
} else {
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
error
.stderr_output
.lines()
.take(3)
.collect::<Vec<_>>()
.join("\n")
}
}
@@ -945,16 +979,22 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
let candidates = codex_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/codex.exe"),
home.join(".local/bin/codex.cmd"),
home.join(".local/share/mise/shims/codex.exe"),
home.join(".local/share/mise/shims/codex.cmd"),
home.join(".asdf/shims/codex.exe"),
home.join(".asdf/shims/codex.cmd"),
home.join(".codex/bin/codex.cmd"),
home.join(".npm-global/bin/codex.cmd"),
home.join(".npm-global/bin/codex.exe"),
home.join(".npm/bin/codex.cmd"),
home.join(".npm/bin/codex.exe"),
home.join(".bun/bin/codex.cmd"),
home.join("AppData/Roaming/npm/codex.cmd"),
home.join("AppData/Roaming/npm/codex.exe"),
home.join("AppData/Local/pnpm/codex.cmd"),
home.join("AppData/Local/pnpm/codex.exe"),
home.join("scoop/shims/codex.cmd"),
home.join("scoop/shims/codex.exe"),
];
@@ -967,6 +1007,16 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
}
}
#[test]
fn codex_availability_reports_installed_even_when_version_probe_fails() {
let binary = PathBuf::from("C:/Users/alex/AppData/Roaming/npm/codex.cmd");
let availability = codex_availability_from_binary_result(Ok(binary));
assert!(availability.installed);
assert_eq!(availability.version, None);
}
#[test]
fn codex_binary_candidates_include_nvm_managed_node_installs() {
let home = tempfile::tempdir().unwrap();
@@ -979,18 +1029,6 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
assert!(candidates.contains(&codex), "missing {}", codex.display());
}
#[cfg(unix)]
#[test]
fn usable_codex_binary_skips_broken_shims() {
let dir = tempfile::tempdir().unwrap();
let broken = executable_script(dir.path(), "broken-codex", "exit 1\n");
let working = executable_script(dir.path(), "codex", "echo codex-cli 0.124.0-alpha.2\n");
let found = find_usable_codex_binary(vec![broken, working.clone()]);
assert_eq!(found, Some(working));
}
#[test]
fn first_existing_path_skips_empty_and_missing_lines() {
let dir = tempfile::tempdir().unwrap();
@@ -1003,6 +1041,22 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
assert_eq!(first_existing_path(&stdout), Some(codex));
}
#[test]
fn windows_path_lookup_prefers_cmd_shim_over_extensionless_npm_script() {
let dir = tempfile::tempdir().unwrap();
let shell_script = dir.path().join("codex");
let cmd_shim = dir.path().join("codex.cmd");
std::fs::write(&shell_script, "#!/bin/sh\n").unwrap();
std::fs::write(&cmd_shim, "@ECHO off\n").unwrap();
let stdout = format!("{}\n{}\n", shell_script.display(), cmd_shim.display());
assert_eq!(
first_existing_path_for_platform(&stdout, true),
Some(cmd_shim)
);
}
#[cfg(unix)]
#[test]
fn command_path_from_shell_finds_codex_from_login_shell() {
@@ -1024,7 +1078,7 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
.unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(command_path_from_shell(&shell, "codex"), Some(codex));
assert_eq!(codex_path_from_shell(&shell), Some(codex));
}
#[test]
@@ -1129,10 +1183,10 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
#[test]
fn format_codex_error_explains_vault_write_permission_failures() {
let message = format_codex_error(
"The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(),
"exit status: 1".into(),
);
let message = format_codex_error(CodexProcessError {
stderr_output: "The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(),
status: "exit status: 1".into(),
});
assert!(message.contains("active vault"));
assert!(message.contains("writable"));

View File

@@ -0,0 +1,11 @@
#[cfg(desktop)]
#[tauri::command]
pub fn update_app_icon(app_handle: tauri::AppHandle, theme_mode: String) -> Result<(), String> {
crate::app_icon::update_app_icon_for_theme(&app_handle, &theme_mode)
}
#[cfg(mobile)]
#[tauri::command]
pub fn update_app_icon(_theme_mode: String) -> Result<(), String> {
Ok(())
}

View File

@@ -0,0 +1,207 @@
#[cfg(desktop)]
use std::io::Write;
#[cfg(desktop)]
use std::process::{Child, Command, Output, Stdio};
#[cfg(desktop)]
use std::thread;
#[cfg(desktop)]
use std::time::{Duration, Instant};
#[cfg(desktop)]
const NATIVE_CLIPBOARD_COMMAND_TIMEOUT: Duration = Duration::from_secs(2);
#[cfg(desktop)]
const NATIVE_CLIPBOARD_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25);
#[cfg(target_os = "macos")]
fn clipboard_command() -> Command {
crate::hidden_command("pbcopy")
}
#[cfg(target_os = "macos")]
fn clipboard_read_command() -> Command {
crate::hidden_command("pbpaste")
}
#[cfg(target_os = "windows")]
fn clipboard_command() -> Command {
crate::hidden_command("clip.exe")
}
#[cfg(target_os = "windows")]
fn clipboard_read_command() -> Command {
let mut command = crate::hidden_command("powershell.exe");
command.args(["-NoProfile", "-Command", "Get-Clipboard -Raw"]);
command
}
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
fn clipboard_command() -> Command {
let mut command = crate::hidden_command("sh");
command.args([
"-c",
"if command -v wl-copy >/dev/null 2>&1; then wl-copy; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --input; else exit 127; fi",
]);
command
}
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
fn clipboard_read_command() -> Command {
let mut command = crate::hidden_command("sh");
command.args([
"-c",
"if command -v wl-paste >/dev/null 2>&1; then wl-paste; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard -out; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --output; else exit 127; fi",
]);
command
}
#[cfg(desktop)]
fn clipboard_failure_message(stderr: &[u8]) -> String {
let message = String::from_utf8_lossy(stderr).trim().to_string();
if message.is_empty() {
"Native clipboard command failed".to_string()
} else {
format!("Native clipboard command failed: {message}")
}
}
#[cfg(desktop)]
fn clipboard_timeout_message(timeout: Duration) -> String {
format!(
"Native clipboard command timed out after {}ms",
timeout.as_millis()
)
}
#[cfg(desktop)]
fn wait_for_native_clipboard_output(mut child: Child, timeout: Duration) -> Result<Output, String> {
let started = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_status)) => {
return child
.wait_with_output()
.map_err(|e| format!("Native clipboard command did not finish: {e}"));
}
Ok(None) if started.elapsed() >= timeout => {
let _ = child.kill();
let _ = child.wait();
return Err(clipboard_timeout_message(timeout));
}
Ok(None) => thread::sleep(NATIVE_CLIPBOARD_COMMAND_POLL_INTERVAL),
Err(e) => return Err(format!("Native clipboard command did not finish: {e}")),
}
}
}
#[cfg(desktop)]
fn write_native_clipboard_with_timeout(
mut command: Command,
text: &str,
timeout: Duration,
) -> Result<(), String> {
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to open native clipboard command: {e}"))?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| "Native clipboard command did not expose stdin".to_string())?;
stdin
.write_all(text.as_bytes())
.map_err(|e| format!("Failed to write native clipboard text: {e}"))?;
drop(stdin);
let output = wait_for_native_clipboard_output(child, timeout)?;
if output.status.success() {
Ok(())
} else {
Err(clipboard_failure_message(&output.stderr))
}
}
#[cfg(desktop)]
fn write_native_clipboard(command: Command, text: &str) -> Result<(), String> {
write_native_clipboard_with_timeout(command, text, NATIVE_CLIPBOARD_COMMAND_TIMEOUT)
}
#[cfg(desktop)]
fn read_native_clipboard_with_timeout(
mut command: Command,
timeout: Duration,
) -> Result<String, String> {
let child = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to read native clipboard text: {e}"))?;
let output = wait_for_native_clipboard_output(child, timeout)?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(clipboard_failure_message(&output.stderr))
}
}
#[cfg(desktop)]
fn read_native_clipboard(command: Command) -> Result<String, String> {
read_native_clipboard_with_timeout(command, NATIVE_CLIPBOARD_COMMAND_TIMEOUT)
}
#[cfg(desktop)]
#[tauri::command]
pub async fn copy_text_to_clipboard(text: String) -> Result<(), String> {
tokio::task::spawn_blocking(move || write_native_clipboard(clipboard_command(), &text))
.await
.map_err(|e| format!("Native clipboard task failed: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn read_text_from_clipboard() -> Result<String, String> {
tokio::task::spawn_blocking(move || read_native_clipboard(clipboard_read_command()))
.await
.map_err(|e| format!("Native clipboard task failed: {e}"))?
}
#[cfg(mobile)]
#[tauri::command]
pub async fn copy_text_to_clipboard(_text: String) -> Result<(), String> {
Err("Clipboard is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn read_text_from_clipboard() -> Result<String, String> {
Err("Clipboard is not available on mobile".into())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(all(desktop, unix))]
#[test]
fn native_clipboard_write_times_out_slow_commands() {
let mut command = Command::new("sh");
command.args(["-c", "cat >/dev/null; sleep 2"]);
let started = Instant::now();
let result =
write_native_clipboard_with_timeout(command, "copy me", Duration::from_millis(50));
let error = result.expect_err("slow clipboard command should time out");
assert!(
error.contains("timed out"),
"unexpected clipboard timeout error: {error}"
);
assert!(
started.elapsed() < Duration::from_secs(1),
"clipboard timeout should return promptly"
);
}
}

View File

@@ -1,10 +1,13 @@
mod ai;
mod app_icon;
mod clipboard;
mod delete;
mod folders;
mod git;
pub mod git_clone;
mod git_connect;
mod memory;
mod runtime;
mod system;
mod vault;
mod version;
@@ -12,11 +15,14 @@ mod version;
use std::borrow::Cow;
pub use ai::*;
pub use app_icon::*;
pub use clipboard::*;
pub use delete::*;
pub use folders::*;
pub use git::*;
pub use git_connect::*;
pub use memory::*;
pub use runtime::*;
pub use system::*;
pub use vault::*;
pub use version::*;

View File

@@ -0,0 +1,29 @@
fn should_use_external_media_preview_for_appimage(is_linux_appimage: bool) -> bool {
is_linux_appimage
}
#[cfg(all(desktop, target_os = "linux"))]
fn linux_appimage_running() -> bool {
crate::linux_appimage::is_running()
}
#[cfg(not(all(desktop, target_os = "linux")))]
fn linux_appimage_running() -> bool {
false
}
#[tauri::command]
pub fn should_use_external_media_preview() -> bool {
should_use_external_media_preview_for_appimage(linux_appimage_running())
}
#[cfg(test)]
mod tests {
use super::should_use_external_media_preview_for_appimage;
#[test]
fn external_media_preview_is_limited_to_linux_appimage() {
assert!(should_use_external_media_preview_for_appimage(true));
assert!(!should_use_external_media_preview_for_appimage(false));
}
}

View File

@@ -1,7 +1,5 @@
#[cfg(desktop)]
use std::io::Write;
#[cfg(desktop)]
use std::process::{Command, Stdio};
use std::process::Command;
#[cfg(desktop)]
use crate::menu;
@@ -142,113 +140,6 @@ pub async fn get_mcp_config_snippet(vault_path: String) -> Result<String, String
.map_err(|e| format!("MCP config task failed: {e}"))?
}
#[cfg(target_os = "macos")]
fn clipboard_command() -> Command {
crate::hidden_command("pbcopy")
}
#[cfg(target_os = "macos")]
fn clipboard_read_command() -> Command {
crate::hidden_command("pbpaste")
}
#[cfg(target_os = "windows")]
fn clipboard_command() -> Command {
crate::hidden_command("clip.exe")
}
#[cfg(target_os = "windows")]
fn clipboard_read_command() -> Command {
let mut command = crate::hidden_command("powershell.exe");
command.args(["-NoProfile", "-Command", "Get-Clipboard -Raw"]);
command
}
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
fn clipboard_command() -> Command {
let mut command = crate::hidden_command("sh");
command.args([
"-c",
"if command -v wl-copy >/dev/null 2>&1; then wl-copy; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --input; else exit 127; fi",
]);
command
}
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
fn clipboard_read_command() -> Command {
let mut command = crate::hidden_command("sh");
command.args([
"-c",
"if command -v wl-paste >/dev/null 2>&1; then wl-paste; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard -out; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --output; else exit 127; fi",
]);
command
}
#[cfg(desktop)]
fn clipboard_failure_message(stderr: &[u8]) -> String {
let message = String::from_utf8_lossy(stderr).trim().to_string();
if message.is_empty() {
"Native clipboard command failed".to_string()
} else {
format!("Native clipboard command failed: {message}")
}
}
#[cfg(desktop)]
fn write_native_clipboard(mut command: Command, text: &str) -> Result<(), String> {
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to open native clipboard command: {e}"))?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| "Native clipboard command did not expose stdin".to_string())?;
stdin
.write_all(text.as_bytes())
.map_err(|e| format!("Failed to write native clipboard text: {e}"))?;
drop(stdin);
let output = child
.wait_with_output()
.map_err(|e| format!("Native clipboard command did not finish: {e}"))?;
if output.status.success() {
Ok(())
} else {
Err(clipboard_failure_message(&output.stderr))
}
}
#[cfg(desktop)]
fn read_native_clipboard(mut command: Command) -> Result<String, String> {
let output = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("Failed to read native clipboard text: {e}"))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(clipboard_failure_message(&output.stderr))
}
}
#[cfg(desktop)]
#[tauri::command]
pub fn copy_text_to_clipboard(text: String) -> Result<(), String> {
write_native_clipboard(clipboard_command(), &text)
}
#[cfg(desktop)]
#[tauri::command]
pub fn read_text_from_clipboard() -> Result<String, String> {
read_native_clipboard(clipboard_read_command())
}
#[cfg(desktop)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(
@@ -299,18 +190,6 @@ pub async fn get_mcp_config_snippet(_vault_path: String) -> Result<String, Strin
Err("MCP is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn copy_text_to_clipboard(_text: String) -> Result<(), String> {
Err("Clipboard is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn read_text_from_clipboard() -> Result<String, String> {
Err("Clipboard is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(
@@ -384,9 +263,33 @@ pub fn trigger_menu_command(_app_handle: tauri::AppHandle, _id: String) -> Resul
}
#[cfg(desktop)]
#[tauri::command]
pub fn update_current_window_min_size(
window: Window,
fn should_apply_window_min_size_constraints(
is_windows: bool,
is_fullscreen: bool,
is_maximized: bool,
) -> bool {
!(is_windows && (is_fullscreen || is_maximized))
}
#[cfg(desktop)]
fn should_skip_window_min_size_update(window: &Window) -> Result<bool, String> {
if !cfg!(target_os = "windows") {
return Ok(false);
}
let is_fullscreen = window.is_fullscreen().map_err(|e| e.to_string())?;
let is_maximized = window.is_maximized().map_err(|e| e.to_string())?;
Ok(!should_apply_window_min_size_constraints(
true,
is_fullscreen,
is_maximized,
))
}
#[cfg(desktop)]
fn apply_window_min_size_update(
window: &Window,
min_width: f64,
min_height: f64,
grow_to_fit: bool,
@@ -416,6 +319,21 @@ pub fn update_current_window_min_size(
.map_err(|e| e.to_string())
}
#[cfg(desktop)]
#[tauri::command]
pub fn update_current_window_min_size(
window: Window,
min_width: f64,
min_height: f64,
grow_to_fit: bool,
) -> Result<(), String> {
if should_skip_window_min_size_update(&window)? {
return Ok(());
}
apply_window_min_size_update(&window, min_width, min_height, grow_to_fit)
}
#[cfg(desktop)]
#[tauri::command]
pub fn perform_current_window_titlebar_double_click(window: Window) -> Result<(), String> {
@@ -623,6 +541,20 @@ mod tests {
}
}
#[test]
fn skips_min_size_updates_for_windows_fullscreen_or_maximized_windows() {
for (is_fullscreen, is_maximized) in [(true, false), (false, true), (true, true)] {
assert!(!should_apply_window_min_size_constraints(
true,
is_fullscreen,
is_maximized
));
}
assert!(should_apply_window_min_size_constraints(true, false, false));
assert!(should_apply_window_min_size_constraints(false, true, true));
}
#[test]
fn propagates_title_bar_action_errors() {
for (state, maximize, unmaximize, expected) in [

View File

@@ -129,17 +129,17 @@ fn load_configured_active_vault_root() -> Result<Option<VaultRootPaths>, String>
fn load_registered_vault_roots() -> Result<Vec<VaultRootPaths>, String> {
let list = vault_list::load_vault_list()?;
let mut roots = list
.vaults
Ok(registered_vault_roots(&list))
}
fn registered_vault_roots(list: &vault_list::VaultList) -> Vec<VaultRootPaths> {
list.vaults
.iter()
.map(|entry| build_vault_root_paths(&entry.path))
.collect::<Result<Vec<_>, _>>()?;
if let Some(active_vault) = list.active_vault.as_deref() {
if !active_vault.trim().is_empty() {
roots.push(build_vault_root_paths(active_vault)?);
}
}
Ok(roots)
.map(|entry| entry.path.as_str())
.chain(list.active_vault.as_deref())
.filter(|path| !path.trim().is_empty())
.filter_map(|path| build_vault_root_paths(path).ok())
.collect()
}
fn is_registered_vault_root(requested: &VaultRootPaths) -> Result<bool, String> {
@@ -378,3 +378,37 @@ pub(crate) fn with_view_file<T>(
action(&requested_root, filename)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault_list::{VaultEntry, VaultList};
#[test]
fn registered_vault_roots_skip_unavailable_vaults() {
let available = tempfile::TempDir::new().unwrap();
let missing = available.path().join("missing-vault");
let list = VaultList {
vaults: vec![
VaultEntry {
label: "Missing".to_string(),
path: missing.to_string_lossy().to_string(),
..Default::default()
},
VaultEntry {
label: "Available".to_string(),
path: available.path().to_string_lossy().to_string(),
..Default::default()
},
],
active_vault: None,
default_workspace_path: None,
hidden_defaults: vec![],
};
let roots = registered_vault_roots(&list);
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].canonical, available.path().canonicalize().unwrap());
}
}

320
src-tauri/src/kiro_cli.rs Normal file
View File

@@ -0,0 +1,320 @@
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
use crate::cli_agent_runtime::AgentStreamRequest;
use regex::Regex;
use std::io::{BufRead, Read, Write};
use std::path::Path;
use std::process::{ChildStderr, ChildStdin, ChildStdout, Stdio};
struct KiroMcpConfig<'a> {
vault_path: &'a str,
vault_paths: &'a [String],
mcp_server_path: &'a str,
}
struct KiroError<'a> {
stderr_output: &'a str,
status: String,
}
pub fn check_cli() -> AiAgentAvailability {
crate::kiro_discovery::check_cli()
}
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = crate::kiro_discovery::find_binary()?;
ensure_mcp_config(&request)?;
let prompt =
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref());
let mut child = spawn_kiro_process(&binary, Path::new(&request.vault_path))?;
let prompt_handle = write_prompt_async(
child.stdin.take().ok_or("No stdin handle")?,
prompt.into_bytes(),
);
let stderr_handle = read_stderr_async(child.stderr.take().ok_or("No stderr handle")?);
let session_id = generate_session_id();
emit(AiAgentStreamEvent::Init {
session_id: session_id.clone(),
});
stream_stdout(child.stdout.take().ok_or("No stdout handle")?, &mut emit);
let mut stderr_output = stderr_handle.join().unwrap_or_default();
if let Some(error) = prompt_write_error(prompt_handle) {
append_stderr_line(&mut stderr_output, error);
}
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
if !status.success() {
emit(AiAgentStreamEvent::Error {
message: format_kiro_error(KiroError {
stderr_output: &stderr_output,
status: status.to_string(),
}),
});
}
emit(AiAgentStreamEvent::Done);
Ok(session_id)
}
fn spawn_kiro_process(binary: &Path, vault_path: &Path) -> Result<std::process::Child, String> {
let mut command = crate::hidden_command(binary);
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
command
.arg("chat")
.arg("--no-interactive")
.arg("--trust-all-tools")
.current_dir(vault_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
command
.spawn()
.map_err(|e| format!("Failed to spawn kiro-cli: {e}"))
}
fn write_prompt_async(
mut stdin: ChildStdin,
prompt: Vec<u8>,
) -> std::thread::JoinHandle<Result<(), String>> {
std::thread::spawn(move || {
stdin
.write_all(&prompt)
.map_err(|e| format!("Failed to write kiro-cli stdin: {e}"))
})
}
fn generate_session_id() -> String {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
format!("kiro-{}-{}", std::process::id(), ts)
}
fn stream_stdout<F>(stdout: ChildStdout, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
let reader = std::io::BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(l) if !l.is_empty() => {
emit(AiAgentStreamEvent::TextDelta {
text: format!("{}\n", strip_ansi_codes(&l)),
});
}
Ok(_) => {
emit(AiAgentStreamEvent::TextDelta {
text: "\n".to_string(),
});
}
Err(e) => {
emit(AiAgentStreamEvent::Error {
message: format!("Read error: {e}"),
});
break;
}
}
}
}
fn read_stderr_async(mut stderr: ChildStderr) -> std::thread::JoinHandle<String> {
std::thread::spawn(move || {
let mut output = String::new();
let _ = stderr.read_to_string(&mut output);
output
})
}
fn prompt_write_error(handle: std::thread::JoinHandle<Result<(), String>>) -> Option<String> {
match handle.join() {
Ok(Ok(())) => None,
Ok(Err(error)) => Some(error),
Err(_) => Some("Failed to write kiro-cli stdin: writer thread panicked".into()),
}
}
fn append_stderr_line(stderr_output: &mut String, line: impl AsRef<str>) {
if !stderr_output.is_empty() {
stderr_output.push('\n');
}
stderr_output.push_str(line.as_ref());
}
fn ensure_mcp_config(request: &AgentStreamRequest) -> Result<(), String> {
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
write_mcp_json(KiroMcpConfig {
vault_path: &request.vault_path,
vault_paths: &request.vault_paths,
mcp_server_path: &mcp_server_path,
})
}
fn write_mcp_json(config: KiroMcpConfig<'_>) -> Result<(), String> {
let config_dir = Path::new(config.vault_path).join(".kiro").join("settings");
std::fs::create_dir_all(&config_dir)
.map_err(|e| format!("Failed to create .kiro/settings: {e}"))?;
let config_path = config_dir.join("mcp.json");
let mut json_config: serde_json::Value = std::fs::read_to_string(&config_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| serde_json::json!({}));
let servers = json_config
.as_object_mut()
.ok_or("Invalid mcp.json: not an object")?
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));
let active_vault_paths =
crate::cli_agent_runtime::active_vault_paths_json(config.vault_path, config.vault_paths);
servers["tolaria"] = serde_json::json!({
"command": "node",
"args": [config.mcp_server_path],
"env": {
"VAULT_PATH": config.vault_path,
"VAULT_PATHS": active_vault_paths,
"WS_UI_PORT": "9711"
},
"disabled": false
});
std::fs::write(
&config_path,
serde_json::to_string_pretty(&json_config)
.map_err(|e| format!("JSON serialize error: {e}"))?,
)
.map_err(|e| format!("Failed to write mcp.json: {e}"))?;
Ok(())
}
fn strip_ansi_codes(input: &str) -> String {
static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let re = RE.get_or_init(|| Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").unwrap());
re.replace_all(input, "").to_string()
}
fn format_kiro_error(error: KiroError<'_>) -> String {
if is_auth_error(error.stderr_output) {
return "Kiro CLI is not authenticated. Run `kiro-cli login` in your terminal to sign in."
.into();
}
if error.stderr_output.trim().is_empty() {
format!("kiro-cli exited with status {}", error.status)
} else {
error
.stderr_output
.lines()
.take(3)
.collect::<Vec<_>>()
.join("\n")
}
}
fn is_auth_error(stderr_output: &str) -> bool {
let lower = stderr_output.to_ascii_lowercase();
["auth", "login", "token"]
.iter()
.any(|needle| lower.contains(needle))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_ansi_codes_removes_terminal_colors() {
assert_eq!(
strip_ansi_codes("\x1b[38;5;141m> \x1b[0mHello! \x1b[2K"),
"> Hello! "
);
assert_eq!(strip_ansi_codes("plain text"), "plain text");
}
#[test]
fn format_kiro_error_detects_auth_errors() {
let result = format_kiro_error(KiroError {
stderr_output: "Error: auth token expired",
status: "1".into(),
});
assert!(result.contains("kiro-cli login"));
}
#[test]
fn format_kiro_error_returns_status_for_empty_stderr() {
let result = format_kiro_error(KiroError {
stderr_output: "",
status: "1".into(),
});
assert!(result.contains("status 1"));
}
#[test]
fn write_mcp_json_creates_config() {
let dir = tempfile::tempdir().unwrap();
let vault_path = dir.path().to_str().unwrap();
write_mcp_json(KiroMcpConfig {
vault_path,
vault_paths: &["/other/vault".into(), vault_path.into()],
mcp_server_path: "/opt/mcp/index.js",
})
.unwrap();
let config_path = dir.path().join(".kiro/settings/mcp.json");
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
assert_eq!(content["mcpServers"]["tolaria"]["command"], "node");
assert_eq!(
content["mcpServers"]["tolaria"]["args"][0],
"/opt/mcp/index.js"
);
assert_eq!(
content["mcpServers"]["tolaria"]["env"]["VAULT_PATH"],
vault_path
);
assert_eq!(
content["mcpServers"]["tolaria"]["env"]["VAULT_PATHS"],
serde_json::json!(serde_json::to_string(&vec![vault_path, "/other/vault"]).unwrap())
);
assert_eq!(
content["mcpServers"]["tolaria"]["env"]["WS_UI_PORT"],
"9711"
);
}
#[test]
fn write_mcp_json_merges_preserving_existing_servers() {
let dir = tempfile::tempdir().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config_dir = dir.path().join(".kiro/settings");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("mcp.json"),
r#"{"mcpServers":{"other":{"command":"python","args":["server.py"]}}}"#,
)
.unwrap();
write_mcp_json(KiroMcpConfig {
vault_path,
vault_paths: &[],
mcp_server_path: "/new/index.js",
})
.unwrap();
let content: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(dir.path().join(".kiro/settings/mcp.json")).unwrap(),
)
.unwrap();
assert_eq!(content["mcpServers"]["tolaria"]["args"][0], "/new/index.js");
assert_eq!(content["mcpServers"]["other"]["command"], "python");
}
}

View File

@@ -0,0 +1,59 @@
use crate::ai_agents::AiAgentAvailability;
use std::path::{Path, PathBuf};
pub(crate) fn check_cli() -> AiAgentAvailability {
crate::cli_agent_runtime::check_cli_availability(find_binary)
}
pub(crate) fn find_binary() -> Result<PathBuf, String> {
crate::cli_agent_runtime::find_cli_binary(
"kiro-cli",
kiro_binary_candidates(),
"Kiro CLI",
"https://kiro.dev/docs/cli",
)
}
fn kiro_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| kiro_binary_candidates_for_home(&home))
.unwrap_or_default()
}
fn kiro_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".local/bin/kiro-cli"),
home.join(".kiro/bin/kiro-cli"),
home.join(".local/share/mise/shims/kiro-cli"),
home.join(".asdf/shims/kiro-cli"),
home.join(".npm-global/bin/kiro-cli"),
home.join(".npm/bin/kiro-cli"),
home.join(".bun/bin/kiro-cli"),
PathBuf::from("/usr/local/bin/kiro-cli"),
PathBuf::from("/opt/homebrew/bin/kiro-cli"),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn binary_candidates_include_supported_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = kiro_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/kiro-cli"),
home.join(".kiro/bin/kiro-cli"),
home.join(".npm-global/bin/kiro-cli"),
PathBuf::from("/opt/homebrew/bin/kiro-cli"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
}

View File

@@ -1,5 +1,6 @@
pub mod ai_agents;
pub mod ai_models;
mod app_icon;
pub mod app_updater;
pub mod claude_cli;
mod claude_invocation;
@@ -11,6 +12,8 @@ pub mod gemini_cli;
mod gemini_config;
mod gemini_discovery;
pub mod git;
pub mod kiro_cli;
mod kiro_discovery;
#[cfg(any(test, all(desktop, target_os = "linux")))]
mod linux_appimage;
pub mod mcp;
@@ -250,6 +253,14 @@ fn sync_ws_bridge_for_selected_vault(app_handle: &tauri::AppHandle) {
fn spawn_initial_ws_bridge_sync(app: &tauri::App) {
let app_handle = app.handle().clone();
spawn_background_task("tolaria-ws-bridge-startup", move || {
#[cfg(all(desktop, target_os = "linux"))]
if linux_appimage::is_running() {
let app_version = app_handle.package_info().version.to_string();
if let Err(e) = mcp::extract_mcp_server_to_stable_dir(&app_version) {
log::warn!("Failed to extract MCP server to stable path: {e}");
}
}
sync_ws_bridge_for_selected_vault(&app_handle);
});
}
@@ -274,9 +285,10 @@ fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error:
.plugin(tauri_plugin_updater::Builder::new().build())?;
app.handle().plugin(tauri_plugin_process::init())?;
app.handle().plugin(tauri_plugin_opener::init())?;
#[cfg(not(target_os = "linux"))]
menu::setup_menu(app)?;
setup_linux_window_chrome(app)?;
if should_use_native_desktop_menu(std::env::consts::OS) {
menu::setup_menu(app)?;
}
setup_custom_window_chrome(app)?;
window_state::restore_main_window_state(app);
show_debug_main_window(app);
Ok(())
@@ -297,8 +309,12 @@ fn show_debug_main_window(app: &mut tauri::App) {
#[cfg(not(debug_assertions))]
fn show_debug_main_window(_app: &mut tauri::App) {}
#[cfg(all(desktop, target_os = "linux"))]
fn setup_linux_window_chrome(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
fn should_use_native_desktop_menu(target_os: &str) -> bool {
target_os == "macos"
}
#[cfg(all(desktop, any(target_os = "linux", target_os = "windows")))]
fn setup_custom_window_chrome(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
use tauri::Manager;
if let Some(window) = app.get_webview_window("main") {
@@ -307,8 +323,8 @@ fn setup_linux_window_chrome(app: &mut tauri::App) -> Result<(), Box<dyn std::er
Ok(())
}
#[cfg(not(all(desktop, target_os = "linux")))]
fn setup_linux_window_chrome(_app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(not(all(desktop, any(target_os = "linux", target_os = "windows"))))]
fn setup_custom_window_chrome(_app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
@@ -486,6 +502,7 @@ macro_rules! app_invoke_handler {
commands::get_settings,
commands::check_for_app_update,
commands::update_menu_state,
commands::update_app_icon,
commands::trigger_menu_command,
commands::update_current_window_min_size,
commands::perform_current_window_titlebar_double_click,
@@ -509,6 +526,7 @@ macro_rules! app_invoke_handler {
commands::get_process_memory_snapshot,
commands::repair_vault,
commands::reinit_telemetry,
commands::should_use_external_media_preview,
commands::list_views,
commands::save_view_cmd,
commands::delete_view_cmd,
@@ -561,6 +579,7 @@ pub fn run() {
#[cfg(test)]
mod tests {
use super::should_use_native_desktop_menu;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
@@ -715,4 +734,11 @@ mod tests {
missing_asset_scope_roots(&allowed_roots, std::slice::from_ref(&vault_a)).is_empty()
);
}
#[test]
fn native_desktop_menu_is_macos_only() {
assert!(should_use_native_desktop_menu("macos"));
assert!(!should_use_native_desktop_menu("windows"));
assert!(!should_use_native_desktop_menu("linux"));
}
}

View File

@@ -4,7 +4,7 @@ struct StartupEnvOverride {
value: &'static str,
}
const LINUX_APPIMAGE_WEBKIT_OVERRIDES: [StartupEnvOverride; 2] = [
const LINUX_WEBKIT_RENDERING_OVERRIDES: [StartupEnvOverride; 2] = [
StartupEnvOverride {
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
value: "1",
@@ -25,6 +25,10 @@ const FCITX_ENV_HINT_KEYS: [&str; 4] = [
"QT_IM_MODULE",
"SDL_IM_MODULE",
];
const FCITX_GTK3_IM_MODULE_RELATIVE_PATH: &str =
"usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so";
#[cfg(all(desktop, target_os = "linux"))]
const TOLARIA_FCITX_IMMODULES_CACHE_FILE: &str = "tolaria-appimage-fcitx5-immodules.cache";
const COLRV1_EMOJI_FONT_FILE: &str = "Noto-COLRv1.ttf";
#[cfg(all(desktop, target_os = "linux"))]
const TOLARIA_COLRV1_FONTCONFIG_FILE: &str = "tolaria-appimage-no-colrv1-emoji.conf";
@@ -54,6 +58,18 @@ where
.any(|key| get_var(key).is_some_and(|value| !value.trim().is_empty()))
}
#[cfg(all(desktop, target_os = "linux"))]
pub(crate) fn is_running() -> bool {
is_linux_appimage_launch(|key| std::env::var(key).ok())
}
fn should_disable_unstable_webkit_rendering<F>(get_var: &mut F) -> bool
where
F: FnMut(&str) -> Option<String>,
{
is_linux_appimage_launch(&mut *get_var) || is_wayland_session(&mut *get_var)
}
fn has_non_empty_env<F>(get_var: &mut F, key: &str) -> bool
where
F: FnMut(&str) -> Option<String>,
@@ -101,6 +117,68 @@ where
.any(|key| get_var(key).is_some_and(|value| env_mentions_fcitx(&value)))
}
fn non_empty_env<F>(get_var: &mut F, key: &str) -> Option<String>
where
F: FnMut(&str) -> Option<String>,
{
get_var(key).filter(|value| !value.trim().is_empty())
}
#[derive(Debug, PartialEq, Eq)]
struct FcitxGtkModuleFileOverride {
cache_path: std::path::PathBuf,
cache_contents: String,
}
fn fcitx_immodules_cache_contents(module_path: &std::path::Path) -> String {
format!(
r#"# Tolaria AppImage GTK input method modules
"{}"
"fcitx" "Fcitx 5" "fcitx" "" "ja:ko:zh:*"
"#,
module_path.display()
)
}
fn should_use_bundled_fcitx_gtk_module<F>(get_var: &mut F) -> bool
where
F: FnMut(&str) -> Option<String>,
{
if !is_linux_appimage_launch(&mut *get_var) {
return false;
}
if has_non_empty_env(get_var, "GTK_IM_MODULE_FILE") {
return false;
}
if let Some(gtk_im_module) = non_empty_env(get_var, "GTK_IM_MODULE") {
return env_mentions_fcitx(&gtk_im_module);
}
has_fcitx_env_hint(get_var)
}
fn fcitx_gtk_im_module_file_override_with<F, E>(
get_var: &mut F,
mut module_exists: E,
cache_path: std::path::PathBuf,
) -> Option<FcitxGtkModuleFileOverride>
where
F: FnMut(&str) -> Option<String>,
E: FnMut(&std::path::Path) -> bool,
{
if !should_use_bundled_fcitx_gtk_module(get_var) {
return None;
}
let appdir = non_empty_env(get_var, "APPDIR")?;
let module_path = std::path::Path::new(appdir.trim()).join(FCITX_GTK3_IM_MODULE_RELATIVE_PATH);
module_exists(&module_path).then(|| FcitxGtkModuleFileOverride {
cache_path,
cache_contents: fcitx_immodules_cache_contents(&module_path),
})
}
fn fcitx_gtk_im_module_override_with<F>(get_var: &mut F) -> Option<StartupEnvOverride>
where
F: FnMut(&str) -> Option<String>,
@@ -108,9 +186,6 @@ where
if !is_linux_appimage_launch(&mut *get_var) {
return None;
}
if !is_wayland_session(&mut *get_var) {
return None;
}
if has_env(get_var, "GTK_IM_MODULE") {
return None;
}
@@ -225,11 +300,11 @@ fn startup_env_overrides_with<F>(mut get_var: F) -> Vec<StartupEnvOverride>
where
F: FnMut(&str) -> Option<String>,
{
if !is_linux_appimage_launch(&mut get_var) {
if !should_disable_unstable_webkit_rendering(&mut get_var) {
return Vec::new();
}
let mut overrides: Vec<_> = LINUX_APPIMAGE_WEBKIT_OVERRIDES
let mut overrides: Vec<_> = LINUX_WEBKIT_RENDERING_OVERRIDES
.into_iter()
.filter(|env_override| !has_non_empty_env(&mut get_var, env_override.key))
.collect();
@@ -245,12 +320,43 @@ where
pub(crate) fn apply_startup_env_overrides() {
apply_wayland_client_preload();
apply_colrv1_emoji_font_guard();
apply_fcitx_gtk_im_module_file();
for env_override in startup_env_overrides_with(|key| std::env::var(key).ok()) {
std::env::set_var(env_override.key, env_override.value);
}
}
#[cfg(all(desktop, target_os = "linux"))]
fn apply_fcitx_gtk_im_module_file() {
let Some(cache_path) = fcitx_immodules_cache_file_path() else {
eprintln!("Tolaria AppImage fcitx GTK module skipped: failed to resolve cache directory");
return;
};
let Some(env_override) = fcitx_gtk_im_module_file_override_with(
&mut |key| std::env::var(key).ok(),
std::path::Path::is_file,
cache_path,
) else {
return;
};
let Some(parent) = env_override.cache_path.parent() else {
return;
};
if let Err(error) = std::fs::create_dir_all(parent) {
eprintln!("Tolaria AppImage fcitx GTK module skipped: failed to prepare cache ({error})");
return;
}
if let Err(error) = std::fs::write(&env_override.cache_path, env_override.cache_contents) {
eprintln!("Tolaria AppImage fcitx GTK module skipped: failed to write cache ({error})");
return;
}
std::env::set_var("GTK_IM_MODULE_FILE", env_override.cache_path.as_os_str());
}
#[cfg(all(desktop, target_os = "linux"))]
fn apply_colrv1_emoji_font_guard() {
let Some(font_path) =
@@ -309,6 +415,18 @@ fn colrv1_fontconfig_file_path() -> Option<std::path::PathBuf> {
)
}
#[cfg(all(desktop, target_os = "linux"))]
fn fcitx_immodules_cache_file_path() -> Option<std::path::PathBuf> {
let cache_dir =
dirs::cache_dir().or_else(|| dirs::home_dir().map(|home| home.join(".cache")))?;
Some(
cache_dir
.join("tolaria")
.join(TOLARIA_FCITX_IMMODULES_CACHE_FILE),
)
}
#[cfg(all(desktop, target_os = "linux"))]
fn launched_appimage_path() -> Result<std::path::PathBuf, String> {
if let Some(appimage) = std::env::var_os("APPIMAGE").filter(|value| !value.is_empty()) {
@@ -366,7 +484,8 @@ fn apply_wayland_client_preload() {
mod tests {
use super::{
colrv1_emoji_font_path_with, colrv1_emoji_fontconfig_contents, elf_library_matches_process,
startup_env_overrides_with, wayland_client_preload_path_with, StartupEnvOverride,
fcitx_gtk_im_module_file_override_with, startup_env_overrides_with,
wayland_client_preload_path_with, StartupEnvOverride, FCITX_GTK3_IM_MODULE_RELATIVE_PATH,
};
fn default_webkit_overrides() -> Vec<StartupEnvOverride> {
@@ -402,14 +521,14 @@ mod tests {
}
#[test]
fn startup_env_overrides_are_empty_outside_appimage_launches() {
fn startup_env_overrides_are_empty_outside_appimage_or_wayland_launches() {
let overrides = startup_env_overrides_with(|_| None);
assert!(overrides.is_empty());
}
#[test]
fn startup_env_overrides_disable_unstable_webkit_rendering_for_appimages() {
fn startup_env_overrides_disable_unstable_webkit_rendering_for_appimage_launches() {
let overrides = startup_env_overrides_with(|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
_ => None,
@@ -418,6 +537,16 @@ mod tests {
assert_eq!(overrides, default_webkit_overrides());
}
#[test]
fn startup_env_overrides_disable_unstable_webkit_rendering_for_native_wayland_launches() {
let overrides = startup_env_overrides_with(|key| match key {
"XDG_SESSION_TYPE" => Some("wayland".to_string()),
_ => None,
});
assert_eq!(overrides, default_webkit_overrides());
}
#[test]
fn startup_env_overrides_preserve_explicit_user_setting_per_variable() {
let overrides = startup_env_overrides_with(|key| match key {
@@ -442,6 +571,18 @@ mod tests {
assert_eq!(overrides, default_webkit_and_fcitx_overrides());
}
#[test]
fn startup_env_overrides_enable_fcitx_gtk_module_for_x11_appimage() {
let overrides = startup_env_overrides_with(|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"XDG_SESSION_TYPE" => Some("x11".to_string()),
"XMODIFIERS" => Some("@im=fcitx".to_string()),
_ => None,
});
assert_eq!(overrides, default_webkit_and_fcitx_overrides());
}
#[test]
fn startup_env_overrides_preserve_explicit_gtk_im_module() {
let overrides =
@@ -461,6 +602,50 @@ mod tests {
assert_eq!(overrides, default_webkit_overrides());
}
#[test]
fn fcitx_module_file_override_points_gtk_to_bundled_appimage_module() {
let appdir = std::path::Path::new("/tmp/.mount_Tolaria");
let module_path = appdir.join(FCITX_GTK3_IM_MODULE_RELATIVE_PATH);
let cache_path = std::path::PathBuf::from("/tmp/tolaria/immodules.cache");
let env_override = fcitx_gtk_im_module_file_override_with(
&mut |key| match key {
"APPDIR" => Some(appdir.display().to_string()),
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"GTK_IM_MODULE" => Some("fcitx".to_string()),
"XDG_SESSION_TYPE" => Some("x11".to_string()),
_ => None,
},
|path| path == module_path,
cache_path.clone(),
)
.unwrap();
assert_eq!(env_override.cache_path, cache_path);
assert!(env_override
.cache_contents
.contains(&module_path.display().to_string()));
assert!(env_override
.cache_contents
.contains("\"fcitx\" \"Fcitx 5\""));
}
#[test]
fn fcitx_module_file_override_preserves_explicit_module_cache() {
let env_override = fcitx_gtk_im_module_file_override_with(
&mut |key| match key {
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"GTK_IM_MODULE" => Some("fcitx".to_string()),
"GTK_IM_MODULE_FILE" => Some("/tmp/custom-immodules.cache".to_string()),
_ => None,
},
|_| true,
std::path::PathBuf::from("/tmp/tolaria/immodules.cache"),
);
assert_eq!(env_override, None);
}
#[test]
fn colrv1_font_guard_targets_reported_appimage_emoji_font() {
let font_path = colrv1_emoji_font_path_with(

View File

@@ -2,9 +2,15 @@ use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
#[cfg(any(test, all(desktop, target_os = "linux")))]
mod extraction;
mod opencode;
mod paths;
mod subprocess;
#[cfg(all(desktop, target_os = "linux"))]
pub(crate) use extraction::extract_mcp_server_to_stable_dir;
const MCP_SERVER_NAME: &str = "tolaria";
const LEGACY_MCP_SERVER_NAME: &str = "laputa";
@@ -18,44 +24,108 @@ pub enum McpStatus {
NotInstalled,
}
/// Find the `node` binary path at runtime.
pub(crate) fn find_node() -> Result<PathBuf, String> {
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),
}
}
Err(last_error.unwrap_or_else(|| "node not found in PATH or common install locations".into()))
/// A resolved runtime that can execute the MCP server scripts.
#[derive(Debug, Clone)]
pub(crate) struct McpRuntime {
pub(crate) kind: McpRuntimeKind,
pub(crate) binary: 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());
/// Which JS runtime was selected for the MCP server.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum McpRuntimeKind {
Node,
Bun,
}
impl McpRuntimeKind {
fn binary_name(self) -> &'static str {
match self {
McpRuntimeKind::Node => node_binary_name(),
McpRuntimeKind::Bun => bun_binary_name(),
}
}
}
/// Find any supported MCP runtime, preferring Node over Bun.
pub(crate) fn find_mcp_runtime() -> Result<McpRuntime, String> {
let mut last_error = None;
for kind in [McpRuntimeKind::Node, McpRuntimeKind::Bun] {
if let Some(binary) = try_runtime(kind, &mut last_error) {
return Ok(McpRuntime { kind, binary });
}
}
Err(last_error.unwrap_or_else(|| {
"No supported MCP runtime found. Install Node.js 18+ or Bun 1+ and ensure it's on PATH."
.into()
}))
}
/// Find the `node` binary specifically. Used by Codex/CLI agent shims that
/// require Node and cannot fall back to Bun.
pub(crate) fn find_node() -> Result<PathBuf, String> {
let mut last_error = None;
if let Some(binary) = try_runtime(McpRuntimeKind::Node, &mut last_error) {
return Ok(binary);
}
Err(last_error.unwrap_or_else(|| {
format!(
"{} not found in PATH or common install locations",
McpRuntimeKind::Node.binary_name()
)
}))
}
fn try_runtime(kind: McpRuntimeKind, last_error: &mut Option<String>) -> Option<PathBuf> {
for path in runtime_binary_candidates(kind) {
match verify_runtime_version(kind, &path) {
Ok(()) => return Some(path),
Err(error) => *last_error = Some(error),
}
}
None
}
fn runtime_binary_candidates(kind: McpRuntimeKind) -> Vec<PathBuf> {
let command = kind.binary_name();
let mut candidates = find_on_path(command);
candidates.extend(find_in_user_shell(command));
candidates.extend(fallback_paths_for(kind));
candidates
}
fn find_node_on_path() -> Vec<PathBuf> {
node_lookup_command()
fn fallback_paths_for(kind: McpRuntimeKind) -> Vec<PathBuf> {
match kind {
McpRuntimeKind::Node => fallback_node_paths(),
McpRuntimeKind::Bun => fallback_bun_paths(),
}
}
fn verify_runtime_version(kind: McpRuntimeKind, path: &Path) -> Result<(), String> {
match kind {
McpRuntimeKind::Node => verify_node_version(path),
McpRuntimeKind::Bun => verify_bun_version(path),
}
}
fn find_on_path(command: &str) -> Vec<PathBuf> {
lookup_command(command)
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| node_lookup_paths(&output.stdout))
.map(|output| lookup_paths(&output.stdout))
.unwrap_or_default()
}
fn find_node_in_user_shell() -> Vec<PathBuf> {
fn find_in_user_shell(command: &str) -> Vec<PathBuf> {
user_shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.filter_map(|shell| command_path_from_shell(&shell, "node"))
.filter_map(|shell| command_path_from_shell(&shell, command))
.collect()
}
fn node_lookup_paths(stdout: &[u8]) -> Vec<PathBuf> {
fn lookup_paths(stdout: &[u8]) -> Vec<PathBuf> {
String::from_utf8_lossy(stdout)
.lines()
.map(str::trim)
@@ -142,14 +212,14 @@ fn node_major_version(version: &str) -> Option<u32> {
.and_then(|major| major.parse().ok())
}
fn node_lookup_command() -> Command {
fn lookup_command(command: &str) -> Command {
#[cfg(windows)]
let mut command = subprocess::command("where.exe");
let mut cmd = subprocess::command("where.exe");
#[cfg(not(windows))]
let mut command = subprocess::command("which");
let mut cmd = subprocess::command("which");
command.arg("node");
command
cmd.arg(command);
cmd
}
fn fallback_node_paths() -> Vec<PathBuf> {
@@ -158,6 +228,9 @@ fn fallback_node_paths() -> Vec<PathBuf> {
PathBuf::from("/usr/local/bin/node"),
];
#[cfg(not(windows))]
candidates.push(PathBuf::from("/home/linuxbrew/.linuxbrew/bin/node"));
#[cfg(windows)]
{
if let Some(program_files) = std::env::var_os("ProgramFiles") {
@@ -197,6 +270,7 @@ fn node_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
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()),
home.join(".linuxbrew").join("bin").join(node_binary_name()),
];
let nvm_dir = home.join(".nvm").join("versions").join("node");
@@ -224,6 +298,81 @@ fn node_binary_name() -> &'static str {
}
}
fn fallback_bun_paths() -> Vec<PathBuf> {
let mut candidates = vec![
PathBuf::from("/opt/homebrew/bin/bun"),
PathBuf::from("/usr/local/bin/bun"),
];
#[cfg(windows)]
{
if let Some(profile) = std::env::var_os("USERPROFILE") {
candidates.push(
PathBuf::from(profile)
.join(".bun")
.join("bin")
.join("bun.exe"),
);
}
}
if let Some(home) = dirs::home_dir() {
candidates.extend(bun_binary_candidates_for_home(&home));
}
candidates
.into_iter()
.filter(|path| path.is_file())
.collect()
}
fn bun_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".bun").join("bin").join(bun_binary_name()),
home.join(".local/share/mise/shims").join(bun_binary_name()),
home.join(".mise").join("shims").join(bun_binary_name()),
home.join(".asdf").join("shims").join(bun_binary_name()),
home.join(".proto").join("bin").join(bun_binary_name()),
]
}
fn bun_binary_name() -> &'static str {
if cfg!(windows) {
"bun.exe"
} else {
"bun"
}
}
fn verify_bun_version(bun: &Path) -> Result<(), String> {
let output = subprocess::command(bun)
.arg("--version")
.output()
.map_err(|e| format!("Failed to run {} --version: {e}", bun.display()))?;
if !output.status.success() {
return Err(format!(
"{} --version failed; install Bun 1+ and make it available on PATH",
bun.display()
));
}
let raw_version = String::from_utf8_lossy(&output.stdout);
let Some(major) = node_major_version(&raw_version) else {
return Err(format!(
"Cannot parse Bun version from '{}'",
raw_version.trim()
));
};
if major < 1 {
return Err(format!(
"Bun 1+ is required for Tolaria MCP tools; found {}",
raw_version.trim()
));
}
Ok(())
}
/// Resolve the path to `mcp-server/`.
///
/// In dev mode, prefers `CARGO_MANIFEST_DIR` and falls back to runtime checkout ancestors.
@@ -249,6 +398,15 @@ pub(crate) fn mcp_server_dir() -> Result<PathBuf, String> {
))
}
fn mcp_server_dir_for_registration() -> Result<PathBuf, String> {
#[cfg(all(desktop, target_os = "linux"))]
if let Some(stable_dir) = extraction::ready_stable_mcp_server_dir() {
return Ok(stable_dir);
}
mcp_server_dir()
}
fn build_time_dev_mcp_server_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
@@ -392,13 +550,13 @@ pub fn spawn_ws_bridge_with_paths(
vault_path: impl AsRef<Path>,
vault_paths: &[PathBuf],
) -> Result<Child, String> {
let node = find_node()?;
let runtime = find_mcp_runtime()?;
let server_dir = mcp_server_dir()?;
let script = server_dir.join("ws-bridge.js");
let vault_path = vault_path.as_ref();
let active_vault_paths = active_vault_paths_json(vault_path, vault_paths);
let mut command = subprocess::command(node);
let mut command = subprocess::command(&runtime.binary);
let child = command
.arg(&script)
.env("VAULT_PATH", vault_path)
@@ -412,8 +570,9 @@ pub fn spawn_ws_bridge_with_paths(
.map_err(|e| format!("Failed to spawn ws-bridge: {e}"))?;
log::info!(
"ws-bridge spawned (pid: {}, vault: {})",
"ws-bridge spawned (pid: {}, runtime: {:?}, vault: {})",
child.id(),
runtime.kind,
vault_path.display()
);
Ok(child)
@@ -482,29 +641,13 @@ fn entry_has_ui_port(entry: &serde_json::Value) -> bool {
entry["env"]["WS_UI_PORT"].as_str() == Some("9711")
}
fn entry_targets_vault(entry: &serde_json::Value, vault_path: &Path) -> bool {
let Some(entry_vault_path) = entry["env"]["VAULT_PATH"].as_str() else {
return false;
};
let Ok(expected) = std::fs::canonicalize(vault_path) else {
return false;
};
let Ok(actual) = std::fs::canonicalize(entry_vault_path) else {
return false;
};
actual == expected
}
/// Build the MCP server entry JSON for a given vault path and index.js path.
fn build_mcp_entry(node_command: &str, index_js: &str, vault_path: &str) -> serde_json::Value {
/// Build the durable external MCP server entry JSON for an index.js path.
fn build_mcp_entry(runtime_command: &str, index_js: &str) -> serde_json::Value {
serde_json::json!({
"type": "stdio",
"command": node_command,
"command": runtime_command,
"args": [index_js],
"env": {
"VAULT_PATH": vault_path,
"WS_UI_PORT": "9711"
}
})
@@ -521,13 +664,16 @@ fn build_mcp_config_snippet(entry: &serde_json::Value) -> Result<String, String>
/// 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 _ = vault_path;
let runtime = find_mcp_runtime().map_err(|e| {
format!(
"Node.js 18+ or Bun 1+ is required on PATH before Tolaria can build MCP config: {e}"
)
})?;
let server_dir = mcp_server_dir()?;
let server_dir = mcp_server_dir_for_registration()?;
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);
let runtime_command = runtime.binary.to_string_lossy().into_owned();
let entry = build_mcp_entry(&runtime_command, &index_js);
build_mcp_config_snippet(&entry)
}
@@ -548,14 +694,23 @@ fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf])
/// Register Tolaria as an MCP server in external AI tool config files.
pub fn register_mcp(vault_path: &str) -> Result<String, String> {
let node = find_node().map_err(|e| {
format!("Node.js 18+ is required on PATH before Tolaria can register MCP tools: {e}")
let _ = vault_path;
let runtime = find_mcp_runtime().map_err(|e| {
format!(
"Node.js 18+ or Bun 1+ is required on PATH before Tolaria can register MCP tools: {e}"
)
})?;
let server_dir = mcp_server_dir()?;
let server_dir = mcp_server_dir_for_registration()?;
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
let node_command = node.to_string_lossy().into_owned();
let runtime_command = runtime.binary.to_string_lossy().into_owned();
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
let entry = build_mcp_entry(&runtime_command, &index_js);
let opencode_entry = opencode::build_entry(&runtime_command, &index_js);
if let Some(config_path) = opencode::config_path() {
if let Err(e) = opencode::upsert_config(&config_path, &opencode_entry) {
log::warn!("Failed to update {}: {}", config_path.display(), e);
}
}
Ok(register_mcp_to_configs(&entry, &mcp_config_paths()))
}
@@ -657,7 +812,19 @@ fn remove_mcp_from_config(config_path: &Path) -> Result<bool, String> {
}
pub fn remove_mcp() -> String {
remove_mcp_from_configs(&mcp_config_paths())
let removed_standard = remove_mcp_from_configs(&mcp_config_paths()) == "removed";
let removed_opencode = opencode::config_path().is_some_and(|config_path| {
opencode::remove_config(&config_path).unwrap_or_else(|e| {
log::warn!("Failed to update {}: {}", config_path.display(), e);
false
})
});
if removed_standard || removed_opencode {
"removed".to_string()
} else {
"already_absent".to_string()
}
}
/// Check whether the MCP server is properly installed and registered.
@@ -666,15 +833,18 @@ pub fn remove_mcp() -> String {
/// an external AI tool config and the referenced index.js file is present.
/// Otherwise returns `NotInstalled`.
pub fn check_mcp_status(vault_path: &str) -> McpStatus {
let active_vault_path = Path::new(vault_path);
if mcp_config_paths().into_iter().any(|config_path| {
let _ = vault_path;
let installed_standard = mcp_config_paths().into_iter().any(|config_path| {
read_registered_mcp_entry(&config_path).is_some_and(|entry| {
entry_uses_stdio(&entry)
&& entry_index_js_exists(&entry)
&& entry_has_ui_port(&entry)
&& entry_targets_vault(&entry, active_vault_path)
entry_uses_stdio(&entry) && entry_index_js_exists(&entry) && entry_has_ui_port(&entry)
})
}) {
});
let installed_opencode = opencode::config_path().is_some_and(|config_path| {
opencode::read_registered_entry(&config_path)
.is_some_and(|entry| opencode::entry_is_installed(&entry))
});
if installed_standard || installed_opencode {
McpStatus::Installed
} else {
McpStatus::NotInstalled
@@ -700,17 +870,17 @@ mod tests {
std::fs::write(config_path, serde_json::to_string(&config).unwrap()).unwrap();
}
fn managed_server(index_js: &str, vault_path: &str) -> serde_json::Value {
fn managed_server(index_js: &str) -> serde_json::Value {
serde_json::json!({
"type": "stdio",
"command": "node",
"args": [index_js],
"env": { "VAULT_PATH": vault_path, "WS_UI_PORT": "9711" }
"env": { "WS_UI_PORT": "9711" }
})
}
fn test_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value {
build_mcp_entry("node", index_js, vault_path)
fn test_mcp_entry(index_js: &str) -> serde_json::Value {
build_mcp_entry("node", index_js)
}
fn write_mcp_servers_config(config_path: &Path, servers: Vec<(&str, serde_json::Value)>) {
@@ -723,7 +893,6 @@ mod tests {
struct ExpectedMcpServer<'a> {
index_js: &'a str,
vault_path: &'a str,
}
fn assert_registered_tolaria_server(
@@ -732,7 +901,8 @@ mod tests {
) {
let server = &config["mcpServers"][MCP_SERVER_NAME];
assert_eq!(server["args"][0], expected.index_js);
assert_eq!(server["env"]["VAULT_PATH"], expected.vault_path);
assert!(server["env"]["VAULT_PATH"].is_null());
assert_eq!(server["env"]["WS_UI_PORT"], "9711");
}
fn write_index_js(dir: &Path) -> PathBuf {
@@ -743,7 +913,7 @@ mod tests {
#[test]
fn build_mcp_entry_produces_correct_json() {
let entry = build_mcp_entry("/usr/local/bin/node", "/path/to/index.js", "/my/vault");
let entry = build_mcp_entry("/usr/local/bin/node", "/path/to/index.js");
assert_eq!(
entry,
serde_json::json!({
@@ -751,7 +921,6 @@ mod tests {
"command": "/usr/local/bin/node",
"args": ["/path/to/index.js"],
"env": {
"VAULT_PATH": "/my/vault",
"WS_UI_PORT": "9711"
}
})
@@ -760,7 +929,7 @@ mod tests {
#[test]
fn build_mcp_config_snippet_wraps_tolaria_server_entry() {
let entry = test_mcp_entry("/path/to/index.js", "/my/vault");
let entry = test_mcp_entry("/path/to/index.js");
let snippet = build_mcp_config_snippet(&entry).unwrap();
let config: serde_json::Value = serde_json::from_str(&snippet).unwrap();
@@ -768,17 +937,14 @@ mod tests {
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/path/to/index.js"
);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
"/my/vault"
);
assert!(config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"].is_null());
}
#[test]
fn node_lookup_paths_keep_non_empty_lines_in_order() {
fn 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!(
node_lookup_paths(stdout),
lookup_paths(stdout),
vec![
PathBuf::from("C:\\Program Files\\nodejs\\node.exe"),
PathBuf::from("C:\\Other\\node.exe"),
@@ -837,6 +1003,7 @@ mod tests {
home.join(".local/share/mise/shims/node"),
home.join(".asdf/shims/node"),
home.join(".volta/bin/node"),
home.join(".linuxbrew/bin/node"),
];
for candidate in expected {
@@ -946,7 +1113,7 @@ mod tests {
fn upsert_creates_new_config() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let entry = test_mcp_entry("/test/index.js", "/test/vault");
let entry = test_mcp_entry("/test/index.js");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
assert!(!was_update);
@@ -956,7 +1123,6 @@ mod tests {
&config,
ExpectedMcpServer {
index_js: "/test/index.js",
vault_path: "/test/vault",
},
);
}
@@ -966,18 +1132,15 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let entry1 = test_mcp_entry("/test/index.js", "/vault/v1");
let entry1 = test_mcp_entry("/test/index.js");
upsert_mcp_config(&config_path, &entry1).unwrap();
let entry2 = test_mcp_entry("/test/index.js", "/vault/v2");
let entry2 = test_mcp_entry("/test/index.js");
let was_update = upsert_mcp_config(&config_path, &entry2).unwrap();
assert!(was_update);
let config = read_config(&config_path);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
"/vault/v2"
);
assert!(config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"].is_null());
}
#[test]
@@ -996,7 +1159,7 @@ mod tests {
});
std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap();
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
assert!(was_update);
@@ -1019,7 +1182,7 @@ mod tests {
)],
);
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
upsert_mcp_config(&config_path, &entry).unwrap();
let raw = std::fs::read_to_string(&config_path).unwrap();
@@ -1042,7 +1205,7 @@ mod tests {
}),
);
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
upsert_mcp_config(&config_path, &entry).unwrap();
let config = read_config(&config_path);
@@ -1069,7 +1232,7 @@ mod tests {
}
}),
);
let entry = test_mcp_entry("/gemini/index.js", "/gemini-vault");
let entry = test_mcp_entry("/gemini/index.js");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
let config = read_config(&config_path);
@@ -1081,7 +1244,6 @@ mod tests {
&config,
ExpectedMcpServer {
index_js: "/gemini/index.js",
vault_path: "/gemini-vault",
},
);
}
@@ -1090,7 +1252,7 @@ mod tests {
fn upsert_creates_parent_dirs() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("nested").join("dir").join("mcp.json");
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
upsert_mcp_config(&config_path, &entry).unwrap();
assert!(config_path.exists());
@@ -1100,7 +1262,7 @@ mod tests {
fn register_mcp_to_configs_returns_registered_for_new() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("claude").join("mcp.json");
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
let status = register_mcp_to_configs(&entry, &[config]);
assert_eq!(status, "registered");
@@ -1110,7 +1272,7 @@ mod tests {
fn register_mcp_to_configs_returns_updated_for_existing() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("mcp.json");
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
// First call
register_mcp_to_configs(&entry, std::slice::from_ref(&config));
@@ -1130,6 +1292,63 @@ mod tests {
);
}
#[test]
fn find_mcp_runtime_returns_valid_runtime() {
let runtime = find_mcp_runtime().unwrap();
assert!(
runtime.binary.exists(),
"runtime binary should exist at {:?}",
runtime.binary
);
let expected = match runtime.kind {
McpRuntimeKind::Node => "node",
McpRuntimeKind::Bun => "bun",
};
assert!(
runtime.binary.to_string_lossy().contains(expected),
"path should contain '{expected}': {:?}",
runtime.binary
);
}
#[test]
fn bun_binary_candidates_include_shell_managed_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = bun_binary_candidates_for_home(&home);
let expected = [
home.join(".bun/bin/bun"),
home.join(".local/share/mise/shims/bun"),
home.join(".mise/shims/bun"),
home.join(".asdf/shims/bun"),
home.join(".proto/bin/bun"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn verify_bun_version_accepts_real_bun_binary() {
let Ok(bun) = find_bun_for_test() else {
// Bun is optional on dev machines; skip when absent.
return;
};
verify_bun_version(&bun).expect("installed bun should satisfy version requirement");
}
fn find_bun_for_test() -> Result<PathBuf, String> {
let mut last_error = None;
if let Some(bin) = try_runtime(McpRuntimeKind::Bun, &mut last_error) {
return Ok(bin);
}
Err(last_error.unwrap_or_else(|| "bun not present in test environment".into()))
}
#[test]
fn mcp_server_dir_resolves_in_dev() {
let dir = mcp_server_dir().unwrap();
@@ -1159,7 +1378,7 @@ mod tests {
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");
let entry = test_mcp_entry("/test/index.js");
register_mcp_to_configs(
&entry,
@@ -1187,7 +1406,6 @@ mod tests {
&config,
ExpectedMcpServer {
index_js: "/test/index.js",
vault_path: "/vault",
},
);
}
@@ -1213,14 +1431,14 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
std::fs::write(&config_path, "not valid json{{{{").unwrap();
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
let result = upsert_mcp_config(&config_path, &entry);
assert!(result.is_err());
}
#[test]
fn register_mcp_to_configs_handles_empty_list() {
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
// Empty config list — function should return "registered" (no existing)
let status = register_mcp_to_configs(&entry, &[]);
// With empty config list, there were no updates, so status should be "registered"
@@ -1233,19 +1451,13 @@ mod tests {
write_mcp_servers_config(
&config_path,
vec![
(
MCP_SERVER_NAME,
managed_server("/primary/index.js", "/primary"),
),
(
LEGACY_MCP_SERVER_NAME,
managed_server("/legacy/index.js", "/legacy"),
),
(MCP_SERVER_NAME, managed_server("/primary/index.js")),
(LEGACY_MCP_SERVER_NAME, managed_server("/legacy/index.js")),
],
);
let entry = read_registered_mcp_entry(&config_path).unwrap();
assert_eq!(entry["env"]["VAULT_PATH"], "/primary");
assert_eq!(entry["args"][0], "/primary/index.js");
}
#[test]
@@ -1253,14 +1465,11 @@ mod tests {
let (_tmp, config_path) = temp_config_path("mcp.json");
write_mcp_servers_config(
&config_path,
vec![(
LEGACY_MCP_SERVER_NAME,
managed_server("/legacy/index.js", "/legacy"),
)],
vec![(LEGACY_MCP_SERVER_NAME, managed_server("/legacy/index.js"))],
);
let entry = read_registered_mcp_entry(&config_path).unwrap();
assert_eq!(entry["env"]["VAULT_PATH"], "/legacy");
assert_eq!(entry["args"][0], "/legacy/index.js");
}
#[test]
@@ -1305,7 +1514,7 @@ mod tests {
let config_path = tmp.path().join("mcp.json");
std::fs::write(&config_path, "[]").unwrap();
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
let result = upsert_mcp_config(&config_path, &entry);
assert!(matches!(result, Err(ref error) if error.contains("Config is not a JSON object")));
}
@@ -1319,7 +1528,7 @@ mod tests {
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
let result = upsert_mcp_config(&config_path, &entry);
assert!(
matches!(result, Err(ref error) if error.contains("mcpServers is not a JSON object"))
@@ -1364,42 +1573,26 @@ mod tests {
}
#[test]
fn check_mcp_status_returns_installed_for_matching_vault() {
fn durable_entry_is_status_eligible_without_vault_env() {
let tmp = tempfile::tempdir().unwrap();
let vault_path = tmp.path().join("vault");
std::fs::create_dir_all(&vault_path).unwrap();
let index_js = write_index_js(tmp.path());
let config_path = tmp.path().join("mcp.json");
let config = serde_json::json!({
"mcpServers": {
"tolaria": {
"type": "stdio",
"command": "node",
"args": [index_js.to_string_lossy()],
"env": { "VAULT_PATH": vault_path.to_string_lossy() }
"env": { "WS_UI_PORT": "9711" }
}
}
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let entry = read_registered_mcp_entry(&config_path).unwrap();
assert!(entry_targets_vault(&entry, &vault_path));
assert!(entry_uses_stdio(&entry));
assert!(entry_index_js_exists(&entry));
}
#[test]
fn entry_targets_vault_requires_matching_existing_directory() {
let tmp = tempfile::tempdir().unwrap();
let first_vault = tmp.path().join("vault-a");
let second_vault = tmp.path().join("vault-b");
std::fs::create_dir_all(&first_vault).unwrap();
std::fs::create_dir_all(&second_vault).unwrap();
let entry = serde_json::json!({
"env": { "VAULT_PATH": first_vault.to_string_lossy() }
});
assert!(entry_targets_vault(&entry, &first_vault));
assert!(!entry_targets_vault(&entry, &second_vault));
assert!(entry_has_ui_port(&entry));
}
#[test]

View File

@@ -0,0 +1,303 @@
use std::fs;
#[cfg(all(desktop, target_os = "linux"))]
use std::fs::OpenOptions;
use std::path::{Path, PathBuf};
#[cfg(all(desktop, target_os = "linux"))]
use std::time::{Duration, Instant, SystemTime};
const VERSION_MARKER_FILE: &str = ".tolaria-version";
#[cfg(all(desktop, target_os = "linux"))]
const LOCK_FILE: &str = "mcp-server.lock";
const STAGING_DIR: &str = "mcp-server.staging";
const BACKUP_DIR: &str = "mcp-server.previous";
#[cfg(all(desktop, target_os = "linux"))]
const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(all(desktop, target_os = "linux"))]
const STALE_LOCK_AFTER: Duration = Duration::from_secs(120);
#[cfg(all(desktop, target_os = "linux"))]
pub(super) fn ready_stable_mcp_server_dir() -> Option<PathBuf> {
let stable_dir = stable_mcp_server_dir().ok()?;
stable_mcp_server_dir_is_ready(&stable_dir).then_some(stable_dir)
}
#[cfg(all(desktop, target_os = "linux"))]
pub(crate) fn extract_mcp_server_to_stable_dir(app_version: &str) -> Result<PathBuf, String> {
let source_dir = super::mcp_server_dir()?;
let target_dir = stable_mcp_server_dir()?;
if !needs_extraction(app_version, &target_dir) {
return Ok(target_dir);
}
let _lock = ExtractionLock::acquire(&extraction_lock_path()?)?;
if !needs_extraction(app_version, &target_dir) {
return Ok(target_dir);
}
replace_stable_server_dir(&source_dir, &target_dir, app_version)?;
Ok(target_dir)
}
fn stable_mcp_server_dir() -> Result<PathBuf, String> {
dirs::data_dir()
.map(|data_dir| data_dir.join("tolaria").join("mcp-server"))
.ok_or_else(|| "Unable to resolve data directory for stable MCP server path".to_string())
}
fn stable_mcp_server_dir_is_ready(dir: &Path) -> bool {
mcp_server_dir_has_files(dir) && read_version_marker(dir).is_some()
}
fn mcp_server_dir_has_files(dir: &Path) -> bool {
dir.join("index.js").is_file() && dir.join("ws-bridge.js").is_file()
}
fn needs_extraction(app_version: &str, target_dir: &Path) -> bool {
!mcp_server_dir_has_files(target_dir)
|| read_version_marker(target_dir).as_deref() != Some(app_version)
}
fn read_version_marker(dir: &Path) -> Option<String> {
fs::read_to_string(dir.join(VERSION_MARKER_FILE))
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn write_version_marker(dir: &Path, app_version: &str) -> Result<(), String> {
let marker = dir.join(VERSION_MARKER_FILE);
fs::write(&marker, app_version)
.map_err(|e| format!("Failed to write version marker {}: {e}", marker.display()))
}
#[cfg(all(desktop, target_os = "linux"))]
fn extraction_lock_path() -> Result<PathBuf, String> {
let stable_dir = stable_mcp_server_dir()?;
stable_dir
.parent()
.map(|parent| parent.join(LOCK_FILE))
.ok_or_else(|| {
format!(
"Stable MCP server path has no parent: {}",
stable_dir.display()
)
})
}
fn replace_stable_server_dir(
source_dir: &Path,
target_dir: &Path,
app_version: &str,
) -> Result<(), String> {
let parent = target_dir.parent().ok_or_else(|| {
format!(
"Stable MCP server path has no parent: {}",
target_dir.display()
)
})?;
let staging_dir = parent.join(STAGING_DIR);
let backup_dir = parent.join(BACKUP_DIR);
remove_dir_if_exists(&staging_dir)?;
remove_dir_if_exists(&backup_dir)?;
copy_dir_all(source_dir, &staging_dir)?;
write_version_marker(&staging_dir, app_version)?;
swap_staging_into_place(&staging_dir, target_dir, &backup_dir)?;
Ok(())
}
fn swap_staging_into_place(
staging_dir: &Path,
target_dir: &Path,
backup_dir: &Path,
) -> Result<(), String> {
if target_dir.exists() {
fs::rename(target_dir, backup_dir)
.map_err(|e| format!("Failed to move stable MCP server aside: {e}"))?;
}
if let Err(error) = fs::rename(staging_dir, target_dir) {
if backup_dir.exists() {
let _ = fs::rename(backup_dir, target_dir);
}
return Err(format!("Failed to activate stable MCP server: {error}"));
}
remove_dir_if_exists(backup_dir)
}
fn copy_dir_all(source: &Path, target: &Path) -> Result<(), String> {
fs::create_dir_all(target)
.map_err(|e| format!("Failed to create {}: {e}", target.display()))?;
for entry in fs::read_dir(source).map_err(|e| {
format!(
"Failed to read MCP server directory {}: {e}",
source.display()
)
})? {
let entry = entry.map_err(|e| format!("Failed to read MCP server entry: {e}"))?;
let source_path = entry.path();
let target_path = target.join(entry.file_name());
if source_path.is_dir() {
copy_dir_all(&source_path, &target_path)?;
} else {
fs::copy(&source_path, &target_path).map_err(|e| {
format!(
"Failed to copy {} to {}: {e}",
source_path.display(),
target_path.display()
)
})?;
}
}
Ok(())
}
fn remove_dir_if_exists(path: &Path) -> Result<(), String> {
if path.exists() {
fs::remove_dir_all(path)
.map_err(|e| format!("Failed to remove {}: {e}", path.display()))?;
}
Ok(())
}
#[cfg(all(desktop, target_os = "linux"))]
struct ExtractionLock {
path: PathBuf,
}
#[cfg(all(desktop, target_os = "linux"))]
impl ExtractionLock {
fn acquire(path: &Path) -> Result<Self, String> {
let started = Instant::now();
loop {
match Self::try_create(path) {
Ok(()) => {
return Ok(Self {
path: path.to_path_buf(),
});
}
Err(error) if lock_is_stale(path) => {
let _ = fs::remove_file(path);
log::warn!("Removed stale MCP extraction lock after error: {error}");
}
Err(error) if started.elapsed() >= LOCK_TIMEOUT => return Err(error),
Err(_) => std::thread::sleep(Duration::from_millis(50)),
}
}
}
fn try_create(path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create MCP extraction lock dir: {e}"))?;
}
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(|e| format!("Failed to acquire MCP extraction lock: {e}"))?;
use std::io::Write;
writeln!(file, "{}", std::process::id())
.map_err(|e| format!("Failed to write MCP extraction lock: {e}"))
}
}
#[cfg(all(desktop, target_os = "linux"))]
impl Drop for ExtractionLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
#[cfg(all(desktop, target_os = "linux"))]
fn lock_is_stale(path: &Path) -> bool {
path.metadata()
.and_then(|metadata| metadata.modified())
.ok()
.and_then(|modified| SystemTime::now().duration_since(modified).ok())
.is_some_and(|age| age >= STALE_LOCK_AFTER)
}
#[cfg(test)]
mod tests {
use super::*;
fn create_server_dir(parent: &Path) -> PathBuf {
let dir = parent.join("server");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("index.js"), "console.log('index');").unwrap();
fs::write(dir.join("ws-bridge.js"), "console.log('bridge');").unwrap();
dir
}
#[test]
fn stable_mcp_server_dir_uses_app_data_dir() {
let expected = dirs::data_dir()
.expect("data dir should exist")
.join("tolaria")
.join("mcp-server");
assert_eq!(stable_mcp_server_dir().unwrap(), expected);
}
#[test]
fn stable_mcp_server_dir_requires_marker_and_files() {
let tmp = tempfile::tempdir().unwrap();
let stable_dir = tmp.path().join("tolaria").join("mcp-server");
fs::create_dir_all(&stable_dir).unwrap();
fs::write(stable_dir.join("index.js"), "").unwrap();
fs::write(stable_dir.join("ws-bridge.js"), "").unwrap();
assert!(!stable_mcp_server_dir_is_ready(&stable_dir));
write_version_marker(&stable_dir, "2026.5.14").unwrap();
assert!(stable_mcp_server_dir_is_ready(&stable_dir));
}
#[test]
fn needs_extraction_tracks_version_marker() {
let target = tempfile::tempdir().unwrap();
fs::write(target.path().join("index.js"), "").unwrap();
fs::write(target.path().join("ws-bridge.js"), "").unwrap();
assert!(needs_extraction("2026.5.14", target.path()));
write_version_marker(target.path(), "2026.5.14").unwrap();
assert!(!needs_extraction("2026.5.14", target.path()));
assert!(needs_extraction("2026.5.15", target.path()));
}
#[test]
fn copy_dir_all_copies_nested_server_files() {
let tmp = tempfile::tempdir().unwrap();
let source = create_server_dir(tmp.path());
fs::create_dir_all(source.join("nested")).unwrap();
fs::write(source.join("nested").join("package.json"), "{}").unwrap();
let target = tmp.path().join("target");
copy_dir_all(&source, &target).unwrap();
assert!(target.join("index.js").is_file());
assert!(target.join("ws-bridge.js").is_file());
assert!(target.join("nested").join("package.json").is_file());
}
#[test]
fn replace_stable_server_dir_swaps_versioned_copy() {
let tmp = tempfile::tempdir().unwrap();
let source = create_server_dir(tmp.path());
let target = tmp.path().join("tolaria").join("mcp-server");
fs::create_dir_all(&target).unwrap();
fs::write(target.join("stale.txt"), "old").unwrap();
replace_stable_server_dir(&source, &target, "2026.5.14").unwrap();
assert!(target.join("index.js").is_file());
assert!(!target.join("stale.txt").exists());
assert_eq!(read_version_marker(&target), Some("2026.5.14".to_string()));
}
}

View File

@@ -0,0 +1,241 @@
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use super::{LEGACY_MCP_SERVER_NAME, MCP_SERVER_NAME};
const OPENCODE_MCP_KEY: &str = "mcp";
pub(super) fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|config_dir| config_dir.join("opencode").join("opencode.json"))
}
pub(super) fn build_entry(node_command: &str, index_js: &str) -> Value {
serde_json::json!({
"type": "local",
"command": [node_command, index_js],
"enabled": true,
"environment": {
"WS_UI_PORT": "9711"
}
})
}
pub(super) fn upsert_config(config_path: &Path, entry: &Value) -> Result<bool, String> {
let mut config = read_config_or_empty(config_path)?;
let servers = ensure_servers_object(&mut config)?;
let was_update =
servers.get(MCP_SERVER_NAME).is_some() || servers.get(LEGACY_MCP_SERVER_NAME).is_some();
servers.remove(LEGACY_MCP_SERVER_NAME);
servers.insert(MCP_SERVER_NAME.to_string(), entry.clone());
write_config(config_path, &config)?;
Ok(was_update)
}
pub(super) fn remove_config(config_path: &Path) -> Result<bool, String> {
if !config_path.exists() {
return Ok(false);
}
let mut config = read_config_or_empty(config_path)?;
let Some(config_object) = config.as_object_mut() else {
return Err("Config is not a JSON object".into());
};
let Some(servers_value) = config_object.get_mut(OPENCODE_MCP_KEY) else {
return Ok(false);
};
let Some(servers) = servers_value.as_object_mut() else {
return Err("mcp is not a JSON object".into());
};
let removed_primary = servers.remove(MCP_SERVER_NAME).is_some();
let removed_legacy = servers.remove(LEGACY_MCP_SERVER_NAME).is_some();
if !removed_primary && !removed_legacy {
return Ok(false);
}
if servers.is_empty() {
config_object.remove(OPENCODE_MCP_KEY);
}
write_config(config_path, &config)?;
Ok(true)
}
pub(super) fn read_registered_entry(config_path: &Path) -> Option<Value> {
let raw = std::fs::read_to_string(config_path).ok()?;
let config: Value = serde_json::from_str(&raw).ok()?;
config
.get(OPENCODE_MCP_KEY)
.and_then(Value::as_object)
.and_then(|servers| {
servers
.get(MCP_SERVER_NAME)
.or_else(|| servers.get(LEGACY_MCP_SERVER_NAME))
})
.cloned()
}
pub(super) fn entry_is_installed(entry: &Value) -> bool {
entry["type"].as_str() == Some("local")
&& entry["enabled"].as_bool() == Some(true)
&& entry["environment"]["WS_UI_PORT"].as_str() == Some("9711")
&& command_index_js_exists(entry)
}
fn command_index_js_exists(entry: &Value) -> bool {
entry["command"]
.as_array()
.and_then(|command| command.get(1))
.and_then(Value::as_str)
.is_some_and(|index_js| Path::new(index_js).exists())
}
fn read_config_or_empty(config_path: &Path) -> Result<Value, String> {
if !config_path.exists() {
return Ok(serde_json::json!({}));
}
let raw = std::fs::read_to_string(config_path)
.map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?;
serde_json::from_str(&raw)
.map_err(|e| format!("Invalid JSON in {}: {e}", config_path.display()))
}
fn ensure_servers_object(config: &mut Value) -> Result<&mut Map<String, Value>, String> {
let servers = config
.as_object_mut()
.ok_or("Config is not a JSON object")?
.entry(OPENCODE_MCP_KEY)
.or_insert_with(|| serde_json::json!({}));
servers
.as_object_mut()
.ok_or_else(|| "mcp is not a JSON object".to_string())
}
fn write_config(config_path: &Path, config: &Value) -> Result<(), String> {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?;
}
let json = serde_json::to_string_pretty(config)
.map_err(|e| format!("Failed to serialize config: {e}"))?;
std::fs::write(config_path, json)
.map_err(|e| format!("Cannot write {}: {e}", config_path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn read_config(config_path: &Path) -> Value {
let raw = std::fs::read_to_string(config_path).unwrap();
serde_json::from_str(&raw).unwrap()
}
fn write_config_json(config_path: &Path, config: Value) {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(config_path, serde_json::to_string(&config).unwrap()).unwrap();
}
#[test]
fn build_entry_uses_opencode_schema_without_vault_path() {
let entry = build_entry("node", "/app/mcp-server/index.js");
assert_eq!(
entry,
serde_json::json!({
"type": "local",
"command": ["node", "/app/mcp-server/index.js"],
"enabled": true,
"environment": {
"WS_UI_PORT": "9711"
}
})
);
assert!(entry["environment"]["VAULT_PATH"].is_null());
}
#[test]
fn upsert_config_preserves_other_opencode_settings() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("opencode.json");
write_config_json(
&config_path,
serde_json::json!({
"$schema": "https://opencode.ai/config.json",
"mcp": {
"other": { "type": "local" }
}
}),
);
let was_update = upsert_config(&config_path, &build_entry("node", "/index.js")).unwrap();
let config = read_config(&config_path);
assert!(!was_update);
assert_eq!(config["$schema"], "https://opencode.ai/config.json");
assert!(config["mcp"]["other"].is_object());
assert_eq!(config["mcp"][MCP_SERVER_NAME]["command"][1], "/index.js");
}
#[test]
fn upsert_config_migrates_legacy_server_name() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("opencode.json");
write_config_json(
&config_path,
serde_json::json!({
"mcp": {
"laputa": { "type": "local", "command": ["node", "/old.js"] }
}
}),
);
let was_update = upsert_config(&config_path, &build_entry("node", "/new.js")).unwrap();
let config = read_config(&config_path);
assert!(was_update);
assert!(config["mcp"][LEGACY_MCP_SERVER_NAME].is_null());
assert_eq!(config["mcp"][MCP_SERVER_NAME]["command"][1], "/new.js");
}
#[test]
fn remove_config_removes_primary_and_legacy_entries() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("opencode.json");
write_config_json(
&config_path,
serde_json::json!({
"mcp": {
"tolaria": { "type": "local" },
"laputa": { "type": "local" },
"other": { "type": "local" }
}
}),
);
assert!(remove_config(&config_path).unwrap());
let config = read_config(&config_path);
assert!(config["mcp"][MCP_SERVER_NAME].is_null());
assert!(config["mcp"][LEGACY_MCP_SERVER_NAME].is_null());
assert!(config["mcp"]["other"].is_object());
}
#[test]
fn entry_is_installed_checks_opencode_shape_and_index_path() {
let tmp = tempfile::tempdir().unwrap();
let index_js = tmp.path().join("index.js");
std::fs::write(&index_js, "").unwrap();
let entry = build_entry("node", &index_js.to_string_lossy());
assert!(entry_is_installed(&entry));
let missing = build_entry("node", &tmp.path().join("missing.js").to_string_lossy());
assert!(!entry_is_installed(&missing));
}
}

View File

@@ -86,14 +86,25 @@ fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf>
}
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)
})
first_existing_path_for_platform(stdout, cfg!(windows))
}
fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option<PathBuf> {
let mut paths = stdout.lines().filter_map(existing_path);
if windows {
return paths.find(|path| crate::cli_agent_runtime::has_windows_cli_extension(path));
}
paths.next()
}
fn existing_path(line: &str) -> Option<PathBuf> {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
}
fn opencode_binary_candidates() -> Vec<PathBuf> {
@@ -105,12 +116,16 @@ fn opencode_binary_candidates() -> Vec<PathBuf> {
fn opencode_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".local/bin/opencode"),
home.join(".local/bin/opencode.cmd"),
home.join(".local/bin/opencode.exe"),
home.join(".opencode/bin/opencode"),
home.join(".opencode/bin/opencode.cmd"),
home.join(".opencode/bin/opencode.exe"),
home.join(".local/share/mise/shims/opencode"),
home.join(".local/share/mise/shims/opencode.cmd"),
home.join(".local/share/mise/shims/opencode.exe"),
home.join(".asdf/shims/opencode"),
home.join(".asdf/shims/opencode.cmd"),
home.join(".asdf/shims/opencode.exe"),
home.join(".npm-global/bin/opencode"),
home.join(".npm-global/bin/opencode.cmd"),
@@ -119,12 +134,14 @@ fn opencode_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
home.join(".npm/bin/opencode.cmd"),
home.join(".npm/bin/opencode.exe"),
home.join(".bun/bin/opencode"),
home.join(".bun/bin/opencode.cmd"),
home.join(".bun/bin/opencode.exe"),
home.join(".linuxbrew/bin/opencode"),
home.join("AppData/Roaming/npm/opencode.cmd"),
home.join("AppData/Roaming/npm/opencode.exe"),
home.join("AppData/Local/pnpm/opencode.cmd"),
home.join("AppData/Local/pnpm/opencode.exe"),
home.join("scoop/shims/opencode.cmd"),
home.join("scoop/shims/opencode.exe"),
PathBuf::from("/home/linuxbrew/.linuxbrew/bin/opencode"),
PathBuf::from("/usr/local/bin/opencode"),
@@ -182,14 +199,20 @@ mod tests {
let home = PathBuf::from(r"C:\Users\alex");
let candidates = opencode_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/opencode.cmd"),
home.join(".opencode/bin/opencode.cmd"),
home.join(".local/share/mise/shims/opencode.cmd"),
home.join(".asdf/shims/opencode.cmd"),
home.join(".npm-global/bin/opencode.cmd"),
home.join(".npm-global/bin/opencode.exe"),
home.join(".npm/bin/opencode.cmd"),
home.join(".npm/bin/opencode.exe"),
home.join(".bun/bin/opencode.cmd"),
home.join("AppData/Roaming/npm/opencode.cmd"),
home.join("AppData/Roaming/npm/opencode.exe"),
home.join("AppData/Local/pnpm/opencode.cmd"),
home.join("AppData/Local/pnpm/opencode.exe"),
home.join("scoop/shims/opencode.cmd"),
home.join("scoop/shims/opencode.exe"),
];
@@ -220,4 +243,20 @@ mod tests {
assert_eq!(first_existing_path(&stdout), Some(opencode));
}
#[test]
fn windows_path_lookup_prefers_cmd_shim_over_extensionless_npm_script() {
let dir = tempfile::tempdir().unwrap();
let shell_script = dir.path().join("opencode");
let cmd_shim = dir.path().join("opencode.cmd");
std::fs::write(&shell_script, "#!/bin/sh\n").unwrap();
std::fs::write(&cmd_shim, "@ECHO off\n").unwrap();
let stdout = format!("{}\n{}\n", shell_script.display(), cmd_shim.display());
assert_eq!(
first_existing_path_for_platform(&stdout, true),
Some(cmd_shim)
);
}
}

View File

@@ -69,12 +69,14 @@ fn user_shell_candidates() -> Vec<PathBuf> {
}
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))
["-lc", "-lic"].into_iter().find_map(|flags| {
crate::hidden_command(shell)
.arg(flags)
.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> {
@@ -118,12 +120,21 @@ fn pi_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
home.join(".local/share/mise/shims/pi.exe"),
home.join(".asdf/shims/pi"),
home.join(".asdf/shims/pi.exe"),
home.join(".volta/bin/pi"),
home.join(".volta/bin/pi.cmd"),
home.join(".volta/bin/pi.exe"),
home.join(".npm-global/bin/pi"),
home.join(".npm-global/bin/pi.cmd"),
home.join(".npm-global/bin/pi.exe"),
home.join(".npm/bin/pi"),
home.join(".npm/bin/pi.cmd"),
home.join(".npm/bin/pi.exe"),
home.join(".local/share/pnpm/pi"),
home.join(".local/share/pnpm/pi.cmd"),
home.join(".local/share/pnpm/pi.exe"),
home.join("Library/pnpm/pi"),
home.join("Library/pnpm/pi.cmd"),
home.join("Library/pnpm/pi.exe"),
home.join(".bun/bin/pi"),
home.join(".bun/bin/pi.exe"),
home.join(".linuxbrew/bin/pi"),
@@ -148,14 +159,21 @@ 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);
let pnpm_home = std::env::var_os("PNPM_HOME").map(PathBuf::from);
pi_binary_candidates_from_prefixes(nvm_bin, npm_config_prefix, npm_config_prefix_upper)
pi_binary_candidates_from_prefixes(
nvm_bin,
npm_config_prefix,
npm_config_prefix_upper,
pnpm_home,
)
}
fn pi_binary_candidates_from_prefixes(
nvm_bin: Option<PathBuf>,
npm_config_prefix: Option<PathBuf>,
npm_config_prefix_upper: Option<PathBuf>,
pnpm_home: Option<PathBuf>,
) -> Vec<PathBuf> {
let mut candidates = Vec::new();
@@ -168,6 +186,9 @@ fn pi_binary_candidates_from_prefixes(
if let Some(path) = npm_config_prefix_upper.filter(|path| !path.as_os_str().is_empty()) {
candidates.push(path.join("bin/pi"));
}
if let Some(path) = pnpm_home.filter(|path| !path.as_os_str().is_empty()) {
candidates.push(path.join("pi"));
}
candidates
}
@@ -203,7 +224,10 @@ mod tests {
home.join(".pi/bin/pi"),
home.join(".local/share/mise/shims/pi"),
home.join(".asdf/shims/pi"),
home.join(".volta/bin/pi"),
home.join(".npm-global/bin/pi"),
home.join(".local/share/pnpm/pi"),
home.join("Library/pnpm/pi"),
home.join(".bun/bin/pi"),
];
@@ -245,6 +269,10 @@ mod tests {
home.join(".npm-global/bin/pi.exe"),
home.join(".npm/bin/pi.cmd"),
home.join(".npm/bin/pi.exe"),
home.join(".local/share/pnpm/pi.cmd"),
home.join(".local/share/pnpm/pi.exe"),
home.join("Library/pnpm/pi.cmd"),
home.join("Library/pnpm/pi.exe"),
home.join("AppData/Roaming/npm/pi.cmd"),
home.join("AppData/Roaming/npm/pi.exe"),
home.join("AppData/Local/pnpm/pi.cmd"),
@@ -298,11 +326,13 @@ mod tests {
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 pnpm_home = PathBuf::from("/Users/alex/Library/pnpm");
let candidates = pi_binary_candidates_from_prefixes(
Some(nvm_bin.clone()),
Some(npm_config_prefix.clone()),
Some(npm_config_prefix_upper.clone()),
Some(pnpm_home.clone()),
);
assert_eq!(
@@ -311,6 +341,7 @@ mod tests {
nvm_bin.join("pi"),
npm_config_prefix.join("bin/pi"),
npm_config_prefix_upper.join("bin/pi"),
pnpm_home.join("pi"),
]
);
}
@@ -326,4 +357,28 @@ mod tests {
assert_eq!(first_existing_path(&stdout), Some(pi));
}
#[cfg(unix)]
#[test]
fn command_path_from_shell_finds_pi_from_interactive_login_shell() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let pi = dir.path().join("pi");
std::fs::write(&pi, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&pi, std::fs::Permissions::from_mode(0o755)).unwrap();
let shell = dir.path().join("shell");
std::fs::write(
&shell,
format!(
"#!/bin/sh\nif [ \"$1\" = \"-lic\" ]; then echo '{}'; fi\n",
pi.display()
),
)
.unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(command_path_from_shell(&shell, "pi"), Some(pi));
}
}

View File

@@ -6,7 +6,8 @@ use crate::ai_models::{normalize_ai_model_providers, AiModelProvider};
const APP_CONFIG_DIR: &str = "com.tolaria.app";
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] = &["claude_code", "codex", "opencode", "pi", "gemini"];
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] =
&["claude_code", "codex", "opencode", "pi", "gemini", "kiro"];
pub const DEFAULT_HIDE_GITIGNORED_FILES: bool = true;
const SUPPORTED_NOTE_WIDTH_MODES: &[&str] = &["normal", "wide"];
const SUPPORTED_DATE_DISPLAY_FORMATS: &[&str] = &["us", "european", "friendly", "iso"];
@@ -67,6 +68,7 @@ const SUPPORTED_UI_LANGUAGE_ALIASES: &[(&str, &str)] = &[
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Settings {
pub auto_pull_interval_minutes: Option<u32>,
pub git_enabled: Option<bool>,
pub autogit_enabled: Option<bool>,
pub autogit_idle_threshold_seconds: Option<u32>,
pub autogit_inactive_threshold_seconds: Option<u32>,
@@ -177,6 +179,7 @@ pub fn normalize_ui_language(value: Option<&str>) -> Option<String> {
fn normalize_settings(settings: Settings) -> Settings {
Settings {
auto_pull_interval_minutes: settings.auto_pull_interval_minutes,
git_enabled: settings.git_enabled,
autogit_enabled: settings.autogit_enabled,
autogit_idle_threshold_seconds: normalize_optional_positive_u32(
settings.autogit_idle_threshold_seconds,
@@ -334,6 +337,7 @@ mod tests {
fn test_settings_json_roundtrip() {
let settings = Settings {
auto_pull_interval_minutes: Some(10),
git_enabled: Some(false),
autogit_enabled: Some(true),
autogit_idle_threshold_seconds: Some(90),
autogit_inactive_threshold_seconds: Some(30),
@@ -376,6 +380,7 @@ mod tests {
fn test_save_and_load_preserves_values() {
let loaded = save_and_reload(Settings {
auto_pull_interval_minutes: Some(10),
git_enabled: Some(false),
autogit_enabled: Some(true),
autogit_idle_threshold_seconds: Some(90),
autogit_inactive_threshold_seconds: Some(30),
@@ -397,6 +402,7 @@ mod tests {
..Default::default()
});
assert_eq!(loaded.auto_pull_interval_minutes, Some(10));
assert_eq!(loaded.git_enabled, Some(false));
assert_eq!(loaded.autogit_enabled, Some(true));
assert_eq!(loaded.autogit_idle_threshold_seconds, Some(90));
assert_eq!(loaded.autogit_inactive_threshold_seconds, Some(30));

View File

@@ -19,8 +19,8 @@ use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry
/// Bump this when VaultEntry fields change to force a full rescan.
/// v12: fix gray_matter YAML sanitization (unquoted colons / hash comments in list items)
/// v13: preserve plain square brackets in parsed markdown H1 titles
const CACHE_VERSION: u32 = 13;
/// v14: preserve scalar-array custom frontmatter properties in VaultEntry
const CACHE_VERSION: u32 = 14;
const CACHE_WRITE_LOCK_STALE_SECS: u64 = 30;
#[derive(Clone, Debug, Serialize, Deserialize)]

View File

@@ -73,8 +73,8 @@ pub struct VaultEntry {
/// Extracted from `[[target]]` and `[[target|display]]` patterns.
#[serde(rename = "outgoingLinks", default)]
pub outgoing_links: Vec<String>,
/// Custom scalar frontmatter properties (non-relationship, non-structural).
/// Only includes strings, numbers, and booleans — arrays/objects are excluded.
/// Custom scalar and scalar-array frontmatter properties (non-relationship, non-structural).
/// Objects and arrays containing wikilinks are excluded.
#[serde(default)]
pub properties: HashMap<String, serde_json::Value>,
/// Properties to display as chips in the note list for this Type's notes.

View File

@@ -289,7 +289,26 @@ fn nested_flow_wikilink(arr: &[serde_json::Value], depth: usize) -> Option<Strin
}
}
/// Extract custom scalar properties from raw YAML frontmatter.
fn scalar_array_property_value(arr: &[serde_json::Value]) -> Option<serde_json::Value> {
let mut values = Vec::new();
for item in arr {
let sanitized = sanitize_array_item(item)?;
match sanitized {
serde_json::Value::String(ref s) if contains_wikilink(s) => return None,
serde_json::Value::String(_)
| serde_json::Value::Number(_)
| serde_json::Value::Bool(_) => values.push(sanitized),
_ => return None,
}
}
match values.as_slice() {
[single] => Some(single.clone()),
_ => Some(serde_json::Value::Array(values)),
}
}
/// Extract custom scalar and scalar-array properties from raw YAML frontmatter.
pub(crate) fn extract_properties(
data: &HashMap<String, serde_json::Value>,
) -> HashMap<String, serde_json::Value> {
@@ -310,13 +329,9 @@ pub(crate) fn extract_properties(
serde_json::Value::Number(_) | serde_json::Value::Bool(_) => {
properties.insert(key.clone(), value.clone());
}
// Handle single-element arrays: unwrap to scalar.
// This ensures YAML like "Owner: [Luca]" or "Owner:\n - Luca" works correctly.
serde_json::Value::Array(arr) => {
if let [serde_json::Value::String(s)] = arr.as_slice() {
if !contains_wikilink(s) {
properties.insert(key.clone(), serde_json::Value::String(s.clone()));
}
if let Some(value) = scalar_array_property_value(arr) {
properties.insert(key.clone(), value);
}
}
_ => {}

View File

@@ -78,16 +78,10 @@ fn assert_alias_parser_recovers(case: AliasRecoveryCase<'_>) {
#[test]
fn test_filtered_properties_stay_hidden() {
let cases = [
HiddenPropertyCase {
content: "---\nMentor: \"[[person/alice]]\"\nCompany: Acme Corp\n---\n# Test\n",
hidden_key: "Mentor",
},
HiddenPropertyCase {
content: "---\nTags:\n - productivity\n - writing\nCompany: Acme Corp\n---\n# Test\n",
hidden_key: "Tags",
},
];
let cases = [HiddenPropertyCase {
content: "---\nMentor: \"[[person/alice]]\"\nCompany: Acme Corp\n---\n# Test\n",
hidden_key: "Mentor",
}];
for case in cases {
assert_filtered_property_stays_hidden(case);
@@ -114,6 +108,21 @@ fn test_single_element_array_properties_unwrap_to_scalars() {
}
}
#[test]
fn test_multi_element_array_properties_are_preserved() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"playlist.md",
"---\ntags:\n - blues\n - chicago\n---\n# Playlist\n",
);
assert_eq!(
entry.properties.get("tags"),
Some(&serde_json::json!(["blues", "chicago"]))
);
}
#[test]
fn test_blank_scalar_properties_are_preserved() {
let dir = TempDir::new().unwrap();

View File

@@ -414,6 +414,7 @@ Use kebab-case: `my-note-title.md`. One note per file.
- Create and edit saved views in `views/`.
- Update `AGENTS.md` only when the user asks for vault-level guidance changes.
- Search the bundled Tolaria docs when the user asks how Tolaria works or when you need product behavior beyond these base conventions.
- Use Portent as the default best-practice model when the user asks how to improve, organize, or restructure the knowledge base. Combine Portent's types, relationships, and capture -> organize -> archive lifecycle with Tolaria's type documents, properties, Inbox, archive, and saved views.
## What agents should avoid

View File

@@ -405,6 +405,34 @@ filters:
assert_eq!(result, vec![0]);
}
#[test]
fn test_contains_matches_scalar_array_property_element() {
let yaml = r#"
name: Tagged
filters:
all:
- field: tags
op: contains
value: blues
"#;
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
let matching = make_entry(|e| {
e.properties
.insert("tags".to_string(), serde_json::json!(["blues", "chicago"]));
});
let partial = make_entry(|e| {
e.properties.insert(
"tags".to_string(),
serde_json::json!(["bluegrass", "chicago"]),
);
});
let entries = vec![matching, partial];
let result = evaluate_view(&def, &entries);
assert_eq!(result, vec![0]);
}
#[test]
fn test_body_contains_filters_on_snippet() {
let yaml = r#"

View File

@@ -7,6 +7,12 @@ pub(super) fn json_scalar_to_string(value: &serde_json::Value) -> Option<String>
}
}
pub(super) fn json_scalar_array_to_strings(value: &serde_json::Value) -> Option<Vec<String>> {
value
.as_array()
.map(|sequence| sequence.iter().filter_map(json_scalar_to_string).collect())
}
pub(super) fn yaml_value_to_string(value: &serde_yaml::Value) -> Option<String> {
match value {
serde_yaml::Value::String(value) => Some(value.clone()),

View File

@@ -12,7 +12,8 @@ use super::view_date_filters::parse_date_filter_timestamp;
use super::view_migration::{is_view_definition_file, migrate_views};
use super::view_relationships::{evaluate_relationship_op, relationship_candidates};
use super::view_value_conversions::{
json_scalar_to_string, yaml_value_to_string, yaml_value_to_string_vec,
json_scalar_array_to_strings, json_scalar_to_string, yaml_value_to_string,
yaml_value_to_string_vec,
};
use super::VaultEntry;
@@ -292,6 +293,7 @@ fn supports_regex(op: &FilterOp) -> bool {
enum ConditionField<'a> {
Scalar(Option<String>),
PropertyArray(Vec<String>),
Relationship(&'a [String]),
}
@@ -314,6 +316,9 @@ fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
}
match field_value {
ConditionField::PropertyArray(values) => {
evaluate_property_array_op(&cond.op, &values, &cond.value)
}
ConditionField::Relationship(rels) => evaluate_relationship_op(&cond.op, rels, &cond.value),
ConditionField::Scalar(value) => evaluate_scalar_op(
&cond.op,
@@ -349,6 +354,9 @@ fn resolve_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionF
fn resolve_dynamic_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionField<'a> {
if let Some(prop) = entry.properties.get(field) {
if let Some(values) = json_scalar_array_to_strings(prop) {
return ConditionField::PropertyArray(values);
}
return ConditionField::Scalar(json_scalar_to_string(prop));
}
if let Some(relationships) = entry.relationships.get(field) {
@@ -380,6 +388,7 @@ fn invalid_regex_requested(cond: &FilterCondition, regex: Option<&Regex>) -> boo
fn evaluate_regex_condition(op: &FilterOp, field: &ConditionField<'_>, regex: &Regex) -> bool {
let matched = match field {
ConditionField::Scalar(Some(value)) => regex.is_match(value),
ConditionField::PropertyArray(values) => values.iter().any(|value| regex.is_match(value)),
ConditionField::Relationship(values) => values.iter().any(|item| {
relationship_candidates(item)
.into_iter()
@@ -395,6 +404,48 @@ fn evaluate_regex_condition(op: &FilterOp, field: &ConditionField<'_>, regex: &R
}
}
fn evaluate_property_array_op(
op: &FilterOp,
values: &[String],
raw_value: &Option<serde_yaml::Value>,
) -> bool {
match op {
FilterOp::Contains => property_array_matches_value(values, raw_value),
FilterOp::NotContains => !property_array_matches_value(values, raw_value),
FilterOp::AnyOf => property_array_matches_any(values, raw_value),
FilterOp::NoneOf => !property_array_matches_any(values, raw_value),
FilterOp::Equals => values.len() == 1 && property_array_matches_value(values, raw_value),
FilterOp::NotEquals => {
!(values.len() == 1 && property_array_matches_value(values, raw_value))
}
FilterOp::IsEmpty => values.is_empty(),
FilterOp::IsNotEmpty => !values.is_empty(),
_ => false,
}
}
fn property_array_matches_value(values: &[String], raw_value: &Option<serde_yaml::Value>) -> bool {
raw_value
.as_ref()
.and_then(yaml_value_to_string)
.is_some_and(|target| property_array_contains(values, &target))
}
fn property_array_matches_any(values: &[String], raw_value: &Option<serde_yaml::Value>) -> bool {
raw_value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default()
.iter()
.any(|target| property_array_contains(values, target))
}
fn property_array_contains(values: &[String], target: &str) -> bool {
values
.iter()
.any(|value| value.eq_ignore_ascii_case(target))
}
fn evaluate_scalar_op(
op: &FilterOp,
field_value: Option<&str>,

View File

@@ -23,19 +23,61 @@ fn is_temp_file_name(name: &OsStr) -> bool {
let Some(name) = name.to_str() else {
return false;
};
name == ".DS_Store"
|| name == ".tolaria-rename-txn"
|| name.starts_with(".#")
|| name.ends_with('~')
|| name.ends_with(".tmp")
|| name.ends_with(".swp")
|| name.ends_with(".swx")
is_exact_temp_file_name(name) || has_temp_file_prefix(name) || has_temp_file_suffix(name)
}
fn is_watchable_path(path: &Path) -> bool {
fn is_exact_temp_file_name(name: &str) -> bool {
[".DS_Store", ".tolaria-rename-txn"].contains(&name)
}
fn has_temp_file_prefix(name: &str) -> bool {
[".#", ".gitstatus."]
.iter()
.any(|prefix| name.starts_with(prefix))
}
fn has_temp_file_suffix(name: &str) -> bool {
["~", ".tmp", ".swp", ".swx", ".icloud"]
.iter()
.any(|suffix| name.ends_with(suffix))
}
/// Resolve the real git directory for `vault_path`. Handles three cases:
/// - regular `.git/` directory
/// - `.git` symlink (e.g. the iCloud `.git -> .git.nosync` workaround)
/// - `.git` file containing `gitdir: <path>` (worktrees, submodules)
fn resolve_git_dir(vault_path: &Path) -> Option<PathBuf> {
let git_path = vault_path.join(".git");
if let Ok(target) = std::fs::read_link(&git_path) {
let resolved = if target.is_absolute() {
target
} else {
vault_path.join(target)
};
return Some(resolved);
}
if git_path.is_dir() {
return Some(git_path);
}
let content = std::fs::read_to_string(&git_path).ok()?;
let rest = content.lines().next()?.strip_prefix("gitdir:")?.trim();
let target = Path::new(rest);
Some(if target.is_absolute() {
target.to_path_buf()
} else {
vault_path.join(target)
})
}
fn is_watchable_path(path: &Path, git_dir: Option<&Path>) -> bool {
if has_ignored_component(path) {
return false;
}
if let Some(git_dir) = git_dir {
if path.starts_with(git_dir) {
return false;
}
}
match path.file_name() {
Some(name) => !is_temp_file_name(name),
None => true,
@@ -51,7 +93,9 @@ mod desktop {
};
use tauri::Emitter;
use super::{is_watchable_path, Path, PathBuf, VaultChangedPayload, VAULT_CHANGED_EVENT};
use super::{
is_watchable_path, resolve_git_dir, Path, PathBuf, VaultChangedPayload, VAULT_CHANGED_EVENT,
};
struct ActiveVaultWatcher {
path: PathBuf,
@@ -93,20 +137,25 @@ mod desktop {
!matches!(event.kind, EventKind::Access(_))
}
fn changed_paths(event: Event) -> Vec<String> {
fn changed_paths(event: Event, git_dir: Option<&Path>) -> Vec<String> {
if !should_emit_event(&event) {
return Vec::new();
}
event
.paths
.into_iter()
.filter(|path| is_watchable_path(path))
.filter(|path| is_watchable_path(path, git_dir))
.map(|path| path.to_string_lossy().to_string())
.collect()
}
fn emit_vault_change(app: &tauri::AppHandle, vault_path: &Path, event: Event) {
let paths = changed_paths(event);
fn emit_vault_change(
app: &tauri::AppHandle,
vault_path: &Path,
git_dir: Option<&Path>,
event: Event,
) {
let paths = changed_paths(event, git_dir);
if paths.is_empty() {
return;
}
@@ -134,9 +183,15 @@ mod desktop {
}
let event_vault_path = vault_path.clone();
let event_git_dir = resolve_git_dir(&vault_path);
let event_app = app.clone();
let mut watcher = recommended_watcher(move |event| match event {
Ok(event) => emit_vault_change(&event_app, &event_vault_path, event),
Ok(event) => emit_vault_change(
&event_app,
&event_vault_path,
event_git_dir.as_deref(),
event,
),
Err(err) => log::warn!("Vault watcher event failed: {}", err),
})
.map_err(|err| format!("Failed to create vault watcher: {err}"))?;
@@ -194,43 +249,49 @@ mod desktop {
#[test]
fn changed_paths_ignores_access_events() {
let paths = changed_paths(event(
EventKind::Access(AccessKind::Read),
&["notes/today.md"],
));
let paths = changed_paths(
event(EventKind::Access(AccessKind::Read), &["notes/today.md"]),
None,
);
assert!(paths.is_empty());
}
#[test]
fn changed_paths_filters_unwatchable_paths() {
let paths = changed_paths(event(
EventKind::Create(CreateKind::File),
&[
".git/index.lock",
"node_modules/pkg/index.js",
"notes/today.md",
],
));
let paths = changed_paths(
event(
EventKind::Create(CreateKind::File),
&[
".git/index.lock",
"node_modules/pkg/index.js",
"notes/today.md",
],
),
None,
);
assert_eq!(paths, vec!["notes/today.md"]);
}
#[test]
fn changed_paths_filters_editor_temporary_files() {
let paths = changed_paths(event(
EventKind::Create(CreateKind::File),
&[
".DS_Store",
".tolaria-rename-txn",
".#draft.md",
"draft.md~",
"draft.tmp",
"draft.swp",
"draft.swx",
"notes/keep.md",
],
));
let paths = changed_paths(
event(
EventKind::Create(CreateKind::File),
&[
".DS_Store",
".tolaria-rename-txn",
".#draft.md",
"draft.md~",
"draft.tmp",
"draft.swp",
"draft.swx",
"notes/keep.md",
],
),
None,
);
assert_eq!(paths, vec!["notes/keep.md"]);
}
@@ -299,30 +360,86 @@ pub fn stop_vault_watcher() -> Result<(), String> {
#[cfg(test)]
mod tests {
use super::is_watchable_path;
use std::path::Path;
use super::{is_watchable_path, resolve_git_dir};
use std::path::{Path, PathBuf};
#[test]
fn ignores_git_and_dependency_directory_changes() {
assert!(!is_watchable_path(Path::new(".git/index.lock")));
assert!(!is_watchable_path(Path::new(
"node_modules/package/index.js"
)));
assert!(!is_watchable_path(Path::new(".git/index.lock"), None));
assert!(!is_watchable_path(
Path::new("node_modules/package/index.js"),
None
));
}
#[test]
fn ignores_common_temporary_files() {
assert!(!is_watchable_path(Path::new("note.md.tmp")));
assert!(!is_watchable_path(Path::new("note.md.swp")));
assert!(!is_watchable_path(Path::new("draft.md~")));
assert!(!is_watchable_path(Path::new(".DS_Store")));
assert!(!is_watchable_path(Path::new(".tolaria-rename-txn")));
assert!(!is_watchable_path(Path::new("note.md.tmp"), None));
assert!(!is_watchable_path(Path::new("note.md.swp"), None));
assert!(!is_watchable_path(Path::new("draft.md~"), None));
assert!(!is_watchable_path(Path::new(".DS_Store"), None));
assert!(!is_watchable_path(Path::new(".tolaria-rename-txn"), None));
assert!(!is_watchable_path(Path::new(".gitstatus.KASSUJ"), None));
assert!(!is_watchable_path(Path::new("notes/draft.md.icloud"), None));
}
#[test]
fn keeps_notes_assets_and_saved_views_watchable() {
assert!(is_watchable_path(Path::new("notes/day.md")));
assert!(is_watchable_path(Path::new("attachments/image.png")));
assert!(is_watchable_path(Path::new(".laputa/views/work.yml")));
assert!(is_watchable_path(Path::new("notes/day.md"), None));
assert!(is_watchable_path(Path::new("attachments/image.png"), None));
assert!(is_watchable_path(Path::new(".laputa/views/work.yml"), None));
}
#[test]
fn ignores_paths_inside_resolved_git_dir() {
// .git -> .git.nosync symlink trick used for iCloud/Dropbox vaults
let git_dir = PathBuf::from("/vault/.git.nosync");
assert!(!is_watchable_path(
Path::new("/vault/.git.nosync/index.lock"),
Some(&git_dir)
));
assert!(!is_watchable_path(
Path::new("/vault/.git.nosync/refs/remotes/origin/HEAD"),
Some(&git_dir)
));
assert!(is_watchable_path(
Path::new("/vault/notes/day.md"),
Some(&git_dir)
));
}
#[test]
fn resolves_real_git_dir_through_symlink() {
let dir = tempfile::tempdir().unwrap();
let real_git = dir.path().join(".git.nosync");
std::fs::create_dir(&real_git).unwrap();
std::os::unix::fs::symlink(".git.nosync", dir.path().join(".git")).unwrap();
let resolved = resolve_git_dir(dir.path()).unwrap();
assert_eq!(resolved, dir.path().join(".git.nosync"));
}
#[test]
fn resolves_real_git_dir_for_regular_directory() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let resolved = resolve_git_dir(dir.path()).unwrap();
assert_eq!(resolved, dir.path().join(".git"));
}
#[test]
fn resolves_real_git_dir_for_worktree_pointer_file() {
let dir = tempfile::tempdir().unwrap();
let worktree_target = dir.path().join("main/.git/worktrees/foo");
std::fs::create_dir_all(&worktree_target).unwrap();
std::fs::write(
dir.path().join(".git"),
format!("gitdir: {}\n", worktree_target.display()),
)
.unwrap();
let resolved = resolve_git_dir(dir.path()).unwrap();
assert_eq!(resolved, worktree_target);
}
}

View File

@@ -132,7 +132,13 @@ const relatedEntry = makeEntry({
title: 'Software Development',
isA: 'Topic',
})
const entries = [activeEntry, relatedEntry]
const secondEntry = makeEntry({
path: '/vault/project/second.md',
filename: 'second.md',
title: 'Second Project',
isA: 'Project',
})
const entries = [activeEntry, relatedEntry, secondEntry]
const noteContent = '---\ntitle: Test Project\ntype: Project\n---\n\n# Test Project\n'
const defaultVaultPath = DEFAULT_VAULTS[0].path || '/Users/mock/Documents/Getting Started'
@@ -215,7 +221,7 @@ describe('App note windows', () => {
)
})
it('passes the loaded vault index to the editor in note windows', async () => {
it('keeps note windows scoped to the active entry without loading the vault index', async () => {
renderApp(<App />)
await waitFor(() => {
@@ -224,18 +230,44 @@ describe('App note windows', () => {
vaultPath: '/vault',
})
})
await waitFor(() => {
expect(commandResults.list_vault).toHaveBeenCalledWith({ path: '/vault' })
})
expect(commandResults.list_vault).not.toHaveBeenCalled()
await waitFor(() => {
expect(screen.getByTestId('mock-editor-entry-titles')).toHaveTextContent(
'Test Project|Software Development',
'Test Project',
)
})
expect(editorSnapshots.at(-1)).toEqual({
activeTabPath: activeEntry.path,
entryTitles: ['Test Project', 'Software Development'],
entryTitles: ['Test Project'],
})
})
it('opens repeated note windows without repeated full-vault scans', async () => {
const firstWindow = renderApp(<App />)
await waitFor(() => {
expect(commandResults.reload_vault_entry).toHaveBeenCalledWith({
path: activeEntry.path,
vaultPath: '/vault',
})
})
firstWindow.unmount()
window.history.replaceState(
{},
'',
'/?window=note&path=%2Fvault%2Fproject%2Fsecond.md&vault=%2Fvault&title=Second+Project',
)
renderApp(<App />)
await waitFor(() => {
expect(commandResults.reload_vault_entry).toHaveBeenCalledWith({
path: secondEntry.path,
vaultPath: '/vault',
})
})
expect(commandResults.reload_vault_entry).toHaveBeenCalledTimes(2)
expect(commandResults.list_vault).not.toHaveBeenCalled()
})
})

View File

@@ -365,24 +365,23 @@ vi.mock('./hooks/useUpdater', async () => {
// Mock BlockNote components (they need DOM APIs not available in jsdom)
vi.mock('@blocknote/core', () => ({
audioParse: vi.fn(() => undefined), createAudioBlockConfig: vi.fn(() => ({})),
BlockNoteSchema: { create: () => ({ extend: () => ({}) }) },
createCodeBlockSpec: vi.fn(() => ({})),
createExtension: (factory: unknown) => () => factory,
defaultInlineContentSpecs: {},
filterSuggestionItems: vi.fn(() => []),
createVideoBlockConfig: vi.fn(() => ({})), defaultInlineContentSpecs: {},
filterSuggestionItems: vi.fn(() => []), videoParse: vi.fn(() => undefined),
}))
vi.mock('@blocknote/code-block', () => ({
codeBlockOptions: {},
}))
vi.mock('@blocknote/code-block', () => ({ codeBlockOptions: {} }))
vi.mock('@blocknote/core/extensions', () => ({
filterSuggestionItems: vi.fn(() => []),
}))
vi.mock('@blocknote/core/extensions', () => ({ filterSuggestionItems: vi.fn(() => []) }))
vi.mock('@blocknote/react', () => ({
AudioBlock: () => null, AudioToExternalHTML: () => null,
createReactBlockSpec: () => () => ({}),
createReactInlineContentSpec: () => ({ render: () => null }),
VideoBlock: () => null, VideoToExternalHTML: () => null,
BlockNoteViewRaw: ({ children, editable }: { children?: ReactNode; editable?: boolean }) => (
<div data-testid="blocknote-view" data-editable={editable !== false ? 'true' : 'false'}>
<div contentEditable={editable !== false} suppressContentEditableWarning data-testid="mock-editor">
@@ -409,6 +408,7 @@ vi.mock('@blocknote/react', () => ({
DeleteLinkButton: () => null,
SideMenuController: () => null,
SuggestionMenuController: () => null,
GridSuggestionMenuController: () => null,
useComponentsContext: () => ({
LinkToolbar: {
Button: ({
@@ -525,7 +525,7 @@ describe('App', () => {
// Entries appear in both Sidebar and NoteList
expect(screen.getAllByText('Test Project').length).toBeGreaterThan(0)
expect(screen.getAllByText('Software Development').length).toBeGreaterThan(0)
})
}, { timeout: SLOW_APP_READY_TIMEOUT_MS })
})
it('keeps the app shell usable while the vault note scan is pending', async () => {
@@ -576,7 +576,7 @@ describe('App', () => {
})
})
it('opens a note window by loading the requested entry and vault index', async () => {
it('opens a note window by loading only the requested entry', async () => {
const listVault = vi.fn(() => mockEntries)
const reloadVaultEntry = vi.fn(({ path }: { path: string }) =>
mockEntries.find((entry) => entry.path === path) ?? null,
@@ -599,7 +599,7 @@ describe('App', () => {
expect(getNoteContent).toHaveBeenCalledWith({ path: '/vault/project/test.md', vaultPath: '/vault' })
await waitFor(() => expect(window.__laputaTest?.activeTabPath).toBe('/vault/project/test.md'))
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true')
expect(listVault).toHaveBeenCalledWith({ path: '/vault' })
expect(listVault).not.toHaveBeenCalled()
})
it('shows keyboard shortcut hints', async () => {
@@ -630,9 +630,7 @@ describe('App', () => {
it('persists a Cmd+N note before opening it in the editor', async () => {
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
let resolveSave!: () => void
const saveNoteContent = vi.fn(() => new Promise<void>((resolve) => {
resolveSave = resolve
}))
const saveNoteContent = vi.fn(() => new Promise<void>((resolve) => { resolveSave = resolve }))
mockCommandResults.save_note_content = saveNoteContent
try {
@@ -645,6 +643,7 @@ describe('App', () => {
expect(saveNoteContent).toHaveBeenCalledWith({
path: '/vault/untitled-note-1700000000.md',
content: '---\ntype: Note\n---\n\n# \n\n',
vaultPath: '/vault',
})
})
expect(window.__laputaTest?.activeTabPath).not.toBe('/vault/untitled-note-1700000000.md')
@@ -1302,7 +1301,7 @@ describe('App', () => {
render(<App />)
expect(await screen.findByTestId('status-missing-git')).toBeInTheDocument()
expect(await screen.findByTestId('status-missing-git', {}, { timeout: SLOW_APP_READY_TIMEOUT_MS })).toBeInTheDocument()
fireEvent.click(screen.getByTestId('status-missing-git'))
expect(await screen.findByText('Enable Git for this vault?')).toBeInTheDocument()

View File

@@ -74,19 +74,20 @@ import {
import { createViewFilename } from './utils/viewFilename'
import { nextViewOrder } from './utils/viewOrdering'
import { viewMatchesSelection, viewVaultPath } from './utils/viewIdentity'
import { viewCreationVaultPath } from './utils/viewTargetVault'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
import { DeleteProgressNotice } from './components/DeleteProgressNotice'
import { UpdateBanner } from './components/UpdateBanner'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import type { SidebarSelection, InboxPeriod, ModifiedFile, VaultEntry, ViewDefinition, ViewFile, WorkspaceIdentity } from './types'
import type { GitSetupPreference, SidebarSelection, InboxPeriod, ModifiedFile, VaultEntry, ViewDefinition, ViewFile, WorkspaceIdentity } from './types'
import type { NoteListItem } from './utils/ai-context'
import { initializeNoteProperties } from './utils/initializeNoteProperties'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { openNoteInNewWindow } from './utils/openNoteWindow'
import { isWindows } from './utils/platform'
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
import { getPulledVaultUpdateOptions, refreshPulledVaultState } from './utils/pulledVaultRefresh'
import { isNoteWindow, getNoteWindowParams } from './utils/windowMode'
import { GitSetupDialog } from './components/GitRequiredModal'
import { RenameDetectedBanner } from './components/RenameDetectedBanner'
@@ -95,6 +96,7 @@ import type { NoteListMultiSelectionCommands } from './components/note-list/mult
import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents'
import { trackEvent } from './lib/telemetry'
import { areAiFeaturesEnabled } from './lib/aiFeatures'
import { areGitFeaturesEnabled } from './lib/gitSettings'
import { useAppCommandAiActions } from './hooks/useAppCommandAiActions'
import { TOLARIA_DOCS_URL } from './constants/feedback'
import { openExternalUrl } from './utils/url'
@@ -128,6 +130,7 @@ import { useVaultRenameDetection } from './hooks/useVaultRenameDetection'
import { useVaultOpenedTelemetry } from './hooks/useVaultOpenedTelemetry'
import { useStartupScreenState } from './hooks/useStartupScreenState'
import { useGitFileWorkflows } from './hooks/useGitFileWorkflows'
import { useAutoGitWork } from './hooks/useAutoGitWork'
import './App.css'
const ACTIVE_EDITOR_SURFACE_SELECTOR = '.editor__blocknote-container, .raw-editor-codemirror'
@@ -355,20 +358,28 @@ function App() {
vaultSwitcherLoaded: vaultSwitcher.loaded,
windowMode: Boolean(noteWindowParams),
})
const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath)
const gitFeaturesEnabled = areGitFeaturesEnabled(settings)
const handleGitSetupPreferenceChange = useCallback((preference: GitSetupPreference) => {
updateConfig('git_setup_preference', preference)
}, [updateConfig])
const {
dismissGitSetupDialog,
gitRepoState,
handleInitGitRepo,
neverForVaultGitSetupDialog,
openGitSetupDialog,
shouldShowGitSetupDialog,
} = useGitSetupState({
gitSetupPreference: vaultConfig.git_setup_preference,
onGitSetupPreferenceChange: handleGitSetupPreferenceChange,
onToast: setToastMessage,
resolvedPath,
windowMode: Boolean(noteWindowParams),
})
const vault = useVaultLoader(
resolvedPath,
noteWindowParams ? '' : resolvedPath,
graphVaults,
multiWorkspaceEnabled ? defaultWorkspacePath : null,
folderVaults,
@@ -411,7 +422,6 @@ function App() {
aiFeaturesEnabled ? resolvedPath : null,
buildVaultAiGuidanceRefreshKey(vault.entries),
)
const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath)
const explicitOrganizationEnabled = isExplicitOrganizationEnabled(vaultConfig.inbox?.explicitOrganization)
const effectiveSelection = sanitizeSelectionForOrganization(selection, vaultConfig.inbox?.explicitOrganization)
const isChangesSelection = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'changes'
@@ -473,24 +483,28 @@ function App() {
const loadDefaultVaultModifiedFiles = vault.loadModifiedFiles
const loadAllGitModifiedFiles = gitSurfaces.loadAllModifiedFiles
const loadModifiedFilesForRepository = gitSurfaces.loadModifiedFilesForRepository
const refreshAllGitRemoteStatuses = gitSurfaces.refreshAllRemoteStatuses
const refreshRemoteStatusForRepository = gitSurfaces.refreshRemoteStatusForRepository
const refreshGitRemoteStatus = useCallback(
() => refreshRemoteStatusForRepository(resolvedPath),
[refreshRemoteStatusForRepository, resolvedPath],
)
const refreshGitModifiedFiles = useCallback(async () => {
if (!gitFeaturesEnabled) return
await Promise.all([
loadDefaultVaultModifiedFiles(),
loadAllGitModifiedFiles({ includeStats: isChangesSelection }),
])
}, [isChangesSelection, loadAllGitModifiedFiles, loadDefaultVaultModifiedFiles])
}, [gitFeaturesEnabled, isChangesSelection, loadAllGitModifiedFiles, loadDefaultVaultModifiedFiles])
const loadVaultModifiedFiles = refreshGitModifiedFiles
useEffect(() => {
if (!gitFeaturesEnabled) return
if (gitRepoState !== 'ready') return
void loadVaultModifiedFiles()
void refreshGitRemoteStatus()
}, [gitRepoState, loadVaultModifiedFiles, refreshGitRemoteStatus])
void refreshAllGitRemoteStatuses()
}, [gitFeaturesEnabled, gitRepoState, loadVaultModifiedFiles, refreshAllGitRemoteStatuses, refreshGitRemoteStatus])
const openMcpSetupDialog = useCallback(() => {
setShowMcpSetupDialog(true)
@@ -602,6 +616,7 @@ function App() {
onMissingActiveVault: handleMissingActiveVault,
onTypeStateChanged: async () => { await vault.reloadVault() },
replaceEntry: vault.replaceEntry,
onInternalVaultWrite: markRecentVaultWrite,
onFrontmatterPersisted: refreshGitModifiedFiles,
onPathRenamed: (oldPath, newPath) => appSave.trackRenamedPath(oldPath, newPath),
})
@@ -621,8 +636,9 @@ function App() {
})
const handleVaultUpdate = useCallback(async (
updatedFiles: string[],
options: { preserveFocusedEditor?: boolean } = {},
options: { preserveFocusedEditor?: boolean; vaultPath?: string } = {},
) => {
const updateVaultPath = options.vaultPath ?? resolvedPath
await refreshPulledVaultState({
activeTabPath: notes.activeTabPath,
closeAllTabs,
@@ -636,7 +652,7 @@ function App() {
reloadViews: vault.reloadViews,
replaceActiveTab: handleReplaceActiveTab,
updatedFiles,
vaultPath: resolvedPath,
vaultPath: updateVaultPath,
})
await refreshGitModifiedFiles()
}, [
@@ -652,7 +668,10 @@ function App() {
vault.unsavedPaths,
])
const handlePulledVaultUpdate = useCallback(
(updatedFiles: string[]) => handleVaultUpdate(updatedFiles),
(updatedFiles: string[], vaultPath: string) => handleVaultUpdate(updatedFiles, {
...getPulledVaultUpdateOptions(),
vaultPath,
}),
[handleVaultUpdate],
)
const handleFocusedVaultUpdate = useCallback(
@@ -678,8 +697,9 @@ function App() {
filterChangedPaths: filterExternalVaultPaths,
})
const autoSync = useAutoSync({
enabled: gitRepoState === 'ready',
vaultPath: resolvedPath,
enabled: gitFeaturesEnabled && gitRepoState === 'ready',
vaultPath: gitSurfaces.syncRepositoryPath,
vaultPaths: activeGitRepositoryPaths,
intervalMinutes: settings.auto_pull_interval_minutes,
onVaultUpdated: handlePulledVaultUpdate,
onConflict: (files) => {
@@ -732,7 +752,8 @@ function App() {
})
const conflictFlow = useConflictFlow({
resolvedPath, entries: visibleEntries,
resolvedPath: autoSync.conflictVaultPath ?? graphDefaultWorkspacePath,
entries: visibleEntries,
conflictFiles: autoSync.conflictFiles,
pausePull: autoSync.pausePull, resumePull: autoSync.resumePull,
triggerSync: autoSync.triggerSync, reloadVault: vault.reloadVault,
@@ -812,11 +833,13 @@ function App() {
onSetFilter: (filterType) => {
handleSetSelection({ kind: 'sectionGroup', type: filterType })
},
onVaultChanged: (path) => { void handlePulledVaultUpdate(path ? [path] : []) },
onVaultChanged: (path) => { void handlePulledVaultUpdate(path ? [path] : [], resolvedPath) },
})
const handleInitializeProperties = useCallback(async (path: string) => {
await initializeNoteProperties(notes.handleUpdateFrontmatter, path)
const handleInitializeProperties = useCallback((path: string) => {
void initializeNoteProperties(notes.handleUpdateFrontmatter, path).catch((err) => {
console.warn('Failed to initialize note properties:', err)
})
}, [notes])
const handleRemoveNoteIcon = useCallback(async (path: string) => {
@@ -925,7 +948,7 @@ function App() {
const selectedChangesModifiedFiles = gitSurfaces.changesModifiedFiles
const commitModifiedFiles = gitSurfaces.commitModifiedFiles
const changesRepositoryPath = gitSurfaces.changesRepositoryPath
const gitModifiedCount = allGitModifiedFiles.length
const gitModifiedCount = gitFeaturesEnabled ? allGitModifiedFiles.length : 0
const {
activeDeletedFile,
@@ -967,24 +990,29 @@ function App() {
resolveRemoteStatusForVaultPath: refreshRemoteStatusForRepository,
setToastMessage,
onPushRejected: autoSync.handlePushRejected,
automaticVaultPaths: activeGitRepositoryPaths,
automaticVaultPaths: gitFeaturesEnabled ? activeGitRepositoryPaths : [],
locale: appLocale,
manualVaultPath: gitSurfaces.commitRepositoryPath,
vaultPath: resolvedPath,
})
const suggestedCommitMessage = useMemo(() => generateCommitMessage(commitModifiedFiles), [commitModifiedFiles])
const isGitVault = gitRepoState !== 'missing'
const modifiedFilesSignature = useMemo(
() => allGitModifiedFiles.map((file) => `${file.vaultPath ?? ''}:${file.relativePath}:${file.status}`).sort().join('|'),
[allGitModifiedFiles],
)
const isGitVault = gitFeaturesEnabled && gitRepoState !== 'missing'
const {
activitySignature: autoGitActivitySignature,
hasPendingWork: autoGitHasPendingWork,
} = useAutoGitWork({
activeRemoteStatus: autoSync.remoteStatus,
activeVaultPath: resolvedPath,
modifiedFiles: allGitModifiedFiles,
repositoryPaths: activeGitRepositoryPaths,
remoteStatusForRepository: gitSurfaces.remoteStatusForRepository,
})
const autoGit = useAutoGit({
enabled: settings.autogit_enabled === true,
idleThresholdSeconds: settings.autogit_idle_threshold_seconds ?? 90,
inactiveThresholdSeconds: settings.autogit_inactive_threshold_seconds ?? 30,
isGitVault,
hasPendingChanges: gitModifiedCount > 0
|| ((autoSync.remoteStatus?.hasRemote ?? false) && (autoSync.remoteStatus?.ahead ?? 0) > 0),
hasPendingChanges: autoGitHasPendingWork,
hasUnsavedChanges: vault.unsavedPaths.size > 0,
onCheckpoint: () => commitFlow.runAutomaticCheckpoint(),
})
@@ -994,24 +1022,39 @@ function App() {
const handleAppContentChange = appSave.handleContentChange
const handleAppSave = appSave.handleSave
const loadModifiedFiles = refreshGitModifiedFiles
const triggerSync = autoSync.triggerSync
const pullAndPush = autoSync.pullAndPush
useEffect(() => {
if (!gitFeaturesEnabled) return
if (!isChangesSelection) return
void loadModifiedFilesForRepository(changesRepositoryPath, { includeStats: true })
}, [changesRepositoryPath, isChangesSelection, loadModifiedFilesForRepository])
}, [changesRepositoryPath, gitFeaturesEnabled, isChangesSelection, loadModifiedFilesForRepository])
useEffect(() => {
if (modifiedFilesSignature.length === 0) return
if (autoGitActivitySignature.length === 0) return
recordAutoGitActivity()
}, [modifiedFilesSignature, recordAutoGitActivity])
}, [autoGitActivitySignature, recordAutoGitActivity])
const handleCommitPush = useCallback(() => {
if (!gitFeaturesEnabled) return
triggerCommitEntryAction({
autoGitEnabled: settings.autogit_enabled === true,
openCommitDialog,
runAutomaticCheckpoint,
})
}, [openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled])
}, [gitFeaturesEnabled, openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled])
const handlePullRepository = useCallback((targetVaultPath: string) => {
if (!gitFeaturesEnabled) return
triggerSync(targetVaultPath)
}, [gitFeaturesEnabled, triggerSync])
const handlePullSelectedRepository = useCallback(() => {
if (!gitFeaturesEnabled) return
triggerSync()
}, [gitFeaturesEnabled, triggerSync])
const handlePullAndPushSelectedRepository = useCallback(() => {
pullAndPush()
}, [pullAndPush])
const handleTrackedContentChange = useCallback((path: string, content: string) => {
recordAutoGitActivity()
@@ -1155,8 +1198,13 @@ function App() {
const handleCreateOrUpdateView = useCallback(async (definition: ViewDefinition) => {
const editing = dialogs.editingView
const activeVaultViews = viewsForVault(vault.views, resolvedPath)
const targetVaultPath = editing?.rootPath ?? resolvedPath
const targetVaultPath = viewCreationVaultPath({
editingRootPath: editing?.rootPath,
fallbackVaultPath: resolvedPath,
graphDefaultWorkspacePath,
multiWorkspaceEnabled,
})
const activeVaultViews = viewsForVault(vault.views, targetVaultPath)
const filename = savedViewFilename(definition, editing, activeVaultViews)
const nextDefinition = savedViewDefinition(definition, editing, activeVaultViews)
const target = isTauri() ? invoke : mockInvoke
@@ -1177,7 +1225,7 @@ function App() {
setToastMessage(`Could not save view: ${message}`)
return false
}
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView, setToastMessage])
}, [graphDefaultWorkspacePath, multiWorkspaceEnabled, resolvedPath, vault, handleSetSelection, dialogs.editingView, setToastMessage])
const handleUpdateViewDefinition = useCallback(async (filename: string, patch: Partial<ViewDefinition>, rootPath?: string) => {
const existing = vault.views.find((view) => viewMatchesSelection(view, viewSelection(filename, rootPath)))
@@ -1526,9 +1574,12 @@ function App() {
onDeleteNote: deleteActions.handleDeleteNote,
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: handleCommitPush,
gitRepositories,
gitFeaturesEnabled,
isGitVault,
onInitializeGit: openGitSetupDialog,
onPull: autoSync.triggerSync,
onPull: handlePullSelectedRepository,
onPullRepository: handlePullRepository,
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
onSetViewMode: handleSetViewMode,
onToggleInspector: handleToggleInspector,
@@ -1676,7 +1727,7 @@ function App() {
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={gitSurfaces.historyRepositoryPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.historyRepositoryPath} onRepositoryChange={gitSurfaces.setHistoryRepositoryPath} locale={appLocale} />
) : (
<NoteList entries={visibleEntries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} loading={isVaultContentLoading} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={noteListModifiedFiles} modifiedFilesError={noteListModifiedFilesError} gitRepositories={gitRepositories} selectedGitRepositoryPath={gitSurfaces.changesRepositoryPath} onGitRepositoryChange={gitSurfaces.setChangesRepositoryPath} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} allNotesFileVisibility={allNotesFileVisibility} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
<NoteList entries={visibleEntries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} loading={isVaultContentLoading} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={noteListModifiedFiles} modifiedFilesError={noteListModifiedFilesError} gitRepositories={gitRepositories} selectedGitRepositoryPath={gitSurfaces.changesRepositoryPath} onGitRepositoryChange={gitSurfaces.setChangesRepositoryPath} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onToggleFavorite={entryActions.handleToggleFavorite} onToggleOrganized={explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined} onRevealFile={fileActions.revealFile} onCopyFilePath={fileActions.copyFilePath} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} allNotesFileVisibility={allNotesFileVisibility} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -1687,7 +1738,7 @@ function App() {
tabs={notes.tabs}
activeTabPath={notes.activeTabPath}
isVaultLoading={isVaultContentLoading}
entries={visibleEntries}
entries={noteWindowParams && activeTab ? [activeTab.entry] : visibleEntries}
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={loadDiffForPath}
onLoadDiffAtCommit={loadDiffAtCommitForPath}
@@ -1758,11 +1809,11 @@ function App() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} />
<GitSetupDialog open={shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} />
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.syncRepositoryPath} onRepositoryChange={gitSurfaces.setSyncRepositoryPath} onTriggerSync={handlePullSelectedRepository} onPullAndPush={handlePullAndPushSelectedRepository} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} />
<GitSetupDialog open={gitFeaturesEnabled && shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} onNeverForVault={neverForVaultGitSetupDialog} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={visibleEntries} isLoading={vault.isLoading} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} locale={appLocale} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={visibleEntries} isLoading={vault.isLoading} onSelect={notes.handleSelectNote} onCreateNote={(title) => notes.handleCreateNote(title, 'Note', 'quick_open')} onClose={dialogs.closeQuickOpen} locale={appLocale} />
<CommandPalette
open={dialogs.showCommandPalette}
commands={commands}
@@ -1808,7 +1859,7 @@ function App() {
onCommit={conflictResolver.commitResolution}
onClose={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} initialSectionId={settingsInitialSectionId} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} vaults={vaultSwitcher.allVaults} defaultWorkspacePath={vaultSwitcher.defaultWorkspacePath} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onRemoveVault={vaultSwitcher.removeVault} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} isGitVault={isGitVault} onSave={saveSettings} onCopyMcpConfig={handleCopyMcpConfig} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<SettingsPanel open={dialogs.showSettings} initialSectionId={settingsInitialSectionId} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} vaults={vaultSwitcher.allVaults} defaultWorkspacePath={vaultSwitcher.defaultWorkspacePath} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} isGitVault={gitRepoState !== 'missing'} onSave={saveSettings} onCopyMcpConfig={handleCopyMcpConfig} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} manualConfigSnippet={mcpConfigSnippet} manualConfigLoading={mcpConfigLoading} manualConfigError={mcpConfigError} locale={appLocale} 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} />

148
src/NoteWindowApp.test.tsx Normal file
View File

@@ -0,0 +1,148 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import NoteWindowApp from './NoteWindowApp'
import type { NoteStatus, VaultEntry } from './types'
type MockEditorProps = {
activeTabPath: string | null
entries: VaultEntry[]
getNoteStatus?: (path: string) => NoteStatus
onContentChange?: (path: string, content: string) => void
tabs: Array<{ entry: VaultEntry; content: string }>
vaultPath?: string
vaultPaths?: string[]
}
const mocks = vi.hoisted(() => ({
editorProps: [] as MockEditorProps[],
invoke: vi.fn(),
setTitle: vi.fn(),
warn: vi.fn(),
}))
vi.mock('@tauri-apps/api/core', () => ({
invoke: mocks.invoke,
}))
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({ setTitle: mocks.setTitle }),
}))
vi.mock('./mock-tauri', () => ({
isTauri: () => true,
mockInvoke: vi.fn(),
}))
vi.mock('./components/Editor', () => ({
Editor: (props: MockEditorProps) => {
mocks.editorProps.push(props)
return (
<button
data-testid="mock-note-window-editor"
type="button"
onClick={() => props.onContentChange?.(props.activeTabPath ?? '', 'Updated content')}
>
{props.tabs[0]?.content}
</button>
)
},
}))
vi.mock('./components/Toast', () => ({
Toast: ({ message }: { message: string | null }) => (
<div data-testid="mock-toast">{message}</div>
),
}))
function makeEntry(): VaultEntry {
return {
path: 'Notes/entry.md',
filename: 'entry.md',
title: 'Entry',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1_700_000_000,
createdAt: null,
fileSize: 256,
snippet: '',
wordCount: 10,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: true,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
fileKind: 'markdown',
}
}
function setSearch(search: string) {
Object.defineProperty(window, 'location', {
writable: true,
value: { ...window.location, search },
})
}
describe('NoteWindowApp', () => {
beforeEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
vi.spyOn(console, 'warn').mockImplementation(mocks.warn)
mocks.editorProps.length = 0
setSearch('?window=note&path=%2Fvault%2FNotes%2Fentry.md&vault=%2Fvault&title=Entry')
mocks.invoke.mockImplementation(async (command: string) => {
if (command === 'sync_vault_asset_scope_for_window') return undefined
if (command === 'reload_vault_entry') return makeEntry()
if (command === 'get_note_content') return '# Entry'
if (command === 'save_note_content') return undefined
throw new Error(`Unexpected command: ${command}`)
})
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('loads one note without booting the full vault and saves editor changes', async () => {
render(<NoteWindowApp />)
const editor = await screen.findByTestId('mock-note-window-editor')
expect(editor).toHaveTextContent('# Entry')
expect(mocks.editorProps.at(-1)).toEqual(expect.objectContaining({
activeTabPath: 'Notes/entry.md',
vaultPath: '/vault',
vaultPaths: ['/vault'],
}))
expect(mocks.invoke).not.toHaveBeenCalledWith('list_vault', expect.anything())
vi.useFakeTimers()
fireEvent.click(editor)
expect(mocks.editorProps.at(-1)?.getNoteStatus?.('Notes/entry.md')).toBe('unsaved')
await act(async () => {
await vi.advanceTimersByTimeAsync(700)
})
vi.useRealTimers()
expect(mocks.invoke).toHaveBeenCalledWith('save_note_content', {
path: 'Notes/entry.md',
content: 'Updated content',
vaultPath: '/vault',
})
})
})

199
src/NoteWindowApp.tsx Normal file
View File

@@ -0,0 +1,199 @@
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { Editor } from './components/Editor'
import { Toast } from './components/Toast'
import { isTauri, mockInvoke } from './mock-tauri'
import type { NoteStatus, VaultEntry } from './types'
import {
getNoteWindowPathCandidates,
getNoteWindowParams,
type NoteWindowParams,
} from './utils/windowMode'
import './App.css'
const NOTE_WINDOW_SAVE_DELAY_MS = 700
const MISSING_NOTE_WINDOW_PARAMS_MESSAGE = ''
const noop = () => {}
type NoteWindowLoadState =
| { kind: 'loading' }
| { kind: 'ready'; entry: VaultEntry; content: string }
| { kind: 'error'; message: string }
type ReadyNoteWindowState = Extract<NoteWindowLoadState, { kind: 'ready' }>
type SaveRequest = { path: string; content: string; vaultPath: string }
type NoteWindowCommand =
| 'get_note_content'
| 'reload_vault_entry'
| 'save_note_content'
| 'sync_vault_asset_scope_for_window'
function tauriCall<T>(command: NoteWindowCommand, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
async function resolveNoteWindowEntry(params: NoteWindowParams): Promise<VaultEntry | null> {
for (const path of getNoteWindowPathCandidates(params)) {
try {
const entry = await tauriCall<VaultEntry | null>('reload_vault_entry', {
path,
vaultPath: params.vaultPath,
})
if (entry) return entry
} catch {
// Keep trying normalized path candidates before surfacing a load failure.
}
}
return null
}
async function loadNoteWindow(params: NoteWindowParams): Promise<Extract<NoteWindowLoadState, { kind: 'ready' }>> {
await tauriCall('sync_vault_asset_scope_for_window', { vaultPath: params.vaultPath }).catch(() => undefined)
const entry = await resolveNoteWindowEntry(params)
if (!entry) throw new Error(`Could not open "${params.noteTitle}" in this window`)
const content = await tauriCall<string>('get_note_content', {
path: entry.path,
vaultPath: params.vaultPath,
})
return { kind: 'ready', entry, content }
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function initialNoteWindowState(params: NoteWindowParams | null): NoteWindowLoadState {
return params ? { kind: 'loading' } : { kind: 'error', message: MISSING_NOTE_WINDOW_PARAMS_MESSAGE }
}
function useNoteWindowState(params: NoteWindowParams | null): NoteWindowLoadState {
const [state, setState] = useState<NoteWindowLoadState>(() => initialNoteWindowState(params))
useEffect(() => {
if (!params) return
let cancelled = false
loadNoteWindow(params)
.then((nextState) => {
if (!cancelled) setState(nextState)
})
.catch((error) => {
if (!cancelled) setState({ kind: 'error', message: errorMessage(error) })
})
return () => { cancelled = true }
}, [params])
return state
}
function getWindowTitle(state: NoteWindowLoadState, params: NoteWindowParams | null): string | null {
return state.kind === 'ready' ? state.entry.title : params?.noteTitle ?? null
}
function useNoteWindowTitle(state: NoteWindowLoadState, params: NoteWindowParams | null): void {
useEffect(() => {
const title = getWindowTitle(state, params)
if (!title) return
document.title = title
if (!isTauri()) return
import('@tauri-apps/api/window')
.then(({ getCurrentWindow }) => getCurrentWindow().setTitle(title))
.catch((error) => console.warn('[window] Failed to update note window title:', error))
}, [params, state])
}
function clearSaveTimer(saveTimerRef: MutableRefObject<number | null>): void {
if (saveTimerRef.current === null) return
window.clearTimeout(saveTimerRef.current)
saveTimerRef.current = null
}
function saveNoteWindowContent(request: SaveRequest, setStatus: (status: NoteStatus) => void): void {
setStatus('pendingSave')
void tauriCall('save_note_content', request)
.then(() => setStatus('clean'))
.catch((error) => {
console.warn('[window] Failed to save note window content:', error)
setStatus('unsaved')
})
}
function useDebouncedNoteWindowSave(vaultPath: string | null, setStatus: (status: NoteStatus) => void) {
const saveTimerRef = useRef<number | null>(null)
useEffect(() => () => clearSaveTimer(saveTimerRef), [])
return useCallback((path: string, content: string) => {
if (!vaultPath) return
setStatus('unsaved')
clearSaveTimer(saveTimerRef)
saveTimerRef.current = window.setTimeout(() => {
saveTimerRef.current = null
saveNoteWindowContent({ path, content, vaultPath }, setStatus)
}, NOTE_WINDOW_SAVE_DELAY_MS)
}, [setStatus, vaultPath])
}
function NoteWindowEditor({ params, state }: {
params: NoteWindowParams
state: ReadyNoteWindowState
}) {
const [tabContent, setTabContent] = useState(state.content)
const [noteStatus, setNoteStatus] = useState<NoteStatus>('clean')
const saveContent = useDebouncedNoteWindowSave(params.vaultPath, setNoteStatus)
const tab = useMemo(() => ({ entry: state.entry, content: tabContent }), [state.entry, tabContent])
const updateContent = useCallback((path: string, content: string) => {
setTabContent(content)
saveContent(path, content)
}, [saveContent])
return (
<Editor
tabs={[tab]}
activeTabPath={state.entry.path}
entries={[state.entry]}
onNavigateWikilink={noop}
inspectorCollapsed
onToggleInspector={noop}
inspectorWidth={320}
onInspectorResize={noop}
inspectorEntry={state.entry}
inspectorContent={tabContent}
gitHistory={[]}
onContentChange={updateContent}
getNoteStatus={() => noteStatus}
vaultPath={params.vaultPath}
vaultPaths={[params.vaultPath]}
locale="en"
/>
)
}
export default function NoteWindowApp() {
const params = useMemo(() => getNoteWindowParams(), [])
const state = useNoteWindowState(params)
useNoteWindowTitle(state, params)
return (
<div className="app-shell">
<div className="app">
<div className="app__editor">
{state.kind === 'ready' && params ? (
<NoteWindowEditor params={params} state={state} />
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{state.kind === 'error' ? state.message : null}
</div>
)}
</div>
</div>
{state.kind === 'error' && <Toast message={state.message} onDismiss={noop} />}
</div>
)
}

View File

@@ -1,10 +1,10 @@
import { CalendarBlank as CalendarIcon, Check, X } from '@phosphor-icons/react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { CalendarIcon, Check, X } from 'lucide-react'
import {
type PropertyDisplayMode,
formatDateValue,

View File

@@ -1,6 +1,7 @@
import { ArrowUpRight, Bot, CheckCircle2, Cloud, HardDrive, Loader2, Terminal } from 'lucide-react'
import { ArrowUpRight, CheckCircle as CheckCircle2, CircleNotch as Loader2, Cloud, HardDrive, Robot as Bot, Terminal } from '@phosphor-icons/react'
import {
AI_AGENT_DEFINITIONS,
getAiAgentAvailability,
getAiAgentDefinition,
hasAnyInstalledAiAgent,
isAiAgentsStatusChecking,
@@ -81,7 +82,7 @@ function AgentStatusList({ statuses }: { statuses: AiAgentsStatus }) {
return (
<div className="space-y-3">
{AI_AGENT_DEFINITIONS.map((definition) => {
const status = statuses[definition.id]
const status = getAiAgentAvailability(statuses, definition.id)
const ready = status.status === 'installed'
return (
<div
@@ -113,8 +114,8 @@ export function AiAgentsOnboardingPrompt({
onContinue,
}: AiAgentsOnboardingPromptProps) {
const copy = getPromptCopy(statuses)
const showLegacyClaudeCompatibility = statuses.claude_code.status !== 'installed'
const missingAgents = AI_AGENT_DEFINITIONS.filter((definition) => statuses[definition.id].status === 'missing')
const showLegacyClaudeCompatibility = getAiAgentAvailability(statuses, 'claude_code').status !== 'installed'
const missingAgents = AI_AGENT_DEFINITIONS.filter((definition) => getAiAgentAvailability(statuses, definition.id).status === 'missing')
return (
<OnboardingShell

View File

@@ -71,7 +71,7 @@ describe('BreadcrumbBar filename visibility', () => {
it('offsets the editor-only breadcrumb title past the macOS traffic lights', () => {
const editorCss = readFileSync(`${process.cwd()}/src/components/Editor.css`, 'utf8')
expect(editorCss).toContain('.app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar')
expect(editorCss).toContain('body.mac-chrome .app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar')
expect(editorCss).toContain('--breadcrumb-bar-left-padding: 90px;')
})

View File

@@ -1,4 +1,4 @@
import { ArrowUpRight, Bot, CheckCircle2, Loader2 } from 'lucide-react'
import { ArrowUpRight, CheckCircle as CheckCircle2, CircleNotch as Loader2, Robot as Bot } from '@phosphor-icons/react'
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
import { openExternalUrl } from '../utils/url'
import { Button } from './ui/button'

View File

@@ -1,4 +1,4 @@
import { AlertTriangle } from 'lucide-react'
import { Warning as AlertTriangle } from '@phosphor-icons/react'
import { translate, type AppLocale } from '../lib/i18n'
interface ConflictNoteBannerProps {

View File

@@ -1,7 +1,7 @@
import { Check, CircleNotch as Loader2, FileText, Warning as AlertTriangle } from '@phosphor-icons/react'
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AlertTriangle, FileText, Check, Loader2 } from 'lucide-react'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import type { ConflictFileState } from '../hooks/useConflictResolver'
import { cn } from '@/lib/utils'

View File

@@ -1,5 +1,5 @@
import { CircleNotch as Loader2 } from '@phosphor-icons/react'
import { memo } from 'react'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
interface DeleteProgressNoticeProps {

View File

@@ -30,7 +30,7 @@
transition: border-color 0.2s ease;
}
.app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar {
body.mac-chrome .app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar {
--breadcrumb-bar-left-padding: 90px;
}

View File

@@ -49,30 +49,39 @@ const blockNoteViewState = vi.hoisted(() => ({
// Mock BlockNote components
vi.mock('@blocknote/core', () => ({
audioParse: vi.fn(() => undefined),
BlockNoteSchema: { create: () => ({ extend: () => ({}) }) },
createAudioBlockConfig: vi.fn(() => ({})),
createCodeBlockSpec: vi.fn(() => ({})),
createExtension: (factory: unknown) => () => factory,
createVideoBlockConfig: vi.fn(() => ({})),
defaultInlineContentSpecs: {},
filterSuggestionItems: vi.fn(() => []),
videoParse: vi.fn(() => undefined),
}))
vi.mock('@blocknote/code-block', () => ({
codeBlockOptions: {},
}))
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
const mockFilterSuggestionItems = vi.fn((...args: any[]) => args[0] ?? [])
const mockFilterSuggestionItems = vi.fn((...args: unknown[]) => args[0] ?? [])
vi.mock('@blocknote/core/extensions', () => ({
filterSuggestionItems: (...args: unknown[]) => mockFilterSuggestionItems(...args),
}))
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
const capturedGetItemsByTrigger: Record<string, (query: string) => Promise<any[]>> = {}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
let capturedGetItems: ((query: string) => Promise<any[]>) | null = null
type SuggestionControllerProps = {
triggerCharacter: string
getItems: (query: string) => Promise<unknown[]>
}
const capturedGetItemsByTrigger: Record<string, (query: string) => Promise<unknown[]>> = {}
let capturedGetItems: ((query: string) => Promise<unknown[]>) | null = null
vi.mock('@blocknote/react', () => ({
AudioBlock: () => null,
AudioToExternalHTML: () => null,
createReactBlockSpec: () => () => ({}),
createReactInlineContentSpec: () => ({ render: () => null }),
VideoBlock: () => null,
VideoToExternalHTML: () => null,
useCreateBlockNote: (options: unknown) => {
blockNoteCreation.options.push(options)
return mockEditor
@@ -106,12 +115,15 @@ vi.mock('@blocknote/react', () => ({
EditLinkButton: () => null,
DeleteLinkButton: () => null,
SideMenuController: () => null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
SuggestionMenuController: (props: any) => {
SuggestionMenuController: (props: SuggestionControllerProps) => {
capturedGetItemsByTrigger[props.triggerCharacter] = props.getItems
if (props.triggerCharacter === '[[') capturedGetItems = props.getItems
return null
},
GridSuggestionMenuController: (props: SuggestionControllerProps) => {
capturedGetItemsByTrigger[props.triggerCharacter] = props.getItems
return null
},
useComponentsContext: () => ({
LinkToolbar: {
Button: ({
@@ -565,7 +577,7 @@ describe('Editor', () => {
fireEvent.click(screen.getByRole('button', { name: 'Open table of contents' }))
expect(screen.getByTestId('table-of-contents-panel')).toBeInTheDocument()
expect(await screen.findByRole('button', { name: 'Table Heading' })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: 'Table Heading' }, { timeout: 5000 })).toBeInTheDocument()
})
// Regression: editor content did not appear on first load because BlockNote's

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