Compare commits

..

422 Commits

Author SHA1 Message Date
lucaronin
bf3caab862 fix: recover blocked native folder picker 2026-06-07 15:00:51 +02:00
lucaronin
2556907f36 fix: launch Pi CLI from Windows shims 2026-06-07 12:04:55 +02:00
lucaronin
8e3f8fbb77 fix: bound ai agent startup status probe 2026-06-07 05:46:41 +02:00
lucaronin
501df0e265 refactor: share rich editor input transforms 2026-06-07 03:25:09 +02:00
lucaronin
48eac1e40d fix: recover paragraph editor index errors 2026-06-07 02:49:04 +02:00
lucaronin
3d28e21a5a fix: keep palette keyboard selection stable 2026-06-06 14:49:43 +02:00
lucaronin
191066a3bf feat: copy note git URLs 2026-06-06 14:24:51 +02:00
lucaronin
3a88724d8f test: update stable signing bypass guard 2026-06-06 12:06:17 +02:00
lucaronin
65ea3f37fc ci: allow v2026-06-06 stable without windows signing 2026-06-06 11:42:57 +02:00
lucaronin
0e81f20e31 docs: add release notes for v2026-06-06 2026-06-06 11:32:51 +02:00
lucaronin
3c8edd8346 fix: let OpenAI API create notes 2026-06-06 10:42:33 +02:00
lucaronin
80ee94da73 fix: localize highlight toolbar copy 2026-06-06 10:35:21 +02:00
lucaronin
008e8f3471 fix: support rich-editor markdown highlights 2026-06-06 10:15:44 +02:00
lucaronin
6aaaca8e24 fix: sync mcp server lockfile 2026-06-06 09:31:38 +02:00
lucaronin
c5dc4a9300 fix: handle symlinked gitignored vault paths 2026-06-06 08:21:49 +02:00
lucaronin
25c7ba0926 test: cover stack-based null append recovery 2026-06-06 04:24:52 +02:00
lucaronin
2b1f4395b3 fix: recover null editor transform errors 2026-06-06 03:57:21 +02:00
lucaronin
e3ef3f6073 test: stabilize wikilink ime composition test 2026-06-06 03:50:51 +02:00
lucaronin
8d47b8d7d2 fix: correct Chinese file manager labels 2026-06-06 03:45:14 +02:00
lucaronin
8fc366c8c0 fix: scope math block selection highlight 2026-06-06 03:23:04 +02:00
lucaronin
42d7a3ac44 refactor: share mcp tool orchestration 2026-06-06 02:27:19 +02:00
lucaronin
3e349fa017 fix: render markdown highlight syntax 2026-06-06 01:34:06 +02:00
lucaronin
8138925bb0 fix: localize custom menus and feedback 2026-06-06 00:48:10 +02:00
lucaronin
cc4203ab1c fix: expose mermaid raw edit action 2026-06-05 23:32:01 +02:00
lucaronin
401087a8ef fix: recover plain table row index errors 2026-06-05 22:58:28 +02:00
lucaronin
0053a36b6e fix: guard title focus without block id 2026-06-05 22:36:26 +02:00
lucaronin
762ac4ff48 fix: guard native drag drop payloads 2026-06-05 22:11:49 +02:00
lucaronin
4716003ca1 fix: refresh sync history surfaces 2026-06-05 21:43:53 +02:00
lucaronin
af9cc118e7 fix: inherit shell ai provider env 2026-06-05 20:44:28 +02:00
lucaronin
95bdbc90c7 feat: add whiteboard fullscreen workspace 2026-06-05 20:01:55 +02:00
lucaronin
12a229e604 fix: align inline math with text baseline 2026-06-05 19:21:22 +02:00
lucaronin
cc52a52216 fix: recognize raw editor backslash shortcut 2026-06-05 18:49:14 +02:00
lucaronin
057c73376c fix: preserve rich editor copy html 2026-06-05 18:16:16 +02:00
lucaronin
a194763847 fix: restore editor focus after active refresh 2026-06-05 17:20:43 +02:00
lucaronin
6945de0060 fix: tolerate stale Pi agent config entries 2026-06-05 16:45:59 +02:00
lucaronin
4e81ab8aa3 fix: add MCP create note tool 2026-06-05 16:27:18 +02:00
lucaronin
1309cf322e fix: respect configured Git author identity 2026-06-05 15:55:28 +02:00
lucaronin
c9467711ad fix: keep view filters focused across workspaces 2026-06-05 15:22:13 +02:00
lucaronin
82b2ff2ac4 ci: allow unsigned Windows stable build for v2026-06-01 2026-06-01 16:25:07 +02:00
lucaronin
34a22c485d docs: add release notes for v2026-06-01 2026-06-01 16:13:26 +02:00
Tolaria
c12aa4f4fd fix: keep math source selection readable 2026-06-01 15:42:44 +02:00
Luca Rossi
6c979addb3 Delete docs/LARGE-VAULT-LOADING-QA.md 2026-06-01 12:45:46 +02:00
Tolaria
7fa284f0d0 fix: edit display math as block property 2026-06-01 10:05:11 +02:00
Tolaria
74891e0283 fix: export notes directly to PDF 2026-05-31 15:11:29 +02:00
lucaronin
f003b38518 fix: avoid Windows npm cmd shim launches 2026-05-31 12:13:18 +02:00
lucaronin
b84d6579da fix: refresh clean active notes after external edits 2026-05-30 23:04:02 +02:00
lucaronin
4f52f3e803 refactor: reduce view filter test duplication 2026-05-30 21:39:09 +02:00
lucaronin
83cd7bc20d refactor: improve ai workspace code health 2026-05-30 21:10:41 +02:00
lucaronin
0cdf758f7d fix: localize ai workspace copy 2026-05-30 04:19:51 +02:00
lucaronin
cba038766c refactor: share ai workspace cross-window store 2026-05-30 03:49:40 +02:00
lucaronin
944ab9705a refactor: extract app shell side effects 2026-05-30 03:23:29 +02:00
lucaronin
1a08102188 refactor: extract app orchestration hooks 2026-05-30 02:43:28 +02:00
lucaronin
1633f10752 fix: swallow stale tauri listener cleanup 2026-05-29 21:12:23 +02:00
lucaronin
c09f1b9c93 test: stabilize ai workspace title persistence 2026-05-29 20:46:35 +02:00
lucaronin
8cee321ebe feat: add common code block grammars 2026-05-29 20:35:53 +02:00
lucaronin
f79cdac570 fix: require signing for stable windows releases 2026-05-29 19:43:48 +02:00
lucaronin
10aed871da feat: export notes as pdf 2026-05-29 19:37:38 +02:00
lucaronin
30cde097d2 ci: allow unsigned Windows stable build 2026-05-29 19:04:15 +02:00
lucaronin
d4e66443bd docs: add release notes for v2026-05-29 2026-05-29 18:36:36 +02:00
lucaronin
1d5d573138 feat: refine AI workspace side panel 2026-05-29 18:00:32 +02:00
lucaronin
ed8a0b155b fix: recover block type render mismatch 2026-05-29 17:44:24 +02:00
lucaronin
8b937012f9 fix: restore editable math source 2026-05-29 17:16:26 +02:00
lucaronin
6a80d01c34 fix: create unmatched quick open notes safely 2026-05-29 16:34:05 +02:00
lucaronin
ef55891cc2 fix: preserve katex square root radicals 2026-05-29 11:50:09 +02:00
lucaronin
9a2c025d07 fix: recover table row render errors 2026-05-29 11:20:45 +02:00
lucaronin
9be7049bfd docs: streamline agent instructions 2026-05-29 11:02:13 +02:00
Luca Rossi
ff569bfba4 updated README 2026-05-29 10:54:15 +02:00
lucaronin
34fda19b62 docs: add ADR for request-scoped AI stream events (guard — from commit 78892c4) 2026-05-29 08:01:49 +02:00
lucaronin
b69cf8dd5b refactor: consolidate git remote helpers 2026-05-29 05:18:52 +02:00
lucaronin
0234314945 test: guard note-list body search against stale results 2026-05-29 04:06:09 +02:00
lucaronin
6bf70eb635 refactor: consolidate note-list body search 2026-05-29 03:19:59 +02:00
lucaronin
78892c4637 feat: add side AI workspace 2026-05-29 00:58:40 +02:00
lucaronin
6910347bf3 fix: preserve edited vault guidance status 2026-05-28 22:00:29 +02:00
lucaronin
181ff6de8c fix: search note list body content 2026-05-28 21:19:55 +02:00
lucaronin
07c47e68f9 fix: preserve spaced filenames in pending changes 2026-05-28 20:02:21 +02:00
lucaronin
05a6e61f11 fix: extend favorite row hover state 2026-05-28 15:56:38 +02:00
lucaronin
7de2726f27 fix: resolve packaged macos mcp server path 2026-05-28 11:18:11 +02:00
lucaronin
a45e44d0f3 test: align release workflow assertions 2026-05-28 10:30:42 +02:00
lucaronin
14b1668ebe fix: unblock alpha release without windows cert 2026-05-28 10:18:45 +02:00
lucaronin
c5049e4332 docs: add ADR for reusable release artifact workflow (guard - from commit c6e570d) 2026-05-28 08:01:42 +02:00
lucaronin
c6e570d2e3 refactor: deduplicate release artifact workflows 2026-05-28 01:51:08 +02:00
lucaronin
e93676bbb7 test: format git no remote regression 2026-05-28 00:59:55 +02:00
lucaronin
952e632622 test: stabilize add remote smoke 2026-05-28 00:53:25 +02:00
lucaronin
641dc2c885 fix: merge local-only git remote PR #739 2026-05-28 00:42:44 +02:00
lucaronin
78f8469c98 perf: merge AI agent startup discovery PR #751 2026-05-27 23:59:59 +02:00
lucaronin
1ec5f7c745 fix: complete Indonesian locale keys 2026-05-27 23:20:15 +02:00
lucaronin
f616fe449b feat: merge Indonesian translation PR #757 2026-05-27 23:14:50 +02:00
lucaronin
8ad23f72e1 fix: recover table-cell transform errors 2026-05-27 19:50:16 +02:00
lucaronin
41b531e899 fix: avoid inactive AI chat context work 2026-05-27 19:23:06 +02:00
lucaronin
03a667661e fix: guard whiteboard permission rejections 2026-05-27 18:56:36 +02:00
lucaronin
908994c253 fix: repair malformed nested editor blocks 2026-05-27 18:32:18 +02:00
lucaronin
af83d270fd fix: coalesce overlapping editor saves 2026-05-27 17:37:16 +02:00
lucaronin
24f4ee3272 fix: harden tldraw text measurement guard 2026-05-27 16:42:12 +02:00
lucaronin
bbb30d6b91 refactor: consolidate anchored dropdown positioning 2026-05-27 16:08:24 +02:00
lucaronin
ba3f324a52 fix: sign Windows release installers 2026-05-27 15:27:34 +02:00
github-actions[bot]
5240aa3b4e Merge branch 'main' into feat/add-indonesia-locale 2026-05-27 13:04:29 +00:00
lucaronin
f80e571c78 feat: add vault item deep links 2026-05-27 14:51:32 +02:00
github-actions[bot]
df5c2a46e6 Merge branch 'main' into feat/add-indonesia-locale 2026-05-27 10:53:16 +00:00
lucaronin
5090ed31bb fix: stabilize native search navigation 2026-05-27 12:42:04 +02:00
github-actions[bot]
0bad22eb57 Merge branch 'main' into feat/add-indonesia-locale 2026-05-27 09:39:26 +00:00
lucaronin
05fc93677c fix: stabilize wikilink ime flushing 2026-05-27 11:26:24 +02:00
lucaronin
bdb9fabfd0 fix: stabilize global search keyboard navigation 2026-05-27 11:13:45 +02:00
lucaronin
4296977fe3 test: cover folder note creation event bridge 2026-05-27 04:05:26 +02:00
github-actions[bot]
bb5296ba3d Merge branch 'main' into feat/add-indonesia-locale 2026-05-26 23:39:16 +00:00
lucaronin
cfc62b1ce2 test: document folder parent creation 2026-05-27 01:25:34 +02:00
github-actions[bot]
29bfde6a20 Merge branch 'main' into feat/add-indonesia-locale 2026-05-26 23:18:02 +00:00
lucaronin
759af948b4 fix: polish ai workspace follow-up 2026-05-27 01:03:31 +02:00
github-actions[bot]
5fb1389d69 Merge branch 'main' into feat/add-indonesia-locale 2026-05-26 17:51:07 +00:00
lucaronin
f5475f7773 fix: complete action history undo redo 2026-05-26 19:41:25 +02:00
github-actions[bot]
1d93da3ade Merge branch 'main' into feat/add-indonesia-locale 2026-05-26 17:29:26 +00:00
lucaronin
29e5dd8f7b fix: stabilize AI workspace QA 2026-05-26 19:15:05 +02:00
github-actions[bot]
18c6d18474 Merge branch 'main' into feat/add-indonesia-locale 2026-05-26 15:02:15 +00:00
lucaronin
8828516d43 feat: rework AI workspace window 2026-05-26 16:52:38 +02:00
github-actions[bot]
8df1f3043d Merge branch 'main' into pr-726 2026-05-26 13:35:13 +00:00
github-actions[bot]
95b89e331d Merge branch 'main' into feat/add-indonesia-locale 2026-05-26 13:35:09 +00:00
lucaronin
2382305576 feat: support action history undo redo 2026-05-26 14:56:39 +02:00
lucaronin
b9f90b0657 fix: use cached startup loads for note windows 2026-05-26 12:58:08 +02:00
Aris Ripandi
0f5ac35f67 fix: add missing locales keys for Indonesian translation 2026-05-26 17:21:07 +07:00
Aris Ripandi
6424509bb9 Merge branch 'main' into feat/add-indonesia-locale 2026-05-26 17:10:28 +07:00
Aris Ripandi
7614db62f2 fix: increase sidebar width to avoid truncated label 2026-05-26 17:06:15 +07:00
github-actions[bot]
42dc2f726f Merge branch 'main' into pr-726 2026-05-26 10:05:58 +00:00
lucaronin
56c6cb56d6 feat: create notes inside selected folders 2026-05-26 11:56:28 +02:00
github-actions[bot]
37536e1974 Merge branch 'main' into pr-726 2026-05-26 09:53:49 +00:00
Aris Ripandi
9234ffcac2 Merge branch 'refactoringhq:main' into feat/add-indonesia-locale 2026-05-26 16:49:37 +07:00
Aris Ripandi
198b7c993f feat: add Indonesia translation 2026-05-26 16:47:45 +07:00
lucaronin
ea54560e11 fix: simplify local agent setup surfaces 2026-05-26 11:42:22 +02:00
lucaronin
fbb51a0b4c fix: resolve codacy high severity findings 2026-05-26 10:52:53 +02:00
github-actions[bot]
c8eba73863 Merge branch 'main' into pr-726 2026-05-26 08:42:14 +00:00
lucaronin
ae7ee4064f refactor: keep merged PRs above code health gate 2026-05-26 10:26:29 +02:00
lucaronin
e3bfeba163 Merge remote-tracking branch 'refs/remotes/pr/745' 2026-05-26 10:24:56 +02:00
lucaronin
8c8a2bccfe Merge remote-tracking branch 'refs/remotes/pr/740' 2026-05-26 10:24:52 +02:00
lucaronin
42065d9843 Merge remote-tracking branch 'refs/remotes/pr/733' 2026-05-26 10:24:49 +02:00
lucaronin
6080500df0 Merge remote-tracking branch 'refs/remotes/pr/738' 2026-05-26 10:24:46 +02:00
Elias Stiffel
fe1ff59879 perf: defer and parallelize AI agent CLI discovery at startup
get_ai_agents_status used to probe all six agent CLIs (claude_code,
codex, opencode, pi, gemini, kiro) sequentially during cold start. For
each agent missing from PATH it would fall through to three login-shell
spawns (`<shell> -lc 'command -v <agent>'`), each evaluating the user's
full zsh init. On a machine with no agents installed this added ~5 s to
cold start.

The result is not on the first-paint render path. It feeds the status
bar AI badge, the AI tab in settings, and the AI agents onboarding
prompt — all of which are gated behind aiFeaturesEnabled and the
onboarding flow, so they happily render nothing while the probe is
pending.

Three additive changes:

* useAiAgentsStatus now takes an `enabled` option. When false (AI
  features off, or running in a detached note window) the probe never
  fires. App.tsx passes `aiFeaturesEnabled && !noteWindowParams`.
* When enabled, the invoke call is deferred via requestIdleCallback
  (with setTimeout(0) fallback because WKWebView lacks the API) so it
  lands after first paint instead of during it.
* Rust get_ai_agents_status is now async and fans the six check_cli()
  calls out under tokio::task::spawn_blocking + tokio::join!. Per-agent
  check_cli() signatures stay sync, so run_agent_stream callers are
  unaffected and the per-agent unit tests need no changes.

Cold-start measurements on this machine (no agents on PATH, dev mode):

  Baseline (main, AI on):       cascade settles at t+15.5 s
  Patched, AI features off:                       t+5.0 s
  Patched, AI features on:                        t+6.0 s,
                                  AI status arrives ~1.6 s after first
                                  paint, fully off the critical path.

Fixes: https://github.com/refactoringhq/tolaria/issues/726

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:35:15 +02:00
github-actions[bot]
fe939763cf Merge branch 'main' into pr-728 2026-05-25 15:18:46 +00:00
github-actions[bot]
e15aa76826 Merge branch 'main' into pr-736 2026-05-25 15:18:44 +00:00
github-actions[bot]
ebe85faf53 Merge branch 'main' into pr-735 2026-05-25 15:18:39 +00:00
github-actions[bot]
d0ce5ae165 Merge branch 'main' into main 2026-05-25 15:18:35 +00:00
lucaronin
780e33bff2 fix: recover rich editor keydown join errors 2026-05-25 17:09:11 +02:00
github-actions[bot]
443d34b8b2 Merge branch 'main' into pr-728 2026-05-25 10:22:31 +00:00
github-actions[bot]
77fa49d5f5 Merge branch 'main' into pr-736 2026-05-25 10:22:29 +00:00
github-actions[bot]
a2094b57b7 Merge branch 'main' into pr-735 2026-05-25 10:22:25 +00:00
github-actions[bot]
59d272cf24 Merge branch 'main' into main 2026-05-25 10:22:21 +00:00
lucaronin
afc2ead14c fix: restore full app note windows 2026-05-25 12:07:25 +02:00
github-actions[bot]
80fb3dc6c9 Merge branch 'main' into pr-728 2026-05-25 09:58:00 +00:00
github-actions[bot]
181906cb17 Merge branch 'main' into pr-736 2026-05-25 09:57:58 +00:00
github-actions[bot]
1b00eaf461 Merge branch 'main' into pr-735 2026-05-25 09:57:54 +00:00
github-actions[bot]
9ad5ab822a Merge branch 'main' into main 2026-05-25 09:57:49 +00:00
lucaronin
c255c91a0b fix: keep sort dropdown within viewport 2026-05-25 11:45:34 +02:00
github-actions[bot]
384aa22fbd Merge branch 'main' into pr-728 2026-05-25 09:24:11 +00:00
github-actions[bot]
093d331719 Merge branch 'main' into pr-736 2026-05-25 09:24:09 +00:00
github-actions[bot]
f9c5e70916 Merge branch 'main' into pr-735 2026-05-25 09:24:05 +00:00
github-actions[bot]
d08a08195c Merge branch 'main' into main 2026-05-25 09:23:59 +00:00
lucaronin
2a3589181e fix: dedupe multi-vault auto sync fetches 2026-05-25 11:09:49 +02:00
github-actions[bot]
fb02644358 Merge branch 'main' into pr-728 2026-05-25 08:56:32 +00:00
github-actions[bot]
7c2e7a8990 Merge branch 'main' into pr-736 2026-05-25 08:56:30 +00:00
github-actions[bot]
e33f1b3106 Merge branch 'main' into pr-735 2026-05-25 08:56:26 +00:00
github-actions[bot]
815827b6e2 Merge branch 'main' into main 2026-05-25 08:56:21 +00:00
lucaronin
deb6998c97 refactor: consolidate type definition lookup 2026-05-25 10:41:24 +02:00
github-actions[bot]
1427035623 Merge branch 'main' into pr-728 2026-05-25 08:03:29 +00:00
github-actions[bot]
b7301568b8 Merge branch 'main' into pr-736 2026-05-25 08:03:26 +00:00
github-actions[bot]
0bf921dad1 Merge branch 'main' into pr-735 2026-05-25 08:03:21 +00:00
github-actions[bot]
563030982b Merge branch 'main' into main 2026-05-25 08:03:15 +00:00
lucaronin
41121ebf70 docs: add release notes for v2026-05-25 2026-05-25 10:02:26 +02:00
mehmet turac
7516dfe48a fix: treat name-only git remotes as local-only 2026-05-24 22:14:05 +03:00
mehmet turac
8764b6f78e fix: respect Kiro default agent fallback 2026-05-24 21:00:37 +03:00
github-actions[bot]
094f59eb7b Merge branch 'main' into pr-728 2026-05-24 12:54:58 +00:00
github-actions[bot]
62e1ab0e26 Merge branch 'main' into pr-736 2026-05-24 12:54:56 +00:00
github-actions[bot]
0135ce3f10 Merge branch 'main' into pr-735 2026-05-24 12:54:52 +00:00
lucaronin
c752a52e28 docs: update native QA guidance 2026-05-24 14:54:26 +02:00
github-actions[bot]
ede82fc7a2 Merge branch 'main' into pr-728 2026-05-24 12:28:24 +00:00
github-actions[bot]
8c2432339a Merge branch 'main' into pr-736 2026-05-24 12:28:21 +00:00
github-actions[bot]
fbfd0f1cf3 Merge branch 'main' into pr-735 2026-05-24 12:28:18 +00:00
lucaronin
2fb14f4c35 fix: disambiguate sidebar type visibility by vault 2026-05-24 14:18:17 +02:00
github-actions[bot]
51e32dc832 Merge branch 'main' into pr-728 2026-05-24 12:11:53 +00:00
github-actions[bot]
a541c1b916 Merge branch 'main' into pr-736 2026-05-24 12:11:51 +00:00
github-actions[bot]
411c1947ae Merge branch 'main' into pr-735 2026-05-24 12:11:47 +00:00
lucaronin
5bebd1c458 fix: restore light app icon 2026-05-24 13:52:16 +02:00
github-actions[bot]
e2e9e81ee9 Merge branch 'main' into pr-728 2026-05-24 11:49:09 +00:00
github-actions[bot]
11c7e35519 Merge branch 'main' into pr-736 2026-05-24 11:49:07 +00:00
github-actions[bot]
b9bff9223e Merge branch 'main' into pr-735 2026-05-24 11:49:04 +00:00
lucaronin
d204e95460 fix: expose desktop menu in window chrome 2026-05-24 13:33:14 +02:00
github-actions[bot]
764c899b63 Merge branch 'main' into pr-728 2026-05-24 11:26:39 +00:00
github-actions[bot]
a3a9738034 Merge branch 'main' into pr-736 2026-05-24 11:26:36 +00:00
github-actions[bot]
ad28aed309 Merge branch 'main' into pr-735 2026-05-24 11:26:33 +00:00
lucaronin
b5d462b3cd fix: sync active git vaults together 2026-05-24 13:11:29 +02:00
github-actions[bot]
35e50b75fe Merge branch 'main' into pr-728 2026-05-24 10:54:12 +00:00
github-actions[bot]
018a0aff4f Merge branch 'main' into pr-736 2026-05-24 10:54:10 +00:00
github-actions[bot]
0ffacb3a83 Merge branch 'main' into pr-734 2026-05-24 10:54:08 +00:00
github-actions[bot]
46f82d201d Merge branch 'main' into pr-735 2026-05-24 10:54:06 +00:00
lucaronin
c77d2af537 fix: ignore canceled external URL opens 2026-05-24 12:44:51 +02:00
github-actions[bot]
e662730fbc Merge branch 'main' into pr-728 2026-05-24 10:39:32 +00:00
github-actions[bot]
dc19b2d002 Merge branch 'main' into pr-736 2026-05-24 10:39:30 +00:00
github-actions[bot]
bb68ba78b6 Merge branch 'main' into pr-734 2026-05-24 10:39:28 +00:00
github-actions[bot]
4b375932f4 Merge branch 'main' into pr-735 2026-05-24 10:39:25 +00:00
lucaronin
7d6ba934c6 feat: switch app icon with theme 2026-05-24 12:28:29 +02:00
github-actions[bot]
aa4de6f155 Merge branch 'main' into pr-728 2026-05-24 10:18:53 +00:00
github-actions[bot]
7b62cd5e13 Merge branch 'main' into pr-736 2026-05-24 10:18:51 +00:00
github-actions[bot]
86e414d192 Merge branch 'main' into pr-734 2026-05-24 10:18:49 +00:00
github-actions[bot]
69bf1ca27b Merge branch 'main' into pr-735 2026-05-24 10:18:47 +00:00
lucaronin
9ca05c1665 fix: stack narrow status bar footer 2026-05-24 11:04:18 +02:00
github-actions[bot]
6fd0638d14 Merge branch 'main' into pr-728 2026-05-24 09:01:13 +00:00
github-actions[bot]
c62fb2227e Merge branch 'main' into pr-736 2026-05-24 09:01:10 +00:00
github-actions[bot]
29774c4f46 Merge branch 'main' into pr-734 2026-05-24 09:01:07 +00:00
github-actions[bot]
437c15a280 Merge branch 'main' into pr-735 2026-05-24 09:01:04 +00:00
lucaronin
194cebdfe0 fix: clamp selection toolbar to viewport 2026-05-24 10:48:14 +02:00
github-actions[bot]
d7e1de65b6 Merge branch 'main' into pr-728 2026-05-24 08:44:23 +00:00
github-actions[bot]
38b336357f Merge branch 'main' into pr-736 2026-05-24 08:44:21 +00:00
github-actions[bot]
7f7272e09b Merge branch 'main' into pr-734 2026-05-24 08:44:19 +00:00
github-actions[bot]
8458a337bd Merge branch 'main' into pr-735 2026-05-24 08:44:16 +00:00
lucaronin
d8e0524613 test: cover immediate new note editing 2026-05-24 10:30:16 +02:00
github-actions[bot]
72e10d3b41 Merge branch 'main' into pr-728 2026-05-24 08:22:27 +00:00
github-actions[bot]
80e685cc33 Merge branch 'main' into pr-736 2026-05-24 08:22:25 +00:00
github-actions[bot]
d87d15982f Merge branch 'main' into pr-734 2026-05-24 08:22:23 +00:00
github-actions[bot]
b8b5515a7d Merge branch 'main' into pr-735 2026-05-24 08:22:21 +00:00
lucaronin
e4afa7cfa5 fix: ratchet codescene thresholds 2026-05-24 10:08:24 +02:00
Elias Stiffel
42c3b93886 refactor: group applyMountedChange callbacks under a single field
Code review on pr-735 flagged `applyMountedChange` for exceeding the
8-parameter limit. The three effect callbacks (`onSetDefaultWorkspace`,
`onSwitchVault`, `onUpdateWorkspaceIdentity`) are now grouped under a
single `callbacks` field. The function's destructure surface drops
from 8 to 6 fields, and the semantic split between "state + change"
and "effects" is now explicit in the type.

No behaviour change; the six existing tests pass unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:45:01 +02:00
Elias Stiffel
da3e9b9865 fix: switch active vault when unmounting it via the vault switcher
Part of the fix for #735 — top-level folder creation invisible in
multi-workspace mode. The primary route, where the create-folder flow
ignored the sidebar selection and always wrote to the legacy
`resolvedPath`, is fixed in #728 (selection-aware routing in
`handleCreateFolder` and `FolderTree`). This commit fixes the
complementary state-machine bug: the vault switcher could leave
`vaultPath` pointing at an unmounted vault, so any consumer that still
reads `vaultPath` would operate on a vault the user no longer sees.

The vault switcher tracks two related paths:
- `defaultWorkspacePath` — the workspace the user is currently "on"
  (used as the destination for new notes, the graph default, etc.).
- `vaultPath` — the active vault the switcher tracks; read by the file
  watcher, folder/view reloaders, and the active-vault fallback in the
  folder loader.

Unchecking a vault in the switcher previously re-routed only
`defaultWorkspacePath` to another mounted vault, leaving `vaultPath`
pointing at the just-unmounted vault. The two paths drifted apart and
every downstream consumer that read `vaultPath` operated on stale
state — for example the folder loader's active-vault fallback (when
enabled) would silently re-introduce the unmounted vault into the
sidebar, and the file watcher would keep listening to it.

`applyMountedChange` now also calls `onSwitchVault(nextPath)` when the
unmounted vault is the active one, mirroring the existing reroute for
`defaultWorkspacePath`. After the change `vaultPath` and mounted state
stay consistent for every consumer.

`applyMountedChange` is extracted from `VaultMenu.tsx` to a sibling
helper module (`vaultMenuMountedChange.ts`) so it can be unit-tested
directly without tripping `react-refresh/only-export-components`.

Tests:
- New `VaultMenu.applyMountedChange.test.ts` covers the four reroute
  permutations (default+active, active only, default only, neither),
  the bail-out when no alternative mounted vault exists, and the
  remount no-op.

Fixes: https://github.com/refactoringhq/tolaria/issues/735
Related: https://github.com/refactoringhq/tolaria/pull/728

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:08:57 +02:00
lucaronin
c6faf005b5 fix: prefer Windows OpenCode cmd shims 2026-05-24 00:06:53 +02:00
github-actions[bot]
5374e66a80 Merge branch 'main' into pr-728 2026-05-23 21:49:29 +00:00
github-actions[bot]
3efa44df6d Merge branch 'main' into pr-736 2026-05-23 21:49:27 +00:00
github-actions[bot]
f1c2470a6e Merge branch 'main' into pr-734 2026-05-23 21:49:25 +00: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
github-actions[bot]
1be0c4b32f Merge branch 'main' into pr-728 2026-05-23 21:01:34 +00:00
github-actions[bot]
a9f38fb372 Merge branch 'main' into pr-736 2026-05-23 21:01:32 +00:00
github-actions[bot]
2a15ecb3e3 Merge branch 'main' into pr-734 2026-05-23 21:01:30 +00:00
lucaronin
770fdee881 fix: preserve AI session when panel closes 2026-05-23 22:39:02 +02:00
github-actions[bot]
205f3641e3 Merge branch 'main' into pr-728 2026-05-23 20:30:14 +00:00
github-actions[bot]
56e5c98a84 Merge branch 'main' into pr-736 2026-05-23 20:30:11 +00:00
github-actions[bot]
73766ca471 Merge branch 'main' into pr-734 2026-05-23 20:30:09 +00:00
lucaronin
0979c0664c fix: derive AI agent targets from registry 2026-05-23 22:09:39 +02:00
github-actions[bot]
bcbd9d3c5a Merge branch 'main' into pr-728 2026-05-23 20:08:08 +00:00
github-actions[bot]
69000f205a Merge branch 'main' into pr-736 2026-05-23 20:08:06 +00:00
github-actions[bot]
d8e280b9ee Merge branch 'main' into pr-734 2026-05-23 20:08:04 +00:00
lucaronin
ce237039c9 fix: preserve editor focus during active note refresh 2026-05-23 21:53:21 +02:00
github-actions[bot]
6a3d730f0b Merge branch 'main' into pr-728 2026-05-23 19:46:31 +00:00
github-actions[bot]
c1cd894516 Merge branch 'main' into pr-736 2026-05-23 19:46:28 +00:00
github-actions[bot]
2d04335648 Merge branch 'main' into pr-734 2026-05-23 19:46:26 +00:00
lucaronin
1c8207eeb5 refactor: split vault loader workspace tests 2026-05-23 21:26:03 +02:00
github-actions[bot]
fa9855fbf3 Merge branch 'main' into pr-728 2026-05-23 19:06:37 +00:00
github-actions[bot]
d03c780180 Merge branch 'main' into pr-736 2026-05-23 19:06:35 +00:00
github-actions[bot]
c903eb1e79 Merge branch 'main' into pr-734 2026-05-23 19:06:32 +00:00
lucaronin
35d258c0ba fix: keep active vault folder roots visible 2026-05-23 21:01:36 +02:00
github-actions[bot]
720e2a9f60 Merge branch 'main' into pr-728 2026-05-23 18:18:00 +00:00
github-actions[bot]
b8b07a0310 Merge branch 'main' into pr-736 2026-05-23 18:17:57 +00:00
github-actions[bot]
f2bbcef7f0 Merge branch 'main' into pr-734 2026-05-23 18:17:55 +00:00
lucaronin
2df076f834 fix: bound code-block clipboard copies 2026-05-23 19:59:13 +02:00
github-actions[bot]
7e119ee8f0 Merge branch 'main' into pr-728 2026-05-23 17:26:45 +00:00
github-actions[bot]
58f703e0a7 Merge branch 'main' into pr-736 2026-05-23 17:26:43 +00:00
github-actions[bot]
2172719f3e Merge branch 'main' into pr-734 2026-05-23 17:26:40 +00: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
github-actions[bot]
6e97f3d4d3 Merge branch 'main' into pr-728 2026-05-23 16:30:09 +00:00
github-actions[bot]
a89416e9a5 Merge branch 'main' into pr-736 2026-05-23 16:30:07 +00:00
github-actions[bot]
5b275629be Merge branch 'main' into pr-734 2026-05-23 16:30:05 +00:00
Elias Stiffel
4a45616b16 Merge remote-tracking branch 'origin/pr-728' into pr-728 2026-05-23 18:17:22 +02:00
lucaronin
ae529f64cd fix: persist property-only frontmatter edits 2026-05-23 18:12:58 +02:00
Elias Stiffel
d337e2204a fix(lint): use optional chain in FolderTree create handler 2026-05-23 18:10:34 +02:00
github-actions[bot]
7110fef6f8 Merge branch 'main' into pr-728 2026-05-23 16:00:39 +00:00
github-actions[bot]
ec286e98d2 Merge branch 'main' into pr-736 2026-05-23 16:00:36 +00:00
github-actions[bot]
66b1a2b301 Merge branch 'main' into pr-734 2026-05-23 16:00:34 +00:00
lucaronin
2a3ecf7270 fix: recover invalid editor block joins 2026-05-23 17:49:00 +02:00
Elias Stiffel
3403c56394 test: align desktop_remote_commands_report_no_remote with has_remote fix
The test's name says "report_no_remote" but its push assertion still
expected the pre-fix behavior ("error"). After 6d97335d made has_remote
URL-aware, git_push on a vault with no URL-bearing remote short-circuits
with "no_remote" — matching the test name and the pull assertion above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 17:47:58 +02:00
Elias Stiffel
2b368dc8f0 fix(git): list_remotes ignores name-only inherited remote sections
Sibling to the has_remote fix on this branch (6d97335d). Plain `git remote`
lists remote names defined in any config layer, so a [remote "origin"]
section inherited from ~/.gitconfig (e.g. with `prune = true`) made
list_remotes return ["origin"] for fresh local vaults. That broke
git_add_remote (returned "already_configured") and silently neutered
disconnect_all_remotes.

Reuse remote::remote_line_has_url to parse `git remote -v` output and
keep only remotes with a non-empty URL column. Dedupe by name across
the (fetch)/(push) line pair.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 17:46:54 +02:00
Elias Stiffel
6d97335d68 fix: treat name-only remote sections as no remote
`has_remote()` ran bare `git remote`, which lists every remote name from
any config layer — so a `[remote "origin"]` section inherited from the
user's `~/.gitconfig` (e.g. with only `prune = true`) made every vault
look remote-backed. `git_pull` then ran `git pull --no-rebase`, hit
"no tracking information", and the status bar surfaced "Sync failed".

Parse `git remote -v` and require at least one line with a non-empty URL
column. Mirror the guard on `git_push` so direct callers return
`no_remote` instead of issuing a doomed push. Handle `no_remote`
explicitly in `useAutoSync.performPull` / `pullAndPush` / `handlePushResult`
so the frontend short-circuits the push step and keeps a neutral status.

Fixes: https://github.com/refactoringhq/tolaria/issues/734

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 17:19:34 +02:00
github-actions[bot]
3042d6180f Merge branch 'main' into pr-728 2026-05-23 15:09:57 +00:00
lucaronin
12646d4e98 fix: recover stale editor block references 2026-05-23 16:53:43 +02:00
github-actions[bot]
972d13e2cc Merge branch 'main' into pr-728 2026-05-23 14:28:31 +00:00
github-actions[bot]
86e88a3d43 Merge branch 'main' into pr-736 2026-05-23 14:28:29 +00:00
lucaronin
f0fb7b7418 fix: recover invalid insertion depth transforms 2026-05-23 16:11:20 +02:00
github-actions[bot]
ba5464cc24 Merge branch 'main' into pr-728 2026-05-23 13:45:10 +00:00
github-actions[bot]
ec20c02784 Merge branch 'main' into pr-736 2026-05-23 13:45:08 +00:00
lucaronin
5bb3fe0550 fix: recover stale table editor transforms 2026-05-23 15:28:35 +02:00
github-actions[bot]
b4610ab9be Merge branch 'main' into pr-728 2026-05-23 12:57:07 +00:00
github-actions[bot]
7c231b08d7 Merge branch 'main' into pr-736 2026-05-23 12:57:04 +00: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
Elias Stiffel
5dd3b98fec fix: avoid duplicate H1 when template begins with one
`buildNoteBody` unconditionally prepended an empty `# ` placeholder
whenever `initialEmptyHeading` was true, which collided with templates
whose first line is already an H1 (e.g. a Weekly Notes type whose
template starts with `# Woche 2026.21`). The placeholder is now
suppressed when the template's first non-whitespace line is `# `.

Fixes: https://github.com/refactoringhq/tolaria/issues/736

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:35:37 +02:00
Elias Stiffel
f0e32cfd5f fix: nest new folders under the selected parent
The Create Folder action ignored the sidebar selection and always
wrote to the vault root. Now it threads the selected folder as
parent_path to the backend, and expands the parent in the tree so
the new child is visible without re-clicking.

Fixes: https://github.com/refactoringhq/tolaria/issues/728

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:03:47 +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
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
648 changed files with 42899 additions and 7272 deletions

View File

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

View File

@@ -0,0 +1,115 @@
param(
[string]$ConfigPath = "src-tauri/tauri.windows-signing.conf.json"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Read-FirstEnv {
param([string[]]$Names)
foreach ($Name in $Names) {
$Value = [Environment]::GetEnvironmentVariable($Name)
if (-not [string]::IsNullOrWhiteSpace($Value)) {
return $Value.Trim()
}
}
throw "Set one of these environment variables: $($Names -join ', ')"
}
function Read-OptionalEnv {
param(
[string[]]$Names,
[string]$DefaultValue
)
foreach ($Name in $Names) {
$Value = [Environment]::GetEnvironmentVariable($Name)
if (-not [string]::IsNullOrWhiteSpace($Value)) {
return $Value.Trim()
}
}
return $DefaultValue
}
function Normalize-Thumbprint {
param([string]$Thumbprint)
return ($Thumbprint -replace "\s", "").ToUpperInvariant()
}
function Convert-CertificateSecretToBytes {
param([string]$CertificateSecret)
$Base64Lines = $CertificateSecret -split "\r?\n" |
Where-Object { $_ -notmatch "^-+BEGIN " -and $_ -notmatch "^-+END " }
$CertificateBase64 = ($Base64Lines -join "") -replace "\s", ""
try {
return [Convert]::FromBase64String($CertificateBase64)
} catch {
throw "Windows code-signing certificate must be base64-encoded PFX data."
}
}
$CertificateSecret = Read-FirstEnv @("WINDOWS_CODE_SIGNING_CERTIFICATE", "WINDOWS_CERTIFICATE")
$CertificatePassword = Read-FirstEnv @("WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD", "WINDOWS_CERTIFICATE_PASSWORD")
$ConfiguredThumbprint = Read-OptionalEnv @("WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT", "WINDOWS_CERTIFICATE_THUMBPRINT") ""
$DigestAlgorithm = Read-OptionalEnv @("WINDOWS_CODE_SIGNING_DIGEST_ALGORITHM") "sha256"
$TimestampUrl = Read-OptionalEnv @("WINDOWS_CODE_SIGNING_TIMESTAMP_URL", "WINDOWS_TIMESTAMP_URL") "http://timestamp.digicert.com"
$TempRoot = Join-Path ([IO.Path]::GetTempPath()) "tolaria-windows-signing"
if (-not [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) {
$TempRoot = Join-Path $env:RUNNER_TEMP "tolaria-windows-signing"
}
New-Item -ItemType Directory -Force -Path $TempRoot | Out-Null
$PfxPath = Join-Path $TempRoot "certificate.pfx"
[IO.File]::WriteAllBytes($PfxPath, (Convert-CertificateSecretToBytes $CertificateSecret))
$SecurePassword = ConvertTo-SecureString -String $CertificatePassword -Force -AsPlainText
$ImportedCertificates = @(Import-PfxCertificate -FilePath $PfxPath -CertStoreLocation Cert:\CurrentUser\My -Password $SecurePassword)
Remove-Item -Force -ErrorAction SilentlyContinue $PfxPath
$ImportedCertificate = $ImportedCertificates | Where-Object { $_.HasPrivateKey } | Select-Object -First 1
if ($null -eq $ImportedCertificate) {
throw "The imported Windows code-signing certificate does not include a private key."
}
if ([string]::IsNullOrWhiteSpace($ConfiguredThumbprint)) {
$CertificateThumbprint = Normalize-Thumbprint $ImportedCertificate.Thumbprint
} else {
$CertificateThumbprint = Normalize-Thumbprint $ConfiguredThumbprint
}
$StoreCertificate = Get-ChildItem Cert:\CurrentUser\My |
Where-Object { (Normalize-Thumbprint $_.Thumbprint) -eq $CertificateThumbprint } |
Select-Object -First 1
if ($null -eq $StoreCertificate) {
throw "The requested Windows code-signing certificate thumbprint was not found in Cert:\CurrentUser\My."
}
$Config = @{
bundle = @{
windows = @{
certificateThumbprint = $CertificateThumbprint
digestAlgorithm = $DigestAlgorithm
timestampUrl = $TimestampUrl
}
}
}
$ResolvedConfigPath = Resolve-Path -Path (Split-Path -Parent $ConfigPath) -ErrorAction SilentlyContinue
if ($null -eq $ResolvedConfigPath) {
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $ConfigPath) | Out-Null
}
$Config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding utf8NoBOM
if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_ENV)) {
"WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT=$CertificateThumbprint" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
}
Write-Host "Prepared Windows Authenticode signing config at $ConfigPath."

View File

@@ -0,0 +1,567 @@
name: Release build artifacts
on:
workflow_call:
inputs:
version:
required: true
type: string
macos_bundles:
required: false
type: string
default: ""
upload_macos_dmg:
required: true
type: boolean
require_windows_authenticode:
required: false
type: boolean
default: true
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
# The production Vite bundle can exceed Node's default ~2GB heap on
# macOS arm64 runners while Tauri runs beforeBuildCommand.
NODE_OPTIONS: --max-old-space-size=4096
jobs:
build:
name: Build (${{ matrix.arch }})
runs-on: macos-15
strategy:
fail-fast: true
matrix:
include:
- arch: aarch64
target: aarch64-apple-darwin
- arch: x86_64
target: x86_64-apple-darwin
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Setup Bun (required for bundle-qmd.sh)
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: latest
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: ${{ matrix.target }}
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/${{ matrix.target }}/release/bundle
- name: Set version
run: |
VERSION="${{ inputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Import Apple Developer certificate into keychain
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
CERT_PATH="$RUNNER_TEMP/apple_cert.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import re
import sys
from urllib.parse import urlparse
DISALLOWED_PLACEHOLDERS = {
"",
"-",
"_",
"false",
"true",
"null",
"undefined",
"none",
"disabled",
}
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def normalize_hostname(hostname: str) -> str:
normalized = hostname.strip().rstrip('.').lower()
if normalized.startswith('[') and normalized.endswith(']'):
normalized = normalized[1:-1]
return normalized
def is_ip_address(hostname: str) -> bool:
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
def is_allowed_hostname(hostname: str) -> bool:
normalized = normalize_hostname(hostname)
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
return False
if normalized == 'localhost':
return True
return '.' in normalized or is_ip_address(normalized)
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if value.lower() in DISALLOWED_PLACEHOLDERS:
errors.append(f"{name} must be set to a real value, not a placeholder")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ inputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
MACOS_BUNDLES="${{ inputs.macos_bundles }}"
if [ -n "$MACOS_BUNDLES" ]; then
pnpm tauri build --target ${{ matrix.target }} --bundles "$MACOS_BUNDLES"
else
pnpm tauri build --target ${{ matrix.target }}
fi
- name: Upload .dmg
if: ${{ inputs.upload_macos_dmg }}
uses: actions/upload-artifact@v4
with:
name: dmg-${{ matrix.arch }}
path: src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
retention-days: 1
- name: Upload updater artifacts (.tar.gz + .sig)
uses: actions/upload-artifact@v4
with:
name: updater-${{ matrix.arch }}
path: |
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
retention-days: 1
build-linux:
name: Build (linux-x86_64)
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Tauri Linux system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
fcitx5-frontend-gtk3 \
libfuse2 \
librsvg2-dev \
curl \
wget \
patchelf \
build-essential \
file \
rpm
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: x86_64-unknown-linux-gnu
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
- name: Set version
run: |
VERSION="${{ inputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- 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 }}
VITE_SENTRY_RELEASE: ${{ inputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
- name: Validate Linux bundles
run: |
shopt -s nullglob
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
)
signatures=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
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
fi
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Linux build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Linux bundles
uses: actions/upload-artifact@v4
with:
name: linux-x86_64-bundles
path: |
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
if-no-files-found: error
retention-days: 1
build-windows:
name: Build (windows-x86_64)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: x86_64-pc-windows-msvc
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~\.cargo\registry
~\.cargo\git
src-tauri\target
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Cache Tauri Windows tools
uses: actions/cache@v4
with:
path: ~\AppData\Local\tauri
key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3
- name: Prefetch Tauri NSIS toolchain
shell: pwsh
run: ./.github/scripts/prefetch-tauri-nsis.ps1
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached Windows bundle artifacts
shell: pwsh
run: |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
- name: Set version
shell: pwsh
run: |
$version = "${{ inputs.version }}"
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
$tauri.version = $version
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
- name: Validate Windows release env
id: windows-signing
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
WINDOWS_CODE_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE }}
WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
REQUIRE_WINDOWS_AUTHENTICODE: ${{ inputs.require_windows_authenticode }}
run: |
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
if [ -z "${!name}" ]; then
echo "::error::$name is required to build signed Windows updater artifacts."
exit 1
fi
done
has_certificate=false
has_password=false
if [ -n "$WINDOWS_CODE_SIGNING_CERTIFICATE" ] || [ -n "$WINDOWS_CERTIFICATE" ]; then
has_certificate=true
fi
if [ -n "$WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD" ] || [ -n "$WINDOWS_CERTIFICATE_PASSWORD" ]; then
has_password=true
fi
if [ "$has_certificate" != "$has_password" ]; then
echo "::error::Windows Authenticode signing is partially configured. Set both certificate and password secrets, or remove both for unsigned alpha Windows artifacts."
exit 1
fi
if [ "$has_certificate" = "true" ]; then
echo "authenticode_available=true" >> "$GITHUB_OUTPUT"
elif [ "$REQUIRE_WINDOWS_AUTHENTICODE" = "true" ]; then
echo "::error::WINDOWS_CODE_SIGNING_CERTIFICATE or WINDOWS_CERTIFICATE is required to Authenticode-sign Windows installers."
exit 1
else
echo "::warning::Windows Authenticode certificate secrets are not configured. Building alpha Windows artifacts without Authenticode signatures; Tauri updater signatures are still required."
echo "authenticode_available=false" >> "$GITHUB_OUTPUT"
fi
- name: Prepare Windows Authenticode signing
if: ${{ steps.windows-signing.outputs.authenticode_available == 'true' }}
shell: pwsh
env:
WINDOWS_CODE_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE }}
WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }}
WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT }}
WINDOWS_CODE_SIGNING_TIMESTAMP_URL: ${{ secrets.WINDOWS_CODE_SIGNING_TIMESTAMP_URL }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
WINDOWS_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CERTIFICATE_THUMBPRINT }}
WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL }}
run: ./.github/scripts/configure-windows-authenticode.ps1
- name: Build Tauri app (Windows bundles)
shell: pwsh
env:
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 }}
VITE_SENTRY_RELEASE: ${{ inputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
if ("${{ steps.windows-signing.outputs.authenticode_available }}" -eq "true") {
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --config src-tauri/tauri.windows-signing.conf.json
} else {
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
}
- name: Validate Windows Authenticode signatures
if: ${{ steps.windows-signing.outputs.authenticode_available == 'true' }}
shell: pwsh
run: |
$expectedThumbprint = $env:WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT
if ([string]::IsNullOrWhiteSpace($expectedThumbprint)) {
throw "WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT was not exported by the signing setup step."
}
$expectedThumbprint = ($expectedThumbprint -replace "\s", "").ToUpperInvariant()
$paths = @()
$paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "*.exe" -File -ErrorAction SilentlyContinue
$paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis" -Filter "*.exe" -File -ErrorAction SilentlyContinue
$paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi" -Filter "*.msi" -File -ErrorAction SilentlyContinue
$paths = @($paths | Sort-Object FullName -Unique)
if ($paths.Count -eq 0) {
throw "No Windows executable or installer artifacts found to verify."
}
foreach ($path in $paths) {
$signature = Get-AuthenticodeSignature -FilePath $path.FullName
if ($signature.Status -ne "Valid") {
throw "Invalid Authenticode signature for $($path.FullName): $($signature.Status)"
}
if ($null -eq $signature.SignerCertificate) {
throw "Missing signer certificate for $($path.FullName)."
}
$actualThumbprint = ($signature.SignerCertificate.Thumbprint -replace "\s", "").ToUpperInvariant()
if ($actualThumbprint -ne $expectedThumbprint) {
throw "Unexpected signer thumbprint for $($path.FullName): $actualThumbprint"
}
Write-Host "Authenticode signature OK: $($path.FullName)"
}
- name: Validate Windows bundles
shell: bash
run: |
shopt -s nullglob
installers=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
)
signatures=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
)
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Windows build produced no installable NSIS or MSI bundle."
exit 1
fi
for installer in "${installers[@]}"; do
if [[ "$(basename "$installer")" != *"${{ inputs.version }}"* ]]; then
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
exit 1
fi
done
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Windows build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Windows bundles
uses: actions/upload-artifact@v4
with:
name: windows-x86_64-bundles
path: |
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
if-no-files-found: error
retention-days: 1

View File

@@ -6,13 +6,6 @@ on:
- 'stable-v*'
- 'v20*'
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
# The production Vite bundle can exceed Node's default ~2GB heap on
# macOS arm64 runners while Tauri runs beforeBuildCommand.
NODE_OPTIONS: --max-old-space-size=4096
concurrency:
group: release-stable-${{ github.ref }}
cancel-in-progress: true
@@ -63,471 +56,28 @@ jobs:
DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-)
echo "### Stable version: \`$DISPLAY_VERSION\`" >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# Phase 2: Build release bundles in parallel
# ─────────────────────────────────────────────────────────────
build:
name: Build (${{ matrix.arch }})
# -------------------------------------------------------------
# Phase 2: Build shared release artifacts
# -------------------------------------------------------------
build-artifacts:
name: Build release artifacts
needs: version
runs-on: macos-15
strategy:
fail-fast: true
matrix:
include:
- arch: aarch64
target: aarch64-apple-darwin
- arch: x86_64
target: x86_64-apple-darwin
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Bun (required for bundle-qmd.sh)
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: latest
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: ${{ matrix.target }}
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/${{ matrix.target }}/release/bundle
- name: Set version
run: |
VERSION="${{ needs.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Import Apple Developer certificate into keychain
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
CERT_PATH="$RUNNER_TEMP/apple_cert.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import re
import sys
from urllib.parse import urlparse
DISALLOWED_PLACEHOLDERS = {
"",
"-",
"_",
"false",
"true",
"null",
"undefined",
"none",
"disabled",
}
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def normalize_hostname(hostname: str) -> str:
normalized = hostname.strip().rstrip('.').lower()
if normalized.startswith('[') and normalized.endswith(']'):
normalized = normalized[1:-1]
return normalized
def is_ip_address(hostname: str) -> bool:
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
def is_allowed_hostname(hostname: str) -> bool:
normalized = normalize_hostname(hostname)
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
return False
if normalized == 'localhost':
return True
return '.' in normalized or is_ip_address(normalized)
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if value.lower() in DISALLOWED_PLACEHOLDERS:
errors.append(f"{name} must be set to a real value, not a placeholder")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target ${{ matrix.target }}
- name: Upload .dmg
uses: actions/upload-artifact@v4
with:
name: dmg-${{ matrix.arch }}
path: src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
retention-days: 1
- name: Upload updater artifacts (.tar.gz + .sig)
uses: actions/upload-artifact@v4
with:
name: updater-${{ matrix.arch }}
path: |
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
retention-days: 1
build-linux:
name: Build (linux-x86_64)
needs: version
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Tauri Linux system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
fcitx5-frontend-gtk3 \
libfuse2 \
librsvg2-dev \
curl \
wget \
patchelf \
build-essential \
file \
rpm
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: x86_64-unknown-linux-gnu
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
- name: Set version
run: |
VERSION="${{ needs.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- 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 }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
- name: Validate Linux bundles
run: |
shopt -s nullglob
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
)
signatures=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
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
fi
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Linux build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Linux bundles
uses: actions/upload-artifact@v4
with:
name: linux-x86_64-bundles
path: |
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
if-no-files-found: error
retention-days: 1
build-windows:
name: Build (windows-x86_64)
needs: version
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: x86_64-pc-windows-msvc
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~\.cargo\registry
~\.cargo\git
src-tauri\target
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Cache Tauri Windows tools
uses: actions/cache@v4
with:
path: ~\AppData\Local\tauri
key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3
- name: Prefetch Tauri NSIS toolchain
shell: pwsh
run: ./.github/scripts/prefetch-tauri-nsis.ps1
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached Windows bundle artifacts
shell: pwsh
run: |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
- name: Set version
shell: pwsh
run: |
$version = "${{ needs.version.outputs.version }}"
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
$tauri.version = $version
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
- name: Validate Windows release env
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
if [ -z "${!name}" ]; then
echo "::error::$name is required to build signed Windows updater artifacts."
exit 1
fi
done
- name: Build Tauri app (Windows bundles)
env:
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 }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
- name: Validate Windows bundles
shell: bash
run: |
shopt -s nullglob
installers=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
)
signatures=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
)
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Windows build produced no installable NSIS or MSI bundle."
exit 1
fi
for installer in "${installers[@]}"; do
if [[ "$(basename "$installer")" != *"${{ needs.version.outputs.version }}"* ]]; then
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
exit 1
fi
done
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Windows build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Windows bundles
uses: actions/upload-artifact@v4
with:
name: windows-x86_64-bundles
path: |
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
if-no-files-found: error
retention-days: 1
uses: ./.github/workflows/release-build-artifacts.yml
with:
version: ${{ needs.version.outputs.version }}
macos_bundles: ""
upload_macos_dmg: true
# One-time stable promotion exceptions while Windows certificate secrets
# are still being provisioned. Future stable tags must keep Authenticode.
require_windows_authenticode: ${{ !contains(fromJson('["v2026-06-01","v2026-06-06"]'), needs.version.outputs.tag) }}
secrets: inherit
# ─────────────────────────────────────────────────────────────
# Phase 3: Publish GitHub Release
# ─────────────────────────────────────────────────────────────
release:
name: GitHub Release (stable)
needs: [version, build, build-linux, build-windows]
needs: [version, build-artifacts]
runs-on: ubuntu-latest
permissions:
contents: write

View File

@@ -10,13 +10,6 @@ on:
- ".github/workflows/release.yml"
- "site/**"
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
# The production Vite bundle can exceed Node's default ~2GB heap on
# macOS arm64 runners while Tauri runs beforeBuildCommand.
NODE_OPTIONS: --max-old-space-size=4096
concurrency:
group: release-alpha-${{ github.ref }}
cancel-in-progress: true
@@ -123,467 +116,27 @@ jobs:
DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-)
echo "### Alpha version: \`$DISPLAY_VERSION\` (\`$VERSION\`)" >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# Phase 2: Build each architecture in parallel
# tauri build handles signing automatically via env vars
# ─────────────────────────────────────────────────────────────
build:
name: Build (${{ matrix.arch }})
# -------------------------------------------------------------
# Phase 2: Build shared release artifacts
# -------------------------------------------------------------
build-artifacts:
name: Build release artifacts
needs: version
runs-on: macos-15
strategy:
fail-fast: true
matrix:
include:
- arch: aarch64
target: aarch64-apple-darwin
- arch: x86_64
target: x86_64-apple-darwin
steps:
- uses: actions/checkout@v4
uses: ./.github/workflows/release-build-artifacts.yml
with:
version: ${{ needs.version.outputs.version }}
macos_bundles: app
upload_macos_dmg: false
require_windows_authenticode: false
secrets: inherit
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Bun (required for bundle-qmd.sh)
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: latest
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: ${{ matrix.target }}
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/${{ matrix.target }}/release/bundle
- name: Set version
run: |
VERSION="${{ needs.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Import Apple Developer certificate into keychain
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
CERT_PATH="$RUNNER_TEMP/apple_cert.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import re
import sys
from urllib.parse import urlparse
DISALLOWED_PLACEHOLDERS = {
"",
"-",
"_",
"false",
"true",
"null",
"undefined",
"none",
"disabled",
}
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def normalize_hostname(hostname: str) -> str:
normalized = hostname.strip().rstrip('.').lower()
if normalized.startswith('[') and normalized.endswith(']'):
normalized = normalized[1:-1]
return normalized
def is_ip_address(hostname: str) -> bool:
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
def is_allowed_hostname(hostname: str) -> bool:
normalized = normalize_hostname(hostname)
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
return False
if normalized == 'localhost':
return True
return '.' in normalized or is_ip_address(normalized)
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if value.lower() in DISALLOWED_PLACEHOLDERS:
errors.append(f"{name} must be set to a real value, not a placeholder")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
# Alpha releases only need the notarized app bundle and updater tarball.
# Skipping DMG packaging avoids fragile bundle_dmg.sh failures on macOS runners.
pnpm tauri build --target ${{ matrix.target }} --bundles app
- name: Upload updater artifacts (.tar.gz + .sig)
uses: actions/upload-artifact@v4
with:
name: updater-${{ matrix.arch }}
path: |
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
retention-days: 1
build-linux:
name: Build (linux-x86_64)
needs: version
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Tauri Linux system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
fcitx5-frontend-gtk3 \
libfuse2 \
librsvg2-dev \
curl \
wget \
patchelf \
build-essential \
file \
rpm
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: x86_64-unknown-linux-gnu
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
- name: Set version
run: |
VERSION="${{ needs.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- 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 }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
- name: Validate Linux bundles
run: |
shopt -s nullglob
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
)
signatures=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
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
fi
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Linux build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Linux bundles
uses: actions/upload-artifact@v4
with:
name: linux-x86_64-bundles
path: |
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
if-no-files-found: error
retention-days: 1
build-windows:
name: Build (windows-x86_64)
needs: version
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: x86_64-pc-windows-msvc
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~\.cargo\registry
~\.cargo\git
src-tauri\target
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Cache Tauri Windows tools
uses: actions/cache@v4
with:
path: ~\AppData\Local\tauri
key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3
- name: Prefetch Tauri NSIS toolchain
shell: pwsh
run: ./.github/scripts/prefetch-tauri-nsis.ps1
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached Windows bundle artifacts
shell: pwsh
run: |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
- name: Set version
shell: pwsh
run: |
$version = "${{ needs.version.outputs.version }}"
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
$tauri.version = $version
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
- name: Validate Windows release env
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
if [ -z "${!name}" ]; then
echo "::error::$name is required to build signed Windows updater artifacts."
exit 1
fi
done
- name: Build Tauri app (Windows bundles)
env:
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 }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
- name: Validate Windows bundles
shell: bash
run: |
shopt -s nullglob
installers=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
)
signatures=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
)
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Windows build produced no installable NSIS or MSI bundle."
exit 1
fi
for installer in "${installers[@]}"; do
if [[ "$(basename "$installer")" != *"${{ needs.version.outputs.version }}"* ]]; then
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
exit 1
fi
done
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Windows build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Windows bundles
uses: actions/upload-artifact@v4
with:
name: windows-x86_64-bundles
path: |
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
if-no-files-found: error
retention-days: 1
# ─────────────────────────────────────────────────────────────
# Phase 3: Publish GitHub Release
# No lipo/re-signing — use the per-arch artifacts directly
# ─────────────────────────────────────────────────────────────
release:
name: GitHub Release (alpha)
needs: [version, build, build-linux, build-windows]
needs: [version, build-artifacts]
runs-on: ubuntu-latest
permissions:
contents: write

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

107
AGENTS.md
View File

@@ -1,71 +1,22 @@
# AGENTS.md — Tolaria App
> Quick links: [Architecture](docs/ARCHITECTURE.md) · [Abstractions](docs/ABSTRACTIONS.md) · [Wireframes](ui-design.pen)
## 1. Development Process
---
## 1. Task Workflow
### 1a. Pick up a task
Run `/laputa-next-task` — fetches next task (To Rework first, then Open), moves to In Progress, returns full description.
### Start working on a task
**Before writing a single line of code:** run `mcp__codescene__code_health_score` to check the current codebase health against `.codescene-thresholds`. If the score is already below the threshold, **stop and refactor first** — find the worst files with the MCP, improve them, commit, then start the task. Never start feature work on a codebase that is already below the gate.
- Read task description and all comments fully
- For To Rework: the ❌ QA failed comment tells you exactly what to fix
- Check `docs/adr/` for relevant architecture decisions before structural choices
- Add a comment: `🚀 Starting work on this task. [Brief description of approach]`
### 1b. Implement
- Work on `main` branch — **no branches, no PRs, ever**. Pre-commit and pre-push block work from any other branch.
- Commit every 2030 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
- **⛔ NEVER use --no-verify**
- For UI tasks: open `ui-design.pen` first, study visual language, design in light mode. You don't need Pencil to use it you can open it as a JSON file.
### 1c. When done
**Phase 1 — Playwright (only for core user flows):**
Write Playwright test in `tests/smoke/<slug>.spec.ts` only if feature touches: vault open, note create/save/delete, search, wikilink navigation, git commit/push, conflict resolution. Tag a test with `@smoke` only if it protects a core pre-push workflow. Do NOT tag cosmetic or mock-heavy checks — keep those in the full regression lane. The curated `pnpm playwright:smoke` suite must stay under **5 minutes**; use `pnpm playwright:regression` for the full Playwright pass.
```bash
pnpm dev --port 5201 &
sleep 3
BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
```
**Phase 2 — Native app QA:**
```bash
pnpm tauri dev &
sleep 10
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
```
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
After both phases pass, add a **completion comment** to the Todoist task. The comment must include:
- What was implemented (a few lines covering logic and UX/UI)
- QA: what was tested and how (Playwright / native screenshot / osascript)
- Tests/coverage: commands run and final coverage result
- CodeScene: before/after touched-file checks plus final Hotspot and Average scores after push
- Codacy: MCP/CLI scan summary; confirm no new Critical/High findings
- Localization: `pnpm l10n:translate` + `pnpm l10n:validate` result, or "no UI copy changes"
- PostHog: event name(s) added, or why no event was needed
- Refactoring: any files refactored to meet the CodeScene gate (or "none needed")
- ADRs: any new/updated ADRs (or "none")
- Docs: any updated docs (ARCHITECTURE.md, ABSTRACTIONS.md, etc.) (or "none")
---
## 2. Development Process
- Check `docs/ARCHITECTURE.md` and `docs/ABSTRACTIONS.md` for relevant structural information
- For UI tasks: study app visual language and components first. Prioritize reusing existing components, assets, and variables over recreating them.
- If working on a Todoist task, add a comment: `🚀 Starting work on this task. [Brief description of approach]`
### Commits & pushes
- Push directly to `main` — no PRs, no branches. Pre-push blocks non-`main` pushes.
- Commit every 2030 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
- Pre-push hook runs full check suite (build + tests + core Playwright smoke + CodeScene)
- **A task is NOT done until `git push origin main` succeeds.** If the hook blocks: read the error, fix it (clippy, tests, CodeScene, build), commit the fix, push again. **⛔ NEVER use --no-verify**
@@ -131,16 +82,46 @@ Coverage is a release gate, not a vanity metric:
- For bug fixes, add a regression test when practical.
- For new behavior, add targeted coverage close to the changed code; do not rely only on broad E2E coverage.
### UI and native QA
**Phase 1 — Playwright (only for core user flows):**
Write Playwright test in `tests/smoke/<slug>.spec.ts` only if feature touches: vault open, note create/save/delete, search, wikilink navigation, git commit/push, conflict resolution. Tag a test with `@smoke` only if it protects a core pre-push workflow. Do NOT tag cosmetic or mock-heavy checks — keep those in the full regression lane. The curated `pnpm playwright:smoke` suite must stay under **5 minutes**; use `pnpm playwright:regression` for the full Playwright pass.
```bash
pnpm dev --port 5201 &
sleep 3
BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
```
**Phase 2 — Native app QA:**
```bash
pnpm tauri dev &
sleep 10
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
```
Use computer-use/browser-control style interaction for native UI QA when available: click, hover, drag, select, scroll, and type the way a real user would with the mouse and trackpad. For every UI feature, test the primary mouse-driven path first, then verify any relevant keyboard shortcut or keyboard-first workflow still works. Tolaria is still a keyboard-first app, but QA must not assume users only interact by keyboard.
Use `osascript` for app focus, keyboard shortcuts, and keyboard-specific checks. **⚠️ WKWebView:** `osascript keystroke` can be blocked inside editor content — use computer use for native editor interaction when possible, and rely on Playwright for deterministic text-input coverage. Write result as Todoist comment (✅ or ❌).
### Release-readiness checklist
Before pushing or moving a task to In Review, verify and mention the result in the Todoist completion comment:
Before pushing or moving a task to In Review, verify the release gates and add a **completion comment** to the Todoist task. The comment must include:
- CodeScene before/after checked for every touched/new scorable file; final Hotspot and Average pass `.codescene-thresholds`.
- What was implemented (a few lines covering logic and UX/UI).
- QA: what was tested and how (Playwright / native screenshot / osascript).
- Tests/coverage: commands run and final coverage result.
- CodeScene: before/after touched-file checks plus final Hotspot and Average scores after push; final scores must pass `.codescene-thresholds`.
- Coverage commands passed (`pnpm test:coverage` and `cargo llvm-cov ... --fail-under-lines 85`) or the change is docs-only.
- Codacy checked via MCP or `.codacy/cli.sh`; all new Critical/High issues fixed.
- Localization checked: any user-facing copy lives in `src/lib/locales/en.json`, `pnpm l10n:translate` was run, and `pnpm l10n:validate` passes. If no copy changed, say “Localization: no UI copy changes”.
- PostHog checked: meaningful new user actions/events are instrumented with safe metadata; noisy/minor changes explicitly say “PostHog: no event needed because …”.
- Docs/ADRs checked under the rules below.
- Codacy: MCP/CLI scan summary; confirm no new Critical/High findings.
- Localization: any user-facing copy lives in `src/lib/locales/en.json`, `pnpm l10n:translate` was run, and `pnpm l10n:validate` passes. If no copy changed, say “Localization: no UI copy changes”.
- PostHog: meaningful new user actions/events are instrumented with safe metadata; noisy/minor changes explicitly say “PostHog: no event needed because …”.
- Refactoring: any files refactored to meet the CodeScene gate, or "none needed".
- ADRs: any new/updated ADRs, or "none".
- Docs: any updated docs (`ARCHITECTURE.md`, `ABSTRACTIONS.md`, etc.), or "none".
- Demo vault dirt checked: `git status --short -- demo-vault demo-vault-v2` is empty unless fixture changes are intentional.
### ADRs & docs
@@ -151,7 +132,7 @@ After any Tauri command, new component/hook, data model change, or new integrati
---
## 3. Product Rules
## 2. Product Rules
### Demo vault hygiene (`demo-vault/`, `demo-vault-v2/`)
@@ -191,7 +172,7 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
---
## 4. Reference
## 3. Reference
### macOS / Tauri gotchas

View File

@@ -43,7 +43,7 @@ brew install --cask tolaria
### Download from releases
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/) for macOS, Windows, or Linux.
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/) for macOS, Windows, or Linux. Windows installers are Authenticode-signed; company-managed devices may still require IT approval of the Tolaria publisher before first install.
## Getting started

View File

@@ -16,7 +16,7 @@ We currently support security fixes for:
## Reporting a vulnerability
Please email **luca@refactoring.club** with the subject line **`[Tolaria Security]`**.
Please use GitHub's private vulnerability reporting flow for this repository.
Include as much of the following as you can:

38
biome.json Normal file
View File

@@ -0,0 +1,38 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"files": {
"includes": [
"**",
"!src-tauri/gen/**",
"!target/**",
"!dist/**",
"!node_modules/**"
]
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"overrides": [
{
"includes": ["site/**/*.vue"],
"linter": {
"rules": {
"correctness": {
"noUnusedImports": "off",
"noUnusedVariables": "off",
"useHookAtTopLevel": "off"
}
}
}
}
],
"linter": {
"rules": {
"correctness": {
"useQwikValidLexicalScope": "off"
}
}
}
}

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
}
```
@@ -175,10 +175,18 @@ interface WorkspaceIdentity {
The status-bar workspace manager edits installation-local identity and mount state. The alias is the durable user-facing namespace for cross-workspace links such as `[[team/projects/alpha]]`; labels and colors are display affordances only. The default workspace controls where new notes and Type files are created; it is not a claim that only one vault is active. When multiple workspaces are enabled, every mounted available workspace participates in the graph and the active Git repository set.
Git-facing renderer code must pass an explicit repository path instead of assuming a single active vault. Changes and Pulse/history display one selected repository at a time, manual commit selects one target repository, and AutoGit checkpoints iterate every active repository. Diff, file history, note saves, and discarded changes resolve the repository from the note's workspace provenance or from the selected Git surface.
Git-facing renderer code must pass an explicit repository path instead of assuming a single active vault. Changes and Pulse/history display one selected repository at a time, manual commit selects one target repository, and AutoGit checkpoints iterate every active repository. Diff, file history, note saves, and discarded changes resolve the repository from the note's workspace provenance or from the selected Git surface. Manual Sync refreshes vault-derived sidebar state and bumps a shared Git history refresh key after successful pulls, including `up_to_date` pulls, while automatic up-to-date checks avoid that heavier reload path.
`useGitFileWorkflows` is the renderer abstraction for note-scoped Git file actions. It translates active tabs, visible entries, and modified-file surfaces into the correct repository path for diff/history commands, deleted-note previews, queued editor diff requests, and discard refresh behavior.
### Tolaria Deep Links
Deep links identify existing vault items with `tolaria://<vault-slug>/<relative-path-with-extension>`. The slug is derived from the registered workspace alias, then label, then path basename; generated links append a stable short hash when two vaults share the same base slug. A manually typed ambiguous base slug is rejected instead of choosing the wrong vault.
The relative path is encoded per segment, preserving `/` as the separator while allowing spaces, Unicode, and reserved characters inside filenames. Decoding rejects `.`, `..`, encoded slashes, backslashes, empty segments, and any resolved path outside the target vault root. Links keep the file extension so Markdown, text, media, PDFs, and other vault files can all route through the same `VaultEntry` lookup.
Deep links are navigation-only. Opening one can focus Tolaria, switch to a registered vault, reload the index once, and open an existing item; it never creates missing files, imports external files, or silently falls back to another vault. v1 links are path-based, so renaming or moving a file changes the canonical link. macOS and Windows are the verified v1 desktop targets; Linux registration is best-effort until package-level QA covers the supported desktop environments.
### File kinds and binary previews
`VaultEntry.fileKind` comes from the Rust vault scanner and intentionally stays coarse-grained:
@@ -191,6 +199,8 @@ Git-facing renderer code must pass an explicit repository path instead of assumi
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.
Markdown note PDF export is not a stored file-kind transformation. `src/utils/notePdfExport.ts` temporarily marks the current webview for print-only rendering, asks for a `.pdf` filesystem destination only when the native capability reports direct save support, and invokes Tauri's native `WKWebView` PDF export command on macOS. Windows/Linux Tauri builds and browser mode keep print-dialog fallback behavior. `src/components/useEditorPdfExport.ts` ensures the rich rendered note is active before export, so frontmatter is ignored and the PDF reflects the current rendered editor DOM while leaving the vault file unchanged.
### Note Content Freshness
The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. `useTabManagement` can reuse cached text immediately when it carries the same `modifiedAt` and `fileSize` identity as the current `VaultEntry`; otherwise it validates the cached string with the `validate_note_content` Tauri command. That command re-enters the same vault path boundary checks as `get_note_content` and compares the cached text against the current on-disk file bytes. A mismatch, missing file, or unreadable file falls back to the normal fresh-read path and existing missing/unreadable recovery. Background note prefetch is bounded to a small number of concurrent native reads, and a note opened while queued is promoted to foreground instead of waiting behind the prefetch backlog. Note-open entry objects are re-normalized at the tab boundary, so transient reload or bridge payloads with missing display metadata fall back to filename/title defaults before editor chrome renders; entries without a usable path are ignored instead of opening a broken tab.
@@ -250,7 +260,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 +292,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:
@@ -344,7 +356,7 @@ type SidebarSelection =
`SidebarSelection.kind === 'folder'` is a first-class navigation target, not just a visual highlight.
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated. The UI wraps backend folder nodes in a synthetic vault-root row with `path: ""` and `rootPath` set to the opened vault so root-level files can be listed without turning the vault root into a mutable folder. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks.
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated. The UI wraps backend folder nodes in a synthetic vault-root row with `path: ""` and `rootPath` set to the opened vault so root-level files can be listed without turning the vault root into a mutable folder. Inline folder creation carries an optional `FolderCreationParent` (`path` plus `rootPath`) through `App` to the `create_vault_folder` command, so new folders land under the selected folder or selected mounted vault root while preserving the active-vault path boundary. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks.
- `src/components/sidebar/sidebarHooks.ts` owns the shared sidebar interaction primitives for menu positioning/dismissal and inline rename input behavior. Folder, Type, and saved View rows keep their domain-specific actions local, but use those primitives so right-click menus and rename fields have the same outside-click, Escape, focus, blur, and submit semantics.
- `useFolderActions()` composes `useFolderRename()` and `useFolderDelete()` to keep folder mutations selection-aware while the rest of `App.tsx` only wires the resulting callbacks into `Sidebar` and the command registry.
- `useNoteRetargeting()` is the shared retargeting abstraction for note drops and command-palette actions. It owns the "can drop here?" checks, updates `type:` via frontmatter when a note lands on a type section, and delegates folder moves through the same crash-safe rename pipeline used by the backend rename commands.
@@ -353,10 +365,16 @@ 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.
`useAppViewActions()` owns the renderer-side saved View lifecycle: choosing the target workspace, preserving mounted-view identity, saving/deleting YAML definitions, reloading affected vault state, and exposing the available note-list fields for the create/edit dialog. `App.tsx` wires those callbacks into `Sidebar`, `NoteList`, `CreateViewDialog`, and command surfaces without duplicating the persistence rules.
`useMcpSetupDialogController()` owns MCP setup dialog state, busy actions, and manual config callbacks so `App.tsx` only passes the controller into settings/status surfaces. `useAiWorkspaceWindowBridgeEvents()` owns native AI-workspace event subscriptions and listener cleanup for popped-out workspace windows.
`createCrossWindowPersistedStore()` is the shared renderer primitive for AI workspace state that must stay synchronized across the main window and popped-out workspace windows. It owns localStorage reads/writes, BroadcastChannel publishing, storage-event synchronization, and external-store subscribers; domain modules such as `aiWorkspaceSessionStore` and `aiWorkspaceWindowSharedContext` provide sanitizers and mutations around that shell.
The renderer uses `viewOrdering` helpers to convert drag or command-palette move intent into dense order updates before saving each affected view file through `save_view_cmd`. The sidebar treats saved View rows like Type rows for direct customization: double-click starts inline rename, right-click opens edit/rename/icon-color/delete actions, and keyboard users can open that same menu from the focused row while command-palette actions remain responsible for saved View ordering.
### Neighborhood Mode
@@ -374,9 +392,11 @@ 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.
`useActionHistory` is the renderer-owned stack for reversible app-level actions. It records note-state actions only after persistence succeeds, replays one undo/redo at a time, and reveals the affected note before applying the reversal so editor pending-content flushes stay path-correct. Text editors and text inputs keep their native undo/redo history; app-level Undo/Redo shortcuts are handled only when focus is outside text-editing surfaces.
## File System Integration
@@ -411,7 +431,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 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.
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. `mcp-server/tool-service.js` owns the shared tool semantics for active-vault resolution, cross-vault lookup/search, note-creation defaults, vault listing, and UI action intents; `mcp-server/index.js` and `mcp-server/ws-bridge.js` remain transport adapters around that service. 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
@@ -496,6 +516,7 @@ interface PulseCommit {
| `history.rs` | File history | `git log` — last 20 commits per file |
| `status.rs` | Modified files | `git status --porcelain` — filtered to `.md` |
| `status.rs` | File diff | `git diff`, fallback to `--cached`, then synthetic for untracked |
| `file_url.rs` | File URL | Builds a copyable remote URL from the primary remote, current branch, and vault-relative path without exposing remote credentials |
| `commit.rs` | Commit | Ensures a local author fallback when needed, then runs `git add -A && git commit -m "..."`; broken signing helpers trigger one unsigned retry for the same app-managed commit |
| `remote.rs` | Pull / Push | `git pull --rebase` / `git push` |
| `connect.rs` | Add remote | Adds `origin`, fetches it, validates history compatibility, and only starts tracking when the remote is safe |
@@ -504,20 +525,23 @@ 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
- Reopens the clean active tab from disk only when the pull changed that active note, so unrelated updates do not remount the editor
- Pulls the active repository set concurrently on launch, focus, interval, and manual sync
- Budgets automatic launch/focus/interval pulls per repository with a short cooldown so focus or low interval settings do not repeat network Git work immediately after a recent sync; manual sync bypasses this budget
- Refreshes aggregate remote status after a pull, and avoids a separate startup status fetch when the initial pull will already refresh it
- 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, then restores editor focus if the editor owned focus before the remount
- 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
External vault mutations are any disk writes Tolaria did not just perform through its own save path: Git pulls, AI-agent writes, filesystem watcher events, and edits from another app. These changes must route through `refreshPulledVaultState()` rather than calling `reloadVault()` in isolation. The shared refresh abstraction reloads entries, folders, and saved views together, preserves unsaved active-editor content, reopens a clean active note only when the changed-path list includes that note, and closes the active tab if the file disappeared. Unknown or unrelated watcher updates refresh vault-derived state without remounting the active editor. `useVaultWatcher` supplies changed filesystem paths to this abstraction after debouncing and after filtering recent app-owned saves. Overlapping entry reloads and modified-file polls are coalesced with a single trailing rerun so watcher and sync bursts do not stack native vault scans or Git status processes.
External vault mutations are any disk writes Tolaria did not just perform through its own save path: Git pulls, AI-agent writes, filesystem watcher events, and edits from another app. These changes must route through `refreshPulledVaultState()` rather than calling `reloadVault()` in isolation. The shared refresh abstraction reloads entries, folders, and saved views together, preserves unsaved active-editor content, reopens a clean active note when the changed-path list includes that note, and closes the active tab if the file disappeared. Editor focus does not block the clean active note from converging to disk when its own file changed externally; if the active editor owned focus before that remount, the app requests editor focus again after the fresh tab is mounted. Unknown or unrelated watcher updates refresh vault-derived state without remounting the active editor. `useVaultWatcher` supplies changed filesystem paths to this abstraction after debouncing and after filtering recent app-owned saves. Overlapping entry reloads and modified-file polls are coalesced with a single trailing rerun so watcher and sync bursts do not stack native vault scans or Git status processes.
`useGitRepositories` is the commit-time companion to `useAutoSync`:
- Owns repository picker validation plus `get_modified_files` and `git_remote_status` loading for active Git repositories
@@ -545,7 +569,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
@@ -573,6 +597,7 @@ Defined in `src/components/editorSchema.tsx` and styled in `src/components/Edito
- The schema overrides BlockNote's default `codeBlock` spec with `createCodeBlockSpec({ ...codeBlockOptions, defaultLanguage: "text" })` from `@blocknote/code-block`.
- Fenced code blocks now use BlockNote's supported Shiki-backed highlighter path, which renders `.shiki` token spans directly inside the editor DOM.
- Missing common grammars live in `src/utils/codeBlockLanguageCatalog.ts` and are registered lazily from direct `@shikijs/langs` imports by `src/components/codeBlockOptions.ts`; known aliases such as `ps1` and `vb` normalize to canonical picker values during Markdown import.
- Tolaria keeps `defaultLanguage: "text"` so unlabeled code blocks do not silently become JavaScript at creation time. Parsed unlabeled code blocks then run through Tolaria's lightweight language inference, while explicit fence languages and user dropdown choices still win.
- Inline-code chip styling remains scoped to `.bn-inline-content code`, so fenced `pre > code` nodes keep the dedicated code-block shell instead of inheriting the muted inline surface.
@@ -582,6 +607,7 @@ Defined in `src/utils/mathMarkdown.ts`, `src/components/editorSchema.tsx`, and s
- `$...$` becomes a `mathInline` schema node and line-owned `$$...$$` / multiline `$$` blocks become `mathBlock` nodes.
- The rich editor renders both node types through KaTeX with `throwOnError: false`, so malformed formulas keep their source visible instead of breaking the note.
- Double-clicking rendered display math edits the math block's `latex` property in-place; Markdown delimiters remain owned by serialization. Inline math can still be reopened as source text for direct editing.
- `serializeMathAwareBlocks()` converts math nodes back to Markdown delimiters before save, raw-mode entry, and editor-position snapshots.
- Raw CodeMirror mode always shows the plain Markdown source, so imported technical notes stay editable outside Tolaria.
@@ -604,6 +630,7 @@ Defined in `src/utils/durableMarkdownBlocks.ts`, `src/utils/editorDurableMarkdow
- The rich editor renders the block with the `tldraw` package and saves debounced document snapshot changes back into the block props, so normal Tolaria autosave writes the board into the `.md` file.
- Whiteboard prop writes re-resolve the live BlockNote block by id before mutating it, and disappear as no-ops if a note reload or mode switch has already removed that block.
- The tldraw runtime receives Tolaria's resolved light/dark mode as its user color scheme, so embedded whiteboards follow the app appearance and update while mounted.
- Embedded whiteboards expose a session-only full-window workspace that reuses the same tldraw store and Markdown snapshot; expanding or closing it does not persist camera, tool, or size state.
- Mermaid and tldraw both register small codecs with the shared durable fenced-block pipeline; scanner, token, block injection, and mixed serialization mechanics live in one owner.
- The `/whiteboard` slash command inserts an empty tldraw block using the same Markdown-durable storage path. Preview images are intentionally omitted; thumbnails can be added later as derived cache artifacts.
@@ -613,10 +640,12 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
- `SingleEditorView` disables BlockNote's default formatting toolbar, `/` menu, and side menu, then mounts Tolaria-owned controllers so the visible formatting surface matches Tolaria's markdown round-trip guarantees.
- `SingleEditorView` owns a whitespace mouse-selection bridge around BlockNote and its rich-editor scroll area: drag starts that land outside the editable text DOM are remapped through the ProseMirror view with clamped coordinates, while drags below the rendered document fall back to the document end. Drags that begin inside BlockNote's contenteditable surface, toolbars, side menu, dialogs, or non-primary mouse buttons stay on BlockNote/native handling.
- `editorRichCopy.ts` owns rich-editor copy serialization for external apps. Normal selections use BlockNote's external clipboard HTML so tables, lists, checklists, and inline marks paste as rich content outside Tolaria, while `SingleEditorView` still normalizes `text/plain` and keeps fenced code-block selections on raw code text.
- The formatting toolbar only exposes inline controls that persist through `blocksToMarkdownLossy()` in Tolaria's save pipeline: bold, italic, strike, nesting, and link creation. Controls that BlockNote can render temporarily but Tolaria cannot faithfully persist, such as underline, color, alignment, and the block-type dropdown, are hidden instead of appearing to work and later disappearing.
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
- `useEditorComposing` tracks editor-owned IME composition events and closes the floating formatting toolbar during composition plus a short post-composition settle window, keeping CJK candidate windows unobstructed without changing normal selection toolbar behavior.
- `createImeCompositionKeyGuardExtension()` intercepts composing `Enter` keydown events before BlockNote's list shortcuts see them, so Korean/Japanese/Chinese IMEs can commit text at the start of list items without Tolaria splitting the current bullet. It stops editor shortcut propagation only; it does not prevent the browser/IME default composition action.
- `richEditorInputTransform.ts` is the shared execution shell for rich-editor Markdown `beforeinput` transforms. It reads the live ProseMirror view, skips IME composition, resets state when a stale view is detected, dispatches transform transactions, prevents native input only after successful dispatch, and reports recoverable editor-transform errors through the same telemetry path. Arrow ligatures, inline math conversion, and `==highlight==` keep their syntax-specific matching in their feature files and are composed for the main editor by `richEditorInputTransformExtension.ts`.
- `useImageLightbox` listens for `dblclick` on the rich-editor container and opens `ImageLightbox` only when the event target resolves to a viewable BlockNote image. The target resolver handles media wrappers, ignores image captions/resize controls, missing sources, and tiny tracking-style images, preserving BlockNote's ordinary single-click image selection path.
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, list blocks, Mermaid diagrams, and whiteboards. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. Tolaria renders the add-block button outside the drag handle so the handle stays next to the block content. The side menu aligns itself to the first rendered text line for the hovered block, so H1/H2 typography, line-height, wrapping, and theme changes do not need per-heading offsets. Block reordering uses a Tolaria-owned pointer gesture and direct BlockNote block moves instead of HTML5 `DataTransfer`, keeping it independent from Tauri's native file-drop system. Block-handle actions re-resolve the current live BlockNote block before mutating or dragging, so note reloads and sync churn cannot leave controls acting on stale block references.
@@ -624,7 +653,8 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
- `SingleEditorView` wraps the BlockNote surface in a narrow render-recovery boundary for BlockNote's transient `Block doesn't have id` node-view failure. The boundary retries the BlockNote view once, records `editor_render_recovered`, and marks the recovered error so the React root handler does not send that handled case back to Sentry. Other render errors still propagate through the normal root error path.
- `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags.
- `plainTextPaste.ts` is the shared plain-text paste target registry. Rich BlockNote and raw CodeMirror surfaces register focused insertion targets, while ordinary focused text controls use DOM selection replacement, so the `Cmd+Shift+V` command can preserve caret/selection behavior without each surface inventing its own clipboard reader.
- `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription and duplicate-unlisten cleanup used by native drop features.
- `tauriEventCleanup.ts` owns safe Tauri event unlisten cleanup. Hooks and stream utilities route listener teardown through it so stale or duplicate native listener removals cannot surface as unhandled promise rejections during fast remounts, window teardown, or stream completion.
- `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription used by native drop features.
- `useNativePathDrop()` is the shared Tauri file/folder-drop abstraction for text inputs that need filesystem paths instead of attachment import. It consumes native window drag/drop events, gates them to the target element bounds or focused text selection, and lets AI composer / command-palette inputs insert formatted paths at the current cursor.
### Markdown-to-BlockNote Pipeline
@@ -722,6 +752,7 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs:
- **Editable properties** (top): Type badge, Status pill with dropdown, number fields, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
- **Property display modes**: `text`, `number`, `date`, `boolean`, `status`, `url`, `tags`, and `color`. Numeric frontmatter values auto-detect as `number`, and custom scalar keys can be explicitly switched to `Number` through the property-type control.
- **Anchored dropdowns**: Fixed-position property menus and note-list sort menus use `src/components/anchoredDropdown.ts` for anchor measurement, viewport clamping, scroll/resize repositioning, and optional max-height calculations. Property-specific filtering and keyboard navigation stay in `propertyDropdownUtils.ts`.
- **Present empty properties**: A top-level frontmatter key with a blank scalar value (for example `start date:`) is treated as present and renders as an editable empty row. Only absent keys are omitted.
- **Type-derived placeholders**: For typed instances, missing custom properties declared on the type document render as gray editable placeholders. Editing one writes the value to the instance frontmatter; merely displaying it does not backfill the note.
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
@@ -755,6 +786,8 @@ interface SearchResult {
- Click result to open note in editor
- Shows relevance score and snippet
The NoteList header search keeps its local title/snippet/property filtering for immediate scoped results, then augments the match set with `search_vault` hits from the visible workspace roots using the command's frontmatter-excluding search option. React stores only matching paths so body-only matches appear in the current list scope without a second content-read pass or rendering private matched text in note rows.
No indexing step required — search runs directly against the filesystem.
## Vault Management
@@ -774,7 +807,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`)
@@ -788,6 +821,7 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte
- `GEMINI.md` is an optional Gemini CLI compatibility shim that points Gemini back to `AGENTS.md`
- `useVaultAiGuidanceStatus` reads `get_vault_ai_guidance_status` and normalizes the backend state into four UI cases: `managed`, `missing`, `broken`, and `custom`
- `restore_vault_ai_guidance` repairs only Tolaria-managed files and creates the optional Gemini shim on explicit request; user-authored custom `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` files are surfaced as custom and left untouched
- Editing a usable `AGENTS.md`, including changing its frontmatter `type`, makes the file custom rather than broken; broken is reserved for missing, empty, frontmatter-only, unreadable, or exact replaceable managed templates/stubs
- The status bar AI badge and command palette consume that abstraction to expose restore actions only when the managed guidance is missing or broken
Vault guidance is intentionally short and vault-specific. General Tolaria product behavior is delivered through the bundled agent docs resource instead:
@@ -795,6 +829,15 @@ Vault guidance is intentionally short and vault-specific. General Tolaria produc
- `src-tauri/resources/agent-docs/AGENTS.md` orients agents to the generated docs bundle, while `index.md`, section bundles, `all.md`, `search-index.json`, and `pages/` provide fast local lookup
- `get_agent_docs_path` exposes the resolved resource folder to the renderer, and `buildAgentSystemPrompt()` tells every app-managed CLI agent to read vault `AGENTS.md` first, then search the bundled docs for Tolaria behavior
### Action History
`useActionHistory` owns renderer-scoped app undo/redo state. It stores explicit action entries with labels plus undo/redo callbacks, suppresses nested recording during replay, and exposes the top labels to command-palette commands.
- Frontmatter mutations record history only after the write succeeds and only for non-silent user actions.
- Entry state toggles such as archive, favorite, and organized record explicit before/after replay callbacks after persistence succeeds.
- Text inputs, contenteditable surfaces, and editor-owned text history keep native undo/redo first; app-level history runs only when focus is outside text editing.
- Irreversible destructive actions stay outside the stack and continue to use confirmation/destructive affordances.
### Getting Started / Onboarding
`useOnboarding` hook detects first launch:
@@ -811,7 +854,9 @@ 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
- App-managed Claude Code runs preserve the same user-managed Anthropic/provider env behavior by forwarding selected exported variables from the app process or the user's zsh/bash startup files without persisting those secrets
- The shared `useAiAgentsStatus` hook defers that command until after the first render, skips it when AI features are disabled or the current window cannot render AI status surfaces, and falls back to missing-agent statuses if the native probe does not return promptly so first-launch onboarding keeps a recovery path
- Persists dismissal locally once the user continues
### Remote Git Operations
@@ -820,7 +865,7 @@ 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 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
@@ -832,6 +877,13 @@ Tolaria delegates remote auth to the user's system git setup:
App-level settings persisted at `~/.config/com.tolaria.app/settings.json` (reads legacy `com.laputa.app` on upgrade):
```typescript
interface AiWorkspaceConversationSetting {
archived: boolean | null
id: string
target_id: string | null
title: string
}
interface Settings {
auto_pull_interval_minutes: number | null
autogit_enabled: boolean | null
@@ -848,9 +900,11 @@ 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
ai_workspace_conversations: AiWorkspaceConversationSetting[] | null
hide_gitignored_files: boolean | null // null = default true
all_notes_show_pdfs: boolean | null // null = default false
all_notes_show_images: boolean | null // null = default false
@@ -858,7 +912,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; env-backed keys can come from the app process or exported zsh/bash startup values on Unix. Direct OpenAI-compatible model streams receive the active vault root and may execute Tolaria's native create-only `create_note` tool, but they do not receive shell access or general file-write tools. `ai_workspace_conversations` stores installation-local AI chat sidebar metadata only: conversation ids, titles, archive state, and explicit target overrides. It does not store vault content, prompts, transcripts, or model credentials. 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
@@ -906,6 +960,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 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. 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.
- **`.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 Windows job builds NSIS with Tauri updater signatures and uses Authenticode signing plus `Get-AuthenticodeSignature` verification when CI certificate secrets are configured; stable remains the strict channel for mandatory Authenticode enforcement. 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. Changes to the shared artifact workflow are not ignored by the alpha trigger, so release-pipeline fixes produce a fresh alpha run. 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, Authenticode-signed Windows x64 installers plus Tauri-signed updater bundles, Linux x86_64 `.deb` / `.rpm` / AppImage artifacts, and a static public download page that starts selected non-Windows installers without replacing the page with a blank download navigation. Windows visitors see an explicit signed-installer action and managed-device approval guidance instead of an automatic download. 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 |
@@ -96,7 +98,7 @@ flowchart LR
#### External Change Detection
The main window starts a native watcher for the active vault through `start_vault_watcher` / `stop_vault_watcher` (`src-tauri/src/vault_watcher.rs`, backed by Rust `notify`). The watcher emits `vault-changed` events for content paths and ignores churn from `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`. `useVaultWatcher` batches those events, suppresses recent app-owned saves, and sends the remaining external paths through `refreshPulledVaultState()` so folders, saved views, note-list state, and the clean active editor all refresh under the ADR-0071 unsaved-edit rules. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads.
The main window starts a native watcher for the active vault through `start_vault_watcher` / `stop_vault_watcher` (`src-tauri/src/vault_watcher.rs`, backed by Rust `notify`). The watcher emits `vault-changed` events for content paths and ignores churn from `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`. `useVaultWatcher` batches those events, suppresses recent app-owned saves, and sends the remaining external paths through `refreshPulledVaultState()` so folders, saved views, note-list state, and clean active-editor content refresh under the ADR-0135 unsaved-edit rules. When that clean active-editor remount starts from a focused editor, the main app dispatches a post-replacement editor focus request so typing can continue in the refreshed tab. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads.
#### Progressive Vault Loading
@@ -108,6 +110,8 @@ Large-vault reproduction and keyboard QA steps live in [LARGE-VAULT-LOADING-QA.m
The registered vault list can act as a mounted-workspace set. `useVaultSwitcher` persists each workspace's installation-local identity (`label`, stable `alias`, color, mount flag) and the default destination for newly created notes in `~/.config/com.tolaria.app/vaults.json`. `useVaultLoader` scans every available mounted workspace and annotates each `VaultEntry` with provenance before React consumes the combined graph. The default workspace is the write target for new notes and Type documents; it is not the only active vault when multiple workspaces are enabled.
Vault item deep links use the registered vault list as their resolver namespace. `src/utils/deepLinks.ts` builds `tolaria://<vault-slug>/<relative-path-with-extension>` URLs from workspace aliases, labels, and paths, appending a short stable hash when generated slugs would collide. `useDeepLinks` validates incoming links, switches vaults when required, reloads the vault index once for recently changed files, and opens the matching `VaultEntry` through the normal note-selection path.
Saved Views participate in that mounted graph as source-scoped chrome. `useVaultLoader` loads view definitions from every mounted vault, annotates each `ViewFile` with its owning `rootPath` and workspace identity, and keeps sidebar selection/persistence keyed by `(rootPath, filename)` so same-named view files from different vaults stay independent.
Git surfaces resolve repository paths explicitly. `useGitRepositories` derives the active repository set from the mounted available workspaces, keeps separate selected repositories for Changes, Pulse/history, and manual commits, and exposes the combined modified-file count for status/commands. AutoGit checkpoints iterate that repository set, while manual commit, history, diff, and discard operations use the selected surface or the note's workspace provenance.
@@ -130,6 +134,7 @@ The note list opportunistically preloads visible and adjacent markdown/text entr
| Frontend | React + TypeScript | React 19, TS 5.9 |
| Editor | BlockNote | 0.46.2 |
| Code block highlighting | @blocknote/code-block | 0.46.2 |
| Additional code grammars | @shikijs/langs | 3.23.0 |
| Diagram rendering | Mermaid | 11.14.0 |
| Whiteboard rendering | tldraw | 4.5.10 |
| Raw editor | CodeMirror 6 | - |
@@ -140,7 +145,7 @@ The note list opportunistically preloads visible and adjacent markdown/text entr
| 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 (workspace) | CLI agent adapters (Claude Code + Codex + OpenCode + Pi + Gemini + Kiro) plus configured local/API model targets | - |
| 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 |
@@ -159,13 +164,15 @@ flowchart TD
NL["NoteList / PulseView\n(filtered list / activity)"]
ED["Editor\n(BlockNote + diff + raw)"]
IN["Right Panel\n(Inspector + TOC)"]
AIP["AiPanel\n(selected CLI agent + tools)"]
AIW["AiWorkspace\n(docked or native window)"]
AIP["AiPanel\n(transcript + composer)"]
SP["SearchPanel\n(keyword search)"]
ST["StatusBar\n(vault picker + sync + version)"]
CP["CommandPalette\n(Cmd+K launcher)"]
App --> WS & SB & NL & ED & SP & ST & CP
ED --> IN & AIP
App --> WS & SB & NL & ED & AIW & SP & ST & CP
ED --> IN
AIW --> AIP
end
subgraph RB["Rust Backend"]
@@ -179,7 +186,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.)"]
@@ -206,7 +213,7 @@ flowchart TD
│ │ OR │ │ OR │
│ All │ Pulse View │ [Breadcrumb Bar] │ TOC │
│ Changes│ │ │ OR │
│ Pulse │ [Search] │ # My Note │ AI Agent
│ Pulse │ [Search] │ # My Note │ Properties
│ Inbox │ [Sort/Filt] │ │ │
│ │ │ │ Context │
│Projects│ Note 1 │ Content here... │ Messages │
@@ -220,10 +227,14 @@ 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.
- **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. Folder creation sends the selected folder's vault-relative path and mounted root to `create_vault_folder`, so a new folder is created under the focused parent instead of defaulting to the active vault root. 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. In mounted multi-vault graphs, duplicate type names still render as one sidebar section, but the visibility picker becomes a workspace matrix and writes visibility to the specific vault's Type document, so hidden type definitions suppress only notes of that type from the same workspace.
- **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. 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).
- **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` plus lazy direct `@shikijs/langs` registrations for missing common grammars. 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. Rich-editor Markdown input transforms for arrows, inline math, and `==highlight==` share one capture-phase `beforeinput` execution path in `src/components/richEditorInputTransform.ts` and are composed by `src/components/richEditorInputTransformExtension.ts`. Navigation history (Cmd+[/]) replaces tabs.
Rich-editor copy uses BlockNote's external HTML serializer for selected note content so tables, lists, checklists, and inline formatting paste richly into other apps, with Tolaria keeping fenced-code selections as raw code text and normalized plain text on the clipboard.
Note PDF export stays renderer-owned for layout: `useEditorPdfExport` exits diff/raw views, applies a print-only stylesheet to the rendered note root, and checks the native PDF capability before choosing a platform path. On macOS, the renderer asks for a filesystem PDF destination before the Tauri `export_current_webview_pdf` command saves the current `WKWebView` print operation directly; on Windows/Linux Tauri builds and in browser mode, the same export action falls back to the native/browser print dialog. The export reuses rendered BlockNote output so frontmatter is omitted, while math, images, Mermaid diagrams, tldraw blocks, code, tables, and links degrade through their existing DOM rather than a second Markdown-to-PDF renderer, and the source Markdown is never modified. Markdown notes expose the same export action from Cmd+K, the native Note menu, the breadcrumb overflow menu, and each Markdown row's note-list context menu.
- **Right side panels** (200-500px or hidden): Properties and Table of Contents 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. The breadcrumb bar toggles Table of Contents and Properties actions. 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).
- **AI workspace** (docked panel or native window): `AiWorkspace` owns the multi-chat orchestration, sidebar tabs, installed-only target picker, permission-mode picker, and dock/pop-out controls. Header/guidance chrome lives in `AiWorkspaceChrome`, edge-resize handles in `AiWorkspaceResizeHandles`, conversation metadata/settings persistence in `aiWorkspaceConversations`, and sizing/class/style helpers in `aiWorkspaceSizing`. The status-bar AI affordance opens this workspace instead of changing the default target inline. Docked workspace mode renders as a compact bounded desktop tool inside the main app; users resize the anchored panel from its left/top edges and resize the chat-list sidebar separately from the transcript area. Pop-out mode opens a dedicated undecorated transparent Tauri webview window labeled `ai-workspace` and boots the lightweight `AiWorkspaceWindowApp` route instead of the full vault shell. The chat header and sidebar header are draggable in native-window mode; closing the pop-out only closes that window, while the dock control emits a dock request back to the main window before closing the pop-out. Chat sessions reuse `AiPanelView` for transcript/composer rendering with the old panel header disabled; target and permission controls live in the composer toolbar so there is one workspace header per active chat. AI workspace cross-window localStorage, BroadcastChannel, storage-event, and subscriber-set plumbing is owned by `createCrossWindowPersistedStore`; domain stores keep only sanitization, mutation, and native persistence behavior.
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`.
@@ -231,14 +242,18 @@ The main Tauri window derives its minimum width from the visible panes instead o
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.
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. AI-agent CLI availability probing is also off the first-paint path: the renderer defers `get_ai_agents_status` until an idle/timeout tick, skips it for disabled AI surfaces and secondary windows, falls back to missing-agent onboarding state if the status IPC does not settle promptly, and the Rust command fans per-agent CLI checks across Tokio's blocking pool with per-agent timeouts. 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.
Desktop startup registers `tauri-plugin-deep-link` and `tauri-plugin-single-instance` before setup so `tolaria://` links can focus the existing main window and deliver URL events to the renderer. `tauri.conf.json` declares the `tolaria` scheme for bundled desktop builds; Windows and Linux also run `register_all()` as a runtime repair path, while macOS relies on bundle registration.
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)
Notes can be opened in separate Tauri windows for focused editing. Secondary windows show only the editor panel (no sidebar, no note list).
Notes can be opened in separate Tauri windows for focused editing. Secondary windows boot the same `App` shell and load the same active workspace graph as the main window, but they start in the editor-only view mode with side panels collapsed.
The AI workspace can also open in a separate Tauri window through `openAiWorkspaceWindow()`. That window uses `?window=ai-workspace` to boot the lightweight `AiWorkspaceWindowApp` route, receives the active vault context through URL params, opts out of main-window size constraints and startup AI onboarding, and redocks by emitting `tolaria:ai-workspace-dock-requested` to the main window. Its webview is transparent so the rounded workspace shell defines the visible floating-window corners across desktop platforms.
**Triggers:**
- `Cmd+Shift+Click` on any note in the note list or sidebar
@@ -248,8 +263,7 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
**Architecture:**
- `openNoteInNewWindow()` (`src/utils/openNoteWindow.ts`) creates a new `WebviewWindow` via the Tauri v2 JS API with URL query params (`?window=note&path=...&vault=...&title=...`)
- `main.tsx` checks `isNoteWindow()` at boot to route between `App` (main window) and `NoteWindow` (secondary window)
- `NoteWindow` (`src/NoteWindow.tsx`) is a minimal shell that loads vault entries, fetches note content, applies the theme, and renders a single `Editor` instance
- `main.tsx` always mounts `App`; `App` checks `isNoteWindow()` at startup, keeps normal vault/workspace loading active, and `useNoteWindowLifecycle` opens the requested note after the app graph is ready
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 1.5s low-end-safe idle debounce, same Rust `save_note_content` command), and raw-editor typing also derives frontmatter-backed `VaultEntry` state in the renderer so Inspector and note-list surfaces react immediately without waiting for a full reload
- Secondary windows are sized 800×700; macOS keeps the overlay title bar, while Linux mounts the shared React titlebar on undecorated windows
- Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels
@@ -263,10 +277,10 @@ 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.
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. Claude Code launches also copy a narrow set of exported provider/auth environment variables from the app process or the user's zsh/bash startup files, including Anthropic API/base URL values, so company proxy and API-key setups that work in Terminal also work when Tolaria is opened from Finder or Dock. Windows npm `.cmd` shims are not spawned directly; the shared CLI runtime resolves them to their quoted Node script or native executable target first so prompt arguments do not hit batch-file argument validation.
CLI-agent system prompts also include a local Tolaria docs orientation when the bundled docs resource is present. `scripts/build-agent-docs.mjs` generates `src-tauri/resources/agent-docs/` from the public VitePress Markdown sources, including `index.md`, `AGENTS.md`, per-section bundles, `all.md`, `search-index.json`, and generated per-page files. Tauri bundles that folder as `agent-docs/`; `get_agent_docs_path` resolves the installed resource path, with a repository fallback for development, and `getAgentDocsPath()` caches it before each agent run. Agents are instructed to read the active vault's `AGENTS.md` for local conventions and search the bundled docs for Tolaria product behavior.
@@ -283,11 +297,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)
@@ -329,17 +343,18 @@ Large active notes are compacted into a head/tail body snapshot before they ente
### Direct Model Targets
Tolaria also supports direct model targets for local servers and API providers. These targets are stored as app-level provider metadata and can be selected in Settings or the status bar alongside coding agents. `src/shared/aiModelProviderCatalog.json` is the shared source for provider defaults, local/API grouping, API-key environment placeholders, and runtime fallback base URLs; the renderer imports it through `aiTargets.ts`, and Tauri includes the same JSON in `ai_models.rs`. Direct model targets run in Chat mode: they receive the same note-context snapshot and conversation history, but they do not receive vault-write tools or shell access. The backend `stream_ai_model` command supports OpenAI-compatible chat completions and Anthropic Messages-compatible calls, including Ollama, LM Studio, OpenRouter, OpenAI, Anthropic, Gemini, and custom compatible endpoints.
Tolaria also supports direct model targets for local servers and API providers. These targets are stored as app-level provider metadata and can be selected in Settings or the status bar alongside coding agents. `src/shared/aiModelProviderCatalog.json` is the shared source for provider defaults, local/API grouping, API-key environment placeholders, and runtime fallback base URLs; the renderer imports it through `aiTargets.ts`, and Tauri includes the same JSON in `ai_models.rs`. Direct model targets run in Chat mode: they receive the same note-context snapshot and conversation history, but they do not receive shell access. OpenAI-compatible direct targets can use Tolaria's narrow native `create_note` tool when an active vault is loaded; the tool calls the same create-only, active-vault-bounded note write command as the UI and emits tool events so the renderer refreshes and opens the created note. The backend `stream_ai_model` command supports OpenAI-compatible chat completions and Anthropic Messages-compatible calls, including Ollama, LM Studio, OpenRouter, OpenAI, Anthropic, Gemini, and custom compatible endpoints.
Provider secrets are not written to `settings.json`. Hosted API targets can use Tolaria's local app-data secrets file (`ai-provider-secrets.json`, outside vaults/worktrees and owner-only on Unix) or reference an environment variable name. Local endpoints can omit authentication.
Provider secrets are not written to `settings.json`. Hosted API targets can use Tolaria's local app-data secrets file (`ai-provider-secrets.json`, outside vaults/worktrees and owner-only on Unix) or reference an environment variable name. Env-backed provider keys are resolved from the app process first, then from exported values in the user's zsh/bash startup files on Unix so GUI-launched sessions can still use shell-managed secrets. Local endpoints can omit authentication.
### Authentication
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed; OpenCode surfaces a friendly prompt to run `opencode auth login` or configure a provider when needed; Pi surfaces a friendly prompt to run `pi /login` or configure a provider API key when needed. Tolaria does not store model-provider API keys in app settings; direct provider secrets stay in local app data or user-managed environment variables. App-managed Pi sessions copy that local Pi agent config into a per-run temporary directory before adding Tolaria MCP, so Tolaria does not overwrite global Pi files and does not drop a working standalone Pi setup.
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login or user-managed Anthropic/provider environment variables; Codex surfaces a friendly prompt to run `codex login` when needed; OpenCode surfaces a friendly prompt to run `opencode auth login` or configure a provider when needed; Pi surfaces a friendly prompt to run `pi /login` or configure a provider API key when needed. Tolaria does not store model-provider API keys in app settings; direct provider secrets stay in local app data or user-managed environment variables. App-managed Pi sessions copy that local Pi agent config into a per-run temporary directory before adding Tolaria MCP, so Tolaria does not overwrite global Pi files and does not drop a working standalone Pi setup.
## MCP Server
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Gemini CLI, Cursor, or any MCP-compatible client).
The stdio entrypoint and desktop WebSocket bridge share `mcp-server/tool-service.js` for mounted-vault resolution, note lookup/search, note creation defaults, vault listing, and UI action intents; `index.js` and `ws-bridge.js` only adapt those semantics to their transport-specific request and response shapes.
### Tool Surface
@@ -347,7 +362,7 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
|------|--------|-------------|
| `open_note` | `path` | Open and read a note by relative path |
| `read_note` | `path` | Read note content (alias for `open_note`) |
| `create_note` | `path, title, [type]` | Create new note with title and optional type frontmatter |
| `create_note` | `path, content, [title], [type], [vaultPath]` | Create a new markdown note inside an active vault without overwriting existing files |
| `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 |
@@ -377,20 +392,23 @@ Tolaria can register itself as an MCP server in:
- `~/.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/OpenCode settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, 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 Node 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 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) — mounted-workspace scoped"]
subgraph MCP["MCP Server (Node.js or Bun) — mounted-workspace scoped"]
IDX["index.js"]
SVC["tool-service.js\n(vault resolution, note operations,\nUI action intents)"]
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 / Gemini / OpenCode"]
IDX --> VAULT
IDX --> WSB
IDX --> SVC
SVC --> VAULT
IDX -.->|"UI action WebSocket client"| WSB
WSB --> SVC
WSB -->|"port 9710 — tool bridge"| AI["AI Clients\n(Claude Code, external)"]
WSB -->|"port 9711 — UI bridge"| FE["Frontend\n(useAiActivity)"]
end
@@ -425,7 +443,7 @@ flowchart LR
| `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 |
| `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)` | Verifies Node.js, 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 |
| `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) |
@@ -443,13 +461,15 @@ Search is keyword-based, using `walkdir` to scan all `.md` files in the vault di
The `search_vault` Tauri command runs the scan in a blocking Tokio task and returns results sorted by relevance score.
The note-list search field combines client-side scoped filtering with that same command: title, snippet, and visible-property matches resolve immediately, while backend body-content hits use `search_vault` with frontmatter excluded before adding matching paths for the currently visible workspace roots without displaying matched body text in the note row.
## Vault Cache System
The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning using git-based incremental updates.
### 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.
@@ -512,6 +532,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
@@ -522,15 +543,15 @@ 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.
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 Git can resolve an author identity without overriding one the user configured: it heals the legacy repo-local `vault@tolaria.md` email earlier versions wrote, respects identities resolvable from local, global, or system scope, skips the legacy email wherever it resolves, and only when nothing resolves writes a repo-local `Tolaria <vault@tolaria.default>` fallback. 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.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions, and Tolaria seeds it as an organized `Note` so it stays out of the way in a fresh vault. Optional `GEMINI.md` guidance is created only by the explicit AI guidance restore action. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions, and Tolaria seeds it as an organized `Note` so it stays out of the way in a fresh vault. Optional `GEMINI.md` guidance is created only by the explicit AI guidance restore action. Once a user edits a usable `AGENTS.md`, including changing its frontmatter `type`, the status command treats it as custom guidance instead of broken; repair remains reserved for missing, empty, frontmatter-only, unreadable, or exact replaceable managed templates/stubs. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
After the clone completes, Tolaria removes every configured git remote from the new starter vault. Getting Started vaults therefore open as local-only by default, and users opt into a remote later with the explicit Add Remote flow.
@@ -633,8 +654,10 @@ flowchart TD
RV --> TAB{"clean active tab?"}
TAB -->|Yes| RT["replace active tab\nwith fresh disk content"]
TAB -->|No| DONE["idle"]
RT --> DONE
PC -->|Up to date| DONE["idle"]
RT --> RF["restore editor focus\nwhen previously focused"]
RF --> DONE
PC -->|Manual up to date| RV
PC -->|Auto up to date| DONE["idle"]
MAN["Manual commit\n(CommitDialog)"] --> RS["useGitRemoteStatus\n(commit-time check)"]
RS --> RCHK["invoke('git_remote_status')"]
@@ -655,9 +678,13 @@ flowchart TD
STATUS["Click sync badge"] --> POPUP["GitStatusPopup\n(branch, ahead/behind)"]
```
Manual Sync forces a visible-state refresh even when `git_pull` reports `up_to_date`, because the working tree may have already changed through another process while the app still holds stale vault and History state. Updated pulls refresh the vault index, folders, saved views, clean active-editor content, and Git history surfaces; manual up-to-date pulls refresh the vault/sidebar surfaces with unknown changed files and bump the History refresh key without showing a "Pulled 0 updates" toast. Automatic mount/focus/interval up-to-date checks stay cheap and do not reload the vault.
`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.
@@ -697,7 +724,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|--------|---------|
| `vault/` | Vault scanning, caching, parsing, rename, image, migration |
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) |
| `git/` | Git operations and shared system-git helpers (`command.rs`, `remote_config.rs`, `commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) |
| `search.rs` | Keyword search — walkdir-based vault file scan with Gitignored-content visibility filtering |
| `ai_agents.rs` | CLI-agent request normalization and adapter dispatch |
| `cli_agent_runtime.rs` | Shared CLI-agent request, prompt, subprocess, version, and MCP path helpers |
@@ -755,6 +782,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `git_pull` | Pull from remote |
| `git_push` | Push to remote |
| `git_remote_status` | Get branch name + ahead/behind counts |
| `git_file_url` | Build a remote-backed browser/git URL for a vault file |
| `git_add_remote` | Connect a local-only vault to a compatible remote and start tracking it |
| `git_resolve_conflict` | Resolve a merge conflict |
| `git_commit_conflict_resolution` | Commit conflict resolution |
@@ -788,9 +816,9 @@ 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 |
| `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 |
@@ -848,11 +876,17 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useNoteActions` | `tabs`, `activeTabPath` | Composes `useNoteCreation` + `useNoteRename` + `frontmatterOps` |
| `useNoteWindowLifecycle` | note-window open/title side effects | Opens `tauri://` note windows without full vault scans and keeps the native title current |
| `useStartupScreenState` | startup visibility booleans | Keeps onboarding, telemetry-consent, missing-vault, and initial indexing decisions out of `App.tsx` |
| `useAppWindowControls` | view mode, panel visibility, command refs, zoom/build labels | Keeps main-window sizing and editor command ref plumbing out of `App.tsx` |
| `useAppViewActions` | saved-view/type creation and saved-view mutation callbacks | Keeps saved-view persistence and Type auto-creation orchestration out of `App.tsx` |
| `useAiWorkspacePublishedContext` | AI workspace note-list snapshot and BroadcastChannel context publishing | Keeps AI workspace context derivation close to its cross-window publication side effect |
| `useMcpSetupDialogController` | MCP setup dialog state/actions | Keeps MCP status, manual config, and connect/disconnect dialog flow out of `App.tsx` |
| `useAiWorkspaceWindowBridgeEvents` | native AI workspace event subscriptions | Owns Tauri listener setup/cleanup for popped-out workspace window bridge events |
| `useGitFileWorkflows` | git diff/history/discard callbacks | Resolves note-scoped repository paths and owns deleted-file preview and queued diff side effects |
| `useVaultRenameDetection` | detected rename banner state | Detects external Git renames on focus and owns the wikilink update callback |
| `useNoteCreation` | — | Note/type creation with optimistic persistence |
| `useNoteRename` | — | Note renaming and folder moves with wikilink update |
| `useNoteRetargeting` | — | Shared note retargeting logic for drag/drop and command-palette actions |
| `useDeepLinks` | Deep-link listener and copy actions | Resolves `tolaria://` links into known vault navigation and clipboard URLs |
| `useTauriDragDropEvent` | — | Shared native window drag/drop event subscription and cleanup |
| `useNativePathDrop` | — | Tauri-native file/folder path drops for AI and command text inputs |
| `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch |
@@ -865,8 +899,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`.
@@ -888,7 +922,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
| Cmd+[ / Cmd+] | Navigate back / forward |
| `[[` in editor | Open wikilink suggestion menu |
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Markdown notes expose "Export note as PDF" from Cmd+K, the native Note menu, the breadcrumb overflow menu, and the note-list context menu, all routed through the same editor export hook. Remote-backed notes expose "Copy git URL" from the breadcrumb overflow and note-list context menu; the renderer gates the action per note workspace via `git_remote_status`, then asks `git_file_url` to build the copied URL from the primary remote, current branch, and vault-relative path. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
Shortcut routing is explicit:
@@ -899,7 +933,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()`
@@ -918,18 +952,18 @@ push to main
and a GitHub-sorted tag alpha-vYYYY.M.D-alpha.NNNN
→ use today's UTC date unless the latest stable-vYYYY.M.D tag already uses today
→ if stable already uses today, advance alpha to the next calendar day so semver still increases
→ build job:
→ build-artifacts job, via `.github/workflows/release-build-artifacts.yml`:
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin --bundles app
→ pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin --bundles app
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig updater artifacts named Tolaria_<version>_macOS_Silicon and Tolaria_<version>_macOS_Intel
→ 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
→ pnpm install, stamp version, import the Windows code-signing certificate when configured
→ tauri build --target x86_64-pc-windows-msvc --bundles nsis, using the Authenticode signing config when certificate secrets exist
→ verify the Windows app executable and installer Authenticode signatures with Get-AuthenticodeSignature when Authenticode is configured
→ upload NSIS installer, optional MSI artifacts, and signed Windows 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
@@ -947,17 +981,17 @@ Stable promotions trigger `.github/workflows/release-stable.yml`:
```
push stable-vYYYY.M.D tag
→ version job: validate YYYY.M.D from the tag
→ build job:
→ build-artifacts job, via `.github/workflows/release-build-artifacts.yml`:
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin
→ 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,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
→ pnpm install, stamp version, import the Windows code-signing certificate
→ tauri build --target x86_64-pc-windows-msvc --bundles nsis with Authenticode signing config
→ verify the Windows app executable and installer Authenticode signatures with Get-AuthenticodeSignature
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
→ release job:
→ generate stable-latest.json with macOS Apple Silicon, macOS Intel, Linux, and Windows updater URLs plus platform-specific manual download URLs
@@ -966,7 +1000,7 @@ 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, require an explicit Windows installer click with managed-device signing guidance, and expose RPM as a manual Linux option when the stable release includes one
→ preserve alpha/latest.json
→ deploy to gh-pages
```
@@ -1087,7 +1121,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

@@ -53,7 +53,7 @@ Linux release CI currently uses Tauri's stock linuxdeploy AppImage output plugin
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.
Release validation verifies that the Linux job produced an AppImage, at least one installer bundle, and updater signature artifacts. Windows release jobs import the CI code-signing certificate, build NSIS with a generated Tauri Authenticode signing config, and verify the app executable plus installer signatures before upload. 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
@@ -69,9 +69,14 @@ pnpm dev
pnpm tauri dev
# Run tests
pnpm test # Vitest unit tests
cargo test # Rust tests (from src-tauri/)
pnpm playwright:smoke # Curated Playwright core smoke lane (~5 min)
pnpm test # Vitest unit tests
cargo test # Rust tests (from src-tauri/)
# Or, run Rust tests from root project directory
cargo test --manifest-path src-tauri/Cargo.toml
# E2E tests
pnpm playwright:smoke # Curated Playwright core smoke lane (~5 min)
pnpm playwright:regression # Full Playwright regression suite
```
@@ -85,7 +90,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.
@@ -102,7 +107,7 @@ tolaria/
│ ├── theme.json # Editor typography theme configuration
│ ├── index.css # Semantic app theme variables + Tailwind setup
│ │
│ ├── components/ # UI components (~98 files)
│ ├── components/ # UI components (~100 files)
│ │ ├── Sidebar.tsx # Left panel: filters + type groups
│ │ ├── SidebarParts.tsx # Sidebar subcomponents
│ │ ├── NoteList.tsx # Second panel: filtered note list
@@ -115,7 +120,10 @@ tolaria/
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent + per-vault permission mode)
│ │ ├── AiWorkspace.tsx # Multi-chat AI workspace orchestration (docked or native window)
│ │ ├── AiWorkspaceChrome.tsx # AI workspace header and vault-guidance chrome
│ │ ├── AiWorkspaceResizeHandles.tsx # AI workspace edge resize handles
│ │ ├── AiPanel.tsx # AI transcript/composer surface (selected target + per-vault permission mode)
│ │ ├── AiMessage.tsx # Agent message display
│ │ ├── AiActionCard.tsx # Agent tool action cards
│ │ ├── AiAgentsOnboardingPrompt.tsx # First-launch AI agent installer prompt
@@ -125,7 +133,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
@@ -151,7 +159,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
@@ -222,8 +230,8 @@ tolaria/
│ │ ├── frontmatter/ # Frontmatter module
│ │ │ ├── mod.rs, yaml.rs, ops.rs
│ │ ├── git/ # Git module
│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs, clone.rs, connect.rs
│ │ │ ├── conflict.rs, remote.rs, pulse.rs
│ │ │ ├── mod.rs, command.rs, remote_config.rs, commit.rs, status.rs
│ │ │ ├── history.rs, clone.rs, connect.rs, conflict.rs, remote.rs, pulse.rs
│ │ ├── telemetry.rs # Sentry init + path scrubber
│ │ ├── search.rs # Keyword search (walkdir-based)
│ │ ├── ai_agents.rs # CLI-agent request normalization + adapter dispatch
@@ -231,6 +239,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
@@ -239,8 +248,8 @@ tolaria/
│ │ └── menu.rs # Native macOS menu bar
│ └── icons/ # App icons
├── mcp-server/ # MCP bridge (Node.js)
│ ├── index.js # MCP server entry (stdio, 14 tools)
├── mcp-server/ # MCP bridge (Node.js or Bun)
│ ├── index.js # MCP server entry (stdio tools)
│ ├── vault.js # Vault file operations
│ ├── ws-bridge.js # WebSocket bridge (ports 9710, 9711)
│ ├── test.js # MCP server tests
@@ -307,7 +316,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
@@ -326,7 +335,13 @@ tolaria/
| File | Why it matters |
|------|---------------|
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, actions, and per-vault permission mode. |
| `src/components/AiWorkspace.tsx` | Multi-chat AI workspace orchestration — chat sessions, sidebar tabs, target/permission controls, and dock/pop-out wiring. |
| `src/components/AiWorkspaceChrome.tsx` | Header and vault-guidance chrome shared by docked and popped-out AI workspace modes. |
| `src/components/AiWorkspaceResizeHandles.tsx` | Edge resize affordances for docked/side AI workspace layouts. |
| `src/components/aiWorkspaceConversations.ts` | Conversation metadata state, settings persistence, default title generation, and target resolution. |
| `src/components/aiWorkspaceSizing.ts` | AI workspace sizing, localStorage persistence, class names, and layout style helpers. |
| `src/components/AiPanel.tsx` | Reusable AI transcript/composer surface — selected target with tool execution, reasoning, actions, and per-vault permission mode. |
| `src/utils/openAiWorkspaceWindow.ts` | Native Tauri AI workspace window creation, focus, and dock-back traffic-light handling. |
| `src/hooks/useCliAiAgent.ts` | Thin React owner for the selected CLI agent session state. |
| `src/lib/aiAgentSession.ts` | Single message/session lifecycle for prompt normalization, history, streaming, and reset behavior. |
| `src/lib/aiAgentPermissionMode.ts` | Safe/Power User mode normalization, display labels, and local transcript marker text. |
@@ -345,12 +360,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
@@ -386,7 +401,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.
@@ -467,12 +482,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.
7. **Availability probing**: Edit `src/hooks/useAiAgentsStatus.ts` and `src-tauri/src/ai_agents.rs` for AI-agent install/status detection. Keep renderer probing deferred until after first paint, skip it when AI features or AI surfaces are unavailable, and keep backend per-agent CLI checks parallel so missing tools do not serialize shell startup cost.
### Work with external MCP setup
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs` and its `src-tauri/src/mcp/` helpers; 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 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 Node 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 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. **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.

View File

@@ -1,53 +0,0 @@
# Large Vault Loading QA
Use this when validating startup responsiveness for large vaults. The goal is to make the bottleneck reproducible without using a real user vault.
## Synthetic Vault
Create a disposable vault with many markdown files:
```bash
VAULT="$(mktemp -d /tmp/tolaria-large-vault.XXXXXX)"
mkdir -p "$VAULT/type" "$VAULT/archive" "$VAULT/assets"
cat > "$VAULT/type/project.md" <<'EOF'
---
is_a: Type
---
# Project
EOF
for i in $(seq -w 1 20000); do
cat > "$VAULT/project-$i.md" <<EOF
---
is_a: Project
status: Active
related_to:
- "[[project-00001]]"
---
# Project $i
Synthetic body $i with enough text to exercise parsing and snippets.
EOF
done
git -C "$VAULT" init
git -C "$VAULT" config user.email qa@example.invalid
git -C "$VAULT" config user.name "Tolaria QA"
git -C "$VAULT" add .
git -C "$VAULT" commit -m "seed large vault"
echo "$VAULT"
```
## Manual QA
1. Start Tolaria with `pnpm tauri dev`.
2. Open the synthetic vault path printed above.
3. Verify the main shell renders before the full note list finishes indexing.
4. Confirm the status bar shows vault activity while indexing is still in progress.
5. Use keyboard-only flows while indexing continues:
- Cmd+K opens the command palette.
- Cmd+P opens quick open; results may be partial or empty until indexing finishes.
- Create a new note with Cmd+N and type in the editor.
6. Wait for indexing to finish and verify the note list/search state is consistent.
The synthetic vault lives under `/tmp`; remove it after QA if it is no longer needed.

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

@@ -0,0 +1,32 @@
---
type: ADR
id: "0123"
title: "Full vault graph for secondary note windows"
status: active
date: 2026-05-25
supersedes: "0118"
---
## Context
ADR-0118 made secondary note windows entry-scoped to avoid repeated full-vault scans. That reduced startup work, but it also removed the vault-index context that normal Tolaria capabilities depend on: properties, view actions, quick open/search, workspace-aware navigation, and other command surfaces no longer behaved like the main window.
The product expectation is that opening a note in a separate window creates another capable Tolaria window, not a reduced editor shell. Performance remains important, but capability parity is the stronger contract.
## Decision
**Secondary note windows load the same active vault/workspace graph as a normal Tolaria window.** They still start in editor-only view mode and auto-open the requested note from the URL parameters, but the app keeps the shared vault loader, mounted-workspace filtering, watcher scope, editor entry list, and workspace-aware note actions enabled.
`main.tsx` always mounts `App`; note-window mode is handled inside `App` through `getNoteWindowParams()` and `useNoteWindowLifecycle`.
## Alternatives considered
- **Entry-scoped note windows**: faster startup, but loses app-level features that require repository-wide context. Rejected because it breaks the secondary-window product contract.
- **Full app with full active graph** (chosen): keeps one window architecture and restores feature parity. Trade-off: each secondary window performs its own vault load.
- **Shared state from the main window over IPC**: could provide parity without duplicate scans, but adds synchronization complexity and failure modes. Deferred until profiling proves the duplicate load is a real bottleneck.
## Consequences
- Secondary windows can use normal Tolaria capabilities such as Properties, view actions, quick open/search, wikilink navigation, and workspace-aware note operations.
- Opening several note windows can repeat vault-loading work. This is acceptable for now because correctness and parity are more important than avoiding the scan.
- ADR-0118 is superseded. If secondary-window startup becomes too slow, optimize with shared state or incremental loading without removing app capabilities.

View File

@@ -0,0 +1,27 @@
---
type: ADR
id: "0124"
title: "Cached secondary note window startup"
status: active
date: 2026-05-26
supersedes: "0123"
---
## Context
ADR-0123 restored secondary note windows to the normal `App` path so they retain the full vault/workspace graph required by Properties, quick open/search, wikilinks, and workspace-aware note actions.
That parity is still the product contract, but forcing a fresh Tauri `reload_vault` during every secondary-window mount invalidates the backend cache. Opening several note windows can therefore repeat expensive full-vault scans even when the main window has already warmed the cache.
## Decision
**Secondary note windows keep the full vault graph, but their initial vault load uses the cached/incremental `list_vault` path instead of the forced `reload_vault` path.**
Normal main-window startup continues to force a fresh initial reload. Explicit refresh paths, watcher-driven refreshes, and user-initiated reloads still use reload commands where they need disk freshness.
## Consequences
- Secondary note windows remain capable full app windows rather than reduced editor shells.
- Repeated note-window opens can reuse the backend vault cache instead of invalidating it on every startup.
- First open after a cold cache still scans the vault, then warms the shared backend cache for later windows.
- If the cached scan path is stale, the existing backend cache update logic remains responsible for incremental freshness.

View File

@@ -0,0 +1,29 @@
---
id: "0126"
title: "Renderer action history for app-level undo and redo"
status: "active"
date: "2026-05-26"
supersedes:
- "0106"
---
# ADR-0126: Renderer action history for app-level undo and redo
## Context
Tolaria already lets native text surfaces keep their own undo stacks, but app-level state changes such as frontmatter edits, archive toggles, favorite toggles, and organization toggles did not share a consistent undo/redo model. Routing all Undo and Redo through the native menu items left these app actions one-way while also making command-palette discoverability inconsistent.
## Decision
Introduce a renderer-owned `useActionHistory` stack for app-level actions. Supported actions record explicit undo and redo callbacks only after the write succeeds, clear redo after new user actions, and suppress nested recordings while a history entry is replaying.
The Edit menu and command manifest now route Undo and Redo to renderer commands. Focused text-editing controls still receive native text history first through `document.execCommand('undo' | 'redo')`, so editor/input undo behavior remains separate from the app action stack.
Destructive actions that are not safely reversible remain outside this stack and continue to rely on confirmation/destructive UX instead of pretending to be undoable.
## Consequences
- App-level history is scoped to the active renderer session and is not persisted across launches.
- Undo/redo labels can be surfaced in the command palette because the top stack entries expose labels.
- Menu accelerators and keyboard shortcuts use the shared command manifest instead of Tauri's native Undo/Redo menu builders.
- ADR-0106 remains valid for the broader menu ownership model, but its native Undo/Redo exception is superseded by this renderer action-history route.

View File

@@ -0,0 +1,32 @@
---
type: ADR
id: "0127"
title: "Native AI workspace window"
status: active
date: 2026-05-26
---
## Context
The AI panel used to behave like another right-side editor panel. That kept the agent UI inside the main Tolaria window even when the user undocked it, so the "floating" surface could not be moved to another macOS space or placed beside Tolaria as a real window.
The AI surface also needed to support multiple chat sessions, per-chat target selection, and a single header that does not duplicate the old panel title and permission controls.
## Decision
**The AI surface is a renderer-owned `AiWorkspace` that can run either docked in the main app or in a dedicated native Tauri webview window labeled `ai-workspace`.**
The docked and native-window modes share the same React workspace component. The native window boots the normal `App` path with `?window=ai-workspace`, skips main-window size constraints, and uses macOS overlay traffic lights. Close and minimize requests from that window emit a dock request back to the main window before destroying the pop-out window.
## Options considered
- **Native Tauri window** (chosen): gives macOS users real window movement, traffic lights, and normal desktop window management; requires route/window-mode plumbing and explicit dock events.
- **CSS floating panel inside the main window**: simple and preserves component state in one renderer, but it cannot leave the main window bounds and fails the expected macOS behavior.
- **Separate full AI app shell**: isolates the workspace, but would duplicate vault loading and settings flows more than necessary.
## Consequences
- The status-bar AI affordance opens the workspace; target selection now belongs in the workspace header.
- `AiWorkspace` owns multi-chat sidebar state and filters target choices to installed local agents plus configured local/API model providers.
- The old `AiPanel` remains the reusable transcript/composer surface, but its header and prompt/focus effects can be disabled when mounted inside workspace sessions.
- Pop-out/dock currently transfers the workspace at the window level; future persistence can promote active conversations into a shared store if users need exact in-flight chat reparenting across renderer instances.

View File

@@ -0,0 +1,35 @@
---
type: ADR
id: "0128"
title: "Lightweight AI workspace window"
status: active
date: 2026-05-26
supersedes: "0127"
---
## Context
ADR-0127 moved the AI workspace into a native Tauri window, but the first version booted the full `App` shell and used macOS overlay traffic lights. That made pop-out feel slow, duplicated startup work, and left the chat route dependent on main-window vault loading before agent turns could run.
The AI workspace also needs installation-local chat metadata so user-facing chat titles and archive state survive dock/pop-out transitions without writing to a vault.
## Decision
**The AI workspace pop-out uses a lightweight renderer route backed by app settings metadata.**
`openAiWorkspaceWindow()` opens the `ai-workspace` Tauri webview with `?window=ai-workspace` plus active vault context in URL params. `App` routes that window directly to `AiWorkspaceWindowApp`, which loads settings, AI agent status, and vault guidance without mounting the full vault/editor shell. The window is undecorated and transparent, and it relies on `AiWorkspace` headers for drag regions plus separate close and dock controls. Close only closes the pop-out; dock emits the main-window dock request before closing the pop-out.
`settings.ai_workspace_conversations` stores only chat sidebar metadata: conversation id, title, archive state, and explicit target override. Prompt text, transcripts, note content, model credentials, and vault-local configuration stay out of app settings.
## Options considered
- **Lightweight AI route** (chosen): keeps pop-out startup focused on AI state and passes explicit vault context to the agent controller.
- **Full `App` route**: preserves maximum feature parity by default, but repeats vault/editor startup work and delays a window that should contain only the AI workspace.
- **Vault-stored chat metadata**: would travel with a vault, but chat labels and archive state are installation UI preferences rather than vault content.
## Consequences
- Pop-out startup avoids full note graph loading and should be close to instant after the Tauri webview is created.
- The dedicated AI window has no native traffic lights; users close or redock it through separate workspace header controls, and the rounded workspace shell defines the visible floating-window corners.
- Chat titles, archived state, and target overrides persist at the installation level in `settings.json`.
- Future transcript persistence must use a separate storage decision; `ai_workspace_conversations` is intentionally metadata-only.

View File

@@ -0,0 +1,45 @@
---
type: ADR
id: "0129"
title: "Tolaria vault item deep links"
status: active
date: 2026-05-27
---
# ADR-0129: Tolaria vault item deep links
## Context
Users need durable links they can paste into calendars, task managers, chats, and other apps to return to a Tolaria vault item. The link needs to identify a registered vault, preserve the file extension so non-Markdown files can be opened, and fail clearly when the vault or item is unavailable. Links must not create or import files implicitly.
Mounted workspaces make vault naming non-trivial. A readable slug is useful, but two vaults can share a label, alias, or folder name. URL parsing also cannot rely only on the browser URL implementation because dot-segment normalization can hide path traversal attempts before validation runs.
## Decision
Tolaria deep links use this shape:
```text
tolaria://<vault-slug>/<relative-path-with-extension>
```
The vault slug is generated from the registered workspace alias, then label, then path basename. When two vaults would share the same base slug, generated links append a stable short hash derived from the normalized vault path. A handwritten ambiguous base slug is rejected instead of picking an arbitrary vault.
The path component is encoded per segment with `encodeURIComponent`, so spaces, Unicode, and reserved characters are preserved while `/` remains the path separator. Parsing rejects empty path segments, `.`, `..`, decoded separators inside a segment, unsafe Windows separators, and resolved paths outside the selected vault root.
`src/utils/deepLinks.ts` owns URL building, parsing, and vault resolution. `src/hooks/useDeepLinks.ts` owns renderer integration: it receives Tauri deep-link events, validates them against the registered vault list, switches vaults when needed, reloads once if the target file is not in the current index yet, opens existing Markdown/text/binary entries, reports localized errors, and emits safe PostHog outcomes. Deep links are navigation-only; they never create missing files, import external content, or infer a fallback vault.
The desktop shell registers the `tolaria` scheme through `tauri-plugin-deep-link` and keeps second launches focused through `tauri-plugin-single-instance`. Windows and Linux also call runtime `register_all()` as a repair step. macOS uses bundle scheme registration. Linux runtime registration is best-effort and is not part of the verified v1 support target.
Copy surfaces are shared actions:
- Breadcrumb overflow: `Copy note deeplink`
- Command palette: `Copy deep link to current item`
- File preview header: copy action for non-Markdown vault files
## Consequences
Path-based links are understandable and support every vault file kind, but a file rename changes the old link. A future stable item-id layer could supersede this URL shape while preserving path links as a readable fallback.
Collision handling keeps generated links deterministic without exposing full local paths. Ambiguous handwritten slugs fail clearly, which is safer than opening the wrong vault.
Renderer-owned resolution keeps the navigation logic close to mounted-workspace state and note selection. Native plugins stay responsible only for scheme registration, event delivery, and focusing the existing app instance.

View File

@@ -0,0 +1,36 @@
---
type: ADR
id: "0130"
title: "Windows Authenticode signing for release installers"
status: active
date: 2026-05-27
---
## Context
Tolaria's Windows release job already produced Tauri updater signatures, but those signatures are not the Windows trust signal used by SmartScreen, Smart App Control, Defender, or WDAC policies when a user downloads and runs an installer from the browser. A managed Windows 11 user reported that the stable NSIS installer was blocked by Windows Security with no bypass option.
Microsoft's current guidance is that unsigned public installers can be fully blocked by enterprise policy, while signed installers at least carry a publisher identity and can build reputation across releases. Store distribution would provide the strongest SmartScreen outcome, but Tolaria does not currently publish a Microsoft Store package.
## Decision
**Tolaria release CI must Authenticode-sign Windows app executables and installers before publishing them.**
- Alpha and stable Windows release jobs import a CI-provided code-signing certificate from GitHub secrets.
- The workflow generates a temporary Tauri config that sets `bundle.windows.certificateThumbprint`, `digestAlgorithm`, and `timestampUrl`, then passes that config to `pnpm tauri build`.
- The Windows job verifies the produced app executable and installer artifacts with `Get-AuthenticodeSignature` and fails before upload if any signature is missing, invalid, or signed by an unexpected certificate.
- The public stable download page requires an explicit Windows installer click and tells managed-device users that IT may need to approve the Tolaria publisher before first install.
## Options considered
- **CI-enforced Authenticode signing** (chosen): gives Windows users and enterprise admins a real publisher identity, lets certificate reputation transfer across releases, and blocks accidental publication of unsigned installers. Cons: release jobs now depend on code-signing secrets and a valid certificate.
- **Documentation-only SmartScreen warning**: cheaper, but it leaves managed-device users with no supported path when policy removes the bypass option.
- **Microsoft Store distribution only**: strongest SmartScreen behavior, but it requires a separate packaging, submission, and release-management path that Tolaria does not yet own.
- **Portable ZIP fallback**: still downloads executable content from the browser and can remain subject to SmartScreen, Mark-of-the-Web, Smart App Control, or WDAC policy.
## Consequences
- Windows release failures caused by missing or expired code-signing credentials are intentional release blockers.
- Tauri updater signatures remain required for in-app updates, but they are treated as separate from Windows Authenticode trust.
- Enterprise-managed Windows installs can be documented around a stable Tolaria publisher identity instead of asking users to disable security policy.
- A future Microsoft Store/MSIX distribution path can supersede or supplement this policy if Tolaria decides to support Store-managed installs.

View File

@@ -0,0 +1,29 @@
---
type: ADR
id: "0131"
title: "Reusable release artifact build workflow"
status: active
date: 2026-05-28
---
## Context
Tolaria's alpha and stable release workflows both need to build the same platform artifact set: dual-architecture macOS updater bundles, optional stable macOS DMGs, Linux bundles, and signed Windows installers/updater bundles. Keeping those build jobs copied into both release workflows made platform fixes and validation changes easy to apply in one channel while accidentally leaving the other channel behind.
The release workflows still differ in how they compute versions, create releases, and publish alpha vs. stable metadata, but the artifact build contract is shared.
## Decision
**Tolaria centralizes release artifact production in `.github/workflows/release-build-artifacts.yml`, invoked by alpha and stable release workflows through `workflow_call`.** Channel-specific workflows own versioning and publishing; the shared workflow owns platform build, signing, validation, and artifact upload behavior.
## Alternatives considered
- **Reusable artifact workflow** (chosen): keeps alpha and stable artifact behavior aligned while preserving separate channel-specific release orchestration. Cons: release behavior is split across one caller workflow and one called workflow, so debugging requires following both files.
- **Keep duplicated jobs in each release workflow**: makes each workflow self-contained, but every platform build fix must be applied twice and drift is likely.
- **Merge alpha and stable releases into one workflow**: reduces workflow count, but couples different trigger/version/publishing semantics and makes the release pipeline harder to reason about.
## Consequences
Alpha and stable releases now share one platform artifact contract, including macOS, Linux, and Windows validation. Changes to signing, cache keys, bundle validation, or platform matrices should happen in the reusable artifact workflow unless they are genuinely channel-specific.
The architecture documentation should describe the release pipeline as channel orchestration plus shared artifact production, not as independent duplicated build job sets.

View File

@@ -0,0 +1,25 @@
---
type: ADR
id: "0132"
title: "Alpha Authenticode soft gate"
status: active
date: 2026-05-28
amends: "0130"
---
## Context
ADR 0130 made Windows Authenticode signing mandatory for release installers. That is still the right requirement for stable promotions, but the repository does not yet have the Windows code-signing certificate secrets needed by CI. Because alpha releases run on every push to `main`, requiring those secrets there broke the continuous alpha channel before the certificate provisioning work was complete.
## Decision
**Alpha Windows artifacts keep building when Authenticode certificate secrets are absent; stable Windows artifacts still require Authenticode signing.**
- The shared release artifact workflow accepts `require_windows_authenticode`.
- Alpha passes `false`, emits a workflow warning when certificate secrets are absent, and still requires Tauri updater signatures.
- Stable passes `true` and fails before building Windows artifacts unless certificate and password secrets are configured.
- When certificate secrets are present, both channels use the generated Tauri Authenticode config and verify Windows executable/installer signatures before upload.
## Consequences
The alpha updater channel remains live while Windows certificate provisioning is underway. Stable releases continue to enforce the Windows trust policy from ADR 0130 before public promotion. Once the certificate secrets are configured, alpha builds automatically regain Authenticode signing without another workflow change.

View File

@@ -0,0 +1,27 @@
---
type: ADR
id: "0133"
title: "Request-scoped AI stream event channels"
status: active
date: 2026-05-29
---
## Context
AI agent streams and direct model streams used shared Tauri event names (`ai-agent-stream` and `ai-model-stream`). That worked while only one stream of each kind was active, but the side AI workspace and pop-out window can make concurrent or rapidly reused AI sessions more likely. Shared channels risk routing deltas, tool events, or completion events to the wrong renderer listener.
## Decision
**Each native AI stream uses a request-scoped Tauri event channel generated by the renderer and validated by the backend.**
The renderer creates a unique event name with the stream's stable base prefix, listens on that channel, and passes it to the `stream_ai_agent` or `stream_ai_model` command as `event_name`. The Rust command accepts only scoped names that match the expected base prefix and safe character set; invalid or missing names fall back to the legacy shared channel.
## Alternatives considered
- **Request-scoped renderer channel** (chosen): isolates simultaneous streams without changing the stream event payload shape, while preserving backward-compatible command defaults.
- **Keep shared static event names**: simpler, but concurrent agent/model sessions can cross-deliver stream events between workspaces or chats.
- **Backend-generated channel names**: centralizes validation, but requires a setup handshake before the renderer can subscribe and complicates the current fire-and-stream command flow.
## Consequences
Concurrent AI streams can run without contaminating each other's renderer callbacks. The backend keeps a narrow validation boundary for externally supplied event names. Any future AI streaming command should follow the same base-prefix plus scoped-suffix convention instead of adding another process-wide static channel.

View File

@@ -0,0 +1,27 @@
---
type: ADR
id: "0134"
title: "Direct Shiki language registrations for code blocks"
status: active
date: 2026-05-29
---
## Context
Tolaria uses `@blocknote/code-block` for rich-editor fenced-code highlighting. The bundled BlockNote highlighter covers the common web and systems languages already in the editor menu, but it does not include several common Shiki grammars such as PowerShell, VBScript, Dart, Dockerfile, Terraform/HCL, and TOML. Users still expect imported fences like `powershell`, `ps1`, `vb`, and `vbscript` to highlight, show a valid language picker state, and serialize back to a stable Markdown fence.
## Decision
**Tolaria keeps BlockNote's code-block integration and adds direct, lazy `@shikijs/langs` registrations for missing common languages and aliases.**
## Options considered
- **Keep only the BlockNote bundle**: simplest, but leaves PowerShell/VBScript and other common fences unsupported.
- **Register selected `@shikijs/langs` grammars lazily** (chosen): preserves BlockNote's schema and parser path while adding only the extra grammars users need.
- **Replace BlockNote's highlighter with a full custom Shiki bundle**: more control, but a larger structural change than the current requirement needs.
## Consequences
`src/components/codeBlockOptions.ts` remains the owner of the BlockNote highlighter configuration, but extra grammar modules are now imported directly from `@shikijs/langs` only when a matching fence or picker value needs highlighting. `src/utils/codeBlockLanguageCatalog.ts` owns the supported extra language labels and aliases, and `src/utils/codeBlockLanguage.ts` normalizes known imported aliases such as `ps1` and `vb` to the canonical picker language.
The language menu grows, but unsupported aliases still fail safely by staying as plain explicit fence names. If Tolaria later needs a generated language bundle, export-time highlighting, or a substantially smaller menu, this ADR should be superseded by a custom Shiki packaging decision.

View File

@@ -0,0 +1,44 @@
---
type: ADR
id: "0135"
title: "Clean active notes refresh immediately after external edits"
status: active
date: 2026-05-30
supersedes: "0111"
---
## Context
ADR-0111 made external vault refreshes path-aware and preserved focused editor mounts so unrelated watcher events would not disrupt cursor state. That avoided needless remount churn, but it also meant a clean active note edited by Codex or another external process could remain visibly stale while the editor stayed focused. Because no later safe-remount trigger was guaranteed, users could see the old content until a full app restart.
Tolaria's filesystem-first model requires clean in-memory editor state to converge to the file on disk during the current session. Unsaved local editor buffers still need protection, but editor focus alone is not enough reason to keep showing stale content when the changed-path batch identifies the active file.
## Decision
**External vault refreshes now remount a clean active note immediately when the external changed-path batch includes that note, regardless of editor focus.**
The shared `refreshPulledVaultState()` path applies these rules:
1. Reload vault entries, folders, and saved views together for every external change batch.
2. If there is no active note, stop after the shared reload.
3. If the active note changed during the async reload, stop rather than reopening stale context.
4. If the active note has unsaved local edits, keep the current editor buffer mounted.
5. If the active file disappeared, close the tab instead of leaving a stale editor behind.
6. If the changed-path batch includes the clean active file, close and reopen the active tab from disk even when focus is inside the rich or raw editor.
7. Unknown or unrelated change batches refresh vault-derived state without remounting the active editor.
Git pulls, AI-agent refresh callbacks, and filesystem-watcher batches continue to converge through this single reconciliation helper instead of adding separate reload policies.
## Alternatives considered
- **Immediate clean active-note remount** (chosen): restores filesystem convergence for Codex and other external note edits while preserving unsaved local edits. Cons: a focused clean editor can lose cursor state when its own file changes externally.
- **Keep focused-editor preservation from ADR-0111**: avoids cursor disruption, but can leave the active editor stale indefinitely.
- **Defer active-note reload until blur**: reduces focus disruption, but adds another pending-refresh state machine and still allows the active editor to show stale disk content for an unbounded editing session.
## Consequences
- External edits to the currently open clean note become visible without restarting Tolaria.
- Unsaved local content remains authoritative and is not replaced by watcher, pull, or agent refreshes.
- The changed-path batch remains part of the external-refresh contract; callers should pass specific file paths whenever available.
- Unrelated watcher events still avoid active-editor remounts, so broad vault churn does not disturb the editor unless the active file itself changed.
- ADR-0111 is superseded by this stronger filesystem-convergence rule.

View File

@@ -0,0 +1,31 @@
# ADR-0136: macOS Webview PDF Export
## Status
Accepted
## Context
The first note PDF export implementation reused the native webview print command. On macOS that opens the full printer dialog, which is not the product behavior expected from "Export note as PDF"; users should choose a filesystem destination and get a PDF directly.
Tolaria already renders the exportable note in the live BlockNote DOM and applies print-only CSS so math, Mermaid, images, code blocks, tables, links, and custom blocks follow the same rendering path users see in the editor. Introducing a second Markdown-to-PDF renderer would duplicate that rendering logic and create drift.
## Decision
Use the existing Tauri webview and WebKit's own print operation to save the current webview directly to a chosen PDF path. The renderer remains responsible for export preparation:
- exit raw/diff modes
- apply the PDF export body class
- ask the user for a `.pdf` destination
- invoke the native `export_current_webview_pdf` command
The native command uses direct `objc2`, `objc2-app-kit`, `objc2-foundation`, and `objc2-web-kit` dependencies on macOS only. These crates are already part of Tauri's platform stack; declaring them directly lets Tolaria ask `WKWebView` for a WebKit-aware `NSPrintOperation` without adding a separate PDF rendering engine.
Windows, Linux, and browser mode keep print-dialog fallback behavior because they do not have the macOS WebKit/AppKit direct PDF save path yet. The renderer checks the native capability before opening a filesystem save dialog, so unsupported platforms do not ask for a destination that cannot be used.
## Consequences
- The macOS path opens a save-file dialog, not the printer dialog.
- The exported PDF keeps using the rendered note DOM, so frontmatter stays excluded by the existing rich-editor body extraction.
- The feature depends on macOS WebKit/AppKit behavior for direct PDF output. Other desktop platforms use the existing native print dialog until they get a platform-specific direct PDF path.
- The new direct dependencies must stay target-scoped to macOS so Linux and Windows builds do not compile AppKit crates.

View File

@@ -0,0 +1,58 @@
---
type: ADR
id: "0137"
title: "Shared rich-editor input transforms"
status: active
date: 2026-06-07
---
## Context
Tolaria has several Markdown-style conveniences in the rich BlockNote editor:
typed arrows become ligatures, completed inline math becomes a math node, and
completed `==highlight==` syntax becomes the durable highlight mark.
These features were added incrementally as separate `beforeinput` extensions.
Each extension repeated the same lifecycle work: reading the live ProseMirror
view, skipping IME composition, guarding stale views, dispatching transactions,
preventing native input only after a successful transform, and recovering known
BlockNote/ProseMirror transform failures. The syntax matchers differed, but the
execution shell was parallel enough that each new Markdown affordance risked a
slightly different edge-case policy.
## Decision
**Tolaria routes rich-editor Markdown input transforms through one shared
`beforeinput` execution path.**
`src/components/richEditorInputTransform.ts` owns the common lifecycle,
dispatch, and recoverable-error behavior. Feature files such as
`arrowLigaturesExtension.ts`, `mathInputExtension.ts`, and
`markdownHighlightInputExtension.ts` expose small transform objects that only
decide whether the current input event should produce a transaction.
`src/components/richEditorInputTransformExtension.ts` composes the Markdown
transform set used by the main editor and hidden editor probe.
## Options Considered
- **Shared transform primitive with feature-owned matchers** (chosen): removes
duplicate listener, dispatch, composition, and recovery code while keeping each
syntax rule local and testable.
- **One monolithic Markdown input extension**: reduces listener count, but mixes
unrelated syntax rules in one file and makes each future input affordance
harder to test independently.
- **Keep one extension per feature**: preserves local ownership, but keeps the
repeated edge-case shell and invites divergence as more transforms are added.
## Consequences
- `Editor` mounts one Markdown input-transform extension rather than separate
arrow, math, and highlight `beforeinput` listeners.
- Feature-specific files remain responsible for their syntax matching and
transaction construction only.
- Recoverable transform errors use the same telemetry event and fallback policy
across Markdown input transforms.
- New rich-editor Markdown input affordances should plug into the shared
transform primitive instead of adding another capture-phase `beforeinput`
extension.

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,12 +163,28 @@ 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 |
| [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) | Path-aware external vault refresh with focused-editor preservation | 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 | superseded → [0135](0135-clean-active-note-refresh-after-external-edit.md) |
| [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 |
| [0118](0118-entry-scoped-note-windows-without-vault-index-scans.md) | Entry-scoped note windows without vault index scans | superseded -> [0123](0123-full-vault-graph-for-secondary-note-windows.md) |
| [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 |
| [0123](0123-full-vault-graph-for-secondary-note-windows.md) | Full vault graph for secondary note windows | superseded -> [0124](0124-cached-secondary-note-window-startup.md) |
| [0124](0124-cached-secondary-note-window-startup.md) | Cached secondary note window startup | active |
| [0126](0126-renderer-action-history.md) | Renderer action history for app-level undo and redo | active |
| [0127](0127-native-ai-workspace-window.md) | Native AI workspace window | superseded -> [0128](0128-lightweight-ai-workspace-window.md) |
| [0128](0128-lightweight-ai-workspace-window.md) | Lightweight AI workspace window | active |
| [0129](0129-tolaria-vault-item-deep-links.md) | Tolaria vault item deep links | active |
| [0130](0130-windows-authenticode-release-signing.md) | Windows Authenticode signing for release installers | active |
| [0131](0131-reusable-release-artifact-build-workflow.md) | Reusable release artifact build workflow | active |
| [0132](0132-alpha-authenticode-soft-gate.md) | Alpha Authenticode soft gate | active |
| [0133](0133-request-scoped-ai-stream-events.md) | Request-scoped AI stream event channels | active |
| [0134](0134-direct-shiki-language-registrations.md) | Direct Shiki language registrations for code blocks | active |
| [0135](0135-clean-active-note-refresh-after-external-edit.md) | Clean active notes refresh immediately after external edits | active |
| [0137](0137-shared-rich-editor-input-transforms.md) | Shared rich-editor input transforms | active |

View File

@@ -224,7 +224,7 @@ test('full create note flow', async ({ page }) => {
// Count should increase
const countAfter = await page.locator('.note-list__count').textContent()
expect(parseInt(countAfter!)).toBe(parseInt(countBefore!) + 1)
expect(parseInt(countAfter!, 10)).toBe(parseInt(countBefore!, 10) + 1)
// Note should be opened in editor
await expect(page.locator('.editor__tab--active')).toHaveText(/E2E Test Note/)

View File

@@ -32,7 +32,7 @@ test('visual verify: editor theme + list indentation', async ({ page }) => {
window.getComputedStyle(el).paddingLeft
)
console.log(`Level-0 padding-left: ${paddingL0}`)
expect(parseInt(paddingL0)).toBe(40)
expect(parseInt(paddingL0, 10)).toBe(40)
// Bullet widgets and checkboxes are rendered
const bulletCount = await page.locator('.cm-live-bullet').count()

View File

@@ -7,25 +7,6 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono&display=swap" rel="stylesheet" />
<style>
:root {
color-scheme: light;
background: #FFFFFF;
color: #37352F;
}
:root[data-theme="dark"] {
color-scheme: dark;
background: #1F1E1B;
color: #E6E1D8;
}
body {
background: inherit;
color: inherit;
}
</style>
<title>Tolaria</title>
</head>
<body>
<script>
(function () {
function isResizeObserverLoopMessage(message) {
@@ -47,6 +28,63 @@
}
});
try {
if (new URLSearchParams(window.location.search).get('window') === 'ai-workspace') {
document.documentElement.classList.add('ai-workspace-native-window');
}
} catch {
// Leave the normal app chrome untouched if URL parsing is unavailable.
}
})();
</script>
<style>
:root {
color-scheme: light;
background: #FFFFFF;
color: #37352F;
}
:root[data-theme="dark"] {
color-scheme: dark;
background: #1F1E1B;
color: #E6E1D8;
}
body {
background: inherit;
color: inherit;
}
:root.ai-workspace-native-window,
:root.ai-workspace-native-window body {
background: transparent;
height: 100%;
margin: 0;
overflow: hidden;
width: 100%;
}
:root.ai-workspace-native-window body {
box-sizing: border-box;
padding: 20px;
}
:root.ai-workspace-native-window #root {
background: #FFFFFF;
border: 1px solid #E9E9E7;
border-radius: 12px;
box-sizing: border-box;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12), 0 1px 3px rgba(15, 23, 42, 0.10);
height: 100%;
overflow: hidden;
width: 100%;
}
:root.ai-workspace-native-window[data-theme="dark"] #root {
background: #1F1E1B;
border-color: #34322D;
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.30), 0 1px 4px rgba(0, 0, 0, 0.22);
}
</style>
<title>Tolaria</title>
</head>
<body>
<script>
(function () {
var key = 'tolaria-theme';
var legacyKey = 'laputa-theme';
var systemDarkQuery = '(prefers-color-scheme: dark)';
@@ -56,7 +94,7 @@
function prefersDarkTheme() {
try {
return typeof window.matchMedia === 'function' && window.matchMedia(systemDarkQuery).matches;
} catch (err) {
} catch {
return false;
}
}
@@ -69,14 +107,15 @@
document.documentElement.setAttribute('data-theme', mode);
document.documentElement.classList.toggle('dark', mode === 'dark');
}
var mode;
try {
var mode = normalizeTheme(localStorage.getItem(key));
mode = normalizeTheme(localStorage.getItem(key));
if (mode === null) {
mode = normalizeTheme(localStorage.getItem(legacyKey));
if (mode !== null) localStorage.setItem(key, mode);
}
applyTheme(mode);
} catch (err) {
} catch {
applyTheme('light');
}
})();
@@ -93,7 +132,7 @@
function hasReloadAttempted() {
try {
return sessionStorage.getItem(reloadAttemptKey) === '1';
} catch (err) {
} catch {
return true;
}
}
@@ -102,7 +141,7 @@
try {
sessionStorage.setItem(reloadAttemptKey, '1');
return true;
} catch (err) {
} catch {
return false;
}
}
@@ -110,7 +149,7 @@
function clearReloadAttempt() {
try {
sessionStorage.removeItem(reloadAttemptKey);
} catch (err) {
} catch {
// Storage can be unavailable in hardened WebView/privacy modes.
}
}

167
lara.lock
View File

@@ -17,6 +17,63 @@ files:
command.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03
command.contribute: 9887a4451812854f0f1b6f669a874307
command.checkUpdates: f77f38f684c8b974dd4a56bb321cfd5f
menu.application: bc16b1fbe2e90ace4991cafc5149955c
menu.file: 0b27918290ff5323bea1e3b78a9cf04e
menu.edit: 7dce122004969d56ae2e0245cb754d35
menu.view: 4351cfebe4b61d8aa5efa1d020710005
menu.go: 5f075ae3e1f9d0382bb8c4632991f96f
menu.note: 3b0649c72650c313a357338dcdfb64ec
menu.vault: 5b70a213f150f01f0776fa9481ef2ddf
menu.window: c89686a387d2b12b3c729ce35a0bcb5b
menu.file.quickOpen: d79cba7ca4badcccc081bdcba22abd32
menu.file.quickOpenCmdO: 2f1c2a963833b85008562cd615515234
menu.file.quickOpenCtrlO: 8aaba3ce15c61798ce80bda311fc1e2d
menu.file.save: c9cc8cce247e49bae79f15173ce97354
menu.edit.pasteWithoutFormatting: 0219dac5b3b9c457aa65eb19fdd3d22c
menu.edit.findInVault: 7dad10e092a9fe1bfbb4a96c377bc417
menu.edit.toggleNoteListSearch: f6e0cd9529f571609033958a55f18d78
menu.view.allPanels: d33bf99e0756e1d5008cb78902e38ca9
menu.view.zoomIn: f5ca4abce85e2dddb0342d0bae3a7270
menu.view.zoomOut: 30850b501f98539b1aabaa29fabe41ef
menu.view.actualSize: a5dac79ddad1cbdb8b60c5b347ee047e
menu.view.commandPalette: c0a2436cd8b2dc5085c869e4a052572f
menu.go.allNotes: cd76cf851b08a9d2feafaf255bc1f4d5
menu.go.archived: 7d69b3cb4cada18ae61811304f8fbcb5
menu.go.changes: c112bb3542e98308d12d5ecb10a67abc
menu.go.inbox: 3882d32c66e7e768145ecd8f104b0c08
menu.note.toggleOrganized: 003f5648f585407c894e85543a5fda47
menu.note.toggleTableOfContents: c464111f97490e65699fe18970fc00e1
menu.vault.addRemote: 4d9d0f784920d6ef7f9f3b33266dd00e
feedback.title: 96ab5025e38aef43fdb73c9830795264
feedback.description: e900af4efb9167033d4ecc8a02cfe463
feedback.sponsor.title: 2f6a1db54812f51924c3324a178125ed
feedback.sponsor.description: 6517e0c5d4e4801814a6034dd54862b6
feedback.sponsor.cta: a2c71ec076796ab6af0ba0ea882303fc
feedback.sponsor.linkLabel: 7fbe24a8f025219f4d165aeb75352125
feedback.featureRequests.title: 73ea5953d68b2c20a82bb94240f6fe17
feedback.featureRequests.description: 9583575bdef16e12927b0a5acfa84307
feedback.featureRequests.cta: c19ef0158349da249920b0d9f67d7d48
feedback.featureRequests.linkLabel: d125f38d1dc8dbdc7dae3f1778df3a3a
feedback.discussions.title: 560f23c77d499c21038c0b4487f03cb2
feedback.discussions.description: 2e4da4c5f54c7af3b8db9f3d93ed63c5
feedback.discussions.cta: 61ccb6a90ec8037f2926c48c0a90caa3
feedback.discussions.linkLabel: 4f85e3343c5e21d2b11812b2baa8ed83
feedback.contributeCode.title: 8f93fd72b63620711cc118403b85133e
feedback.contributeCode.description: 53230626356951578842a8cd7233d1f1
feedback.contributeCode.cta: c73dd5e7d432cd18c109b333e0b55299
feedback.contributeCode.linkLabel: bfe9dd9816bb39f82fc003de794a075d
feedback.contributingGuide.cta: 6cc1fbcbbc69a70a70a2020c979d8e22
feedback.contributingGuide.linkLabel: b1310938ecab4c502a8b3af2ca1ce188
feedback.reportBug.title: 3406147c3ce729ce17ed76d45c2896f9
feedback.reportBug.description: 6da172412f774e022b0f32ba17305491
feedback.reportBug.cta: 1f5aa905b40956ff5ea8b4cc5ea29315
feedback.reportBug.linkLabel: ffaa2eaa0f015cf037d2b5fe8af6b813
feedback.linkFallback.title: c5396ec50cec23a09977180747737264
feedback.linkFallback.description: 91d1e18130765119606f095ac473d9cd
feedback.copyDiagnostics: dedf5388697bba1fd53173b04823761a
feedback.diagnosticsCopied: 9c7f3f37df9c000fc67c1a6ee35f40e5
feedback.diagnosticsCopiedSentence: 6f8ebe492dbb95827ccf7536965dc672
feedback.clipboardUnavailable: 8e25a424420687ed8e0dc2b1be8c7406
command.group.navigation: 846495f9ceed11accf8879f555936a7d
command.group.note: 3b0649c72650c313a357338dcdfb64ec
command.group.git: 0bcc70105ad279503e31fe7b3f47b665
@@ -36,9 +93,14 @@ files:
command.navigation.showArchivedNotes: 241399b908884adfe8608a3ab827ca74
command.navigation.listType: 4f70c27c6826a37543c8ada561397c22
command.note.newNote: 23c4edda8b815f750782f01870d27271
command.note.newNoteInCurrentFolder: 167b765903c70ab1b645908ff8a899ab
command.note.newType: adce5108f18945cc502a06c02445e32d
command.note.newTypedNote: 1493eda772c2179cb6247169d00723b2
command.note.saveNote: 2a309cda46e95b00d2a5a8afb3cc0047
command.note.undo: 1cdc076b28f70afac5fcedadf99fa119
command.note.undoAction: 83a1f43c314533161109d8d4daf032f2
command.note.redo: 5afeaba074ef570dc720caaa855d49f6
command.note.redoAction: 7556c9dd6845991933c56006bf7ffbac
command.note.pastePlainText: 09e8075dbd91e11878f8f4a138df760c
command.note.findInNote: f72f8f93d8e6119d5d08b60eb3a7b3bc
command.note.replaceInNote: b008e2e20c5e4cd202f4386271d6bed1
@@ -54,11 +116,14 @@ files:
command.note.removeIcon: 4bccbad66025e506b37b4218498b299c
command.note.changeType: 4a54ffd47ed1f65ee97f34bfe8c3de14
command.note.moveToFolder: 3a0f26c42b9168ad839960d134065905
command.note.copyDeepLink: 8de1795f59af124594dc4decf77909a1
command.note.exportPdf: 7a39acf8742a59d1a4f7ae064b6219a0
command.note.openNewWindow: 6f67b2857093fa0fd45b1122dff4865d
command.git.initialize: 54a57cdc7105db6fb22dca661ce01ad6
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
@@ -141,9 +206,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
@@ -179,12 +247,12 @@ files:
settings.allNotesVisibility.unsupported: 257fe04419c9f6543a43e58b1edf9eb5
settings.allNotesVisibility.unsupportedDescription: a3b8aa66900c742f001cd4533b3df44a
settings.aiAgents.title: 27f08387b26e1ceeb1b60bd9c97e7a47
settings.aiAgents.description: 042a8037f1a4f90c76ce31a49fdff59c
settings.aiAgents.description: 768c138c3432fb159ac58c5a4471e786
settings.aiFeatures.enable: af47a6b2bd32a7b7309bac7bef34f026
settings.aiFeatures.enableDescription: 7d3916f7f9e1bf8b8797f50f4533b9da
settings.aiAgents.default: 6af573559fd05d8c35bc57b41cc78e24
settings.aiAgents.defaultTarget: 5acfc74fd97a6dd2d67d15c66de5eed5
settings.aiAgents.agentGroup: 965530cbbec45b1754ed3ece120399f7
settings.aiAgents.agentGroup: 11eaa7f817e3f24d54f3cf3025be49b1
settings.aiAgents.localGroup: d34f4b997ce78da5aa57d657a5d6fcb5
settings.aiAgents.apiGroup: 589a257cbd265eaa7de93de8b4084dff
settings.aiAgents.installed: 73329564760013a7824ff9d5d1af91ff
@@ -195,11 +263,11 @@ files:
settings.aiAgents.apiLocalKey: 119de2f31867b273a134f05bc56b9e57
settings.aiAgents.apiEnv: e011db3dff54ed3deae37a9615e31ebf
settings.aiAgents.apiNoKey: 5bbccbfcd09b28902bbf5319dd6e6053
settings.aiAgents.installedTitle: 5cb9b5ae5ebc66652814dff4584705cc
settings.aiAgents.installedDescription: fb9ba9667725fb40fa1e5d58a2c02692
settings.aiAgents.installedTitle: 0de6536215f76c44351ba4d809804c7a
settings.aiAgents.installedDescription: 27c8ed48439ac4a613409f5303fbca84
settings.aiAgents.noVersion: 89831adb7114946e916b9c31d57a1e33
settings.aiProviders.title: 63eb84d8510f9f086d282ec7d47adaa1
settings.aiProviders.description: 6c86c013eb6fb198b51e29ec47d66464
settings.aiProviders.description: d29dfa440713d15de311d29a64a27767
settings.aiProviders.localTitle: c7c20443d8aaf04271df9d713f5c02ce
settings.aiProviders.localDescription: 7abed40ef2e46741c64ff47e423cca39
settings.aiProviders.apiTitle: 66537b9e5200ebbbfbd091144d8afbcc
@@ -295,7 +363,7 @@ files:
ai.panel.empty.checkingDescription: 31e41510d851c366860377ba0c956f0b
ai.panel.empty.missingTitle: a2b764da52cb43b191bb4ecfd8ea4d26
ai.panel.empty.missingDescription: 91825c7d86b1d588d44a33e1d94af950
ai.panel.empty.withContextTitle: 66f02985937083e4ef62b98cc9f25aee
ai.panel.empty.withContextTitle: cea2547385aeeb68fa494bb3b09b98f5
ai.panel.empty.noContextTitle: e919aeb0a49b58e494e43e5f6f6597c9
ai.panel.empty.withContextDescription: dd8faefcc8160a2db800a41d6628facb
ai.panel.empty.noContextDescription: 6a6e610835808016d1f2790c3f991a54
@@ -303,6 +371,56 @@ files:
ai.panel.placeholder.missing: 547c492026d115608067988b7cfbb9a9
ai.panel.placeholder.ready: 31f5265c2e0432a7ca10d08e5c6f3114
ai.panel.send: 747c6a3564774149c989884e0c581d53
ai.message.regenerate: e464a5330788a9f79d06c96989f840cb
ai.message.copy: 53fa249ce3d02c8a686b657b31d02ee7
ai.message.fork: f5aace5e5b5e7b0954651a2da44b6755
ai.message.reasoning: 87a222577e9c7b7a0739d92337f4fe46
ai.message.toolUse: d6093cfe0c95f805875e5724eecd20aa
ai.workspace.title: 375099f0c804e924c1dc275370710d92
ai.workspace.newChat: a917baaf1484ec5dc85f19ab8fc4f4e9
ai.workspace.collapseSidebar: 9ecc763686b7c61547281f3e05ffd06f
ai.workspace.expandSidebar: 281b619f3da2ba27e604302c3c00db56
ai.workspace.close: 184649ab030e9a609488608221c28d8c
ai.workspace.expandPanel: edbe17b531457367c1412e4b6349c194
ai.workspace.restorePanel: 64eb6176c678c6b395cfc93bac02a16f
ai.workspace.restoreGuidance: 2bd339d85ee3b33e513359ce781b60cc
ai.workspace.closeChat: 6c5be35688a466297db40c3a65ac9805
ai.workspace.popOut: 85ec25ec056b3082651e6e3fcfa57416
ai.workspace.dock: 72e81ed23007ec6fb9820b9abec353c4
ai.workspace.settings: 2c178b1d7a60a3c2b37ca4221d322899
ai.workspace.archive: a02bc089273a8c460ea3d2cd2c257b59
ai.workspace.restore: 98ee8b364870e27dd48aca3240801abf
ai.workspace.showArchived: ae2bcb4cb837cc09a5767153efe4bf39
ai.workspace.hideArchived: e7c72757d5bc7330d876eeb1b6ce9c53
ai.workspace.noActiveChats: f0cdbf09f23eb9a2efbb5977bcc39c1a
ai.workspace.noArchivedChats: 68410efd12fc0308ce1ed2225000d4d5
ai.workspace.chatTitle: 189013cfac88018c68ec02e8edc2379b
ai.workspace.renameChat: 0feba25e166f55afc51c4ebb33d8d757
ai.workspace.status.archived: 7d69b3cb4cada18ae61811304f8fbcb5
ai.workspace.status.idle: e599161956d626eda4cb0a5ffb85271c
ai.workspace.status.running: 5bda814c4aedb126839228f1a3d92f09
ai.workspace.status.done: f92965e2c8a7afb3c1b9a5c09a263636
ai.workspace.status.error: 902b0d55fddef6f8d651fe1035b7d4bd
ai.workspace.permissionMode: 5a7983f759c9e15ce53d7326f0abad71
ai.workspace.guidanceWarning: 00d95da9bc82394785728dec587b026e
ai.workspace.targetLabel: df5dd38c32585fe0c71c955b3a31f83c
ai.workspace.targetLocalAgents: 5a6a65e960093bfc37b4cc81c4a9f773
ai.workspace.targetLocalModels: c7c20443d8aaf04271df9d713f5c02ce
ai.workspace.targetApiModels: 66537b9e5200ebbbfbd091144d8afbcc
ai.workspace.noTargets: 7c3ed1f10281b41070e5a5cbe77453d7
ai.workspace.target.localAgents: 5a6a65e960093bfc37b4cc81c4a9f773
ai.workspace.target.localModels: c7c20443d8aaf04271df9d713f5c02ce
ai.workspace.target.apiModels: 66537b9e5200ebbbfbd091144d8afbcc
ai.workspace.target.none: 7c3ed1f10281b41070e5a5cbe77453d7
ai.workspace.target.unavailable: 453e6aa38d87b28ccae545967c53004f
ai.workspace.target.installed: 98dd43dfae05b11befe1f140e0ec787a
ai.workspace.target.installedVersion: 8677756d761e4cce240a637805482946
ai.workspace.target.localModel: d34f4b997ce78da5aa57d657a5d6fcb5
ai.workspace.target.apiModel: 589a257cbd265eaa7de93de8b4084dff
window.minimize: d27532d90ecd513e97ab811c0f34dbfd
window.maximize: 9369ba148ee259473fc1fb4939b6c2e8
window.restore: 2bd339d85ee3b33e513359ce781b60cc
window.close: d3d2e617335f08df83599665eef8a418
mcp.setup.manageTitle: d786c7b9ab9b2406258648931bca0e01
mcp.setup.setupTitle: b4b373f690c8a0008885b31e78e93a5c
mcp.setup.connectedDescription: a2a3be70c0ac9fa7ef112df5252249fa
@@ -312,7 +430,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
@@ -362,6 +480,7 @@ files:
sidebar.action.createFolder: 5dc1e97c14fbe72d8b566ce29c39f010
sidebar.action.renameFolder: db76034f8218cda07dadb746a5643019
sidebar.action.deleteFolder: 2a2f15300f6f7c81d69860ac1796b517
sidebar.action.createNodeInFolderMenu: 857a479229a4ab0bc62bbfb621d3180a
sidebar.action.revealFolderMenu: 76f4ed52da9937ae432dcf5a108fabb4
sidebar.action.copyFolderPathMenu: 1779ec6018a385912c1a5499a1bbf2b2
sidebar.action.renameFolderMenu: deb1d8fa030292e7eda2c244b313aca2
@@ -383,6 +502,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
@@ -466,8 +587,34 @@ files:
editor.toolbar.delete: f48134a07b016a1de05d4ba6d2fbfb41
editor.toolbar.revealFile: 76f4ed52da9937ae432dcf5a108fabb4
editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27
editor.toolbar.copyNoteDeepLink: 0fff274a7c47e7c12457c0b7412e86ce
editor.toolbar.copyNoteGitUrl: 04a732b98f7585e3c3b5474688e0baac
editor.toolbar.exportPdf: 7a39acf8742a59d1a4f7ae064b6219a0
editor.toolbar.moreActions: 89e19353176b94068dec4c768c25e70b
editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e
editor.formatting.highlight: 0b90582f4589d84be89f5b847d4d1ed1
editor.formatting.highlightTooltip: c27173fea9ec1718030c5fa8440b2c78
editor.whiteboard.enterFullscreen: 606bf766125ecff4b33ca98122222c72
editor.whiteboard.exitFullscreen: afd0a0414d79d047f00319921e4d4944
editor.exportPdf.unavailable: 2fce9834984172b64b66767485d815e9
editor.exportPdf.failed: bf3fa78ff5725f5cbde8979b67ec2e2a
filePreview.copyDeepLink: b75833669968adbef634495636e3fe50
deepLinks.copied: 6ada57681192daf3b4afa07ce7a30575
deepLinks.error.ambiguousVault: 4365c6d5cf8c2c1165259b32d24a6274
deepLinks.error.copyFailed: 57b74ecce2d00b4c327d15830f79410d
deepLinks.error.invalidScheme: 58eaa7ac701a0508cbae90c5d3837ce4
deepLinks.error.malformedUrl: 4f85323322ef0a1d627df621edb749c7
deepLinks.error.missingFile: 79a0c73176508c58b4d785fabaf7d8cc
deepLinks.error.missingPath: a6bb1c92f1a38476ff603a2d3ff81223
deepLinks.error.missingVault: e2936e1f599c7ed229efb5d4a122fc4b
deepLinks.error.openFailed: 97901feafe25688fcc3fc4dcfaf82619
deepLinks.error.outsideVault: fda2fd5675b596df7ba0e0b1a11ec47d
deepLinks.error.unavailableVault: 911dfaf8e855bd8cadefe7df9a98a7e8
deepLinks.error.unknownVault: b4940569e884833f50466177dab1f62f
deepLinks.error.unsafePath: e09caab43be8c2372bfc94e947ea34e6
noteGitUrls.copied: 6832bf85abaa03a6304cd1cce073d747
noteGitUrls.error.copyFailed: 1952ee267f473efe8e28ca90659f75f6
noteGitUrls.error.unavailable: ed08c134bba26350fd28f780c6f1b55b
editor.slash.math: a49950aa047c2292e989e368a97a3aae
tableOfContents.title: f61d6c3e3733db355168b7e1aee6780b
tableOfContents.close: d3292972a10edd1a06a627f3552f760a
@@ -624,6 +771,7 @@ files:
status.ai.vaultGuidance: 7bb4644efe6ae7cec9993c8bd85f1519
status.ai.restoreGuidance: 23331fecc692d11d85cf24838ed273f2
status.ai.openOptions: d387b1ab67586c7a2d83db8900a4dd8e
status.ai.openWorkspace: 178247429f30251ce21265570b612e9f
status.ai.modelTargets: 8f3ceb96e088f8bdaecc236778401ac9
status.ai.localChat: 215a24e3d38fb8b68deea6dad0142326
status.ai.apiChat: 6dffd5b9997885efe701631cea5c470d
@@ -657,3 +805,6 @@ files:
locale.koKR: d0bdb3cde477d82e766da05ebda50ccb
locale.vi: 7b80fae85640c16cdb0261bef0c27636
locale.plPL: c730389bc8d99e59c867766babdd48b5
locale.beBY: 0a6de4460095f398e8a45ed512a7538f
locale.beLatn: 9c454dccccd6aaac0121a45b923dc5ad
locale.idID: cc1c9b57607b4439578b68074a5e2d80

View File

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

View File

@@ -8,6 +8,7 @@
* - search_notes: full-text search across vault notes
* - get_vault_context: vault structure overview (types, note count, folders)
* - get_note: parsed frontmatter + content (convenience over raw cat)
* - create_note: create a new markdown note without overwriting existing files
* - open_note: signal Tolaria UI to open a note as a tab
* - highlight_editor: visually highlight a UI element (editor, tab, etc.)
* - refresh_vault: trigger vault rescan so new/modified files appear
@@ -19,13 +20,22 @@ import {
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import WebSocket from 'ws'
import { searchNotes, getNote } from './vault.js'
import { requireVaultPaths } from './vault-path.js'
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
import path from 'node:path'
import { createMcpToolService } from './tool-service.js'
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,
})
const LOCAL_CREATE_TOOL_ANNOTATIONS = Object.freeze({
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
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).
@@ -34,10 +44,6 @@ let reconnectTimer = null
let shutdownStarted = false
const RECONNECT_INTERVAL_MS = 3000
function activeVaultPaths() {
return requireVaultPaths()
}
function connectUiBridge() {
if (shutdownStarted) return
@@ -103,10 +109,13 @@ function broadcastUiAction(action, payload) {
uiSocket.send(JSON.stringify({ type: 'ui_action', action, ...payload }))
}
const toolService = createMcpToolService({ emitUiAction: broadcastUiAction })
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: {
@@ -119,6 +128,7 @@ const TOOLS = [
{
name: 'get_vault_context',
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: {
@@ -129,6 +139,7 @@ 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: {},
@@ -137,6 +148,7 @@ const TOOLS = [
{
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: {
@@ -146,9 +158,27 @@ const TOOLS = [
required: ['path'],
},
},
{
name: 'create_note',
description: 'Create a new markdown note inside an active Tolaria vault. Does not overwrite existing files. Use content for the full markdown including YAML frontmatter and H1.',
annotations: LOCAL_CREATE_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path inside the vault, or an absolute path inside an active vault. Must end in .md.' },
content: { type: 'string', description: 'Full markdown note content, including YAML frontmatter when needed.' },
title: { type: 'string', description: 'Optional title used only when content is omitted.' },
type: { type: 'string', description: 'Optional note type used only when content is omitted.' },
is_a: { type: 'string', description: 'Legacy alias for type, used only when content is omitted.' },
vaultPath: { type: 'string', description: 'Optional target vault root when multiple vaults are active.' },
},
required: ['path'],
},
},
{
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: {
@@ -161,6 +191,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: {
@@ -173,6 +204,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: {
@@ -183,80 +215,8 @@ const TOOLS = [
},
]
function requestedVaultPath(args = {}) {
const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : ''
if (!requested) return null
if (!activeVaultPaths().includes(requested)) {
throw new Error(`Vault is not active in Tolaria: ${requested}`)
}
return requested
}
function vaultLabel(vaultPath) {
return path.basename(vaultPath) || vaultPath
}
function withVaultMetadata(note, vaultPath) {
return {
...note,
vaultPath,
vaultLabel: vaultLabel(vaultPath),
}
}
async function getNoteFromActiveVaults(notePath, vaultPath = null) {
const candidates = vaultPath ? [vaultPath] : activeVaultPaths()
const matches = []
const errors = []
for (const candidate of candidates) {
try {
matches.push(withVaultMetadata(await getNote(candidate, notePath), candidate))
} catch (error) {
errors.push(error)
}
}
if (matches.length === 1) return matches[0]
if (matches.length > 1) {
throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`)
}
throw errors[0] ?? new Error(`Note not found: ${notePath}`)
}
async function searchActiveVaults(query, limit = 10) {
const requestedLimit = Number.isFinite(limit) && limit > 0 ? limit : 10
const results = []
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
}
return results.slice(0, requestedLimit)
}
async function activeVaultContext(targetVaultPath = null) {
const roots = activeVaultPaths()
if (targetVaultPath) return vaultContextWithInstructions(targetVaultPath)
if (roots.length === 1) return vaultContextWithInstructions(roots[0])
return {
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 roots = activeVaultPaths()
const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '')
return vaultPath ? path.join(vaultPath, notePath) : notePath
}
async function handleSearchNotes(args) {
const results = await searchActiveVaults(args.query, args.limit)
const results = await toolService.searchNotes(args)
const text = results.length === 0
? 'No matching notes found.'
: results.map(r => `**${r.title}** (${r.vaultLabel} / ${r.path})\n${r.snippet}`).join('\n\n')
@@ -264,45 +224,43 @@ async function handleSearchNotes(args) {
}
async function handleVaultContext(args = {}) {
const ctx = await activeVaultContext(requestedVaultPath(args))
const ctx = await toolService.vaultContext(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) }] }
return { content: [{ type: 'text', text: JSON.stringify(await toolService.listVaults(), null, 2) }] }
}
async function handleGetNote(args) {
const note = await getNoteFromActiveVaults(args.path, requestedVaultPath(args))
const note = await toolService.readNote(args)
return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] }
}
async function handleCreateNote(args = {}) {
const note = await toolService.createNote(args)
return {
content: [{
type: 'text',
text: JSON.stringify(note, null, 2),
}],
}
}
function handleOpenNote(args) {
// Refresh vault first so the new/modified note appears in the note list,
// then signal the UI to open it in a tab.
const targetPath = uiPath(args)
broadcastUiAction('vault_changed', { path: targetPath })
broadcastUiAction('open_tab', { path: targetPath })
const { targetPath } = toolService.openNoteAsTab(args)
return { content: [{ type: 'text', text: `Opening ${targetPath} in Tolaria` }] }
}
function handleHighlightEditor(args) {
broadcastUiAction('highlight', { element: args.element, path: args.path })
toolService.highlightEditor(args)
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
}
function handleRefreshVault(args) {
broadcastUiAction('vault_changed', { path: uiPath(args) })
toolService.refreshVault(args)
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
}
@@ -311,6 +269,7 @@ const TOOL_HANDLERS = new Map([
['get_vault_context', handleVaultContext],
['list_vaults', handleListVaults],
['get_note', handleGetNote],
['create_note', handleCreateNote],
['open_note', handleOpenNote],
['highlight_editor', handleHighlightEditor],
['refresh_vault', handleRefreshVault],

View File

@@ -10,7 +10,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
"ws": "^8.19.0"
"ws": "^8.20.1"
}
},
"node_modules/@hono/node-server": {
@@ -619,9 +619,9 @@
}
},
"node_modules/hono": {
"version": "4.12.18",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
"version": "4.12.21",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz",
"integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -914,9 +914,9 @@
}
},
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -1227,9 +1227,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View File

@@ -6,18 +6,19 @@
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "node --test test.js"
"test": "node --test test.js tool-service.test.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
"ws": "^8.19.0"
"ws": "^8.20.1"
},
"overrides": {
"@hono/node-server": "1.19.13",
"express-rate-limit": "8.2.2",
"fast-uri": "3.1.2",
"hono": "4.12.18",
"hono": "4.12.21",
"qs": "6.15.2",
"ip-address": "10.1.1",
"path-to-regexp": "8.4.0"
}

View File

@@ -2,15 +2,17 @@ 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, writeFile,
access, mkdtemp, mkdir, open, readFile, 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,
createNote, findMarkdownFiles, getNote, searchNotes, vaultContext,
} from './vault.js'
import { requireVaultPath, requireVaultPaths } from './vault-path.js'
import { vaultContextWithInstructions } from './agent-instructions.js'
@@ -124,6 +126,74 @@ describe('getNote', () => {
})
})
describe('createNote', () => {
it('creates a new markdown note inside the vault', async () => {
const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-'))
const content = `---
type: Note
---
# MCP Created
`
try {
const note = await createNote(vaultDir, 'note/mcp-created.md', content)
assert.equal(note.path, 'note/mcp-created.md')
assert.equal(await readFile(path.join(vaultDir, note.path), 'utf-8'), content)
} finally {
await rm(vaultDir, { recursive: true, force: true })
}
})
it('does not overwrite an existing note', async () => {
const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-existing-'))
const notePath = path.join(vaultDir, 'existing.md')
await writeFile(notePath, '# Existing\n', 'utf-8')
try {
await assert.rejects(
() => createNote(vaultDir, 'existing.md', '# Replacement\n'),
{ code: 'EEXIST' },
)
assert.equal(await readFile(notePath, 'utf-8'), '# Existing\n')
} finally {
await rm(vaultDir, { recursive: true, force: true })
}
})
it('rejects absolute paths outside the vault', async () => {
const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-vault-'))
const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-outside-'))
try {
await assert.rejects(
() => createNote(vaultDir, path.join(outsideDir, 'outside.md'), '# Outside\n'),
{ message: ACTIVE_VAULT_ERROR },
)
} finally {
await rm(vaultDir, { recursive: true, force: true })
await rm(outsideDir, { recursive: true, force: true })
}
})
it('rejects outside paths before creating missing parent folders', async () => {
const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-vault-'))
const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-outside-'))
const outsideParent = path.join(outsideDir, 'missing-parent')
try {
await assert.rejects(
() => createNote(vaultDir, path.join(outsideParent, 'outside.md'), '# Outside\n'),
{ message: ACTIVE_VAULT_ERROR },
)
await assert.rejects(() => access(outsideParent), { code: 'ENOENT' })
} finally {
await rm(vaultDir, { recursive: true, force: true })
await rm(outsideDir, { recursive: true, force: true })
}
})
})
describe('searchNotes', () => {
it('should find notes matching title', async () => {
const results = await searchNotes(tmpDir, 'Test Project')
@@ -305,6 +375,66 @@ describe('requireVaultPath', () => {
})
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`)
}
const createTool = toolsByName.get('create_note')
assert.ok(createTool, 'Missing MCP tool: create_note')
assert.equal(createTool.annotations?.readOnlyHint, false)
assert.equal(createTool.annotations?.destructiveHint, false)
assert.equal(createTool.annotations?.openWorldHint, false)
} finally {
await closeMcpClient(client, stderr)
}
})
it('creates a note through the MCP create_note tool', async () => {
const { client, stderr } = await connectMcpClient()
const relativePath = 'note/mcp-tool-created.md'
const absolutePath = path.join(tmpDir, relativePath)
const content = `---
type: Note
---
# MCP Tool Created
`
try {
await rm(absolutePath, { force: true })
const result = await client.callTool({
name: 'create_note',
arguments: { path: relativePath, content },
})
assert.equal(await readFile(absolutePath, 'utf-8'), content)
assert.match(JSON.stringify(result.content), /mcp-tool-created\.md/)
} finally {
await rm(absolutePath, { force: true })
await closeMcpClient(client, stderr)
}
})
it('exits when the MCP client closes stdin', async () => {
const child = spawn(process.execPath, ['index.js'], {
cwd: MCP_SERVER_DIR,
@@ -332,6 +462,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')

213
mcp-server/tool-service.js Normal file
View File

@@ -0,0 +1,213 @@
import path from 'node:path'
import {
createNote as createVaultNote,
getNote,
searchNotes as searchVaultNotes,
} from './vault.js'
import { requireVaultPaths } from './vault-path.js'
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
export function createMcpToolService({
resolveVaultPaths = () => requireVaultPaths(),
emitUiAction = () => {},
} = {}) {
function activeVaultPaths() {
return resolveVaultPaths()
}
function requestedVaultPath(args = {}) {
const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : ''
if (!requested) return null
if (!activeVaultPaths().includes(requested)) {
throw new Error(`Vault is not active in Tolaria: ${requested}`)
}
return requested
}
function resolveUiPath(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 ? roots[0] : '')
return vaultPath ? path.join(vaultPath, notePath) : notePath
}
async function readNote(args = {}) {
return getNoteFromActiveVaults(notePathArg(args), requestedVaultPath(args))
}
async function searchNotes(args = {}) {
const requestedLimit = Number.isFinite(args.limit) && args.limit > 0 ? args.limit : 10
const results = []
for (const vaultPath of activeVaultPaths()) {
const vaultResults = await searchVaultNotes(vaultPath, args.query, requestedLimit)
results.push(...vaultResults.map((result) => withVaultMetadata(result, vaultPath)))
if (results.length >= requestedLimit) break
}
return results.slice(0, requestedLimit)
}
async function vaultContext(args = {}) {
const targetVaultPath = requestedVaultPath(args)
const roots = activeVaultPaths()
if (targetVaultPath) return vaultContextWithInstructions(targetVaultPath)
if (roots.length === 1) return vaultContextWithInstructions(roots[0])
return {
vaults: await Promise.all(roots.map(vaultContextWithInstructions)),
}
}
async function listVaults() {
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 { vaults }
}
async function createNote(args = {}) {
const vaultPath = writableVaultPath(args)
const notePath = writableNotePath(args, vaultPath)
const note = await createVaultNote(vaultPath, notePath, createNoteContent(args))
const targetPath = resolveUiPath({ ...args, path: note.path, vaultPath })
emitUiAction('vault_changed', { path: targetPath })
emitUiAction('open_tab', { path: targetPath })
return { path: note.path, absolutePath: note.absolutePath, vaultPath }
}
function openNoteAsTab(args = {}) {
const targetPath = resolveUiPath(args)
emitUiAction('vault_changed', { path: targetPath })
emitUiAction('open_tab', { path: targetPath })
return { targetPath }
}
function openNoteInEditor(args = {}) {
const targetPath = resolveUiPath(args)
emitUiAction('vault_changed', { path: targetPath })
emitUiAction('open_note', { path: targetPath })
return { targetPath }
}
function highlightEditor(args = {}) {
emitUiAction('highlight', { element: args.element, path: args.path })
}
function setFilter(args = {}) {
emitUiAction('set_filter', { filterType: args.type })
}
function refreshVault(args = {}) {
emitUiAction('vault_changed', { path: resolveUiPath(args) })
}
async function getNoteFromActiveVaults(notePath, vaultPath = null) {
const candidates = vaultPath ? [vaultPath] : activeVaultPaths()
const matches = []
const errors = []
for (const candidate of candidates) {
try {
matches.push(withVaultMetadata(await getNote(candidate, notePath), candidate))
} catch (error) {
errors.push(error)
}
}
if (matches.length === 1) return matches[0]
if (matches.length > 1) {
throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`)
}
throw errors[0] ?? new Error(`Note not found: ${notePath}`)
}
function writableVaultPath(args = {}) {
const requested = requestedVaultPath(args)
if (requested) return requested
const roots = activeVaultPaths()
const notePath = notePathArg(args)
if (path.isAbsolute(notePath)) {
const root = roots.find(vaultPath => isInsideVaultRoot(vaultPath, notePath))
if (root) return root
}
if (roots.length === 1) return roots[0]
throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`)
}
return {
activeVaultPaths,
createNote,
highlightEditor,
listVaults,
openNoteAsTab,
openNoteInEditor,
readNote,
refreshVault,
requestedVaultPath,
resolveUiPath,
searchNotes,
setFilter,
vaultContext,
}
}
function writableNotePath(args, vaultPath) {
const notePath = notePathArg(args)
if (!path.isAbsolute(notePath) || !isInsideVaultRoot(vaultPath, notePath)) return notePath
return path.relative(vaultPath, notePath)
}
function withVaultMetadata(note, vaultPath) {
return {
...note,
vaultPath,
vaultLabel: vaultLabel(vaultPath),
}
}
function vaultLabel(vaultPath) {
return path.basename(vaultPath) || vaultPath
}
function isInsideVaultRoot(vaultPath, notePath) {
const relative = path.relative(vaultPath, notePath)
return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative)
}
function notePathArg(args = {}) {
const notePath = typeof args.path === 'string' ? args.path.trim() : ''
if (!notePath) throw new Error('Note path is required')
return notePath
}
function yamlScalar(value) {
return JSON.stringify(value)
}
function fallbackCreateNoteContent(args = {}) {
const title = typeof args.title === 'string' && args.title.trim()
? args.title.trim()
: path.basename(notePathArg(args), '.md')
const type = typeof args.type === 'string' && args.type.trim()
? args.type.trim()
: typeof args.is_a === 'string' && args.is_a.trim()
? args.is_a.trim()
: 'Note'
return `---\ntype: ${yamlScalar(type)}\n---\n\n# ${title}\n`
}
function createNoteContent(args = {}) {
return typeof args.content === 'string' && args.content.trim()
? args.content
: fallbackCreateNoteContent(args)
}

View File

@@ -0,0 +1,156 @@
import { describe, it, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { createMcpToolService } from './tool-service.js'
let tmpDir
let firstVault
let secondVault
beforeEach(async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-service-'))
firstVault = path.join(tmpDir, 'First Vault')
secondVault = path.join(tmpDir, 'Second Vault')
await seedVault(firstVault, {
'note/shared.md': noteFixture('Shared Note', 'Shared content from the first vault.'),
'note/alpha.md': noteFixture('Alpha Project', 'Project planning in the first vault.'),
})
await seedVault(secondVault, {
'AGENTS.md': '# Second Vault Rules\n',
'note/shared.md': noteFixture('Shared Note', 'Shared content from the second vault.'),
'note/beta.md': noteFixture('Beta Project', 'Project planning in the second vault.'),
})
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
describe('createMcpToolService', () => {
it('requires vaultPath when reading an ambiguous note path', async () => {
const service = makeService()
await assert.rejects(
() => service.readNote({ path: 'note/shared.md' }),
/Note path is ambiguous across active vaults/,
)
const note = await service.readNote({
path: 'note/shared.md',
vaultPath: secondVault,
})
assert.equal(note.vaultPath, secondVault)
assert.equal(note.vaultLabel, 'Second Vault')
assert.match(note.content, /second vault/)
})
it('creates notes with fallback markdown and emits refresh and tab actions', async () => {
const emittedActions = []
const service = makeService({ emittedActions })
const absolutePath = path.join(secondVault, 'note/created.md')
const note = await service.createNote({
path: absolutePath,
title: 'Created From MCP',
type: 'Project',
})
assert.equal(note.path, 'note/created.md')
assert.equal(note.vaultPath, secondVault)
assert.equal(path.basename(note.absolutePath), 'created.md')
assert.equal(
await readFile(note.absolutePath, 'utf-8'),
'---\ntype: "Project"\n---\n\n# Created From MCP\n',
)
assert.deepEqual(emittedActions, [
{ action: 'vault_changed', payload: { path: absolutePath } },
{ action: 'open_tab', payload: { path: absolutePath } },
])
})
it('searches active vaults with consistent vault metadata', async () => {
const service = makeService()
const results = await service.searchNotes({ query: 'Project', limit: 2 })
assert.equal(results.length, 2)
assert.deepEqual(
results.map(({ path: notePath, vaultPath, vaultLabel }) => ({
notePath,
vaultPath,
vaultLabel,
})),
[
{ notePath: 'note/alpha.md', vaultPath: firstVault, vaultLabel: 'First Vault' },
{ notePath: 'note/beta.md', vaultPath: secondVault, vaultLabel: 'Second Vault' },
],
)
})
it('lists active vaults with agent-instruction metadata', async () => {
const service = makeService()
assert.deepEqual(await service.listVaults(), {
vaults: [
{
path: firstVault,
label: 'First Vault',
agentInstructionsPath: null,
hasAgentInstructions: false,
},
{
path: secondVault,
label: 'Second Vault',
agentInstructionsPath: path.join(secondVault, 'AGENTS.md'),
hasAgentInstructions: true,
},
],
})
})
it('emits transport-neutral UI intents for note opening and filters', () => {
const emittedActions = []
const service = makeService({ emittedActions })
service.openNoteAsTab({ path: 'note/beta.md', vaultPath: secondVault })
service.openNoteInEditor({ path: 'note/beta.md', vaultPath: secondVault })
service.highlightEditor({ element: 'editor', path: 'note/beta.md' })
service.setFilter({ type: 'Project' })
service.refreshVault({ path: 'note/beta.md', vaultPath: secondVault })
assert.deepEqual(emittedActions, [
{ action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } },
{ action: 'open_tab', payload: { path: path.join(secondVault, 'note/beta.md') } },
{ action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } },
{ action: 'open_note', payload: { path: path.join(secondVault, 'note/beta.md') } },
{ action: 'highlight', payload: { element: 'editor', path: 'note/beta.md' } },
{ action: 'set_filter', payload: { filterType: 'Project' } },
{ action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } },
])
})
})
function makeService({ emittedActions = [] } = {}) {
return createMcpToolService({
resolveVaultPaths: () => [firstVault, secondVault],
emitUiAction: (action, payload) => {
emittedActions.push({ action, payload })
},
})
}
async function seedVault(vaultPath, files) {
for (const [relativePath, content] of Object.entries(files)) {
const filePath = path.join(vaultPath, relativePath)
await mkdir(path.dirname(filePath), { recursive: true })
await writeFile(filePath, content, 'utf-8')
}
}
function noteFixture(title, body) {
return `---\ntitle: ${JSON.stringify(title)}\ntype: Note\n---\n\n# ${title}\n\n${body}\n`
}

View File

@@ -1,9 +1,10 @@
/**
* Vault operations — read-only helpers for Tolaria markdown vault.
* Write operations are handled by the app-managed agent's active permission
* profile and native file-edit tools when available.
* Most write operations are handled by the app-managed agent's active
* permission profile and native file-edit tools; createNote is intentionally
* narrow so read-only agents can create a new Markdown file without overwrite.
*/
import { open, opendir, realpath } from 'node:fs/promises'
import { mkdir, open, opendir, realpath } from 'node:fs/promises'
import path from 'node:path'
import matter from 'gray-matter'
@@ -60,6 +61,22 @@ export async function getNote(vaultPath, notePath) {
}
}
/**
* Create a new markdown note inside the vault without overwriting an existing file.
* @param {string} vaultPath
* @param {string} notePath
* @param {string} content
* @returns {Promise<{path: string, absolutePath: string}>}
*/
export async function createNote(vaultPath, notePath, content) {
const { requestedPath, relativePath } = await resolveNewVaultNotePath(vaultPath, notePath)
await writeNewUtf8File(requestedPath, content)
return {
path: relativePath,
absolutePath: requestedPath,
}
}
/**
* Search notes by title or content substring.
* @param {string} vaultPath
@@ -145,6 +162,61 @@ function resolveRequestedNotePath(vaultRoot, notePath) {
return resolved
}
async function resolveNewVaultNotePath(vaultPath, notePath) {
const requestedNotePath = validateNewNotePath(notePath)
const vaultRoot = await realpath(vaultPath)
const requestedPath = resolveRequestedNotePath(vaultRoot, requestedNotePath)
const relativePath = relativeNotePathInsideVault(vaultRoot, requestedPath)
await ensureWritableParentInsideVault(vaultRoot, requestedPath)
return { requestedPath, relativePath }
}
function validateNewNotePath(notePath) {
const trimmedPath = typeof notePath === 'string' ? notePath.trim() : ''
if (!trimmedPath) {
throw new Error('Note path is required')
}
if (!trimmedPath.endsWith('.md')) {
throw new Error('New notes must be markdown files ending in .md')
}
return trimmedPath
}
async function ensureWritableParentInsideVault(vaultRoot, requestedPath) {
const parentPath = path.dirname(requestedPath)
const existingAncestor = await nearestExistingAncestor(parentPath)
assertInsideVault(vaultRoot, existingAncestor)
await mkdir(parentPath, { recursive: true })
assertInsideVault(vaultRoot, await realpath(parentPath))
}
async function nearestExistingAncestor(targetPath) {
let currentPath = targetPath
while (currentPath && currentPath !== path.dirname(currentPath)) {
try {
return await realpath(currentPath)
} catch (error) {
if (error?.code !== 'ENOENT') throw error
currentPath = path.dirname(currentPath)
}
}
return realpath(currentPath)
}
function assertInsideVault(vaultRoot, targetPath) {
if (!isVaultRelativePath(path.relative(vaultRoot, targetPath))) {
throw new Error(ACTIVE_VAULT_ERROR)
}
}
function relativeNotePathInsideVault(vaultRoot, requestedPath) {
const relativePath = path.relative(vaultRoot, requestedPath)
if (!isVaultRelativePath(relativePath) || !relativePath) {
throw new Error(ACTIVE_VAULT_ERROR)
}
return relativePath
}
function resolveInside(root, target) {
const resolved = path.resolve(root, target)
const relative = path.relative(root, resolved)
@@ -338,6 +410,15 @@ async function readUtf8File(filePath) {
}
}
async function writeNewUtf8File(filePath, content) {
const handle = await open(filePath, 'wx')
try {
await handle.writeFile(content, 'utf-8')
} finally {
await handle.close()
}
}
async function statFile(filePath) {
const handle = await open(filePath, 'r')
try {

View File

@@ -21,12 +21,7 @@
*/
import { createServer } from 'node:http'
import { WebSocketServer } from 'ws'
import {
getNote, searchNotes,
} from './vault.js'
import { requireVaultPaths } from './vault-path.js'
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
import path from 'node:path'
import { createMcpToolService } from './tool-service.js'
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
@@ -41,66 +36,6 @@ const TRUSTED_UI_ORIGINS = new Set([
let uiBridge = null
const UNKNOWN_TOOL = Symbol('unknown tool')
function activeVaultPaths() {
return requireVaultPaths()
}
function requestedVaultPath(args = {}) {
const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : ''
if (!requested) return null
if (!activeVaultPaths().includes(requested)) {
throw new Error(`Vault is not active in Tolaria: ${requested}`)
}
return requested
}
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 ? roots[0] : '')
return vaultPath ? path.join(vaultPath, notePath) : notePath
}
async function getNoteFromActiveVaults(notePath, vaultPath = null) {
const candidates = vaultPath ? [vaultPath] : activeVaultPaths()
const matches = []
const errors = []
for (const candidate of candidates) {
try {
matches.push({ ...(await getNote(candidate, notePath)), vaultPath: candidate })
} catch (error) {
errors.push(error)
}
}
if (matches.length === 1) return matches[0]
if (matches.length > 1) {
throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`)
}
throw errors[0] ?? new Error(`Note not found: ${notePath}`)
}
async function searchActiveVaults(query, limit = 10) {
const requestedLimit = Number.isFinite(limit) && limit > 0 ? limit : 10
const results = []
for (const vaultPath of activeVaultPaths()) {
const vaultResults = await searchNotes(vaultPath, query, requestedLimit)
results.push(...vaultResults.map((result) => ({ ...result, vaultPath })))
if (results.length >= requestedLimit) break
}
return results.slice(0, requestedLimit)
}
async function activeVaultContext() {
const roots = activeVaultPaths()
if (roots.length === 1) return vaultContextWithInstructions(roots[0])
return { vaults: await Promise.all(roots.map(vaultContextWithInstructions)) }
}
function broadcastUiAction(action, payload) {
if (!uiBridge) return
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
@@ -109,61 +44,49 @@ function broadcastUiAction(action, payload) {
}
}
const toolService = createMcpToolService({ emitUiAction: broadcastUiAction })
async function readNoteTool(args) {
const note = await getNoteFromActiveVaults(args.path, requestedVaultPath(args))
const note = await toolService.readNote(args)
return { content: note.content, frontmatter: note.frontmatter }
}
function uiOpenNoteTool(args) {
const targetPath = uiPath(args)
broadcastUiAction('vault_changed', { path: targetPath })
broadcastUiAction('open_note', { path: targetPath })
toolService.openNoteInEditor(args)
return { ok: true }
}
function uiOpenTabTool(args) {
const targetPath = uiPath(args)
broadcastUiAction('vault_changed', { path: targetPath })
broadcastUiAction('open_tab', { path: targetPath })
toolService.openNoteAsTab(args)
return { ok: true }
}
async function createNoteTool(args = {}) {
return { ok: true, ...(await toolService.createNote(args)) }
}
function highlightTool(args) {
broadcastUiAction('highlight', { element: args.element, path: args.path })
toolService.highlightEditor(args)
return { ok: true }
}
function uiSetFilterTool(args) {
broadcastUiAction('set_filter', { filterType: args.type })
toolService.setFilter(args)
return { ok: true }
}
function refreshVaultTool(args) {
broadcastUiAction('vault_changed', { path: uiPath(args) })
toolService.refreshVault(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()],
['create_note', createNoteTool],
['search_notes', (args) => toolService.searchNotes(args)],
['vault_context', (args) => toolService.vaultContext(args)],
['list_vaults', () => toolService.listVaults()],
['ui_open_note', uiOpenNoteTool],
['ui_open_tab', uiOpenTabTool],
['ui_highlight', highlightTool],
@@ -279,7 +202,7 @@ export function startUiBridge(port = WS_UI_PORT) {
}
export function startBridge(port = WS_PORT) {
const currentVaultPaths = activeVaultPaths()
const currentVaultPaths = toolService.activeVaultPaths()
const wss = new WebSocketServer({
port,
host: LOOPBACK_HOST,
@@ -309,7 +232,7 @@ export function startBridge(port = WS_PORT) {
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
if (isMain) {
try {
activeVaultPaths()
toolService.activeVaultPaths()
startUiBridge().then(() => startBridge())
} catch (err) {
console.error(`[ws-bridge] ${err.message}`)

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",
@@ -53,8 +53,10 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/react": "^10.47.0",
"@shikijs/langs": "3.23.0",
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-deep-link": "2.4.9",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-process": "^2.3.1",
@@ -119,10 +121,26 @@
"mermaid>uuid": "11.1.1",
"path-to-regexp": "8.4.0",
"fast-uri": "3.1.2",
"fast-xml-builder": "1.1.7",
"flatted": "3.4.2",
"minimatch@3.1.2": "3.1.5",
"minimatch@3.1.3": "3.1.5",
"minimatch@9.0.5": "9.0.9",
"minimatch@9.0.6": "9.0.9",
"picomatch": "4.0.4",
"postcss": "8.5.10",
"protobufjs": "7.5.6",
"rollup": "4.59.0"
"qs": "6.15.2",
"rollup": "4.59.0",
"undici": "7.25.0",
"@blocknote/core>uuid": "11.1.1"
},
"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

122
pnpm-lock.yaml generated
View File

@@ -12,12 +12,24 @@ overrides:
mermaid>uuid: 11.1.1
path-to-regexp: 8.4.0
fast-uri: 3.1.2
fast-xml-builder: 1.1.7
flatted: 3.4.2
minimatch@3.1.2: 3.1.5
minimatch@3.1.3: 3.1.5
minimatch@9.0.5: 9.0.9
minimatch@9.0.6: 9.0.9
picomatch: 4.0.4
postcss: 8.5.10
protobufjs: 7.5.6
qs: 6.15.2
rollup: 4.59.0
undici: 7.25.0
'@blocknote/core>uuid': 11.1.1
patchedDependencies:
'@blocknote/code-block@0.46.2':
hash: c006cad64c22a2efc10299d85bf72407150fd29a1f16d497b8db0faa071535f8
path: patches/@blocknote__code-block@0.46.2.patch
'@blocknote/core@0.46.2':
hash: 9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2
path: patches/@blocknote__core@0.46.2.patch
@@ -40,7 +52,7 @@ 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=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@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=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
@@ -110,12 +122,18 @@ importers:
'@sentry/react':
specifier: ^10.47.0
version: 10.47.0(react@19.2.4)
'@shikijs/langs':
specifier: 3.23.0
version: 3.23.0
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.1.18(vite@7.3.2(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))
'@tauri-apps/api':
specifier: ^2.10.1
version: 2.10.1
'@tauri-apps/plugin-deep-link':
specifier: 2.4.9
version: 2.4.9
'@tauri-apps/plugin-dialog':
specifier: ^2.6.0
version: 2.6.0
@@ -283,8 +301,8 @@ importers:
specifier: ^4.0.3
version: 4.0.3
ws:
specifier: ^8.19.0
version: 8.19.0
specifier: ^8.20.1
version: 8.21.0
packages:
@@ -2445,6 +2463,9 @@ packages:
'@tauri-apps/api@2.10.1':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
'@tauri-apps/api@2.11.0':
resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
'@tauri-apps/cli-darwin-arm64@2.10.0':
resolution: {integrity: sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==}
engines: {node: '>= 10'}
@@ -2521,6 +2542,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-deep-link@2.4.9':
resolution: {integrity: sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==}
'@tauri-apps/plugin-dialog@2.6.0':
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
@@ -3324,8 +3348,8 @@ packages:
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brace-expansion@2.1.0:
resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
brace-expansion@5.0.5:
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
@@ -3949,8 +3973,8 @@ packages:
fast-uri@3.1.2:
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
fast-xml-builder@1.1.5:
resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==}
fast-xml-builder@1.1.7:
resolution: {integrity: sha512-Yh7/7rQuMXICNr0oMYDR2yHP6oUvmQsTToFeOWj/kIDhAwQ+c4Ol/lbcwOmEM5OHYQmh6S6EQSQ1sljCKP36bQ==}
fast-xml-parser@5.7.2:
resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==}
@@ -3989,8 +4013,8 @@ packages:
engines: {node: '>=18'}
hasBin: true
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
focus-trap@7.8.0:
resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==}
@@ -4765,11 +4789,11 @@ packages:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
minipass@7.1.3:
@@ -5072,8 +5096,8 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qs@6.15.0:
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
query-selector-shadow-dom@1.0.1:
@@ -5561,8 +5585,8 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@7.22.0:
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
undici@7.25.0:
resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
engines: {node: '>=20.18.1'}
unicode-emoji-json@0.8.0:
@@ -5658,10 +5682,6 @@ packages:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@@ -5878,6 +5898,18 @@ packages:
utf-8-validate:
optional: true
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
@@ -6211,7 +6243,7 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@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=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@shikijs/core': 3.23.0
@@ -6259,7 +6291,7 @@ snapshots:
remark-stringify: 11.0.0
unified: 11.0.5
unist-util-visit: 5.1.0
uuid: 8.3.2
uuid: 11.1.1
y-prosemirror: 1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)
y-protocols: 1.0.7(yjs@13.6.29)
yjs: 13.6.29
@@ -6666,7 +6698,7 @@ snapshots:
dependencies:
'@eslint/object-schema': 2.1.7
debug: 4.4.3
minimatch: 3.1.2
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -6687,7 +6719,7 @@ snapshots:
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.1.1
minimatch: 3.1.2
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -8156,6 +8188,8 @@ snapshots:
'@tauri-apps/api@2.10.1': {}
'@tauri-apps/api@2.11.0': {}
'@tauri-apps/cli-darwin-arm64@2.10.0':
optional: true
@@ -8203,6 +8237,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.10.0
'@tauri-apps/cli-win32-x64-msvc': 2.10.0
'@tauri-apps/plugin-deep-link@2.4.9':
dependencies:
'@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-dialog@2.6.0':
dependencies:
'@tauri-apps/api': 2.10.1
@@ -8870,7 +8908,7 @@ snapshots:
'@typescript-eslint/types': 8.55.0
'@typescript-eslint/visitor-keys': 8.55.0
debug: 4.4.3
minimatch: 9.0.5
minimatch: 9.0.9
semver: 7.7.4
tinyglobby: 0.2.15
ts-api-utils: 2.4.0(typescript@5.9.3)
@@ -9191,7 +9229,7 @@ snapshots:
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
qs: 6.15.0
qs: 6.15.2
raw-body: 3.0.2
type-is: 2.0.1
transitivePeerDependencies:
@@ -9202,7 +9240,7 @@ snapshots:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.0.2:
brace-expansion@2.1.0:
dependencies:
balanced-match: 1.0.2
@@ -9773,7 +9811,7 @@ snapshots:
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
lodash.merge: 4.6.2
minimatch: 3.1.2
minimatch: 3.1.5
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
@@ -9852,7 +9890,7 @@ snapshots:
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
qs: 6.15.0
qs: 6.15.2
range-parser: 1.2.1
router: 2.2.0
send: 1.2.1
@@ -9879,14 +9917,14 @@ snapshots:
fast-uri@3.1.2: {}
fast-xml-builder@1.1.5:
fast-xml-builder@1.1.7:
dependencies:
path-expression-matcher: 1.5.0
fast-xml-parser@5.7.2:
dependencies:
'@nodable/entities': 2.1.0
fast-xml-builder: 1.1.5
fast-xml-builder: 1.1.7
path-expression-matcher: 1.5.0
strnum: 2.2.3
@@ -9918,12 +9956,12 @@ snapshots:
flat-cache@4.0.1:
dependencies:
flatted: 3.3.3
flatted: 3.4.2
keyv: 4.5.4
flat@6.0.1: {}
flatted@3.3.3: {}
flatted@3.4.2: {}
focus-trap@7.8.0:
dependencies:
@@ -10356,7 +10394,7 @@ snapshots:
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.0
undici: 7.22.0
undici: 7.25.0
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
@@ -10940,13 +10978,13 @@ snapshots:
dependencies:
brace-expansion: 5.0.5
minimatch@3.1.2:
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.12
minimatch@9.0.5:
minimatch@9.0.9:
dependencies:
brace-expansion: 2.0.2
brace-expansion: 2.1.0
minipass@7.1.3: {}
@@ -11284,7 +11322,7 @@ snapshots:
punycode@2.3.1: {}
qs@6.15.0:
qs@6.15.2:
dependencies:
side-channel: 1.1.0
@@ -11915,7 +11953,7 @@ snapshots:
undici-types@7.16.0: {}
undici@7.22.0: {}
undici@7.25.0: {}
unicode-emoji-json@0.8.0: {}
@@ -12009,8 +12047,6 @@ snapshots:
uuid@11.1.1: {}
uuid@8.3.2: {}
vary@1.1.2: {}
vfile-location@5.0.3:
@@ -12211,6 +12247,8 @@ snapshots:
ws@8.19.0: {}
ws@8.21.0: {}
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}

View File

@@ -0,0 +1,8 @@
# AI Agent Icons
- Claude Code: Claude mark from the official Claude wordmark on https://claude.com/product/claude-code
- Codex: OpenAI mark from https://openai.com/favicon.svg
- OpenCode: official OpenCode brand asset from https://opencode.ai/brand
- Pi: official Pi favicon from https://hey.pi.ai
- Gemini CLI: Google-hosted Gemini sparkle from https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg
- Kiro: official Kiro icon from https://kiro.dev/icon.svg?fe599162bb293ea0

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 125 125" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M54.375 118.75L56.125 111L58.125 101L59.75 93L61.25 83.125L62.125 79.875L62 79.625L61.375 79.75L53.875 90L42.5 105.375L33.5 114.875L31.375 115.75L27.625 113.875L28 110.375L30.125 107.375L42.5 91.5L50 81.625L54.875 76L54.75 75.25H54.5L21.5 96.75L15.625 97.5L13 95.125L13.375 91.25L14.625 90L24.5 83.125L49.125 69.375L49.5 68.125L49.125 67.5H47.875L43.75 67.25L29.75 66.875L17.625 66.375L5.75 65.75L2.75 65.125L0 61.375L0.25 59.5L2.75 57.875L6.375 58.125L14.25 58.75L26.125 59.5L34.75 60L47.5 61.375H49.5L49.75 60.5L49.125 60L48.625 59.5L36.25 51.25L23 42.5L16 37.375L12.25 34.75L10.375 32.375L9.625 27.125L13 23.375L17.625 23.75L18.75 24L23.375 27.625L33.25 35.25L46.25 44.875L48.125 46.375L49 45.875V45.5L48.125 44.125L41.125 31.375L33.625 18.375L30.25 13L29.375 9.75C29.0417 8.625 28.875 7.375 28.875 6L32.75 0.750006L34.875 0L40.125 0.750006L42.25 2.625L45.5 10L50.625 21.625L58.75 37.375L61.125 42.125L62.375 46.375L62.875 47.75H63.75V47L64.375 38L65.625 27.125L66.875 13.125L67.25 9.125L69.25 4.375L73.125 1.87501L76.125 3.25L78.625 6.875L78.25 9.125L76.875 18.75L73.875 33.875L72 44.125H73.125L74.375 42.75L79.5 36L88.125 25.25L91.875 21L96.375 16.25L99.25 14H104.625L108.5 19.875L106.75 26L101.25 33L96.625 38.875L90 47.75L86 54.875L86.375 55.375H87.25L102.125 52.125L110.25 50.75L119.75 49.125L124.125 51.125L124.625 53.125L122.875 57.375L112.625 59.875L100.625 62.25L82.75 66.5L82.5 66.625L82.75 67L90.75 67.75L94.25 68H102.75L118.5 69.125L122.625 71.875L125 75.125L124.625 77.75L118.25 80.875L109.75 78.875L89.75 74.125L83 72.5H82V73L87.75 78.625L98.125 88L111.25 100.125L111.875 103.125L110.25 105.625L108.5 105.375L97 96.625L92.5 92.75L82.5 84.375H81.875V85.25L84.125 88.625L96.375 107L97 112.625L96.125 114.375L92.875 115.5L89.5 114.875L82.25 104.875L74.875 93.5L68.875 83.375L68.25 83.875L64.625 121.625L63 123.5L59.25 125L56.125 122.625L54.375 118.75Z" fill="#D97757" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,3 @@
<svg width="41" height="41" viewBox="0 0 41 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M37.5324 16.8707C37.9808 15.5241 38.1363 14.0974 37.9886 12.6859C37.8409 11.2744 37.3934 9.91076 36.676 8.68622C35.6126 6.83404 33.9882 5.3676 32.0373 4.4985C30.0864 3.62941 27.9098 3.40259 25.8215 3.85078C24.8796 2.7893 23.7219 1.94125 22.4257 1.36341C21.1295 0.785575 19.7249 0.491269 18.3058 0.500197C16.1708 0.495044 14.0893 1.16803 12.3614 2.42214C10.6335 3.67624 9.34853 5.44666 8.6917 7.47815C7.30085 7.76286 5.98686 8.3414 4.8377 9.17505C3.68854 10.0087 2.73073 11.0782 2.02839 12.312C0.956464 14.1591 0.498905 16.2988 0.721698 18.4228C0.944492 20.5467 1.83612 22.5449 3.268 24.1293C2.81966 25.4759 2.66413 26.9026 2.81182 28.3141C2.95951 29.7256 3.40701 31.0892 4.12437 32.3138C5.18791 34.1659 6.8123 35.6322 8.76321 36.5013C10.7141 37.3704 12.8907 37.5973 14.9789 37.1492C15.9208 38.2107 17.0786 39.0587 18.3747 39.6366C19.6709 40.2144 21.0755 40.5087 22.4946 40.4998C24.6307 40.5054 26.7133 39.8321 28.4418 38.5772C30.1704 37.3223 31.4556 35.5506 32.1119 33.5179C33.5027 33.2332 34.8167 32.6547 35.9659 31.821C37.115 30.9874 38.0728 29.9178 38.7752 28.684C39.8458 26.8371 40.3023 24.6979 40.0789 22.5748C39.8556 20.4517 38.9639 18.4544 37.5324 16.8707ZM22.4978 37.8849C20.7443 37.8874 19.0459 37.2733 17.6994 36.1501C17.7601 36.117 17.8666 36.0586 17.936 36.0161L25.9004 31.4156C26.1003 31.3019 26.2663 31.137 26.3813 30.9378C26.4964 30.7386 26.5563 30.5124 26.5549 30.2825V19.0542L29.9213 20.998C29.9389 21.0068 29.9541 21.0198 29.9656 21.0359C29.977 21.052 29.9842 21.0707 29.9867 21.0902V30.3889C29.9842 32.375 29.1946 34.2791 27.7909 35.6841C26.3872 37.0892 24.4838 37.8806 22.4978 37.8849ZM6.39227 31.0064C5.51397 29.4888 5.19742 27.7107 5.49804 25.9832C5.55718 26.0187 5.66048 26.0818 5.73461 26.1244L13.699 30.7248C13.8975 30.8408 14.1233 30.902 14.3532 30.902C14.583 30.902 14.8088 30.8408 15.0073 30.7248L24.731 25.1103V28.9979C24.7321 29.0177 24.7283 29.0376 24.7199 29.0556C24.7115 29.0736 24.6988 29.0893 24.6829 29.1012L16.6317 33.7497C14.9096 34.7416 12.8643 35.0097 10.9447 34.4954C9.02506 33.9811 7.38785 32.7263 6.39227 31.0064ZM4.29707 13.6194C5.17156 12.0998 6.55279 10.9364 8.19885 10.3327C8.19885 10.4013 8.19491 10.5228 8.19491 10.6071V19.808C8.19351 20.0378 8.25334 20.2638 8.36823 20.4629C8.48312 20.6619 8.64893 20.8267 8.84863 20.9404L18.5723 26.5542L15.206 28.4979C15.1894 28.5089 15.1703 28.5155 15.1505 28.5173C15.1307 28.5191 15.1107 28.516 15.0924 28.5082L7.04046 23.8557C5.32135 22.8601 4.06716 21.2235 3.55289 19.3046C3.03862 17.3858 3.30624 15.3413 4.29707 13.6194ZM31.955 20.0556L22.2312 14.4411L25.5976 12.4981C25.6142 12.4872 25.6333 12.4805 25.6531 12.4787C25.6729 12.4769 25.6928 12.4801 25.7111 12.4879L33.7631 17.1364C34.9967 17.849 36.0017 18.8982 36.6606 20.1613C37.3194 21.4244 37.6047 22.849 37.4832 24.2684C37.3617 25.6878 36.8382 27.0432 35.9743 28.1759C35.1103 29.3086 33.9415 30.1717 32.6047 30.6641C32.6047 30.5947 32.6047 30.4733 32.6047 30.3889V21.188C32.6066 20.9586 32.5474 20.7328 32.4332 20.5338C32.319 20.3348 32.154 20.1698 31.955 20.0556ZM35.3055 15.0128C35.2464 14.9765 35.1431 14.9142 35.069 14.8717L27.1045 10.2712C26.906 10.1554 26.6803 10.0943 26.4504 10.0943C26.2206 10.0943 25.9948 10.1554 25.7963 10.2712L16.0726 15.8858V11.9982C16.0715 11.9783 16.0753 11.9585 16.0837 11.9405C16.0921 11.9225 16.1048 11.9068 16.1207 11.8949L24.1719 7.25025C25.4053 6.53903 26.8158 6.19376 28.2383 6.25482C29.6608 6.31589 31.0364 6.78077 32.2044 7.59508C33.3723 8.40939 34.2842 9.53945 34.8334 10.8531C35.3826 12.1667 35.5464 13.6095 35.3055 15.0128ZM14.2424 21.9419L10.8752 19.9981C10.8576 19.9893 10.8423 19.9763 10.8309 19.9602C10.8195 19.9441 10.8122 19.9254 10.8098 19.9058V10.6071C10.8107 9.18295 11.2173 7.78848 11.9819 6.58696C12.7466 5.38544 13.8377 4.42659 15.1275 3.82264C16.4173 3.21869 17.8524 2.99464 19.2649 3.1767C20.6775 3.35876 22.0089 3.93941 23.1034 4.85067C23.0427 4.88379 22.937 4.94215 22.8668 4.98473L14.9024 9.58517C14.7025 9.69878 14.5366 9.86356 14.4215 10.0626C14.3065 10.2616 14.2466 10.4877 14.2479 10.7175L14.2424 21.9419ZM16.071 17.9991L20.4018 15.4978L24.7325 17.9975V22.9985L20.4018 25.4983L16.071 22.9985V17.9991Z" fill="#111111" />
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,10 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 28C14 26.0633 13.6267 24.2433 12.88 22.54C12.1567 20.8367 11.165 19.355 9.905 18.095C8.645 16.835 7.16333 15.8433 5.46 15.12C3.75667 14.3733 1.93667 14 0 14C1.93667 14 3.75667 13.6383 5.46 12.915C7.16333 12.1683 8.645 11.165 9.905 9.905C11.165 8.645 12.1567 7.16333 12.88 5.46C13.6267 3.75667 14 1.93667 14 0C14 1.93667 14.3617 3.75667 15.085 5.46C15.8317 7.16333 16.835 8.645 18.095 9.905C19.355 11.165 20.8367 12.1683 22.54 12.915C24.2433 13.6383 26.0633 14 28 14C26.0633 14 24.2433 14.3733 22.54 15.12C20.8367 15.8433 19.355 16.835 18.095 18.095C16.835 19.355 15.8317 20.8367 15.085 22.54C14.3617 24.2433 14 26.0633 14 28Z" fill="url(#paint0_radial_16771_53212)" />
<defs>
<radialGradient id="paint0_radial_16771_53212" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(2.77876 11.3795) rotate(18.6832) scale(29.8025 238.737)">
<stop offset="0.0671246" stop-color="#9168C0" />
<stop offset="0.342551" stop-color="#5684D1" />
<stop offset="0.672076" stop-color="#1BA1E3" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,11 @@
<svg width="1200" height="1200" viewBox="0 0 1200 1200" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1200" height="1200" rx="260" fill="#9046FF" />
<mask id="mask0_1106_4856" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="272" y="202" width="655" height="796">
<path d="M926.578 202.793H272.637V997.857H926.578V202.793Z" fill="white" />
</mask>
<g mask="url(#mask0_1106_4856)">
<path d="M398.554 818.914C316.315 1001.03 491.477 1046.74 620.672 940.156C658.687 1059.66 801.052 970.473 852.234 877.795C964.787 673.567 919.318 465.357 907.64 422.374C827.637 129.443 427.623 128.946 358.8 423.865C342.651 475.544 342.402 534.18 333.458 595.051C328.986 625.86 325.507 645.488 313.83 677.785C306.873 696.424 297.68 712.819 282.773 740.645C259.915 783.881 269.604 867.113 387.87 823.883L399.051 818.914H398.554Z" fill="white" />
<path d="M636.123 549.353C603.328 549.353 598.359 510.097 598.359 486.742C598.359 465.623 602.086 448.977 609.293 438.293C615.504 428.852 624.697 424.131 636.123 424.131C647.555 424.131 657.492 428.852 664.447 438.541C672.398 449.474 676.623 466.12 676.623 486.742C676.623 525.998 661.471 549.353 636.375 549.353H636.123Z" fill="black" />
<path d="M771.24 549.353C738.445 549.353 733.477 510.097 733.477 486.742C733.477 465.623 737.203 448.977 744.41 438.293C750.621 428.852 759.814 424.131 771.24 424.131C782.672 424.131 792.609 428.852 799.564 438.541C807.516 449.474 811.74 466.12 811.74 486.742C811.74 525.998 796.588 549.353 771.492 549.353H771.24Z" fill="black" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,18 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(30, 0)">
<g clip-path="url(#clip0_1401_86274)">
<mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
<path d="M240 0H0V300H240V0Z" fill="white" />
</mask>
<g mask="url(#mask0_1401_86274)">
<path d="M180 240H60V120H180V240Z" fill="#CFCECD" />
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E" />
</g>
</g>
</g>
<defs>
<clipPath id="clip0_1401_86274">
<rect width="240" height="300" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 713 B

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" fill="none">
<g clip-path="url(#a)">
<path fill="#FAF3EA" d="M-14-14h208v208H-14z" />
<path fill="#038247" d="M121.084 57.922c0 7.408 6.061 13.469 13.469 13.469 7.408 0 13.469-6.061 13.469-13.47 0-7.407-6.061-13.468-13.469-13.468-7.408 0-13.469 6.06-13.469 13.469Zm-5.657 81.622v-.135c0-2.828 1.617-4.579 5.388-5.792 3.771-1.347 4.714-2.289 4.714-7.273v-24.648c0-4.984-1.347-5.657-5.118-6.87-3.098-.942-4.984-2.424-4.984-4.713 0-1.886 1.213-3.368 2.963-4.041l19.531-7.004c4.31-1.616 7.677.539 7.677 4.445v42.831c0 4.984.943 5.926 4.714 7.273 3.771 1.213 5.388 2.964 5.388 5.792v.135c0 2.694-2.155 4.579-5.388 4.579h-29.497c-3.233 0-5.388-1.885-5.388-4.579ZM65.053 68.427c0-17.24 2.425-22.897 9.832-22.897 9.025 0 14.278 8.755 14.278 23.706 0 14.95-5.119 23.974-13.47 23.974-8.35 0-10.64-6.6-10.64-24.783ZM27.205 139.41v.135c0 2.694 2.155 4.579 5.388 4.579H76.77c3.233 0 5.388-1.885 5.388-4.579v-.135c0-2.559-1.482-4.445-5.253-5.253-9.428-2.155-10.64-3.098-10.64-8.351v-19.26c0-2.425 1.077-4.041 3.367-4.041s4.175 1.078 8.35 1.078c21.82 0 37.31-14.547 37.31-35.02 0-20.473-14.547-33.672-35.29-33.672-11.313 0-19.664 4.175-30.574 4.175H32.593c-3.233 0-5.388 1.886-5.388 4.58v.134c0 2.829 1.616 4.58 5.388 5.522 6.734 1.617 7.677 2.56 7.677 7.543v69.5c0 4.984-.943 5.926-7.677 7.543-3.772.943-5.388 2.694-5.388 5.522Z" />
</g>
<defs>
<clipPath id="a">
<rect width="180" height="180" fill="#fff" rx="45" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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,19 @@
## New Features
- 🔎 **Create Notes from Quick Open** — Start a new note directly from Quick Open when your search does not already match an existing note.
- 🌓 **Theme-Matched App Icons** — Tolaria can now switch its application icon to match the current light or dark appearance.
- 🧭 **Multi-Vault Type Visibility** — Sidebar type controls now distinguish vault and workspace context more clearly when several vaults are open.
## Improvements
- 🔄 **Smarter Multi-Vault Git Sync** — Sync actions now target the active Git-backed vaults more consistently, including mounted workspaces.
- 🪟 **Better Desktop Window Chrome** — Custom window chrome now exposes the desktop menu more reliably and behaves better on Windows.
- 🤖 **More Reliable Local Agent Detection** — Codex, Claude, and OpenCode launch detection handles Windows command shims and Homebrew-style runtime paths more consistently.
- ✂️ **Safer Code Block Copying** — Large fenced-code copies are bounded more carefully so editor clipboard actions stay responsive.
## Stability and Fixes
- Editor recovery was hardened around stale BlockNote references, invalid table transforms, insertion-depth errors, side-menu actions, emoji suggestions, and selection toolbar placement.
- Vault and workspace behavior is steadier around empty startup vault reloads, missing frontmatter write paths, active-note refresh focus, and sidebar type visibility across vaults.
- Git and AI workflows are more predictable around selected-repository sync, multi-vault autosync, retained AI panel sessions, and agent target selection.
- Status-bar layout, external URL cancellation, light icon assets, release CodeScene thresholds, native QA guidance, and new smoke coverage were addressed before promotion.

View File

@@ -0,0 +1,21 @@
## New Features
- 🤖 **Side AI Workspace** — Work with AI in a dedicated side workspace that keeps conversations and context closer to the note you are editing.
- ↩️ **Action History Undo and Redo** — Note actions now participate in a clearer undo/redo history, making structural edits easier to reverse.
- 📁 **Create Notes Inside Folders** — New notes can be created directly inside the selected folder instead of always starting at the vault root.
- 🌏 **Indonesian Language Support** — Tolaria now includes an Indonesian translation alongside the existing supported languages.
## Improvements
- 🔎 **Search Note Body Content** — Note-list search can now find matches inside note bodies, not only titles and visible metadata.
- 🧭 **Vault Item Deep Links** — Links to specific vault items route more reliably across notes, folders, and workspace surfaces.
-**Faster AI Agent Startup** — Local AI agent discovery is deferred and parallelized so opening Tolaria does less blocking work up front.
- 🪟 **Refined AI Workspace Layout** — The AI workspace side panel, floating button, conversation titles, and shared sessions feel more stable and focused.
- 🔄 **More Reliable Git Remote Handling** — Git remote detection now treats local-only and name-only configurations more consistently.
## Stability and Fixes
- Editor saves, malformed nested blocks, table rendering, math source editing, KaTeX radicals, and whiteboard permission errors were hardened before promotion.
- Search keyboard navigation, quick-open note creation, favorite hover states, sort dropdown positioning, and full app note windows were stabilized.
- Release automation now reuses artifact build workflows more cleanly and handles Windows signing/authenticode paths more defensively.
- Packaged macOS MCP server paths, pending-change filenames with spaces, vault guidance edit status, and multi-vault autosync behavior were repaired.

View File

@@ -0,0 +1,17 @@
## New Features
- 📄 **Export Notes to PDF** — Export the active note directly to a PDF from Tolaria, with the release build carrying the native desktop export path.
- 🧩 **More Code Block Languages** — Rich-editor code blocks now recognize more common grammars, including shell, config, infrastructure, and scripting formats.
## Improvements
- 🧮 **Editable Math Source Panel** — Double-click rendered display formulas to edit the underlying math source without turning the formula into normal Markdown text.
- ✍️ **Cleaner Math Editing UX** — Selected math source text stays readable in the editor panel, and the focused panel now avoids the doubled active-border treatment.
- 🤖 **More Focused AI Workspace Internals** — The AI workspace keeps shared sessions and orchestration logic cleaner while preserving the existing side-panel behavior.
## Stability and Fixes
- Active notes refresh more reliably after external edits without disturbing clean in-editor state.
- Windows command-shim handling is steadier for npm-launched agent commands.
- PDF export, display-math editing, KaTeX source selection, AI workspace title persistence, and Tauri listener cleanup were hardened before promotion.
- Stable release signing and artifact workflows were tightened so the stable channel can publish the expected macOS, Windows, and Linux bundles.

View File

@@ -0,0 +1,18 @@
## New Features
- 📝 **AI Agents Can Create Notes Directly** — OpenAI-backed note creation now works inside Tolaria, making agent-driven capture and drafting feel more complete.
- 🧠 **Built-in MCP Note Creation** — Tolaria now exposes a native MCP tool for creating notes, improving automation workflows that operate on the vault.
- 🗺️ **Fullscreen Whiteboards** — Whiteboards can now open in a dedicated fullscreen workspace for more focused visual work.
## Improvements
-**Markdown Highlights Render Properly** — Highlight syntax now displays correctly in the editor, with matching toolbar copy and better visual consistency.
- 🌍 **Better Localization Coverage** — More menus, feedback text, and Chinese file-manager labels are now translated and aligned with the rest of the app.
- 🔧 **Shared MCP Orchestration Internals** — MCP tool execution now runs through a more unified internal path, reducing inconsistency across tools.
## Stability and Fixes
- Copying from the rich editor is more reliable, inline math sits correctly in text, Mermaid raw-edit access is restored, and table-row parsing is more resilient.
- Focus and refresh behavior were hardened across note editing, active-note refreshes, title focus, and view filters across multiple workspaces.
- Git identity handling, symlinked gitignored vault paths, sync history surfaces, drag-and-drop guards, Pi agent config recovery, and shell AI-provider inheritance all received targeted fixes.
- MCP and editor error recovery were tightened, including null editor transform handling, stale config tolerance, and lockfile synchronization.

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

@@ -23,11 +23,15 @@ brew install --cask tolaria
| Platform | Status | Notes |
| --- | --- | --- |
| 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. |
| Windows | Supported, early | NSIS installers are Authenticode-signed and updater bundles are Tauri-signed. Company-managed SmartScreen, Defender, or WDAC policies can still require IT approval of the Tolaria publisher before first install. |
| 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.
## Managed Windows Devices
Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, validate that the downloaded installer has a valid Tolaria Authenticode signature, then install it through the normal Windows prompt. If company policy still blocks the first run because reputation is not yet established, ask IT to approve the Tolaria publisher or deploy the same signed installer through the approved software portal.
## After Installing
1. Open Tolaria.

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.

189
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"
@@ -628,6 +634,26 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -717,6 +743,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -892,7 +924,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -947,6 +979,15 @@ dependencies = [
"syn 2.0.115",
]
[[package]]
name = "dlv-list"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
dependencies = [
"const-random",
]
[[package]]
name = "dpi"
version = "0.1.2"
@@ -1079,7 +1120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1934,7 +1975,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
dependencies = [
"byteorder",
"png",
"png 0.17.16",
]
[[package]]
@@ -2051,6 +2092,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 +2523,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 +2548,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-foundation",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
@@ -2955,6 +3019,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
dependencies = [
"dlv-list",
"hashbrown 0.14.5",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -3250,6 +3324,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 +3479,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"
@@ -3453,7 +3546,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -3840,6 +3933,16 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
dependencies = [
"cfg-if",
"ordered-multimap",
]
[[package]]
name = "rust_decimal"
version = "1.40.0"
@@ -3887,7 +3990,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3944,7 +4047,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4742,6 +4845,7 @@ dependencies = [
"heck 0.5.0",
"http",
"http-range",
"image",
"jni",
"libc",
"log",
@@ -4809,7 +4913,7 @@ dependencies = [
"ico",
"json-patch",
"plist",
"png",
"png 0.17.16",
"proc-macro2",
"quote",
"semver",
@@ -4856,6 +4960,27 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
dependencies = [
"dunce",
"plist",
"rust-ini",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"tracing",
"url",
"windows-registry",
"windows-result 0.3.4",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
@@ -4964,6 +5089,22 @@ dependencies = [
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin-deep-link",
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.0"
@@ -5108,7 +5249,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5195,6 +5336,15 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tinystr"
version = "0.8.2"
@@ -5290,6 +5440,10 @@ dependencies = [
"gray_matter",
"log",
"notify",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"objc2-web-kit",
"regex",
"reqwest 0.12.28",
"sentry",
@@ -5298,11 +5452,13 @@ dependencies = [
"serde_yaml",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-prevent-default",
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tempfile",
"tokio",
@@ -5517,7 +5673,7 @@ dependencies = [
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
@@ -6045,7 +6201,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6172,6 +6328,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-result"
version = "0.3.4"

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 = ["macos-private-api", "protocol-asset", "devtools", "image-png"] }
tauri-plugin-log = "2"
gray_matter = "0.2"
walkdir = "2"
@@ -40,5 +40,13 @@ sentry = "0.37"
uuid = { version = "1", features = ["v4"] }
tempfile = "3"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
tauri-plugin-deep-link = "2.4.9"
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6.3"
objc2-app-kit = "0.3.2"
objc2-foundation = "0.3.2"
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "objc2-app-kit"] }
[dev-dependencies]

View File

@@ -4,6 +4,7 @@
"description": "enables the default permissions",
"windows": [
"main",
"ai-workspace",
"note-*"
],
"platforms": ["linux", "macOS", "windows"],
@@ -17,7 +18,9 @@
"core:window:allow-close",
"core:window:allow-set-title",
"core:webview:allow-create-webview-window",
"core:event:default",
"dialog:default",
"deep-link:default",
"updater:default",
"process:default",
"opener:default"

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 113 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

@@ -120,11 +120,15 @@ brew install --cask tolaria
| Platform | Status | Notes |
| --- | --- | --- |
| 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. |
| Windows | Supported, early | NSIS installers are Authenticode-signed and updater bundles are Tauri-signed. Company-managed SmartScreen, Defender, or WDAC policies can still require IT approval of the Tolaria publisher before first install. |
| 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.
## Managed Windows Devices
Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, validate that the downloaded installer has a valid Tolaria Authenticode signature, then install it through the normal Windows prompt. If company policy still blocks the first run because reputation is not yet established, ask IT to approve the Tolaria publisher or deploy the same signed installer through the approved software portal.
## After Installing
1. Open Tolaria.
@@ -1023,6 +1027,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 +1356,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

@@ -28,11 +28,15 @@ brew install --cask tolaria
| Platform | Status | Notes |
| --- | --- | --- |
| 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. |
| Windows | Supported, early | NSIS installers are Authenticode-signed and updater bundles are Tauri-signed. Company-managed SmartScreen, Defender, or WDAC policies can still require IT approval of the Tolaria publisher before first install. |
| 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.
## Managed Windows Devices
Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, validate that the downloaded installer has a valid Tolaria Authenticode signature, then install it through the normal Windows prompt. If company policy still blocks the first run because reputation is not yet established, ask IT to approve the Tolaria publisher or deploy the same signed installer through the approved software portal.
## After Installing
1. Open Tolaria.

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

@@ -39,6 +39,7 @@
"Download",
"Homebrew",
"Platform Status",
"Managed Windows Devices",
"After Installing"
]
},
@@ -323,6 +324,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

@@ -111,11 +111,15 @@ brew install --cask tolaria
| Platform | Status | Notes |
| --- | --- | --- |
| 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. |
| Windows | Supported, early | NSIS installers are Authenticode-signed and updater bundles are Tauri-signed. Company-managed SmartScreen, Defender, or WDAC policies can still require IT approval of the Tolaria publisher before first install. |
| 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.
## Managed Windows Devices
Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, validate that the downloaded installer has a valid Tolaria Authenticode signature, then install it through the normal Windows prompt. If company policy still blocks the first run because reputation is not yet established, ask IT to approve the Tolaria publisher or deploy the same signed installer through the approved software portal.
## After Installing
1. Open Tolaria.

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

@@ -1,4 +1,8 @@
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::task::JoinHandle;
const AI_AGENT_STATUS_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
@@ -8,6 +12,7 @@ pub enum AiAgentId {
Opencode,
Pi,
Gemini,
Kiro,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
@@ -31,6 +36,7 @@ pub struct AiAgentsStatus {
pub opencode: AiAgentAvailability,
pub pi: AiAgentAvailability,
pub gemini: AiAgentAvailability,
pub kiro: AiAgentAvailability,
}
#[derive(Debug, Clone, Serialize)]
@@ -71,6 +77,8 @@ pub struct AiAgentStreamRequest {
#[serde(default)]
pub vault_paths: Vec<String>,
pub permission_mode: Option<AiAgentPermissionMode>,
#[serde(default)]
pub event_name: Option<String>,
}
impl AiAgentStreamRequest {
@@ -79,13 +87,60 @@ impl AiAgentStreamRequest {
}
}
pub fn get_ai_agents_status() -> AiAgentsStatus {
/// Probe every supported AI-agent CLI in parallel.
///
/// Each per-agent `check_cli()` is synchronous and can block for up to ~1 s
/// when the binary is missing and we fall through to the login-shell
/// fallback (`/bin/zsh -lc 'command -v <agent>'` etc., evaluating the full
/// shell startup). Running them sequentially used to add ~5 s to cold start
/// when no agents are installed. Fan them out across Tokio's blocking pool
/// so the user-perceived wall time is the slowest single probe rather than
/// the sum of all six.
///
/// A panicking probe is mapped to `installed: false` so the IPC handler
/// always returns a fully populated `AiAgentsStatus` and the frontend can
/// keep rendering.
pub async fn get_ai_agents_status() -> AiAgentsStatus {
let claude = tokio::task::spawn_blocking(availability_from_claude);
let codex = tokio::task::spawn_blocking(crate::codex_cli::check_cli);
let opencode = tokio::task::spawn_blocking(crate::opencode_cli::check_cli);
let pi = tokio::task::spawn_blocking(crate::pi_cli::check_cli);
let gemini = tokio::task::spawn_blocking(crate::gemini_cli::check_cli);
let kiro = tokio::task::spawn_blocking(crate::kiro_cli::check_cli);
let (claude, codex, opencode, pi, gemini, kiro) = tokio::join!(
availability_or_missing(claude, AI_AGENT_STATUS_PROBE_TIMEOUT),
availability_or_missing(codex, AI_AGENT_STATUS_PROBE_TIMEOUT),
availability_or_missing(opencode, AI_AGENT_STATUS_PROBE_TIMEOUT),
availability_or_missing(pi, AI_AGENT_STATUS_PROBE_TIMEOUT),
availability_or_missing(gemini, AI_AGENT_STATUS_PROBE_TIMEOUT),
availability_or_missing(kiro, AI_AGENT_STATUS_PROBE_TIMEOUT)
);
AiAgentsStatus {
claude_code: availability_from_claude(),
codex: crate::codex_cli::check_cli(),
opencode: crate::opencode_cli::check_cli(),
pi: crate::pi_cli::check_cli(),
gemini: crate::gemini_cli::check_cli(),
claude_code: claude,
codex,
opencode,
pi,
gemini,
kiro,
}
}
async fn availability_or_missing(
probe: JoinHandle<AiAgentAvailability>,
timeout: Duration,
) -> AiAgentAvailability {
match tokio::time::timeout(timeout, probe).await {
Ok(Ok(availability)) => availability,
Ok(Err(_)) | Err(_) => missing_availability(),
}
}
fn missing_availability() -> AiAgentAvailability {
AiAgentAvailability {
installed: false,
version: None,
}
}
@@ -149,9 +204,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 {
@@ -208,6 +282,7 @@ mod tests {
vault_path: "/tmp/vault".into(),
vault_paths: Vec::new(),
permission_mode,
event_name: None,
}
}
@@ -223,15 +298,16 @@ mod tests {
);
}
#[test]
fn normalize_status_contains_all_agents() {
let status = get_ai_agents_status();
#[tokio::test]
async fn normalize_status_contains_all_agents() {
let status = get_ai_agents_status().await;
let install_flags = [
status.claude_code.installed,
status.codex.installed,
status.opencode.installed,
status.pi.installed,
status.gemini.installed,
status.kiro.installed,
];
assert!(install_flags
@@ -239,6 +315,22 @@ mod tests {
.all(|installed| matches!(installed, true | false)));
}
#[tokio::test]
async fn availability_probe_timeout_returns_missing_status() {
let handle = tokio::task::spawn_blocking(|| {
std::thread::sleep(std::time::Duration::from_millis(50));
AiAgentAvailability {
installed: true,
version: Some("late".into()),
}
});
let status = availability_or_missing(handle, std::time::Duration::from_millis(1)).await;
assert!(!status.installed);
assert_eq!(status.version, None);
}
#[test]
fn map_claude_done_event_preserves_completion_signal() {
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Done);

View File

@@ -0,0 +1,623 @@
use crate::ai_agents::AiAgentStreamEvent;
use crate::ai_models::{AiModelProviderKind, AiModelStreamRequest};
use std::path::{Path, PathBuf};
const CREATE_NOTE_TOOL_NAME: &str = "create_note";
const CREATE_NOTE_TOOL_JSON: &str = r#"{
"type": "function",
"function": {
"name": "create_note",
"description": "Create a new markdown note inside the active Tolaria vault without overwriting existing files.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path inside the vault, or an absolute path inside the active vault. Must end in .md."
},
"content": {
"type": "string",
"description": "Full markdown note content, including YAML frontmatter and H1 when needed."
},
"title": {
"type": "string",
"description": "Optional title used only when content is omitted."
},
"type": {
"type": "string",
"description": "Optional note type used only when content is omitted."
},
"is_a": {
"type": "string",
"description": "Legacy alias for type, used only when content is omitted."
},
"vaultPath": {
"type": "string",
"description": "Optional target vault root when multiple vaults are active."
}
},
"required": ["path"],
"additionalProperties": false
}
}
}"#;
struct OpenAiToolCall {
id: String,
name: String,
arguments: serde_json::Value,
raw_arguments: String,
}
struct CreatedNoteToolResult {
summary: String,
output: String,
}
pub(crate) fn openai_chat_payload(request: &AiModelStreamRequest) -> serde_json::Value {
let mut payload = serde_json::json!({
"model": request.model_id,
"messages": openai_chat_messages(request),
"stream": false
});
if should_offer_openai_tools(request) {
payload["tools"] = serde_json::Value::Array(vec![openai_create_note_tool()]);
payload["tool_choice"] = serde_json::Value::String("auto".into());
}
payload
}
pub(crate) fn execute_openai_tool_calls<F>(
request: &AiModelStreamRequest,
json: &serde_json::Value,
emit: F,
) -> Result<Option<String>, String>
where
F: FnMut(AiAgentStreamEvent),
{
let tool_calls = openai_tool_calls(json)?;
if tool_calls.is_empty() {
return Ok(None);
}
run_openai_tool_calls(request, &tool_calls, emit).map(Some)
}
fn openai_chat_messages(request: &AiModelStreamRequest) -> Vec<serde_json::Value> {
let mut messages = Vec::new();
if let Some(system_prompt) = non_empty_option(request.system_prompt.as_deref()) {
messages.push(serde_json::json!({ "role": "system", "content": system_prompt }));
}
messages.push(serde_json::json!({ "role": "user", "content": request.message }));
messages
}
fn should_offer_openai_tools(request: &AiModelStreamRequest) -> bool {
let has_active_vault = non_empty_option(request.vault_path.as_deref()).is_some();
has_active_vault
&& (request.provider.kind == AiModelProviderKind::OpenAi
|| selected_model_supports_tools(request))
}
fn selected_model_supports_tools(request: &AiModelStreamRequest) -> bool {
request
.provider
.models
.iter()
.find(|model| model.id == request.model_id)
.is_some_and(|model| model.capabilities.tools)
}
fn openai_create_note_tool() -> serde_json::Value {
serde_json::from_str(CREATE_NOTE_TOOL_JSON).expect("create_note tool schema must be valid JSON")
}
fn run_openai_tool_calls<F>(
request: &AiModelStreamRequest,
tool_calls: &[OpenAiToolCall],
mut emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let mut summaries = Vec::new();
for tool_call in tool_calls {
summaries.push(execute_openai_tool_call(request, tool_call, &mut emit)?);
}
Ok(summaries.join("\n"))
}
fn execute_openai_tool_call<F>(
request: &AiModelStreamRequest,
tool_call: &OpenAiToolCall,
emit: &mut F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
if tool_call.name != CREATE_NOTE_TOOL_NAME {
return Err(format!(
"AI provider requested unsupported tool: {}",
tool_call.name
));
}
emit(AiAgentStreamEvent::ToolStart {
tool_name: CREATE_NOTE_TOOL_NAME.into(),
tool_id: tool_call.id.clone(),
input: Some(tool_call.raw_arguments.clone()),
});
match create_note_from_tool_args(request, &tool_call.arguments) {
Ok(result) => {
emit(AiAgentStreamEvent::ToolDone {
tool_id: tool_call.id.clone(),
output: Some(result.output),
});
Ok(result.summary)
}
Err(error) => {
emit(AiAgentStreamEvent::ToolDone {
tool_id: tool_call.id.clone(),
output: Some(format!("Error: {error}")),
});
Err(error)
}
}
}
fn openai_tool_calls(json: &serde_json::Value) -> Result<Vec<OpenAiToolCall>, String> {
let Some(calls) = json["choices"][0]["message"]["tool_calls"].as_array() else {
return Ok(Vec::new());
};
calls
.iter()
.enumerate()
.map(|(index, call)| openai_tool_call(index, call))
.collect()
}
fn openai_tool_call(index: usize, call: &serde_json::Value) -> Result<OpenAiToolCall, String> {
let function = &call["function"];
let name = function["name"]
.as_str()
.ok_or_else(|| "AI provider tool call did not include a function name.".to_string())?
.to_string();
let (arguments, raw_arguments) = parse_tool_arguments(&function["arguments"])?;
let id = call["id"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| format!("tool_call_{index}"));
Ok(OpenAiToolCall {
id,
name,
arguments,
raw_arguments,
})
}
fn parse_tool_arguments(value: &serde_json::Value) -> Result<(serde_json::Value, String), String> {
if let Some(raw) = value.as_str() {
let parsed = serde_json::from_str(raw)
.map_err(|error| format!("Failed to parse AI tool arguments: {error}"))?;
return Ok((parsed, raw.to_string()));
}
if value.is_object() {
let raw = serde_json::to_string(value)
.map_err(|error| format!("Failed to serialize AI tool arguments: {error}"))?;
return Ok((value.clone(), raw));
}
Ok((serde_json::json!({}), "{}".into()))
}
fn create_note_from_tool_args(
request: &AiModelStreamRequest,
args: &serde_json::Value,
) -> Result<CreatedNoteToolResult, String> {
let note_path = required_tool_string(args, "path")?;
let content = create_note_tool_content(args, note_path);
let vault_path = tool_vault_path(request, args)?;
crate::commands::create_note_content(
PathBuf::from(note_path),
content,
Some(PathBuf::from(vault_path)),
)?;
let output = serde_json::json!({
"path": note_path,
"vaultPath": vault_path,
})
.to_string();
Ok(CreatedNoteToolResult {
summary: format!("Created note: {note_path}"),
output,
})
}
fn tool_vault_path<'a>(
request: &'a AiModelStreamRequest,
args: &'a serde_json::Value,
) -> Result<&'a str, String> {
if let Some(vault_path) = string_arg(args, "vaultPath") {
return active_tool_vault_path(request, vault_path);
}
request
.vault_path
.as_deref()
.and_then(non_empty_str)
.ok_or_else(|| "No active vault is available for create_note.".to_string())
}
fn active_tool_vault_path<'a>(
request: &'a AiModelStreamRequest,
vault_path: &'a str,
) -> Result<&'a str, String> {
if active_vault_paths(request).any(|active| active == vault_path) {
Ok(vault_path)
} else {
Err(format!("Vault is not active in Tolaria: {vault_path}"))
}
}
fn active_vault_paths(request: &AiModelStreamRequest) -> impl Iterator<Item = &str> {
request
.vault_path
.as_deref()
.into_iter()
.chain(request.vault_paths.iter().map(String::as_str))
.filter_map(non_empty_str)
}
fn create_note_tool_content(args: &serde_json::Value, note_path: &str) -> String {
if let Some(content) = content_arg(args, "content") {
return content.to_string();
}
let title = string_arg(args, "title")
.map(str::to_string)
.unwrap_or_else(|| fallback_note_title(note_path));
let note_type = string_arg(args, "type")
.or_else(|| string_arg(args, "is_a"))
.unwrap_or("Note");
let note_type_yaml = serde_json::to_string(note_type).unwrap_or_else(|_| "\"Note\"".into());
format!("---\ntype: {note_type_yaml}\n---\n\n# {title}\n")
}
fn fallback_note_title(note_path: &str) -> String {
let normalized = note_path.replace('\\', "/");
Path::new(&normalized)
.file_stem()
.and_then(|stem| stem.to_str())
.filter(|title| !title.trim().is_empty())
.unwrap_or("Untitled")
.to_string()
}
fn required_tool_string<'a>(args: &'a serde_json::Value, key: &str) -> Result<&'a str, String> {
string_arg(args, key).ok_or_else(|| format!("create_note requires {key}."))
}
fn string_arg<'a>(args: &'a serde_json::Value, key: &str) -> Option<&'a str> {
args[key].as_str().and_then(non_empty_str)
}
fn content_arg<'a>(args: &'a serde_json::Value, key: &str) -> Option<&'a str> {
args[key]
.as_str()
.filter(|content| !content.trim().is_empty())
}
fn non_empty_option(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn non_empty_str(value: &str) -> Option<&str> {
non_empty_option(Some(value))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai_models::{
AiModelApiKeyStorage, AiModelCapabilities, AiModelDefinition, AiModelProvider,
};
use serde_json::json;
use std::fs;
const CREATED_NOTE_PATH: &str = "nota-longa-teste-gerada-2.md";
const CREATED_NOTE_CONTENT: &str = "---\ntype: Note\n---\n\n# Nota longa de teste - gerada 2\n";
fn request(vault_path: String) -> AiModelStreamRequest {
request_with_provider(provider(), Some(vault_path), Vec::new())
}
fn request_with_provider(
provider: AiModelProvider,
vault_path: Option<String>,
vault_paths: Vec<String>,
) -> AiModelStreamRequest {
AiModelStreamRequest {
provider,
model_id: "gpt-5-nano".into(),
message: "Create the note".into(),
system_prompt: Some("Use create_note for new notes.".into()),
vault_path,
vault_paths,
api_key_override: None,
event_name: None,
}
}
fn provider() -> AiModelProvider {
AiModelProvider {
id: "openai".into(),
name: "OpenAI".into(),
kind: AiModelProviderKind::OpenAi,
base_url: Some("https://api.openai.com/v1".into()),
api_key_storage: Some(AiModelApiKeyStorage::LocalFile),
api_key_env_var: None,
headers: None,
models: vec![AiModelDefinition {
id: "gpt-5-nano".into(),
display_name: None,
context_window: None,
max_output_tokens: None,
capabilities: AiModelCapabilities {
streaming: false,
tools: false,
vision: false,
json_mode: false,
reasoning: false,
},
}],
}
}
fn create_note_response() -> serde_json::Value {
tool_call_response(json!({
"id": "call_create",
"function": {
"name": CREATE_NOTE_TOOL_NAME,
"arguments": serde_json::to_string(&json!({
"path": CREATED_NOTE_PATH,
"content": CREATED_NOTE_CONTENT
})).unwrap()
}
}))
}
fn tool_call_response(tool_call: serde_json::Value) -> serde_json::Value {
json!({
"choices": [{
"message": {
"tool_calls": [tool_call]
}
}]
})
}
fn create_note_response_with_args(arguments: serde_json::Value) -> serde_json::Value {
tool_call_response(json!({
"function": {
"name": CREATE_NOTE_TOOL_NAME,
"arguments": arguments,
}
}))
}
fn create_note_error(arguments: serde_json::Value) -> String {
let dir = tempfile::tempdir().unwrap();
let request = request(dir.path().to_string_lossy().into_owned());
execute_openai_tool_calls(&request, &create_note_response_with_args(arguments), |_| {})
.unwrap_err()
}
fn assert_note_created(vault_path: &Path) {
let actual = fs::read_to_string(vault_path.join(CREATED_NOTE_PATH)).unwrap();
assert_eq!(actual, CREATED_NOTE_CONTENT);
}
fn assert_summary(summary: Option<String>) {
assert_eq!(
summary.as_deref(),
Some("Created note: nota-longa-teste-gerada-2.md"),
);
}
fn assert_tool_events(events: &[AiAgentStreamEvent]) {
assert!(matches!(
&events[0],
AiAgentStreamEvent::ToolStart { tool_name, tool_id, input: Some(input) }
if tool_name == CREATE_NOTE_TOOL_NAME && tool_id == "call_create" && input.contains(CREATED_NOTE_PATH)
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::ToolDone { tool_id, output: Some(output) }
if tool_id == "call_create" && output.contains(CREATED_NOTE_PATH)
));
}
#[test]
fn openai_payload_offers_create_note_when_active_vault_is_loaded() {
let dir = tempfile::tempdir().unwrap();
let payload = openai_chat_payload(&request(dir.path().to_string_lossy().into_owned()));
assert!(payload["tools"][0]["function"]["name"] == CREATE_NOTE_TOOL_NAME);
}
#[test]
fn openai_payload_offers_create_note_for_tool_capable_custom_models() {
let dir = tempfile::tempdir().unwrap();
let mut provider = provider();
provider.kind = AiModelProviderKind::OpenAiCompatible;
provider.models[0].capabilities.tools = true;
let request = request_with_provider(
provider,
Some(dir.path().to_string_lossy().into_owned()),
vec![],
);
let payload = openai_chat_payload(&request);
assert!(payload["tools"][0]["function"]["name"] == CREATE_NOTE_TOOL_NAME);
}
#[test]
fn openai_payload_skips_create_note_when_no_active_vault_is_loaded() {
let payload = openai_chat_payload(&request_with_provider(provider(), None, Vec::new()));
assert!(payload.get("tools").is_none());
assert!(payload.get("tool_choice").is_none());
}
#[test]
fn execute_openai_tool_calls_returns_none_without_tool_calls() {
let dir = tempfile::tempdir().unwrap();
let request = request(dir.path().to_string_lossy().into_owned());
let summary = execute_openai_tool_calls(
&request,
&json!({ "choices": [{ "message": { "content": "No tools" } }] }),
|_| {},
)
.unwrap();
assert_eq!(summary, None);
}
#[test]
fn executes_openai_create_note_tool_call_inside_active_vault() {
let dir = tempfile::tempdir().unwrap();
let request = request(dir.path().to_string_lossy().into_owned());
let mut events = Vec::new();
let summary = execute_openai_tool_calls(&request, &create_note_response(), |event| {
events.push(event)
})
.unwrap();
assert_note_created(dir.path());
assert_summary(summary);
assert_tool_events(&events);
}
#[test]
fn executes_openai_create_note_with_object_arguments_and_selected_vault() {
let primary = tempfile::tempdir().unwrap();
let secondary = tempfile::tempdir().unwrap();
let secondary_path = secondary.path().to_string_lossy().into_owned();
let request = request_with_provider(
provider(),
Some(primary.path().to_string_lossy().into_owned()),
vec![secondary_path.clone()],
);
let response = tool_call_response(json!({
"function": {
"name": CREATE_NOTE_TOOL_NAME,
"arguments": {
"path": "Generated/fallback-note.md",
"is_a": "Project",
"vaultPath": secondary_path,
}
}
}));
let summary = execute_openai_tool_calls(&request, &response, |_| {}).unwrap();
assert_eq!(
fs::read_to_string(secondary.path().join("Generated/fallback-note.md")).unwrap(),
"---\ntype: \"Project\"\n---\n\n# fallback-note\n",
);
assert_eq!(
summary.as_deref(),
Some("Created note: Generated/fallback-note.md")
);
assert!(!primary.path().join("Generated/fallback-note.md").exists());
}
#[test]
fn execute_openai_tool_calls_rejects_unsupported_tool_before_running_it() {
let dir = tempfile::tempdir().unwrap();
let request = request(dir.path().to_string_lossy().into_owned());
let response = tool_call_response(json!({
"id": "call_delete",
"function": {
"name": "delete_note",
"arguments": "{}",
}
}));
let mut events = Vec::new();
let error =
execute_openai_tool_calls(&request, &response, |event| events.push(event)).unwrap_err();
assert_eq!(error, "AI provider requested unsupported tool: delete_note");
assert!(events.is_empty());
}
#[test]
fn execute_openai_tool_calls_rejects_malformed_arguments() {
let error = create_note_error(json!("{not-json"));
assert!(error.contains("Failed to parse AI tool arguments"));
}
#[test]
fn execute_openai_tool_calls_treats_non_object_arguments_as_empty() {
let error = create_note_error(json!([]));
assert_eq!(error, "create_note requires path.");
}
#[test]
fn execute_openai_tool_calls_rejects_existing_note_without_overwriting() {
let dir = tempfile::tempdir().unwrap();
let request = request(dir.path().to_string_lossy().into_owned());
fs::write(dir.path().join("existing.md"), "# Existing\n").unwrap();
let response = tool_call_response(json!({
"function": {
"name": CREATE_NOTE_TOOL_NAME,
"arguments": serde_json::to_string(&json!({
"path": "existing.md",
"content": "# Replacement\n",
})).unwrap(),
}
}));
let error = execute_openai_tool_calls(&request, &response, |_| {}).unwrap_err();
assert!(error.contains("already exists"));
assert_eq!(
fs::read_to_string(dir.path().join("existing.md")).unwrap(),
"# Existing\n",
);
}
#[test]
fn execute_openai_tool_calls_requires_path() {
let error = create_note_error(json!("{}"));
assert_eq!(error, "create_note requires path.");
}
#[test]
fn execute_openai_tool_calls_rejects_inactive_explicit_vault() {
let active = tempfile::tempdir().unwrap();
let inactive = tempfile::tempdir().unwrap();
let request = request(active.path().to_string_lossy().into_owned());
let response = tool_call_response(json!({
"function": {
"name": CREATE_NOTE_TOOL_NAME,
"arguments": serde_json::to_string(&json!({
"path": "inactive.md",
"content": "# Inactive\n",
"vaultPath": inactive.path().to_string_lossy(),
})).unwrap(),
}
}));
let error = execute_openai_tool_calls(&request, &response, |_| {}).unwrap_err();
assert!(error.starts_with("Vault is not active in Tolaria:"));
assert!(!inactive.path().join("inactive.md").exists());
}
}

View File

@@ -61,7 +61,12 @@ pub struct AiModelStreamRequest {
pub model_id: String,
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: Option<String>,
#[serde(default)]
pub vault_paths: Vec<String>,
pub api_key_override: Option<String>,
#[serde(default)]
pub event_name: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
@@ -164,7 +169,7 @@ where
session_id: format!("api-{}", uuid::Uuid::new_v4()),
});
let text = send_model_message(&request)?;
let text = send_model_message(&request, &mut emit)?;
emit(AiAgentStreamEvent::TextDelta { text });
emit(AiAgentStreamEvent::Done);
@@ -180,32 +185,39 @@ pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result<Str
"You are testing whether this model endpoint is reachable. Reply with exactly OK."
.into(),
),
vault_path: None,
vault_paths: Vec::new(),
api_key_override: normalize_optional_string(request.api_key_override),
event_name: None,
};
send_model_message(&request)
send_model_message(&request, &mut |_| {})
}
fn send_model_message(request: &AiModelStreamRequest) -> Result<String, String> {
fn send_model_message<F>(request: &AiModelStreamRequest, emit: &mut F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
match request.provider.kind {
AiModelProviderKind::Anthropic => send_anthropic_message(request),
_ => send_openai_compatible_message(request),
_ => send_openai_compatible_message(request, emit),
}
}
fn send_openai_compatible_message(request: &AiModelStreamRequest) -> Result<String, String> {
fn send_openai_compatible_message<F>(
request: &AiModelStreamRequest,
emit: &mut F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let endpoint = format!("{}/chat/completions", normalized_base_url(request)?);
let mut messages = Vec::new();
if let Some(system_prompt) = non_empty_option(request.system_prompt.as_deref()) {
messages.push(serde_json::json!({ "role": "system", "content": system_prompt }));
}
messages.push(serde_json::json!({ "role": "user", "content": request.message }));
let payload = serde_json::json!({
"model": request.model_id,
"messages": messages,
"stream": false
});
let payload = crate::ai_model_tools::openai_chat_payload(request);
let json = send_json_request(request, endpoint, payload)?;
if let Some(tool_summary) =
crate::ai_model_tools::execute_openai_tool_calls(request, &json, emit)?
{
return Ok(tool_summary);
}
extract_openai_text(&json)
}
@@ -420,18 +432,32 @@ fn api_key_from_local_file(request: &AiModelStreamRequest) -> Result<Option<Stri
}
fn api_key_from_env(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
api_key_from_env_with_lookup(
request,
crate::cli_agent_runtime::env_value_from_process_or_user_shell,
)
}
fn api_key_from_env_with_lookup(
request: &AiModelStreamRequest,
lookup: impl Fn(crate::cli_agent_runtime::EnvName<'_>) -> Option<String>,
) -> Result<Option<String>, String> {
let Some(name) = request
.provider
.api_key_env_var
.as_deref()
.and_then(non_empty_str)
.and_then(crate::cli_agent_runtime::EnvName::new)
else {
return Ok(None);
};
std::env::var(name)
.map(Some)
.map_err(|_| format!("Environment variable {name} is not set for this AI provider."))
lookup(name).map(Some).ok_or_else(|| {
format!(
"Environment variable {} is not set for this AI provider.",
name.as_str()
)
})
}
fn api_key_from_provider(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
@@ -534,7 +560,10 @@ mod tests {
model_id: "demo-model".into(),
message: "Hello".into(),
system_prompt: Some(" Be concise. ".into()),
vault_path: None,
vault_paths: Vec::new(),
api_key_override: None,
event_name: None,
}
}
@@ -605,6 +634,51 @@ mod tests {
assert_eq!(headers, vec![("X-Demo", "demo")]);
}
#[test]
fn request_builders_apply_auth_and_provider_headers() {
let client = reqwest::blocking::Client::new();
let mut anthropic_provider = provider(AiModelProviderKind::Anthropic);
anthropic_provider.api_key_env_var = None;
anthropic_provider.headers = Some(BTreeMap::from([
("Authorization".into(), "ignored".into()),
("X-Demo".into(), "demo".into()),
]));
let mut anthropic_request = request(anthropic_provider);
anthropic_request.api_key_override = Some(" secret ".into());
let built = apply_provider_headers(
apply_auth_headers(client.post("https://example.test"), &anthropic_request).unwrap(),
&anthropic_request,
)
.build()
.unwrap();
let headers = built.headers();
assert_eq!(
(
headers["x-api-key"].to_str().unwrap(),
headers["anthropic-version"].to_str().unwrap(),
headers["X-Demo"].to_str().unwrap(),
headers.get("authorization").is_none(),
),
("secret", "2023-06-01", "demo", true),
);
let mut openai_provider = provider(AiModelProviderKind::OpenAi);
openai_provider.api_key_env_var = None;
let mut openai_request = request(openai_provider);
openai_request.api_key_override = Some(" openai-secret ".into());
let built = apply_auth_headers(client.post("https://example.test"), &openai_request)
.unwrap()
.build()
.unwrap();
assert_eq!(
built.headers()["authorization"].to_str().unwrap(),
"Bearer openai-secret"
);
}
#[test]
fn shared_provider_catalog_supplies_runtime_base_urls() {
assert_eq!(
@@ -632,6 +706,19 @@ mod tests {
);
}
#[test]
fn env_api_key_uses_shell_lookup_when_process_env_is_missing() {
let mut provider = provider(AiModelProviderKind::Anthropic);
provider.api_key_storage = Some(AiModelApiKeyStorage::Env);
provider.api_key_env_var = Some("ANTHROPIC_API_KEY".into());
let request = request(provider);
let api_key =
api_key_from_env_with_lookup(&request, |_| Some("shell-secret".to_string())).unwrap();
assert_eq!(api_key.as_deref(), Some("shell-secret"));
}
#[test]
fn extracts_provider_text_payloads_and_reports_empty_responses() {
let openai = json!({

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

@@ -0,0 +1,59 @@
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}"))?;
}
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

@@ -1,9 +1,36 @@
pub use crate::cli_agent_runtime::AgentStreamRequest;
use crate::cli_agent_runtime::EnvName;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{ExitStatus, Stdio};
const CLAUDE_PROVIDER_ENV_KEYS: &[EnvName<'static>] = &[
EnvName::trusted("ANTHROPIC_API_KEY"),
EnvName::trusted("ANTHROPIC_AUTH_TOKEN"),
EnvName::trusted("ANTHROPIC_BASE_URL"),
EnvName::trusted("ANTHROPIC_CUSTOM_HEADERS"),
EnvName::trusted("ANTHROPIC_MODEL"),
EnvName::trusted("ANTHROPIC_SMALL_FAST_MODEL"),
EnvName::trusted("CLAUDE_CODE_USE_BEDROCK"),
EnvName::trusted("CLAUDE_CODE_USE_VERTEX"),
EnvName::trusted("AWS_ACCESS_KEY_ID"),
EnvName::trusted("AWS_SECRET_ACCESS_KEY"),
EnvName::trusted("AWS_SESSION_TOKEN"),
EnvName::trusted("AWS_PROFILE"),
EnvName::trusted("AWS_REGION"),
EnvName::trusted("AWS_DEFAULT_REGION"),
EnvName::trusted("GOOGLE_APPLICATION_CREDENTIALS"),
EnvName::trusted("CLOUD_ML_REGION"),
EnvName::trusted("VERTEX_REGION"),
EnvName::trusted("HTTPS_PROXY"),
EnvName::trusted("HTTP_PROXY"),
EnvName::trusted("NO_PROXY"),
EnvName::trusted("SSL_CERT_FILE"),
EnvName::trusted("SSL_CERT_DIR"),
EnvName::trusted("NODE_EXTRA_CA_CERTS"),
];
/// Status returned by `check_claude_cli`.
#[derive(Debug, Serialize, Clone)]
pub struct ClaudeCliStatus {
@@ -118,20 +145,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> {
@@ -380,7 +420,7 @@ fn build_claude_command(
) -> Result<std::process::Command, String> {
let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(request.bin)?;
let mut cmd = crate::hidden_command(&target.program);
crate::cli_agent_runtime::configure_agent_command_environment(&mut cmd, request.bin);
configure_claude_command_environment(&mut cmd, request.bin);
if let Some(first_arg) = target.first_arg {
cmd.arg(first_arg);
}
@@ -395,6 +435,11 @@ fn build_claude_command(
Ok(cmd)
}
fn configure_claude_command_environment(cmd: &mut std::process::Command, bin: &Path) {
crate::cli_agent_runtime::configure_agent_command_environment(cmd, bin);
crate::cli_agent_runtime::apply_user_shell_env_vars_if_missing(cmd, CLAUDE_PROVIDER_ENV_KEYS);
}
fn format_failed_claude_exit(failure: ClaudeFailure<'_>) -> String {
if is_claude_auth_error(failure.stderr) {
return "Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into();
@@ -710,6 +755,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(
@@ -1119,6 +1180,12 @@ mod tests {
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
}
#[test]
fn claude_provider_env_keys_include_reported_anthropic_overrides() {
assert!(CLAUDE_PROVIDER_ENV_KEYS.contains(&EnvName::trusted("ANTHROPIC_API_KEY")));
assert!(CLAUDE_PROVIDER_ENV_KEYS.contains(&EnvName::trusted("ANTHROPIC_BASE_URL")));
}
#[cfg(unix)]
#[derive(Clone, Copy)]
struct MockClaudeScript<'a>(&'a str);
@@ -1236,6 +1303,7 @@ mod tests {
assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done)));
}
#[cfg(unix)]
#[test]
fn run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open() {
use std::io::Read;
@@ -1254,7 +1322,7 @@ mod tests {
let child_stdin = child.stdin.take().unwrap();
let mut stdout = child.stdout.take().unwrap();
let mut stderr = child.stderr.take().unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let deadline = Instant::now() + Duration::from_secs(30);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
@@ -1280,6 +1348,7 @@ mod tests {
);
}
#[cfg(unix)]
#[ignore = "spawned by run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open"]
#[test]
fn stdin_probe_parent_child() {
@@ -1287,25 +1356,15 @@ mod tests {
return;
}
let fake_bin = current_test_binary();
let args = vec![
"stdin_probe_mock_claude_child".to_string(),
"--ignored".to_string(),
"--nocapture".to_string(),
];
std::env::set_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD", "1");
let mut events = vec![];
let result = run_claude_subprocess(
ClaudeSubprocessRequest {
bin: &fake_bin,
args: &args,
fallback_args: &[],
stdin_text: None,
cwd: None,
},
&mut |event| events.push(event),
);
std::env::remove_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD");
let (result, events) = run_mock_script(MockClaudeScript(concat!(
"#!/bin/sh\n",
"stdin=\"$(cat)\"\n",
"if [ -n \"$stdin\" ]; then\n",
" echo \"stdin was not closed\" >&2\n",
" exit 9\n",
"fi\n",
"printf '%s\\n' '{\"type\":\"result\",\"result\":\"stdin closed\",\"session_id\":\"stdin-ok\"}'\n",
)));
assert_eq!(result.unwrap(), "stdin-ok");
assert!(matches!(
@@ -1316,28 +1375,6 @@ mod tests {
assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done)));
}
#[ignore = "spawned by stdin_probe_parent_child"]
#[test]
fn stdin_probe_mock_claude_child() {
if std::env::var_os("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD").is_none() {
return;
}
use std::io::Read;
let mut stdin = String::new();
std::io::stdin().read_to_string(&mut stdin).unwrap();
assert!(stdin.is_empty(), "stdin was not EOF");
println!(
"{}",
serde_json::json!({
"type": "result",
"result": "stdin closed",
"session_id": "stdin-ok"
})
);
}
#[cfg(unix)]
#[test]
fn run_subprocess_skips_blank_and_non_json_lines() {

View File

@@ -5,6 +5,13 @@ use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus};
mod shell_env;
mod windows_cmd_shim;
pub(crate) use shell_env::{
apply_user_shell_env_vars_if_missing, env_value_from_process_or_user_shell, EnvName,
};
#[derive(Debug, Clone, Deserialize)]
pub struct AgentStreamRequest {
pub message: String,
@@ -107,13 +114,8 @@ pub(crate) fn version_for_binary(binary: &Path) -> Option<String> {
pub(crate) fn command_target_avoiding_windows_cmd_shim(
binary: &Path,
) -> Result<AgentCommandTarget, String> {
if is_windows_batch_shim(binary) {
if let Some(script) = node_script_from_windows_cmd_shim(binary) {
return Ok(AgentCommandTarget {
program: crate::mcp::find_node()?,
first_arg: Some(script),
});
}
if let Some(target) = windows_cmd_shim::command_target(binary)? {
return Ok(target);
}
Ok(AgentCommandTarget {
@@ -244,43 +246,6 @@ pub(crate) fn has_windows_cli_extension(path: &Path) -> bool {
})
}
fn is_windows_batch_shim(binary: &Path) -> bool {
binary
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat")
})
}
fn node_script_from_windows_cmd_shim(binary: &Path) -> Option<PathBuf> {
let contents = std::fs::read_to_string(binary).ok()?;
contents
.split('"')
.skip(1)
.step_by(2)
.filter(|token| is_node_script_token(token))
.find_map(|token| resolve_cmd_shim_script_path(binary, token))
}
fn is_node_script_token(token: &str) -> bool {
let lower = token.to_ascii_lowercase();
(lower.ends_with(".js") || lower.ends_with(".mjs") || lower.ends_with(".cjs"))
&& (lower.starts_with("%dp0%") || lower.starts_with("%~dp0"))
}
fn resolve_cmd_shim_script_path(binary: &Path, token: &str) -> Option<PathBuf> {
let relative = token
.strip_prefix("%dp0%")
.or_else(|| token.strip_prefix("%~dp0"))?
.trim_start_matches(['\\', '/']);
let mut script = binary.parent()?.to_path_buf();
for part in relative.split(['\\', '/']).filter(|part| !part.is_empty()) {
script.push(part);
}
script.is_file().then_some(script)
}
pub(crate) fn parse_json_line(
line: Result<String, std::io::Error>,
) -> Result<Option<serde_json::Value>, String> {
@@ -455,6 +420,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

@@ -0,0 +1,313 @@
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const OUTPUT_PREFIX: &str = "__TOLARIA_ENV__:";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct EnvName<'a>(&'a str);
impl<'a> EnvName<'a> {
pub(crate) fn new(raw: &'a str) -> Option<Self> {
is_valid_name(raw).then_some(Self(raw))
}
pub(crate) const fn trusted(raw: &'a str) -> Self {
Self(raw)
}
pub(crate) fn as_str(self) -> &'a str {
self.0
}
}
pub(crate) fn apply_user_shell_env_vars_if_missing(command: &mut Command, names: &[EnvName<'_>]) {
let missing = valid_unique_names(names)
.into_iter()
.filter(|name| !process_has_value(name) && !command_has_value(command, name))
.collect::<Vec<_>>();
for binding in user_shell_bindings(&missing) {
command.env(binding.name, binding.value);
}
}
pub(crate) fn env_value_from_process_or_user_shell(name: EnvName<'_>) -> Option<String> {
process_value(name).or_else(|| user_shell_value(name))
}
#[derive(Debug, PartialEq, Eq)]
struct EnvBinding {
name: String,
value: String,
}
fn process_has_value(name: &EnvName<'_>) -> bool {
std::env::var_os(name.as_str()).is_some_and(|value| !value.is_empty())
}
fn command_has_value(command: &Command, name: &EnvName<'_>) -> bool {
command.get_envs().any(|(key, value)| {
key == OsStr::new(name.as_str()) && value.is_some_and(|value| !value.is_empty())
})
}
fn process_value(name: EnvName<'_>) -> Option<String> {
std::env::var(name.as_str())
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn user_shell_value(name: EnvName<'_>) -> Option<String> {
user_shell_bindings(&[name])
.into_iter()
.find_map(|binding| (binding.name == name.as_str()).then_some(binding.value))
}
fn user_shell_bindings(names: &[EnvName<'_>]) -> Vec<EnvBinding> {
let names = valid_unique_names(names);
if names.is_empty() {
return Vec::new();
}
user_shell_bindings_for_platform(&names)
}
fn valid_unique_names<'a>(names: &[EnvName<'a>]) -> Vec<EnvName<'a>> {
let mut unique = Vec::new();
for name in names.iter().copied() {
if !unique.iter().any(|existing| existing == &name) {
unique.push(name);
}
}
unique
}
fn is_valid_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
(first == '_' || first.is_ascii_alphabetic())
&& chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
#[cfg(unix)]
fn user_shell_bindings_for_platform(names: &[EnvName<'_>]) -> Vec<EnvBinding> {
shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| user_shell_bindings_from_shell(&shell, names))
.unwrap_or_default()
}
#[cfg(not(unix))]
fn user_shell_bindings_for_platform(_names: &[EnvName<'_>]) -> Vec<EnvBinding> {
Vec::new()
}
#[cfg(unix)]
fn user_shell_bindings_from_shell(shell: &Path, names: &[EnvName<'_>]) -> Option<Vec<EnvBinding>> {
let output = crate::hidden_command(shell)
.arg("-lc")
.arg(shell_probe_script(shell, names))
.stdin(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let bindings = parse_probe_output(&String::from_utf8_lossy(&output.stdout), names);
(!bindings.is_empty()).then_some(bindings)
}
#[cfg(unix)]
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
}
#[cfg(unix)]
fn shell_probe_script(shell: &Path, names: &[EnvName<'_>]) -> String {
format!(
"{}\nfor name in {}; do\n value=$(printenv \"$name\" 2>/dev/null || true)\n if [ -n \"$value\" ]; then\n printf '{}%s=%s\\n' \"$name\" \"$value\"\n fi\ndone\n",
rc_source_command(shell),
joined_names(names),
OUTPUT_PREFIX
)
}
fn joined_names(names: &[EnvName<'_>]) -> String {
names
.iter()
.map(|name| name.as_str())
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(unix)]
fn rc_source_command(shell: &Path) -> &'static str {
let name = shell
.file_name()
.and_then(OsStr::to_str)
.unwrap_or_default();
if name.contains("zsh") {
return "if [ -n \"${ZDOTDIR:-}\" ] && [ -r \"${ZDOTDIR}/.zshrc\" ]; then . \"${ZDOTDIR}/.zshrc\" >/dev/null 2>&1 || true; elif [ -r \"$HOME/.zshrc\" ]; then . \"$HOME/.zshrc\" >/dev/null 2>&1 || true; fi";
}
if name.contains("bash") {
return "if [ -r \"$HOME/.bashrc\" ]; then . \"$HOME/.bashrc\" >/dev/null 2>&1 || true; fi";
}
""
}
struct ProbeOutput<'a>(&'a str);
struct ProbeLine<'a>(&'a str);
fn parse_probe_output(stdout: &str, names: &[EnvName<'_>]) -> Vec<EnvBinding> {
ProbeOutput(stdout)
.0
.lines()
.filter_map(|line| parse_probe_line(ProbeLine(line), names))
.collect()
}
fn parse_probe_line(line: ProbeLine<'_>, names: &[EnvName<'_>]) -> Option<EnvBinding> {
let (name, value) = line.0.strip_prefix(OUTPUT_PREFIX)?.split_once('=')?;
let name = EnvName::new(name)?;
let value = value.trim();
(names.iter().any(|expected| expected == &name) && !value.is_empty()).then(|| EnvBinding {
name: name.as_str().to_string(),
value: value.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_value_uses_trimmed_process_value() {
let key = "TOLARIA_TEST_SHELL_ENV_PROCESS_VALUE";
std::env::set_var(key, " process-secret ");
let value = env_value_from_process_or_user_shell(EnvName::trusted(key));
std::env::remove_var(key);
assert_eq!(value.as_deref(), Some("process-secret"));
}
#[test]
fn command_value_marks_name_as_already_available() {
let mut command = Command::new("demo");
command.env("TOLARIA_TEST_COMMAND_VALUE", "command-secret");
apply_user_shell_env_vars_if_missing(
&mut command,
&[EnvName::trusted("TOLARIA_TEST_COMMAND_VALUE")],
);
let values = command
.get_envs()
.map(|(key, value)| (key.to_string_lossy().to_string(), value.is_some()))
.collect::<Vec<_>>();
assert_eq!(values, vec![("TOLARIA_TEST_COMMAND_VALUE".into(), true)]);
}
#[test]
fn parse_probe_output_filters_invalid_unexpected_and_blank_values() {
let names = [
EnvName::trusted("GOOD_VALUE"),
EnvName::trusted("OTHER_VALUE"),
];
let bindings = parse_probe_output(
"__TOLARIA_ENV__:GOOD_VALUE=kept\n\
ignored\n\
__TOLARIA_ENV__:BAD-NAME=bad\n\
__TOLARIA_ENV__:OTHER_VALUE= \n\
__TOLARIA_ENV__:UNEXPECTED=value\n",
&names,
);
assert_eq!(
bindings,
vec![EnvBinding {
name: "GOOD_VALUE".into(),
value: "kept".into(),
}]
);
}
#[cfg(unix)]
#[test]
fn user_shell_bindings_from_shell_returns_none_for_failing_shell() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let shell = dir.path().join("zsh");
std::fs::write(&shell, "#!/bin/sh\nexit 7\n").unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
let values = user_shell_bindings_from_shell(&shell, &[EnvName::trusted("MISSING")]);
assert_eq!(values, None);
}
#[cfg(unix)]
#[test]
fn rc_source_command_ignores_unknown_shells() {
assert_eq!(rc_source_command(Path::new("fish")), "");
}
#[cfg(unix)]
#[test]
fn user_shell_bindings_from_shell_reads_zshrc_exports_for_requested_keys() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let shell = dir.path().join("zsh");
let zshrc = dir.path().join(".zshrc");
std::fs::write(
&shell,
"#!/bin/sh\nexport HOME=$(dirname \"$0\")\nexec /bin/sh -c \"$2\"\n",
)
.unwrap();
std::fs::write(
&zshrc,
"export ANTHROPIC_API_KEY=from-zshrc\nexport ANTHROPIC_BASE_URL=https://proxy.example.test\n",
)
.unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
let values = user_shell_bindings_from_shell(
&shell,
&[
EnvName::trusted("ANTHROPIC_API_KEY"),
EnvName::trusted("ANTHROPIC_BASE_URL"),
EnvName::trusted("IGNORED_SECRET"),
],
)
.expect("zshrc exports should be readable");
assert_eq!(
values,
vec![
EnvBinding {
name: "ANTHROPIC_API_KEY".to_string(),
value: "from-zshrc".to_string(),
},
EnvBinding {
name: "ANTHROPIC_BASE_URL".to_string(),
value: "https://proxy.example.test".to_string(),
},
]
);
}
}

View File

@@ -0,0 +1,115 @@
use super::AgentCommandTarget;
use std::path::{Path, PathBuf};
pub(super) fn command_target(binary: &Path) -> Result<Option<AgentCommandTarget>, String> {
if !is_batch_shim(binary) {
return Ok(None);
}
let Some(target) = target_path(binary) else {
return Ok(None);
};
if is_node_script(&target) {
return Ok(Some(AgentCommandTarget {
program: crate::mcp::find_node()?,
first_arg: Some(target),
}));
}
Ok(Some(AgentCommandTarget {
program: target,
first_arg: None,
}))
}
fn is_batch_shim(binary: &Path) -> bool {
has_extension(binary, &["cmd", "bat"])
}
fn target_path(binary: &Path) -> Option<PathBuf> {
let contents = std::fs::read_to_string(binary).ok()?;
contents
.split('"')
.skip(1)
.step_by(2)
.find_map(|token| resolve_target_path(binary, token))
}
fn resolve_target_path(binary: &Path, token: &str) -> Option<PathBuf> {
let relative = token
.strip_prefix("%dp0%")
.or_else(|| token.strip_prefix("%~dp0"))?
.trim_start_matches(['\\', '/']);
let mut target = binary.parent()?.to_path_buf();
for part in relative.split(['\\', '/']).filter(|part| !part.is_empty()) {
target.push(part);
}
is_supported_target(&target)
.then_some(target)
.filter(|target| target.is_file())
}
fn is_supported_target(path: &Path) -> bool {
is_node_script(path) || is_native_executable(path)
}
fn is_node_script(path: &Path) -> bool {
has_extension(path, &["js", "mjs", "cjs"])
}
fn is_native_executable(path: &Path) -> bool {
has_extension(path, &["exe", "com"]) && !has_file_name(path, "node.exe")
}
fn has_extension(path: &Path, expected_extensions: &[&str]) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
expected_extensions
.iter()
.any(|expected| extension.eq_ignore_ascii_case(expected))
})
}
fn has_file_name(path: &Path, expected_name: &str) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case(expected_name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_target_uses_native_exe_from_windows_cmd_shim() {
let dir = tempfile::tempdir().unwrap();
let shim = dir.path().join("claude.cmd");
let native_exe = dir
.path()
.join("node_modules")
.join("@anthropic-ai")
.join("claude-code")
.join("bin")
.join("claude.exe");
std::fs::create_dir_all(native_exe.parent().unwrap()).unwrap();
std::fs::write(dir.path().join("node.exe"), "node runtime").unwrap();
std::fs::write(&native_exe, "native claude launcher").unwrap();
std::fs::write(
&shim,
r#"@ECHO off
IF EXIST "%~dp0\node.exe" (
SET "_prog=%~dp0\node.exe"
)
"%~dp0\node_modules\@anthropic-ai\claude-code\bin\claude.exe" %*
"#,
)
.unwrap();
let target = command_target(&shim).unwrap().unwrap();
assert_eq!(target.program, native_exe);
assert_eq!(target.first_arg, None);
}
}

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

@@ -16,7 +16,7 @@ const AGENT_DOCS_RESOURCE_DIR: &str = "agent-docs";
#[cfg(desktop)]
async fn run_desktop_stream<Event, Request, Runner>(
app_handle: tauri::AppHandle,
event_name: &'static str,
event_name: String,
request: Request,
runner: Runner,
) -> Result<String, String>
@@ -31,7 +31,7 @@ where
runner(
request,
Box::new(move |event| {
let _ = app_handle.emit(event_name, &event);
let _ = app_handle.emit(event_name.as_str(), &event);
}),
)
})
@@ -47,11 +47,32 @@ macro_rules! define_desktop_stream_command {
app_handle: tauri::AppHandle,
request: $request,
) -> Result<String, String> {
run_desktop_stream(app_handle, $event_name, request, $runner).await
run_desktop_stream(app_handle, $event_name.to_string(), request, $runner).await
}
};
}
#[cfg(desktop)]
fn is_scoped_stream_event_name(default_event_name: &str, event_name: &str) -> bool {
event_name
.strip_prefix(default_event_name)
.and_then(|suffix| suffix.strip_prefix('-'))
.is_some_and(|suffix| {
!suffix.is_empty()
&& suffix
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '-')
})
}
#[cfg(desktop)]
fn stream_event_name(default_event_name: &'static str, requested: Option<&str>) -> String {
requested
.filter(|event_name| is_scoped_stream_event_name(default_event_name, event_name))
.unwrap_or(default_event_name)
.to_string()
}
// ── Claude CLI commands (desktop) ───────────────────────────────────────────
#[cfg(desktop)]
@@ -62,8 +83,8 @@ pub fn check_claude_cli() -> ClaudeCliStatus {
#[cfg(desktop)]
#[tauri::command]
pub fn get_ai_agents_status() -> AiAgentsStatus {
crate::ai_agents::get_ai_agents_status()
pub async fn get_ai_agents_status() -> AiAgentsStatus {
crate::ai_agents::get_ai_agents_status().await
}
#[cfg(desktop)]
@@ -135,20 +156,36 @@ fn run_normalized_ai_agent_stream(
}
#[cfg(desktop)]
define_desktop_stream_command!(
stream_ai_agent,
AiAgentStreamRequest,
"ai-agent-stream",
run_normalized_ai_agent_stream
);
#[tauri::command]
pub async fn stream_ai_agent(
app_handle: tauri::AppHandle,
request: AiAgentStreamRequest,
) -> Result<String, String> {
let event_name = stream_event_name("ai-agent-stream", request.event_name.as_deref());
run_desktop_stream(
app_handle,
event_name,
request,
run_normalized_ai_agent_stream,
)
.await
}
#[cfg(desktop)]
define_desktop_stream_command!(
stream_ai_model,
AiModelStreamRequest,
"ai-model-stream",
crate::ai_models::run_ai_model_stream
);
#[tauri::command]
pub async fn stream_ai_model(
app_handle: tauri::AppHandle,
request: AiModelStreamRequest,
) -> Result<String, String> {
let event_name = stream_event_name("ai-model-stream", request.event_name.as_deref());
run_desktop_stream(
app_handle,
event_name,
request,
crate::ai_models::run_ai_model_stream,
)
.await
}
#[cfg(desktop)]
#[tauri::command]
@@ -280,6 +317,7 @@ mod tests {
vault_path: "~/Vaults/content".into(),
vault_paths: vec!["~/Vaults/secondary".into()],
permission_mode: None,
event_name: None,
};
let normalized = normalize_agent_request(request);
@@ -308,6 +346,7 @@ mod tests {
vault_path: "/Users/example/vault".into(),
vault_paths: Vec::new(),
permission_mode: None,
event_name: None,
};
let normalized = normalize_agent_request(request);
@@ -315,6 +354,23 @@ mod tests {
assert_eq!(normalized.vault_path, "/Users/example/vault");
}
#[cfg(desktop)]
#[test]
fn stream_event_name_accepts_only_scoped_names() {
assert_eq!(
stream_event_name("ai-agent-stream", Some("ai-agent-stream-chat-123")),
"ai-agent-stream-chat-123",
);
assert_eq!(
stream_event_name("ai-agent-stream", Some("ai-model-stream-chat-123")),
"ai-agent-stream",
);
assert_eq!(
stream_event_name("ai-agent-stream", Some("ai-agent-stream/../bad")),
"ai-agent-stream",
);
}
#[test]
fn guidance_commands_report_and_restore_vault_guidance_files() {
let dir = tempfile::TempDir::new().unwrap();

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

@@ -150,6 +150,19 @@ pub async fn git_remote_status(vault_path: VaultPathArg) -> Result<GitRemoteStat
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn git_file_url(
vault_path: VaultPathArg,
path: NotePathArg,
) -> Result<Option<String>, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
let path = expand_tilde(&path).into_owned();
tokio::task::spawn_blocking(move || crate::git::git_file_url(&vault_path, &path))
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub fn git_discard_file(
@@ -344,6 +357,15 @@ pub async fn git_remote_status(_vault_path: VaultPathArg) -> Result<GitRemoteSta
})
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_file_url(
_vault_path: VaultPathArg,
_path: NotePathArg,
) -> Result<Option<String>, String> {
Ok(None)
}
#[cfg(mobile)]
#[tauri::command]
pub fn git_discard_file(
@@ -475,7 +497,7 @@ mod tests {
assert_eq!(pull.status, "no_remote");
let push = git_push(vault.clone()).await.unwrap();
assert_eq!(push.status, "error");
assert_eq!(push.status, "no_remote");
let status = git_remote_status(vault.clone()).await.unwrap();
assert!(!status.has_remote);

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 pdf_export;
mod runtime;
mod system;
mod vault;
@@ -13,11 +16,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 pdf_export::*;
pub use runtime::*;
pub use system::*;
pub use vault::*;

View File

@@ -0,0 +1,141 @@
#[tauri::command]
pub fn export_current_webview_pdf(
window: tauri::WebviewWindow,
output_path: String,
) -> Result<(), String> {
native::export_current_webview_pdf(window, output_path)
}
#[tauri::command]
pub fn can_export_current_webview_pdf() -> bool {
native::can_export_current_webview_pdf()
}
#[cfg(target_os = "macos")]
mod native {
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
use objc2::runtime::ProtocolObject;
use objc2::ClassType;
use objc2_app_kit::{NSPrintInfo, NSPrintJobSavingURL, NSPrintSaveJob};
use objc2_foundation::{NSString, NSURL};
use objc2_web_kit::WKWebView;
const PDF_EXPORT_TIMEOUT: Duration = Duration::from_secs(15);
pub fn can_export_current_webview_pdf() -> bool {
true
}
pub fn export_current_webview_pdf(
window: tauri::WebviewWindow,
output_path: String,
) -> Result<(), String> {
validate_output_path(&output_path)?;
let (sender, receiver) = mpsc::channel();
window
.with_webview(move |webview| {
let result = save_webview_pdf(webview, &output_path);
let _ = sender.send(result);
})
.map_err(|error| format!("Failed to access the current webview: {error}"))?;
receiver
.recv_timeout(PDF_EXPORT_TIMEOUT)
.map_err(|_| "Timed out while exporting the current note as PDF".to_string())?
}
fn validate_output_path(output_path: &str) -> Result<(), String> {
if output_path.trim().is_empty() {
return Err("Missing PDF export path".to_string());
}
let path = Path::new(output_path);
if path.file_name().is_none() {
return Err("PDF export path must include a file name".to_string());
}
Ok(())
}
fn save_webview_pdf(
webview: tauri::webview::PlatformWebview,
output_path: &str,
) -> Result<(), String> {
let output = NSString::from_str(output_path);
let output_url = NSURL::fileURLWithPath(&output);
let print_info = NSPrintInfo::sharedPrintInfo();
let print_settings = unsafe { print_info.dictionary() };
let previous_job_disposition = print_info.jobDisposition();
print_info.setJobDisposition(unsafe { NSPrintSaveJob });
unsafe {
print_settings.setObject_forKey(
output_url.as_super().as_super(),
ProtocolObject::from_ref(NSPrintJobSavingURL),
);
}
let webview: &WKWebView = unsafe { &*webview.inner().cast() };
let window = webview
.window()
.ok_or_else(|| "Failed to access the webview window for PDF export".to_string())?;
let operation = unsafe { webview.printOperationWithPrintInfo(&print_info) };
operation.setShowsPrintPanel(false);
operation.setShowsProgressPanel(false);
operation.setCanSpawnSeparateThread(true);
unsafe {
operation.runOperationModalForWindow_delegate_didRunSelector_contextInfo(
&window,
None,
None,
std::ptr::null_mut(),
);
}
print_info.setJobDisposition(&previous_job_disposition);
unsafe {
print_settings.removeObjectForKey(NSPrintJobSavingURL);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::validate_output_path;
#[test]
fn output_path_requires_a_non_blank_value() {
assert!(validate_output_path("").is_err());
assert!(validate_output_path(" ").is_err());
}
#[test]
fn output_path_requires_a_file_name() {
assert!(validate_output_path("/").is_err());
}
#[test]
fn output_path_accepts_a_pdf_file_path() {
assert!(validate_output_path("/tmp/tolaria-note.pdf").is_ok());
}
}
}
#[cfg(not(target_os = "macos"))]
mod native {
pub fn can_export_current_webview_pdf() -> bool {
false
}
pub fn export_current_webview_pdf(
_window: tauri::WebviewWindow,
_output_path: String,
) -> Result<(), String> {
Err("Direct PDF export is currently only supported on macOS".to_string())
}
}

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