diff --git a/.gitignore b/.gitignore
index 8f08ac8..9822fdf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,7 @@ coverage/
# Project-layer settings written by running the minerva TUI inside this repo
# (persisted "always allow" rules) — local dev state, not shared config.
.minerva/
+# Tauri GUI build state; binaries/ holds the prepared sidecar pair (host-built).
+apps/gui/src-tauri/target/
+apps/gui/src-tauri/binaries/
+apps/gui/src-tauri/gen/schemas/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7c585ae..bb1dd53 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -3,7 +3,7 @@
## Setup
```sh
-bun install # Bun ≥ 1.3 (CI pins 1.3.12)
+bun install # Bun ≥ 1.3 (CI pins 1.3.14)
bun run verify # typecheck + lint + all tests — the gate for everything
```
@@ -11,6 +11,11 @@ The repo is a Bun workspace; packages export TypeScript source directly (no
build step). `tsc --noEmit` typechecks, Biome lints/formats, `bun test` runs
everything, and knip guards against dead exports and unused dependencies.
+Working on the GUI (`apps/gui`) additionally needs the Rust toolchain
+([rustup](https://rustup.rs)) — plus webkit2gtk on Linux. The webview's
+TypeScript is covered by the normal gate; the Rust side builds locally only
+(`tauri dev` / `tauri build`), never in CI.
+
## The verify gate
`bun run verify` must be green before anything lands on `main`. CI enforces it
@@ -42,8 +47,14 @@ on every PR (ubuntu + macos), plus:
| `packages/cli/test/acp.test.ts` | Real child process over stdio (ACP wire contract) |
| `packages/cli/test/app.test.tsx` | Ink UI via ink-testing-library, real kernel underneath |
| `packages/kernel/test/mcp.test.ts` | Real spawned MCP server from project settings |
+| `packages/cli/test/acp.test.ts` (keyless cases) | `acp --allow-unconfigured` hosting contract |
+| `apps/gui/test/` | GUI pure modules: transport framing, kernel manager (fake JSON-RPC bridge), tabs, permission queue, diff alignment, notification matrix |
| `scripts/*.exp` | The TUI under a genuine pseudo-terminal |
+GUI tests deliberately import only pure modules — never React components or
+the one file touching `@tauri-apps/api` — so the per-file coverage threshold
+stays meaningful (files loaded during a test run are measured).
+
Tests never touch `~/.minerva`: they pass a temp `dataDir` to the kernel (or
set `MINERVA_DATA_DIR` for spawned processes). Do the same for manual testing:
@@ -57,8 +68,26 @@ MINERVA_DATA_DIR=$(mktemp -d) bun run --cwd packages/cli dev
bun run --cwd packages/cli dev # interactive TUI (needs ANTHROPIC_API_KEY)
bun run packages/cli/src/index.tsx acp # kernel on stdio for editors
bun run build:release # single-file executable (see README caveat)
+bun run --cwd apps/gui dev # Tauri GUI, kernel from source
+bun run --cwd apps/gui prepare-sidecar && bun run --cwd apps/gui build # packaged app
```
+## GUI manual smoke (before landing apps/gui changes)
+
+`tauri dev` with a temp `MINERVA_DATA_DIR` and walk:
+
+1. First run (no keys in env, empty data dir) → config dialog appears; store
+ a key → first prompt streams without a restart.
+2. Prompt that writes a file → permission dialog (y/a/n hotkeys, Esc cancels
+ the turn); diff preview renders; toggle unified/split.
+3. Sessions → resume a TUI-created session (shared JSONL store).
+4. Second tab on another repo; prompt both concurrently over one kernel.
+5. `kill -9` the kernel process → banner, auto-restart, tabs restore;
+ kill it again → stays down until the restart button.
+6. Reload the webview (dev) → still exactly one kernel process.
+7. Packaged app: run a grep-using prompt (proves the bundled rg sidecar),
+ then Cmd+Q → kernel process gone.
+
## Design context
Read [docs/DESIGN.md](docs/DESIGN.md) (architecture decisions and their
diff --git a/README.md b/README.md
index 2da8b1d..5befc59 100644
--- a/README.md
+++ b/README.md
@@ -3,12 +3,12 @@
[](https://github.com/hutusi/minerva/actions/workflows/ci.yml)
A cross-platform, model-agnostic code agent — a headless kernel with multiple
-frontends (CLI now, GUI later).
+frontends (Ink CLI and Tauri desktop GUI).
- **Architecture**: protocol everywhere ([ACP](https://agentclientprotocol.com)
core + `minerva/*` extensions), one kernel, swappable transports (in-process,
stdio; WebSocket planned).
-- **Stack**: TypeScript, Bun, Vercel AI SDK, Ink (CLI), Tauri 2 (GUI, planned).
+- **Stack**: TypeScript, Bun, Vercel AI SDK, Ink (CLI), Tauri 2 (GUI).
Docs: [design record](docs/DESIGN.md) · [wire protocol](docs/PROTOCOL.md) ·
[contributing](CONTRIBUTING.md) · [changelog](CHANGELOG.md)
@@ -342,7 +342,26 @@ bun test packages/kernel
The repo is a Bun workspace: `packages/protocol` (JSON-RPC + ACP types +
transports), `packages/kernel` (agent loop, sessions, tools, permissions,
MCP), `packages/providers` (model adapters), `packages/client` (shared
-frontend core), `packages/cli` (Ink UI + acp host), `apps/gui` (planned).
+frontend core), `packages/cli` (Ink UI + acp host), `apps/gui` (Tauri 2
+desktop app).
+
+## Desktop GUI
+
+The GUI spawns the kernel as a stdio sidecar (`minerva acp
+--allow-unconfigured`) and reuses `@minerva/client` for everything the TUI
+renders, plus GUI-native extras: multi-project tabs over one kernel, a
+side-by-side diff viewer, and native notifications when a long turn finishes
+in the background. Sessions are shared with the TUI — resume either's
+sessions from the other.
+
+```sh
+# prerequisites: the Rust toolchain (rustup) — plus, on Linux, webkit2gtk
+bun run --cwd apps/gui dev # tauri dev: kernel runs from source
+
+# packaged app (unsigned, local): bundles the compiled minerva + rg pair
+bun run --cwd apps/gui prepare-sidecar
+bun run --cwd apps/gui build # .app/.dmg under apps/gui/src-tauri/target
+```
## Release build
diff --git a/apps/gui/components.json b/apps/gui/components.json
new file mode 100644
index 0000000..13e1db0
--- /dev/null
+++ b/apps/gui/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/index.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
diff --git a/apps/gui/index.html b/apps/gui/index.html
new file mode 100644
index 0000000..e1449c7
--- /dev/null
+++ b/apps/gui/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Minerva
+
+
+
+
+
+
diff --git a/apps/gui/package.json b/apps/gui/package.json
new file mode 100644
index 0000000..2d00750
--- /dev/null
+++ b/apps/gui/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@minerva/gui",
+ "version": "0.3.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
+ "dev:web": "vite",
+ "build": "tauri build",
+ "build:web": "vite build",
+ "prepare-sidecar": "bun run scripts/prepare-sidecar.ts",
+ "tauri": "tauri"
+ },
+ "dependencies": {
+ "@minerva/client": "workspace:*",
+ "@minerva/protocol": "workspace:*",
+ "@tauri-apps/api": "^2",
+ "@tauri-apps/plugin-dialog": "^2",
+ "@tauri-apps/plugin-notification": "^2",
+ "dompurify": "^3",
+ "marked": "^16",
+ "react": "^19",
+ "react-dom": "^19"
+ },
+ "devDependencies": {
+ "@minerva/kernel": "workspace:*",
+ "@minerva/providers": "workspace:*",
+ "@tailwindcss/vite": "^4.1.11",
+ "@tauri-apps/cli": "^2",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "@vitejs/plugin-react": "^5",
+ "tailwindcss": "^4.1.11",
+ "vite": "^7"
+ }
+}
diff --git a/apps/gui/scripts/prepare-sidecar.ts b/apps/gui/scripts/prepare-sidecar.ts
new file mode 100644
index 0000000..0099993
--- /dev/null
+++ b/apps/gui/scripts/prepare-sidecar.ts
@@ -0,0 +1,58 @@
+/**
+ * Prepare the kernel sidecar pair for a packaged GUI build.
+ *
+ * Runs the repo's release build (the `dist/{minerva, rg}` pair — see
+ * scripts/build-release.ts for why the pair is host-only) and copies both
+ * into src-tauri/binaries/ with the host target-triple suffix Tauri requires
+ * for externalBin sources. At bundle time Tauri strips the suffix and places
+ * both next to the app executable (Contents/MacOS/ on macOS), so the kernel's
+ * process.execPath sits beside rg and resolveRgPath works unchanged.
+ *
+ * Only needed before `tauri build`; dev spawns the kernel from source.
+ */
+import { chmodSync, copyFileSync, mkdirSync } from "node:fs";
+import { join } from "node:path";
+
+const repoRoot = join(import.meta.dir, "..", "..", "..");
+
+const build = Bun.spawnSync(["bun", "run", "build:release"], {
+ cwd: repoRoot,
+ stdout: "inherit",
+ stderr: "inherit",
+});
+if (!build.success) process.exit(build.exitCode || 1);
+
+// The triple must match what the tauri CLI expects for this host, and rustc
+// is authoritative (and always present — tauri build needs it too). Guard
+// the whole probe: a missing rustc can throw from spawnSync or leave stdout
+// unset, and either must fail with the hint rather than a stack trace.
+const triple = (() => {
+ try {
+ const rustc = Bun.spawnSync(["rustc", "-vV"]);
+ if (!rustc.success || !rustc.stdout) return null;
+ return (
+ rustc.stdout
+ .toString()
+ .split("\n")
+ .find((line) => line.startsWith("host: "))
+ ?.slice("host: ".length)
+ .trim() ?? null
+ );
+ } catch {
+ return null;
+ }
+})();
+if (!triple) {
+ console.error("cannot determine host target triple: is rustc installed?");
+ process.exit(1);
+}
+
+const binariesDir = join(import.meta.dir, "..", "src-tauri", "binaries");
+mkdirSync(binariesDir, { recursive: true });
+for (const name of ["minerva", "rg"]) {
+ const source = join(repoRoot, "dist", name);
+ const dest = join(binariesDir, `${name}-${triple}`);
+ copyFileSync(source, dest);
+ chmodSync(dest, 0o755);
+ console.log(`prepared sidecar: ${dest}`);
+}
diff --git a/apps/gui/src-tauri/Cargo.lock b/apps/gui/src-tauri/Cargo.lock
new file mode 100644
index 0000000..9f40c22
--- /dev/null
+++ b/apps/gui/src-tauri/Cargo.lock
@@ -0,0 +1,5095 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "alloc-no-stdlib"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "atk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
+dependencies = [
+ "atk-sys",
+ "glib",
+ "libc",
+]
+
+[[package]]
+name = "atk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2",
+]
+
+[[package]]
+name = "blocking"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
+[[package]]
+name = "brotli"
+version = "8.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "5.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cairo-rs"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
+dependencies = [
+ "bitflags 2.13.0",
+ "cairo-sys-rs",
+ "glib",
+ "libc",
+ "once_cell",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "cairo-sys-rs"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "camino"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "cargo_toml"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77"
+dependencies = [
+ "serde",
+ "toml 0.9.12+spec-1.1.0",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.67"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[package]]
+name = "cfb"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
+dependencies = [
+ "byteorder",
+ "fnv",
+ "uuid",
+]
+
+[[package]]
+name = "cfg-expr"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
+dependencies = [
+ "smallvec",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "num-traits",
+ "serde",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "cookie"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
+dependencies = [
+ "time",
+ "version_check",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
+dependencies = [
+ "bitflags 2.13.0",
+ "core-foundation",
+ "core-graphics-types",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
+dependencies = [
+ "bitflags 2.13.0",
+ "core-foundation",
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "cssparser"
+version = "0.36.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa",
+ "phf",
+ "smallvec",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "ctor"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98"
+dependencies = [
+ "ctor-proc-macro",
+ "dtor",
+]
+
+[[package]]
+name = "ctor-proc-macro"
+version = "0.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
+
+[[package]]
+name = "darling"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "dbus"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
+dependencies = [
+ "libc",
+ "libdbus-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "dirs"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "libc",
+ "objc2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "dlopen2"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4"
+dependencies = [
+ "dlopen2_derive",
+ "libc",
+ "once_cell",
+ "winapi",
+]
+
+[[package]]
+name = "dlopen2_derive"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "dom_query"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
+dependencies = [
+ "bit-set",
+ "cssparser",
+ "foldhash",
+ "html5ever",
+ "precomputed-hash",
+ "selectors",
+ "tendril",
+]
+
+[[package]]
+name = "dpi"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
+[[package]]
+name = "dtor"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4"
+dependencies = [
+ "dtor-proc-macro",
+]
+
+[[package]]
+name = "dtor-proc-macro"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
+
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "dyn-clone"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+[[package]]
+name = "embed-resource"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd"
+dependencies = [
+ "cc",
+ "memchr",
+ "rustc_version",
+ "toml 1.1.2+spec-1.1.0",
+ "vswhom",
+ "winreg",
+]
+
+[[package]]
+name = "embed_plist"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "erased-serde"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
+dependencies = [
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "field-offset"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
+dependencies = [
+ "memoffset",
+ "rustc_version",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "gdk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691"
+dependencies = [
+ "cairo-rs",
+ "gdk-pixbuf",
+ "gdk-sys",
+ "gio",
+ "glib",
+ "libc",
+ "pango",
+]
+
+[[package]]
+name = "gdk-pixbuf"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
+dependencies = [
+ "gdk-pixbuf-sys",
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+]
+
+[[package]]
+name = "gdk-pixbuf-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
+dependencies = [
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "gdk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
+dependencies = [
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "pkg-config",
+ "system-deps",
+]
+
+[[package]]
+name = "gdkwayland-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pkg-config",
+ "system-deps",
+]
+
+[[package]]
+name = "gdkx11"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe"
+dependencies = [
+ "gdk",
+ "gdkx11-sys",
+ "gio",
+ "glib",
+ "libc",
+ "x11",
+]
+
+[[package]]
+name = "gdkx11-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "libc",
+ "system-deps",
+ "x11",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+]
+
+[[package]]
+name = "gio"
+version = "0.18.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-util",
+ "gio-sys",
+ "glib",
+ "libc",
+ "once_cell",
+ "pin-project-lite",
+ "smallvec",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "gio-sys"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+ "winapi",
+]
+
+[[package]]
+name = "glib"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
+dependencies = [
+ "bitflags 2.13.0",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-task",
+ "futures-util",
+ "gio-sys",
+ "glib-macros",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "memchr",
+ "once_cell",
+ "smallvec",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "glib-macros"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
+dependencies = [
+ "heck 0.4.1",
+ "proc-macro-crate 2.0.2",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "glib-sys"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
+dependencies = [
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
+name = "gobject-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "gtk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a"
+dependencies = [
+ "atk",
+ "cairo-rs",
+ "field-offset",
+ "futures-channel",
+ "gdk",
+ "gdk-pixbuf",
+ "gio",
+ "glib",
+ "gtk-sys",
+ "gtk3-macros",
+ "libc",
+ "pango",
+ "pkg-config",
+]
+
+[[package]]
+name = "gtk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414"
+dependencies = [
+ "atk-sys",
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "system-deps",
+]
+
+[[package]]
+name = "gtk3-macros"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d"
+dependencies = [
+ "proc-macro-crate 1.3.1",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "html5ever"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2"
+dependencies = [
+ "log",
+ "markup5ever",
+]
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core 0.62.2",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "ico"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
+dependencies = [
+ "byteorder",
+ "png 0.17.16",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+ "serde",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "infer"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
+dependencies = [
+ "cfb",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "javascriptcore-rs"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc"
+dependencies = [
+ "bitflags 1.3.2",
+ "glib",
+ "javascriptcore-rs-sys",
+]
+
+[[package]]
+name = "javascriptcore-rs-sys"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "jni"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+dependencies = [
+ "cesu8",
+ "cfg-if",
+ "combine",
+ "jni-sys 0.3.1",
+ "log",
+ "thiserror 1.0.69",
+ "walkdir",
+ "windows-sys 0.45.0",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "json-patch"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08"
+dependencies = [
+ "jsonptr",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "jsonptr"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "keyboard-types"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
+dependencies = [
+ "bitflags 2.13.0",
+ "serde",
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "libappindicator"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
+dependencies = [
+ "glib",
+ "gtk",
+ "gtk-sys",
+ "libappindicator-sys",
+ "log",
+]
+
+[[package]]
+name = "libappindicator-sys"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
+dependencies = [
+ "gtk-sys",
+ "libloading",
+ "once_cell",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libdbus-sys"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
+dependencies = [
+ "pkg-config",
+]
+
+[[package]]
+name = "libloading"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
+dependencies = [
+ "cfg-if",
+ "winapi",
+]
+
+[[package]]
+name = "libredox"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "mac-notification-sys"
+version = "0.6.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
+dependencies = [
+ "cc",
+ "log",
+ "objc2",
+ "objc2-foundation",
+ "time",
+ "uuid",
+]
+
+[[package]]
+name = "markup5ever"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "minerva-gui"
+version = "0.3.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-build",
+ "tauri-plugin-dialog",
+ "tauri-plugin-notification",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "muda"
+version = "0.19.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878"
+dependencies = [
+ "crossbeam-channel",
+ "dpi",
+ "gtk",
+ "keyboard-types",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "once_cell",
+ "png 0.18.1",
+ "serde",
+ "thiserror 2.0.18",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "ndk"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
+dependencies = [
+ "bitflags 2.13.0",
+ "jni-sys 0.3.1",
+ "log",
+ "ndk-sys",
+ "num_enum",
+ "raw-window-handle",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ndk-sys"
+version = "0.6.0+11769913"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "notify-rust"
+version = "4.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891"
+dependencies = [
+ "futures-lite",
+ "log",
+ "mac-notification-sys",
+ "serde",
+ "tauri-winrt-notification",
+ "zbus",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+ "objc2-exception-helper",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
+dependencies = [
+ "bitflags 2.13.0",
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.13.0",
+ "dispatch2",
+ "objc2",
+]
+
+[[package]]
+name = "objc2-core-graphics"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
+dependencies = [
+ "bitflags 2.13.0",
+ "dispatch2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-io-surface",
+]
+
+[[package]]
+name = "objc2-core-image"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-location"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-text"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
+dependencies = [
+ "bitflags 2.13.0",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-exception-helper"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "libc",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-io-surface"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
+dependencies = [
+ "bitflags 2.13.0",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
+dependencies = [
+ "bitflags 2.13.0",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "objc2",
+ "objc2-cloud-kit",
+ "objc2-core-data",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-core-image",
+ "objc2-core-location",
+ "objc2-core-text",
+ "objc2-foundation",
+ "objc2-quartz-core",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-web-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "pango"
+version = "0.18.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
+dependencies = [
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+ "pango-sys",
+]
+
+[[package]]
+name = "pango-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+ "serde",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plist"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
+dependencies = [
+ "base64 0.22.1",
+ "indexmap 2.14.0",
+ "quick-xml",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags 2.13.0",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+[[package]]
+name = "proc-macro-crate"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
+dependencies = [
+ "once_cell",
+ "toml_edit 0.19.15",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
+dependencies = [
+ "toml_datetime 0.6.3",
+ "toml_edit 0.20.2",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit 0.25.12+spec-1.1.0",
+]
+
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.0",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "ref-cast"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "reqwest"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde",
+ "serde_json",
+ "sync_wrapper",
+ "tokio",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+]
+
+[[package]]
+name = "rfd"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
+dependencies = [
+ "block2",
+ "dispatch2",
+ "glib-sys",
+ "gobject-sys",
+ "gtk-sys",
+ "js-sys",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "raw-window-handle",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.0",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "schemars"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
+dependencies = [
+ "dyn-clone",
+ "indexmap 1.9.3",
+ "schemars_derive",
+ "serde",
+ "serde_json",
+ "url",
+ "uuid",
+]
+
+[[package]]
+name = "schemars"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars_derive"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde_derive_internals",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "selectors"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
+dependencies = [
+ "bitflags 2.13.0",
+ "cssparser",
+ "derive_more",
+ "log",
+ "new_debug_unreachable",
+ "phf",
+ "phf_codegen",
+ "precomputed-hash",
+ "rustc-hash",
+ "servo_arc",
+ "smallvec",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-untagged"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058"
+dependencies = [
+ "erased-serde",
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "serde_derive_internals"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_with"
+version = "3.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
+dependencies = [
+ "base64 0.22.1",
+ "bs58",
+ "chrono",
+ "hex",
+ "indexmap 1.9.3",
+ "indexmap 2.14.0",
+ "schemars 0.9.0",
+ "schemars 1.2.1",
+ "serde_core",
+ "serde_json",
+ "serde_with_macros",
+ "time",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "serialize-to-javascript"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5"
+dependencies = [
+ "serde",
+ "serde_json",
+ "serialize-to-javascript-impl",
+]
+
+[[package]]
+name = "serialize-to-javascript-impl"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "servo_arc"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
+dependencies = [
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "softbuffer"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
+dependencies = [
+ "bytemuck",
+ "js-sys",
+ "ndk",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "objc2-quartz-core",
+ "raw-window-handle",
+ "redox_syscall",
+ "tracing",
+ "wasm-bindgen",
+ "web-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "soup3"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f"
+dependencies = [
+ "futures-channel",
+ "gio",
+ "glib",
+ "libc",
+ "soup3-sys",
+]
+
+[[package]]
+name = "soup3-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27"
+dependencies = [
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "string_cache"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "swift-rs"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7"
+dependencies = [
+ "base64 0.21.7",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "system-deps"
+version = "6.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
+dependencies = [
+ "cfg-expr",
+ "heck 0.5.0",
+ "pkg-config",
+ "toml 0.8.2",
+ "version-compare",
+]
+
+[[package]]
+name = "tao"
+version = "0.35.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "core-foundation",
+ "core-graphics",
+ "crossbeam-channel",
+ "dbus",
+ "dispatch2",
+ "dlopen2",
+ "dpi",
+ "gdkwayland-sys",
+ "gdkx11-sys",
+ "gtk",
+ "jni",
+ "libc",
+ "log",
+ "ndk",
+ "ndk-sys",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "once_cell",
+ "parking_lot",
+ "percent-encoding",
+ "raw-window-handle",
+ "tao-macros",
+ "unicode-segmentation",
+ "url",
+ "windows",
+ "windows-core 0.61.2",
+ "windows-version",
+ "x11-dl",
+]
+
+[[package]]
+name = "tao-macros"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+
+[[package]]
+name = "tauri"
+version = "2.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5"
+dependencies = [
+ "anyhow",
+ "bytes",
+ "cookie",
+ "dirs",
+ "dunce",
+ "embed_plist",
+ "getrandom 0.3.4",
+ "glob",
+ "gtk",
+ "heck 0.5.0",
+ "http",
+ "jni",
+ "libc",
+ "log",
+ "mime",
+ "muda",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "percent-encoding",
+ "plist",
+ "raw-window-handle",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "serialize-to-javascript",
+ "swift-rs",
+ "tauri-build",
+ "tauri-macros",
+ "tauri-runtime",
+ "tauri-runtime-wry",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "tokio",
+ "tray-icon",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "window-vibrancy",
+ "windows",
+]
+
+[[package]]
+name = "tauri-build"
+version = "2.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632"
+dependencies = [
+ "anyhow",
+ "cargo_toml",
+ "dirs",
+ "glob",
+ "heck 0.5.0",
+ "json-patch",
+ "schemars 0.8.22",
+ "semver",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "tauri-winres",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-codegen"
+version = "2.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5"
+dependencies = [
+ "base64 0.22.1",
+ "brotli",
+ "ico",
+ "json-patch",
+ "plist",
+ "png 0.17.16",
+ "proc-macro2",
+ "quote",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2",
+ "syn 2.0.118",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "time",
+ "url",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-macros"
+version = "2.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+ "tauri-codegen",
+ "tauri-utils",
+]
+
+[[package]]
+name = "tauri-plugin"
+version = "2.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
+dependencies = [
+ "anyhow",
+ "glob",
+ "plist",
+ "schemars 0.8.22",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-plugin-dialog"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
+dependencies = [
+ "log",
+ "raw-window-handle",
+ "rfd",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "tauri-plugin-fs",
+ "thiserror 2.0.18",
+ "url",
+]
+
+[[package]]
+name = "tauri-plugin-fs"
+version = "2.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
+dependencies = [
+ "anyhow",
+ "dunce",
+ "glob",
+ "log",
+ "objc2-foundation",
+ "percent-encoding",
+ "schemars 0.8.22",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "tauri",
+ "tauri-plugin",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "toml 1.1.2+spec-1.1.0",
+ "url",
+]
+
+[[package]]
+name = "tauri-plugin-notification"
+version = "2.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
+dependencies = [
+ "log",
+ "notify-rust",
+ "rand",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+ "time",
+ "url",
+]
+
+[[package]]
+name = "tauri-runtime"
+version = "2.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8"
+dependencies = [
+ "cookie",
+ "dpi",
+ "gtk",
+ "http",
+ "jni",
+ "objc2",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "raw-window-handle",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "windows",
+]
+
+[[package]]
+name = "tauri-runtime-wry"
+version = "2.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
+dependencies = [
+ "gtk",
+ "http",
+ "jni",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "once_cell",
+ "percent-encoding",
+ "raw-window-handle",
+ "softbuffer",
+ "tao",
+ "tauri-runtime",
+ "tauri-utils",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "windows",
+ "wry",
+]
+
+[[package]]
+name = "tauri-utils"
+version = "2.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887"
+dependencies = [
+ "anyhow",
+ "brotli",
+ "cargo_metadata",
+ "ctor",
+ "dom_query",
+ "dunce",
+ "glob",
+ "http",
+ "infer",
+ "json-patch",
+ "log",
+ "memchr",
+ "phf",
+ "plist",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "schemars 0.8.22",
+ "semver",
+ "serde",
+ "serde-untagged",
+ "serde_json",
+ "serde_with",
+ "swift-rs",
+ "thiserror 2.0.18",
+ "toml 1.1.2+spec-1.1.0",
+ "url",
+ "urlpattern",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-winres"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
+dependencies = [
+ "dunce",
+ "embed-resource",
+ "toml 1.1.2+spec-1.1.0",
+]
+
+[[package]]
+name = "tauri-winrt-notification"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
+dependencies = [
+ "thiserror 2.0.18",
+ "windows",
+ "windows-version",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tendril"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
+dependencies = [
+ "new_debug_unreachable",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "time"
+version = "0.3.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
+dependencies = [
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.3",
+ "toml_edit 0.20.2",
+]
+
+[[package]]
+name = "toml"
+version = "0.9.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde_core",
+ "serde_spanned 1.1.1",
+ "toml_datetime 0.7.5+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 0.7.15",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde_core",
+ "serde_spanned 1.1.1",
+ "toml_datetime 1.1.1+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 1.0.3",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.7.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.19.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
+dependencies = [
+ "indexmap 2.14.0",
+ "toml_datetime 0.6.3",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.3",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
+dependencies = [
+ "indexmap 2.14.0",
+ "toml_datetime 1.1.1+spec-1.1.0",
+ "toml_parser",
+ "winnow 1.0.3",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
+dependencies = [
+ "winnow 1.0.3",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags 2.13.0",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "tray-icon"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc"
+dependencies = [
+ "crossbeam-channel",
+ "dirs",
+ "libappindicator",
+ "muda",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "once_cell",
+ "png 0.18.1",
+ "serde",
+ "thiserror 2.0.18",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typeid"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "unic-char-property"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221"
+dependencies = [
+ "unic-char-range",
+]
+
+[[package]]
+name = "unic-char-range"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc"
+
+[[package]]
+name = "unic-common"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc"
+
+[[package]]
+name = "unic-ucd-ident"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987"
+dependencies = [
+ "unic-char-property",
+ "unic-char-range",
+ "unic-ucd-version",
+]
+
+[[package]]
+name = "unic-ucd-version"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4"
+dependencies = [
+ "unic-common",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "urlpattern"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d"
+dependencies = [
+ "regex",
+ "serde",
+ "unic-ucd-ident",
+ "url",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.23.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "version-compare"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "vswhom"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
+dependencies = [
+ "libc",
+ "vswhom-sys",
+]
+
+[[package]]
+name = "vswhom-sys"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-streams"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web_atoms"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
+[[package]]
+name = "webkit2gtk"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-rs",
+ "gdk",
+ "gdk-sys",
+ "gio",
+ "gio-sys",
+ "glib",
+ "glib-sys",
+ "gobject-sys",
+ "gtk",
+ "gtk-sys",
+ "javascriptcore-rs",
+ "libc",
+ "once_cell",
+ "soup3",
+ "webkit2gtk-sys",
+]
+
+[[package]]
+name = "webkit2gtk-sys"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-sys-rs",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "gtk-sys",
+ "javascriptcore-rs-sys",
+ "libc",
+ "pkg-config",
+ "soup3-sys",
+ "system-deps",
+]
+
+[[package]]
+name = "webview2-com"
+version = "0.38.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
+dependencies = [
+ "webview2-com-macros",
+ "webview2-com-sys",
+ "windows",
+ "windows-core 0.61.2",
+ "windows-implement",
+ "windows-interface",
+]
+
+[[package]]
+name = "webview2-com-macros"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "webview2-com-sys"
+version = "0.38.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
+dependencies = [
+ "thiserror 2.0.18",
+ "windows",
+ "windows-core 0.61.2",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "window-vibrancy"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
+dependencies = [
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "raw-window-handle",
+ "windows-sys 0.59.0",
+ "windows-version",
+]
+
+[[package]]
+name = "windows"
+version = "0.61.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
+dependencies = [
+ "windows-collections",
+ "windows-core 0.61.2",
+ "windows-future",
+ "windows-link 0.1.3",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
+dependencies = [
+ "windows-core 0.61.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link 0.1.3",
+ "windows-result 0.3.4",
+ "windows-strings 0.4.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link 0.2.1",
+ "windows-result 0.4.1",
+ "windows-strings 0.5.1",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-numerics"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link 0.2.1",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows-threading"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-version"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "winnow"
+version = "0.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+
+[[package]]
+name = "winnow"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winreg"
+version = "0.55.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "wry"
+version = "0.55.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
+dependencies = [
+ "base64 0.22.1",
+ "block2",
+ "cookie",
+ "crossbeam-channel",
+ "dirs",
+ "dom_query",
+ "dpi",
+ "dunce",
+ "gdkx11",
+ "gtk",
+ "http",
+ "javascriptcore-rs",
+ "jni",
+ "libc",
+ "ndk",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "once_cell",
+ "percent-encoding",
+ "raw-window-handle",
+ "sha2",
+ "soup3",
+ "tao-macros",
+ "thiserror 2.0.18",
+ "url",
+ "webkit2gtk",
+ "webkit2gtk-sys",
+ "webview2-com",
+ "windows",
+ "windows-core 0.61.2",
+ "windows-version",
+ "x11-dl",
+]
+
+[[package]]
+name = "x11"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11-dl"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
+dependencies = [
+ "libc",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+ "synstructure",
+]
+
+[[package]]
+name = "zbus"
+version = "5.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow 1.0.3",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2"
+dependencies = [
+ "serde",
+ "winnow 1.0.3",
+ "zvariant",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9"
+
+[[package]]
+name = "zvariant"
+version = "5.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow 1.0.3",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 2.0.118",
+ "winnow 1.0.3",
+]
diff --git a/apps/gui/src-tauri/Cargo.toml b/apps/gui/src-tauri/Cargo.toml
new file mode 100644
index 0000000..0235a77
--- /dev/null
+++ b/apps/gui/src-tauri/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "minerva-gui"
+version = "0.3.0"
+edition = "2021"
+publish = false
+
+[lib]
+name = "minerva_gui_lib"
+crate-type = ["staticlib", "cdylib", "rlib"]
+
+[build-dependencies]
+tauri-build = { version = "2", features = [] }
+
+[dependencies]
+tauri = { version = "2", features = [] }
+tauri-plugin-dialog = "2"
+tauri-plugin-notification = "2"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
diff --git a/apps/gui/src-tauri/build.rs b/apps/gui/src-tauri/build.rs
new file mode 100644
index 0000000..d860e1e
--- /dev/null
+++ b/apps/gui/src-tauri/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ tauri_build::build()
+}
diff --git a/apps/gui/src-tauri/capabilities/default.json b/apps/gui/src-tauri/capabilities/default.json
new file mode 100644
index 0000000..4dd6503
--- /dev/null
+++ b/apps/gui/src-tauri/capabilities/default.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "../gen/schemas/desktop-schema.json",
+ "identifier": "default",
+ "description": "The webview only needs core events; the sidecar bridge is app-defined commands, which need no capability grants. No shell permissions on purpose: a compromised webview must not be able to spawn processes.",
+ "windows": ["main"],
+ "permissions": ["core:default", "dialog:default", "notification:default"]
+}
diff --git a/apps/gui/src-tauri/icons/128x128.png b/apps/gui/src-tauri/icons/128x128.png
new file mode 100644
index 0000000..1c9d98d
Binary files /dev/null and b/apps/gui/src-tauri/icons/128x128.png differ
diff --git a/apps/gui/src-tauri/icons/128x128@2x.png b/apps/gui/src-tauri/icons/128x128@2x.png
new file mode 100644
index 0000000..d6126c8
Binary files /dev/null and b/apps/gui/src-tauri/icons/128x128@2x.png differ
diff --git a/apps/gui/src-tauri/icons/32x32.png b/apps/gui/src-tauri/icons/32x32.png
new file mode 100644
index 0000000..2cc1ba9
Binary files /dev/null and b/apps/gui/src-tauri/icons/32x32.png differ
diff --git a/apps/gui/src-tauri/icons/64x64.png b/apps/gui/src-tauri/icons/64x64.png
new file mode 100644
index 0000000..c4938cf
Binary files /dev/null and b/apps/gui/src-tauri/icons/64x64.png differ
diff --git a/apps/gui/src-tauri/icons/icon.icns b/apps/gui/src-tauri/icons/icon.icns
new file mode 100644
index 0000000..44874f2
Binary files /dev/null and b/apps/gui/src-tauri/icons/icon.icns differ
diff --git a/apps/gui/src-tauri/icons/icon.ico b/apps/gui/src-tauri/icons/icon.ico
new file mode 100644
index 0000000..26510fc
Binary files /dev/null and b/apps/gui/src-tauri/icons/icon.ico differ
diff --git a/apps/gui/src-tauri/icons/icon.png b/apps/gui/src-tauri/icons/icon.png
new file mode 100644
index 0000000..8aa311e
Binary files /dev/null and b/apps/gui/src-tauri/icons/icon.png differ
diff --git a/apps/gui/src-tauri/src/lib.rs b/apps/gui/src-tauri/src/lib.rs
new file mode 100644
index 0000000..92b9954
--- /dev/null
+++ b/apps/gui/src-tauri/src/lib.rs
@@ -0,0 +1,23 @@
+mod sidecar;
+
+pub fn run() {
+ tauri::Builder::default()
+ .plugin(tauri_plugin_dialog::init())
+ .plugin(tauri_plugin_notification::init())
+ .manage(sidecar::SidecarState::default())
+ .invoke_handler(tauri::generate_handler![
+ sidecar::sidecar_start,
+ sidecar::sidecar_send,
+ sidecar::sidecar_kill,
+ sidecar::default_cwd
+ ])
+ .build(tauri::generate_context!())
+ .expect("failed to build tauri application")
+ .run(|app, event| {
+ // The kernel child must never outlive the app: webview teardown is
+ // not a reliable signal (reloads, crashes), so the kill lives here.
+ if let tauri::RunEvent::Exit = event {
+ sidecar::kill_sidecar(app);
+ }
+ });
+}
diff --git a/apps/gui/src-tauri/src/main.rs b/apps/gui/src-tauri/src/main.rs
new file mode 100644
index 0000000..6b67b5f
--- /dev/null
+++ b/apps/gui/src-tauri/src/main.rs
@@ -0,0 +1,9 @@
+// Prevents an extra console window on Windows in release builds.
+#![cfg_attr(
+ all(not(debug_assertions), target_os = "windows"),
+ windows_subsystem = "windows"
+)]
+
+fn main() {
+ minerva_gui_lib::run()
+}
diff --git a/apps/gui/src-tauri/src/sidecar.rs b/apps/gui/src-tauri/src/sidecar.rs
new file mode 100644
index 0000000..bfbd781
--- /dev/null
+++ b/apps/gui/src-tauri/src/sidecar.rs
@@ -0,0 +1,415 @@
+//! Kernel sidecar process manager.
+//!
+//! Rust owns the child (not the webview) so a webview reload — constant under
+//! Vite HMR — can never orphan a kernel. The protocol is newline-delimited
+//! JSON-RPC (see packages/protocol/src/stdio.ts): framing happens here on the
+//! raw byte stream, so a frame split across pipe reads or a multibyte UTF-8
+//! character on a chunk boundary can't corrupt a message. Each complete line
+//! is forwarded verbatim to the webview as a `minerva://line` event; process
+//! death surfaces as `minerva://exit`. Policy (restart, reconnect) lives in TS.
+
+use std::io::{BufRead, BufReader, Read, Write};
+use std::process::{Child, ChildStdin, Command, Stdio};
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Barrier, Mutex};
+use std::time::{Duration, Instant};
+
+use serde::Serialize;
+use tauri::{AppHandle, Emitter, Manager, State};
+
+/// Monotonic child generation: every spawn gets a fresh id so a lingering
+/// stdout thread from an earlier child can never mistake a newer child for
+/// its own (take it out of state, reap it, or report its death).
+static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
+
+pub struct Running {
+ generation: u64,
+ /// Shared with the stdout reader so EOF can terminate an otherwise-live
+ /// child without first removing it from the slot. Keeping the slot
+ /// occupied until the process is dead prevents a retry from spawning a
+ /// replacement beside an unreachable orphan.
+ child: Arc>,
+ /// Shared so sends can write without holding the slot lock; the inner
+ /// mutex keeps whole frames atomic. Shutdown drops the slot's clone to
+ /// signal EOF — an in-flight blocked writer holds the pipe open a little
+ /// longer, until the grace-expiry kill breaks its write.
+ stdin: Arc>,
+ /// The stdout forwarder thread — joined during shutdown so no stray
+ /// line events can outlive an intentional kill.
+ reader: Option>,
+}
+
+/// Two locks with strictly separated roles (deadlock audit):
+/// - `slot` is fine-grained: taken briefly by send, the reader thread's EOF
+/// path, and start/kill state swaps. Never held across a wait.
+/// - `lifecycle` serializes whole start/kill operations: a start during a
+/// shutdown waits until the old process is reaped AND its forwarder is
+/// joined, so a replacement can never race a draining kernel (webview
+/// reloads invoke start at any time). The reader thread NEVER takes
+/// `lifecycle`, and shutdown never holds `slot` while joining the reader —
+/// so the join cannot deadlock, even for a crash racing a kill.
+#[derive(Default)]
+pub struct SidecarState {
+ slot: Mutex>,
+ lifecycle: tauri::async_runtime::Mutex<()>,
+ /// Serializes stdin writes in invoke-arrival order (tokio mutexes are
+ /// fair) now that sends run off the slot lock and off the main thread.
+ writes: tauri::async_runtime::Mutex<()>,
+}
+
+#[derive(Clone, Serialize)]
+struct ExitPayload {
+ generation: u64,
+ code: Option,
+}
+
+/// Lock helper: a poisoned mutex only means a forwarding thread panicked
+/// mid-update; the Option inside is still coherent enough to take/replace.
+fn lock(state: &SidecarState) -> std::sync::MutexGuard<'_, Option> {
+ state
+ .slot
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+fn lock_child(child: &Mutex) -> std::sync::MutexGuard<'_, Child> {
+ child
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+/// Stdout is the response half of the protocol. Once it closes, a live child
+/// is unusable and must be terminated before its slot is released; otherwise
+/// start/kill can lose the only handle and leave an orphan beside a retry.
+fn reap_after_stdout_close(child: &Mutex) -> Option {
+ let mut child = lock_child(child);
+ match child.try_wait() {
+ Ok(Some(status)) => status.code(),
+ Ok(None) | Err(_) => {
+ let _ = child.kill();
+ child.wait().ok().and_then(|status| status.code())
+ }
+ }
+}
+
+/// Dev runs the kernel from source via Bun; release runs the compiled
+/// `minerva` binary bundled next to the app executable (externalBin).
+fn kernel_command() -> Result {
+ if cfg!(debug_assertions) {
+ // CARGO_MANIFEST_DIR = /apps/gui/src-tauri at compile time.
+ let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../../..")
+ .canonicalize()
+ .map_err(|e| format!("cannot resolve repo root: {e}"))?;
+ let entry = repo_root.join("packages/cli/src/index.tsx");
+ let mut cmd = Command::new("bun");
+ cmd.arg("run")
+ .arg(entry)
+ .arg("acp")
+ .arg("--allow-unconfigured");
+ cmd.current_dir(repo_root);
+ Ok(cmd)
+ } else {
+ let exe = std::env::current_exe().map_err(|e| format!("cannot resolve app path: {e}"))?;
+ let dir = exe
+ .parent()
+ .ok_or("app executable has no parent directory")?;
+ let mut cmd = Command::new(dir.join("minerva"));
+ cmd.arg("acp").arg("--allow-unconfigured");
+ Ok(cmd)
+ }
+}
+
+#[tauri::command]
+pub async fn sidecar_start(app: AppHandle, state: State<'_, SidecarState>) -> Result {
+ // Wait out any in-flight shutdown before touching the slot: without this,
+ // a webview-reload start could spawn a replacement while the old kernel
+ // is still draining and its forwarder still emitting into our events.
+ let _lifecycle = state.lifecycle.lock().await;
+ let existing = {
+ let guard = lock(&state);
+ guard
+ .as_ref()
+ .map(|running| (running.generation, Arc::clone(&running.child)))
+ };
+ if let Some((generation, child)) = existing {
+ // Still alive → idempotent no-op (webview reloads call start again).
+ // Exited but never taken (e.g. no listener saw the exit) → reap and respawn.
+ let status = lock_child(&child).try_wait();
+ match status {
+ Ok(None) => return Ok(generation),
+ _ => {
+ let dead = {
+ let mut guard = lock(&state);
+ match guard.as_ref() {
+ Some(running) if running.generation == generation => guard.take(),
+ _ => None,
+ }
+ };
+ if let Some(mut dead) = dead {
+ // Same discipline as the kill path: the dead child's reader
+ // must finish before a successor exists, or its last buffered
+ // frames could reach the successor's connection. The lifecycle
+ // lock (held for all of start) keeps anything else out of the
+ // empty slot while the reader finishes.
+ let _ = lock_child(&dead.child).wait();
+ if let Some(reader) = dead.reader.take() {
+ let _ = reader.join();
+ }
+ }
+ // If `dead` was None, the EOF reader already reaped and removed
+ // this generation. Either way, spawning below is now safe.
+ }
+ }
+ }
+
+ let mut guard = lock(&state);
+
+ let mut child = kernel_command()?
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .map_err(|e| format!("failed to spawn kernel: {e}"))?;
+
+ let stdin = child.stdin.take().ok_or("kernel stdin unavailable")?;
+ let stdout = child.stdout.take().ok_or("kernel stdout unavailable")?;
+ let stderr = child.stderr.take().ok_or("kernel stderr unavailable")?;
+
+ // stderr is diagnostics only (the protocol owns stdout) — mirror it to the
+ // dev terminal so kernel logs stay visible.
+ std::thread::spawn(move || {
+ for line in BufReader::new(stderr).lines().map_while(Result::ok) {
+ eprintln!("[minerva kernel] {line}");
+ }
+ });
+
+ let child = Arc::new(Mutex::new(child));
+ let reader_child = Arc::clone(&child);
+ let generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed);
+ let handle = app.clone();
+ // The child may exit immediately. Do not let the reader inspect the slot
+ // until Running (including its JoinHandle) has been installed.
+ let installed = Arc::new(Barrier::new(2));
+ let reader_installed = Arc::clone(&installed);
+ let reader = std::thread::spawn(move || {
+ reader_installed.wait();
+ let mut reader = stdout;
+ let mut buffer: Vec = Vec::new();
+ let mut chunk = [0u8; 64 * 1024];
+ loop {
+ match reader.read(&mut chunk) {
+ Ok(0) | Err(_) => break,
+ Ok(n) => {
+ buffer.extend_from_slice(&chunk[..n]);
+ // Frames are complete lines; bytes after the last newline
+ // stay buffered until the kernel sends the rest.
+ while let Some(pos) = buffer.iter().position(|&b| b == b'\n') {
+ let line: Vec = buffer.drain(..=pos).collect();
+ let text = String::from_utf8_lossy(&line[..line.len() - 1]);
+ let trimmed = text.trim();
+ if !trimmed.is_empty() {
+ let _ = handle.emit("minerva://line", trimmed);
+ }
+ }
+ }
+ }
+ }
+ // EOF. Only an UNEXPECTED death — our own generation still sitting in
+ // state — is reaped and reported here. If state holds nothing or a
+ // newer generation, this child was intentionally shut down (or
+ // replaced): shutdown_gracefully owns the reaping, and emitting an
+ // exit event would make the frontend run crash recovery against a
+ // kill it asked for (or worse, against the replacement child).
+ let is_current = {
+ let state = handle.state::();
+ let guard = lock(&state);
+ matches!(guard.as_ref(), Some(running) if running.generation == generation)
+ };
+ if !is_current {
+ return;
+ }
+
+ // Keep Running discoverable while reaping. A concurrent targeted kill
+ // can still take the slot and operate on this same shared child; if it
+ // does, the generation check below suppresses the intentional exit.
+ let code = reap_after_stdout_close(&reader_child);
+ let taken = {
+ let state = handle.state::();
+ let mut guard = lock(&state);
+ match guard.as_ref() {
+ Some(running) if running.generation == generation => guard.take(),
+ _ => None,
+ }
+ };
+ let Some(_running) = taken else { return };
+ let _ = handle.emit("minerva://exit", ExitPayload { generation, code });
+ });
+
+ *guard = Some(Running {
+ generation,
+ child,
+ stdin: Arc::new(Mutex::new(stdin)),
+ reader: Some(reader),
+ });
+ drop(guard);
+ installed.wait();
+ Ok(generation)
+}
+
+#[tauri::command]
+pub async fn sidecar_send(line: String, state: State<'_, SidecarState>) -> Result<(), String> {
+ // Clone the stdin handle under a BRIEF slot lock; never hold `slot`
+ // across the write. A kernel that stops draining stdin fills the OS pipe
+ // buffer and blocks write_all — with the old slot-held write, that
+ // wedged kill and app exit behind it forever.
+ let stdin = {
+ let guard = lock(&state);
+ guard.as_ref().ok_or("kernel is not running")?.stdin.clone()
+ };
+ // The writes mutex is fair (FIFO), so concurrent invokes keep frame
+ // ORDER; the per-stdin mutex inside keeps frames ATOMIC; and the
+ // blocking pool keeps a stalled write off the main thread.
+ let _order = state.writes.lock().await;
+ tauri::async_runtime::spawn_blocking(move || {
+ let mut stdin = stdin
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ stdin
+ .write_all(line.as_bytes())
+ .and_then(|()| stdin.write_all(b"\n"))
+ .and_then(|()| stdin.flush())
+ .map_err(|e| format!("write to kernel failed: {e}"))
+ })
+ .await
+ .map_err(|e| format!("write task failed: {e}"))?
+}
+
+/// The kernel drains in-flight work for up to 5s (kernel shutdownDrainMs)
+/// and then flushes session logs; give it that plus margin before killing.
+const SHUTDOWN_GRACE_MS: u64 = 7_000;
+
+/// Durability-preserving shutdown: closing stdin is the kernel's shutdown
+/// signal — the acp host runs kernel.close() (cancel, drain, flush session
+/// logs) on EOF and exits on its own. SIGKILL is the fallback, not the plan;
+/// killing first would lose pending session events and in-progress turns.
+/// Callers must NOT hold the state lock — this can block for the full grace.
+fn shutdown_gracefully(mut running: Running) {
+ drop(running.stdin);
+ let deadline = Instant::now() + Duration::from_millis(SHUTDOWN_GRACE_MS);
+ let mut reaped = false;
+ while Instant::now() < deadline {
+ if let Ok(Some(_)) = lock_child(&running.child).try_wait() {
+ eprintln!("[minerva gui] kernel exited gracefully on stdin close");
+ reaped = true;
+ break;
+ }
+ std::thread::sleep(Duration::from_millis(50));
+ }
+ if !reaped {
+ eprintln!("[minerva gui] kernel shutdown grace expired; killing");
+ let mut child = lock_child(&running.child);
+ let _ = child.kill();
+ let _ = child.wait();
+ }
+ // Drain the forwarder before declaring the shutdown complete: it exits on
+ // pipe EOF (bounded — the process is dead), and joining it guarantees no
+ // stray line event from this kernel can reach a successor's listeners.
+ // The EOF path finds the slot empty (we hold the Running) and returns
+ // without taking any long-held lock, so this join cannot deadlock.
+ if let Some(reader) = running.reader.take() {
+ let _ = reader.join();
+ }
+}
+
+#[tauri::command]
+pub async fn sidecar_kill(
+ generation: Option,
+ state: State<'_, SidecarState>,
+) -> Result<(), String> {
+ // Hold the lifecycle lock for the whole shutdown: a concurrent start
+ // queues behind it instead of spawning a replacement mid-drain.
+ let _lifecycle = state.lifecycle.lock().await;
+ let taken = {
+ let mut guard = lock(&state);
+ match (guard.as_ref(), generation) {
+ // A targeted kill for a superseded generation is a no-op: crash
+ // recovery may already have spawned a replacement, and a stale
+ // kill must not murder it (the caller's world is gone either way).
+ (Some(running), Some(gen)) if running.generation != gen => None,
+ (Some(_), _) => guard.take(),
+ (None, _) => None,
+ }
+ };
+ if let Some(running) = taken {
+ // Off the command thread: the grace wait can block for seconds, and
+ // the caller awaits so it knows when the old kernel is really gone.
+ tauri::async_runtime::spawn_blocking(move || shutdown_gracefully(running))
+ .await
+ .map_err(|e| format!("shutdown task failed: {e}"))?;
+ }
+ Ok(())
+}
+
+/// Exit-time cleanup, called from the RunEvent::Exit hook. Blocking the main
+/// thread is the point here — quit must not complete before the drain does.
+pub fn kill_sidecar(app: &AppHandle) {
+ let state = app.state::();
+ let _lifecycle = tauri::async_runtime::block_on(state.lifecycle.lock());
+ let taken = lock(&state).take();
+ if let Some(running) = taken {
+ shutdown_gracefully(running);
+ }
+}
+
+/// Starting project directory for the first session until the folder picker
+/// lands: the user's home directory, matching what a fresh terminal would use.
+#[tauri::command]
+pub fn default_cwd() -> String {
+ std::env::var("HOME")
+ .or_else(|_| std::env::var("USERPROFILE"))
+ .unwrap_or_else(|_| ".".to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[cfg(unix)]
+ #[test]
+ fn stdout_close_force_reaps_a_still_live_child() {
+ let mut child = Command::new("sh")
+ .arg("-c")
+ .arg("exec 1>&-; exec sleep 30")
+ .stdout(Stdio::piped())
+ .spawn()
+ .expect("spawn fixture child");
+ let mut stdout = child.stdout.take().expect("fixture stdout");
+ let mut byte = [0_u8; 1];
+ assert_eq!(stdout.read(&mut byte).expect("read fixture stdout"), 0);
+
+ let child = Mutex::new(child);
+ assert_eq!(reap_after_stdout_close(&child), None);
+ assert!(lock_child(&child)
+ .try_wait()
+ .expect("query fixture status")
+ .is_some());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn stdout_close_preserves_an_already_exited_status() {
+ let mut child = Command::new("sh")
+ .arg("-c")
+ .arg("exit 7")
+ .stdout(Stdio::piped())
+ .spawn()
+ .expect("spawn fixture child");
+ let mut stdout = child.stdout.take().expect("fixture stdout");
+ let mut byte = [0_u8; 1];
+ assert_eq!(stdout.read(&mut byte).expect("read fixture stdout"), 0);
+
+ let child = Mutex::new(child);
+ assert_eq!(reap_after_stdout_close(&child), Some(7));
+ }
+}
diff --git a/apps/gui/src-tauri/tauri.conf.json b/apps/gui/src-tauri/tauri.conf.json
new file mode 100644
index 0000000..827801c
--- /dev/null
+++ b/apps/gui/src-tauri/tauri.conf.json
@@ -0,0 +1,36 @@
+{
+ "$schema": "https://schema.tauri.app/config/2",
+ "productName": "Minerva",
+ "version": "0.3.0",
+ "identifier": "com.hutusi.minerva",
+ "build": {
+ "beforeDevCommand": "bun run dev:web",
+ "devUrl": "http://localhost:1420",
+ "beforeBuildCommand": "bun run build:web",
+ "frontendDist": "../dist"
+ },
+ "app": {
+ "windows": [
+ {
+ "label": "main",
+ "title": "Minerva",
+ "width": 1200,
+ "height": 800
+ }
+ ],
+ "security": {
+ "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: data:"
+ }
+ },
+ "bundle": {
+ "active": true,
+ "externalBin": ["binaries/minerva", "binaries/rg"],
+ "icon": [
+ "icons/32x32.png",
+ "icons/128x128.png",
+ "icons/128x128@2x.png",
+ "icons/icon.icns",
+ "icons/icon.ico"
+ ]
+ }
+}
diff --git a/apps/gui/src-tauri/tauri.dev.conf.json b/apps/gui/src-tauri/tauri.dev.conf.json
new file mode 100644
index 0000000..a6382e6
--- /dev/null
+++ b/apps/gui/src-tauri/tauri.dev.conf.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://schema.tauri.app/config/2",
+ "bundle": {
+ "externalBin": []
+ }
+}
diff --git a/apps/gui/src/app.tsx b/apps/gui/src/app.tsx
new file mode 100644
index 0000000..7dd27c2
--- /dev/null
+++ b/apps/gui/src/app.tsx
@@ -0,0 +1,676 @@
+import type { MinervaClient, SessionStore } from "@minerva/client";
+import type {
+ ConfigSetModelParams,
+ ConfigStateResult,
+ SessionModeState,
+ SessionSummary,
+ SkillInfo,
+} from "@minerva/protocol";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useReducer,
+ useRef,
+ useState,
+ useSyncExternalStore,
+} from "react";
+import { ConfigDialog } from "./components/ConfigDialog";
+import { Transcript } from "./components/chat/Transcript";
+import { UsageFooter } from "./components/chat/UsageFooter";
+import { HeaderBar } from "./components/HeaderBar";
+import { PermissionDialog } from "./components/PermissionDialog";
+import { ProjectTabs } from "./components/ProjectTabs";
+import { SessionBrowser } from "./components/SessionBrowser";
+import { useSessionStore } from "./hooks/use-session-store";
+import { createKernelManager, type KernelManager } from "./lib/kernel-manager";
+import { notify, pickFolder } from "./lib/native";
+import { decideNotification } from "./lib/notify";
+import { createSessionSlots } from "./lib/session-slots";
+import { createSessionSwitches } from "./lib/session-switches";
+import { createTauriSidecarBridge, fetchDefaultCwd } from "./lib/sidecar-bridge";
+import { ensureTabSession, isStaleSessionError } from "./lib/tab-session";
+import { deserializeTabs, EMPTY_TABS, serializeTabs, type Tab, tabsReducer } from "./lib/tabs";
+
+const TABS_KEY = "minerva.tabs.v1";
+const NOTIFY_MUTED_KEY = "minerva.notifyMuted.v1";
+
+interface Session {
+ id: string;
+ store: SessionStore;
+ cwd: string;
+ modes: SessionModeState | null;
+ profile: string | null;
+ skills: SkillInfo[];
+ profiles: string[];
+}
+
+// The manager lives outside React: StrictMode double-effects and HMR reloads
+// must attach to the running kernel, not respawn it.
+let managerSingleton: KernelManager | null = null;
+function getManager(): KernelManager {
+ managerSingleton ??= createKernelManager(createTauriSidecarBridge());
+ managerSingleton.start();
+ return managerSingleton;
+}
+
+/** Skills and profiles are assistive; a project without them must not block
+ * the session. Both are cwd-scoped (project settings layer). */
+async function fetchExtras(
+ client: MinervaClient,
+ cwd: string,
+): Promise<{ skills: SkillInfo[]; profiles: string[] }> {
+ const [skills, profiles] = await Promise.all([
+ client.listSkills(cwd).catch(() => []),
+ client
+ .listProfiles(cwd)
+ .then((result) => result.profiles.map((p) => p.name))
+ .catch(() => []),
+ ]);
+ return { skills, profiles };
+}
+
+async function createSession(
+ client: MinervaClient,
+ cwd: string,
+ profile?: string,
+): Promise {
+ const result = await client.newSession(cwd, profile !== undefined ? { profile } : {});
+ return {
+ id: result.sessionId,
+ store: result.store,
+ cwd,
+ modes: result.modes ?? null,
+ profile: result.profile ?? null,
+ ...(await fetchExtras(client, cwd)),
+ };
+}
+
+async function resumeSession(
+ client: MinervaClient,
+ sessionId: string,
+ cwd: string,
+): Promise {
+ const result = await client.loadSession(sessionId, cwd);
+ return {
+ id: result.sessionId,
+ store: result.store,
+ cwd,
+ modes: result.modes ?? null,
+ profile: result.profile ?? null,
+ ...(await fetchExtras(client, cwd)),
+ };
+}
+
+export function App() {
+ const manager = useMemo(getManager, []);
+ const kernel = useSyncExternalStore(
+ useCallback((listener: () => void) => manager.subscribe(listener), [manager]),
+ () => manager.snapshot,
+ );
+ const [tabs, dispatch] = useReducer(
+ tabsReducer,
+ undefined,
+ () => deserializeTabs(localStorage.getItem(TABS_KEY)) ?? EMPTY_TABS,
+ );
+ const [sessions, setSessions] = useState>(new Map());
+ const [configState, setConfigState] = useState(null);
+ const [configDone, setConfigDone] = useState(false);
+ const [browserOpen, setBrowserOpen] = useState(false);
+ const [notice, setNotice] = useState(null);
+ const ensuring = useRef(new Set());
+ // Unsent composer drafts survive tab switches (Chat remounts per tab).
+ const drafts = useRef(new Map());
+ // Async install results commit only while their token is current — a newer
+ // switch, a closed tab, or a replaced client makes them stale (discarded
+ // AND closed, so no invisible client registration survives).
+ const slots = useRef(createSessionSlots());
+ // A target has at most one underlying load per tab, even when the user's
+ // latest intent cycles A → B → A while all three selections are pending.
+ const switches = useRef(createSessionSwitches());
+ // Authoritative sessions map, updated synchronously at commit time; React
+ // state mirrors it for rendering. Completion handlers must never read the
+ // render-closure `sessions` — it can lag behind a just-committed install.
+ const sessionsRef = useRef>(new Map());
+
+ // Stable (touches only refs + the stable state setter) so effects can
+ // depend on it without re-running.
+ const commitSession = useCallback(
+ (tabId: string, session: Session, activeClient: MinervaClient) => {
+ const previous = sessionsRef.current.get(tabId);
+ if (previous && previous.id !== session.id) {
+ if (previous.store.snapshot.busy) activeClient.cancel(previous.id);
+ activeClient.closeSession(previous.id);
+ }
+ const next = new Map(sessionsRef.current).set(tabId, session);
+ sessionsRef.current = next;
+ setSessions(next);
+ },
+ [],
+ );
+
+ const removeSession = (tabId: string) => {
+ const next = new Map(sessionsRef.current);
+ next.delete(tabId);
+ sessionsRef.current = next;
+ setSessions(next);
+ };
+
+ /** The single write path for in-place session patches (profile switches,
+ * future metadata edits): applies only while the SAME session is still
+ * installed in the tab — a delayed response after a switch must not
+ * reinstall a detached store — and keeps sessionsRef and React state in
+ * lockstep so later commits can't rebuild from a stale reference. */
+ const updateSession = useCallback(
+ (tabId: string, sessionId: string, patch: Partial): boolean => {
+ const current = sessionsRef.current.get(tabId);
+ if (!current || current.id !== sessionId) return false;
+ const next = new Map(sessionsRef.current).set(tabId, { ...current, ...patch });
+ sessionsRef.current = next;
+ setSessions(next);
+ return true;
+ },
+ [],
+ );
+
+ const client = kernel.phase === "ready" ? kernel.client : null;
+ // Gate sessions on configuration: first run must store a key first.
+ const configReady = client !== null && (!kernel.config?.needsApiKey || configDone);
+ const activeTab = tabs.tabs.find((tab) => tab.id === tabs.activeTabId) ?? null;
+ const activeSession = activeTab ? (sessions.get(activeTab.id) ?? null) : null;
+
+ // A restart hands out a FRESH client; every store from the old one is dead,
+ // and any in-flight install against it must fail its token check.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: client identity IS the trigger
+ useEffect(() => {
+ sessionsRef.current = new Map();
+ setSessions(sessionsRef.current);
+ ensuring.current.clear();
+ slots.current.invalidateAll();
+ switches.current.invalidateAll();
+ }, [client]);
+
+ useEffect(() => {
+ localStorage.setItem(TABS_KEY, serializeTabs(tabs));
+ }, [tabs]);
+
+ // First-run config dialog, once per app life.
+ useEffect(() => {
+ if (kernel.phase === "ready" && kernel.config?.needsApiKey && !configDone) {
+ setConfigState(kernel.config);
+ }
+ }, [kernel.phase, kernel.config, configDone]);
+
+ // Always have at least one tab (home directory until a folder is picked).
+ useEffect(() => {
+ if (kernel.phase !== "ready" || tabs.tabs.length > 0) return;
+ fetchDefaultCwd().then(
+ (cwd) => dispatch({ type: "open", tabId: crypto.randomUUID(), cwd }),
+ (cause: unknown) => setNotice(cause instanceof Error ? cause.message : String(cause)),
+ );
+ }, [kernel.phase, tabs.tabs.length]);
+
+ // Materialize the active tab's session lazily: resume its persisted id, or
+ // fall back to (then record) a fresh one. Background tabs load on activation.
+ useEffect(() => {
+ if (!configReady || !client || !activeTab) return;
+ if (sessions.has(activeTab.id) || ensuring.current.has(activeTab.id)) return;
+ ensuring.current.add(activeTab.id);
+ const tab = activeTab;
+ const token = slots.current.begin(tab.id);
+ ensureTabSession(
+ {
+ load: (sessionId, cwd) => resumeSession(client, sessionId, cwd),
+ create: (cwd) => createSession(client, cwd),
+ },
+ tab,
+ isStaleSessionError,
+ ).then(
+ ({ session }) => {
+ ensuring.current.delete(tab.id);
+ // Superseded (tab closed, user switched, client replaced): the tab
+ // moved on — detach the orphan instead of installing it invisibly.
+ if (!slots.current.isCurrent(tab.id, token)) {
+ client.closeSession(session.id);
+ return;
+ }
+ commitSession(tab.id, session, client);
+ if (session.id !== tab.sessionId) {
+ dispatch({ type: "attach-session", tabId: tab.id, sessionId: session.id });
+ }
+ },
+ (cause: unknown) => {
+ ensuring.current.delete(tab.id);
+ if (!slots.current.isCurrent(tab.id, token)) return; // nobody's waiting
+ // A rejection caused by the kernel dying mid-load beats the [client]
+ // effect that would invalidate this token (microtask vs render). The
+ // crash banner + auto-restart own that story — a "couldn't open"
+ // notice here would be both wrong and permanent.
+ if (manager.snapshot.client !== client) return;
+ const folder = tab.cwd.split("/").filter(Boolean).at(-1) ?? tab.cwd;
+ const message = cause instanceof Error ? cause.message : String(cause);
+ setNotice(
+ `Couldn't open the session in ${folder}: ${message} — switch away and back to retry.`,
+ );
+ },
+ );
+ }, [configReady, client, activeTab, sessions, commitSession, manager]);
+
+ const reportError = (cause: unknown) => {
+ const message = cause instanceof Error ? cause.message : String(cause);
+ if (activeSession) activeSession.store.addError(message);
+ else setNotice(message);
+ };
+
+ /** Replace the active tab's session (browser pick / new session). Rapid
+ * switches resolve in USER order, not completion order: each begins a
+ * token, and a superseded result is closed instead of committed. Repeating
+ * any still-in-flight target reuses its promise but gets a fresh token, so
+ * A → B → A honors the final A without duplicate client registration. */
+ const switchWithinTab = (tab: Tab, target: string, next: () => Promise) => {
+ const activeClient = client;
+ const token = slots.current.begin(tab.id);
+ switches.current.begin(tab.id, target, next).then(
+ (session) => {
+ if (!slots.current.isCurrent(tab.id, token)) {
+ // A stale handler for a reused promise must leave the store attached
+ // for the newer handler targeting that same session.
+ if (!switches.current.isDesired(tab.id, target)) {
+ activeClient?.closeSession(session.id);
+ }
+ return;
+ }
+ switches.current.finish(tab.id, target);
+ setBrowserOpen(false);
+ if (activeClient) commitSession(tab.id, session, activeClient);
+ dispatch({ type: "attach-session", tabId: tab.id, sessionId: session.id });
+ },
+ (cause: unknown) => {
+ if (!slots.current.isCurrent(tab.id, token)) return;
+ switches.current.finish(tab.id, target);
+ setBrowserOpen(false);
+ reportError(cause);
+ },
+ );
+ };
+
+ const addTab = () => {
+ pickFolder().then((path) => {
+ if (path) dispatch({ type: "open", tabId: crypto.randomUUID(), cwd: path });
+ }, reportError);
+ };
+
+ const closeTab = (tabId: string) => {
+ // Outstanding installs for this tab are stale from here on.
+ slots.current.invalidate(tabId);
+ switches.current.invalidate(tabId);
+ const session = sessionsRef.current.get(tabId);
+ if (session && client) {
+ if (session.store.snapshot.busy) client.cancel(session.id);
+ client.closeSession(session.id);
+ }
+ removeSession(tabId);
+ dispatch({ type: "close", tabId });
+ };
+
+ const applyConfig = async (params: ConfigSetModelParams) => {
+ if (!client) throw new Error("kernel is not connected");
+ await client.setModel(params);
+ setConfigDone(true);
+ setConfigState(null);
+ };
+
+ const stores = useMemo(() => {
+ const map = new Map();
+ for (const [tabId, session] of sessions) map.set(tabId, session.store);
+ return map;
+ }, [sessions]);
+
+ return (
+
+ {kernel.phase !== "ready" ? (
+
manager.start()}
+ />
+ ) : null}
+ {notice ? (
+
+ {notice}
+ setNotice(null)} className="px-1">
+ ×
+
+
+ ) : null}
+ dispatch({ type: "activate", tabId })}
+ onCloseTab={closeTab}
+ onAddTab={addTab}
+ />
+ {activeSession && activeTab && client ? (
+ drafts.current.set(activeTab.id, text)}
+ onOpenConfig={() => client.getConfigState().then(setConfigState, reportError)}
+ onOpenFolder={addTab}
+ onOpenSessions={() => setBrowserOpen(true)}
+ onNewSession={() => {
+ if (activeTab) {
+ // Unique target per click: two "New"s are two sessions (the
+ // second supersedes via its token), never a dedup.
+ switchWithinTab(activeTab, `new:${crypto.randomUUID()}`, () =>
+ createSession(client, activeTab.cwd, activeSession.profile ?? undefined),
+ );
+ }
+ }}
+ onSetProfile={(profile) => {
+ const tabId = activeTab.id;
+ const sessionId = activeSession.id;
+ client.setProfile(sessionId, profile).then(
+ () => {
+ // Only lands while this exact session is still installed; a
+ // superseded response must not resurrect a detached store.
+ if (updateSession(tabId, sessionId, { profile })) {
+ sessionsRef.current.get(tabId)?.store.addInfo(`profile → ${profile ?? "(none)"}`);
+ }
+ },
+ (cause: unknown) => {
+ if (sessionsRef.current.get(tabId)?.id === sessionId) reportError(cause);
+ },
+ );
+ }}
+ />
+ ) : (
+
+
+ {kernel.phase !== "ready"
+ ? "Waiting for the kernel…"
+ : configState && !configDone
+ ? "Configure a model to get started."
+ : "Opening session…"}
+
+
+ )}
+ {browserOpen && activeTab && activeSession && client ? (
+ {
+ // If it's already open in another tab, go there instead.
+ const existing = tabs.tabs.find((tab) => tab.sessionId === summary.sessionId);
+ if (existing && existing.id !== activeTab.id) {
+ dispatch({ type: "activate", tabId: existing.id });
+ setBrowserOpen(false);
+ return;
+ }
+ switchWithinTab(activeTab, `resume:${summary.sessionId}`, () =>
+ resumeSession(client, summary.sessionId, summary.cwd),
+ );
+ }}
+ onClose={() => setBrowserOpen(false)}
+ />
+ ) : null}
+ {configState && client ? (
+ setConfigState(null) : undefined
+ }
+ />
+ ) : null}
+
+
+ );
+}
+
+function KernelBanner({
+ phase,
+ exitCode,
+ error,
+ onRetry,
+}: {
+ phase: "starting" | "restarting" | "down";
+ exitCode: number | null;
+ error: string | null;
+ onRetry: () => void;
+}) {
+ const text =
+ phase === "starting"
+ ? "Starting kernel…"
+ : phase === "restarting"
+ ? `Kernel exited${exitCode !== null ? ` (code ${exitCode})` : ""} — restarting…`
+ : `Kernel is down${exitCode !== null ? ` (exit code ${exitCode})` : ""}${error ? `: ${error}` : ""}`;
+ return (
+
+ {text}
+ {phase === "down" ? (
+
+ Restart kernel
+
+ ) : null}
+
+ );
+}
+
+function Chat({
+ client,
+ session,
+ initialDraft,
+ onDraftChange,
+ onOpenConfig,
+ onOpenFolder,
+ onOpenSessions,
+ onNewSession,
+ onSetProfile,
+}: {
+ client: MinervaClient;
+ session: Session;
+ initialDraft: string;
+ onDraftChange: (text: string) => void;
+ onOpenConfig: () => void;
+ onOpenFolder: () => void;
+ onOpenSessions: () => void;
+ onNewSession: () => void;
+ onSetProfile: (profile: string | null) => void;
+}) {
+ const vm = useSessionStore(session.store);
+ const [draft, setDraftState] = useState(initialDraft);
+ const setDraft = (text: string) => {
+ setDraftState(text);
+ onDraftChange(text);
+ };
+ const [notifyMuted, setNotifyMuted] = useState(
+ () => localStorage.getItem(NOTIFY_MUTED_KEY) === "1",
+ );
+ const scrollRef = useRef(null);
+ const didInitialScroll = useRef(false);
+
+ // Stick-to-bottom: follow streaming output unless the reader scrolled up.
+ // Items identity changes on every applied update, so it is the right trigger.
+ // The first paint with content (resume replay, tab switch) always jumps to
+ // the latest message — a fresh scroll container starts at the top.
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (!el || vm.items.length === 0) return;
+ if (!didInitialScroll.current) {
+ didInitialScroll.current = true;
+ el.scrollTop = el.scrollHeight;
+ return;
+ }
+ const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
+ if (nearBottom) el.scrollTop = el.scrollHeight;
+ }, [vm.items]);
+
+ // Esc cancels the running turn, same key as the TUI.
+ useEffect(() => {
+ if (!vm.busy) return;
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") client.cancel(session.id);
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [vm.busy, client, session.id]);
+
+ const reportError = (cause: unknown) => {
+ session.store.addError(cause instanceof Error ? cause.message : String(cause));
+ };
+
+ const submit = () => {
+ const text = draft.trim();
+ if (!text || vm.busy) return;
+ setDraft("");
+ const startedAt = performance.now();
+ client.prompt(session.id, text).then((stopReason) => {
+ const decision = decideNotification({
+ stopReason,
+ focused: document.hasFocus(),
+ durationMs: performance.now() - startedAt,
+ muted: notifyMuted,
+ project: session.cwd.split("/").filter(Boolean).at(-1) ?? session.cwd,
+ });
+ if (decision) void notify(decision.title, decision.body);
+ }, reportError);
+ };
+
+ // Skill autocomplete: assistive only — any text, /-prefixed or not, goes to
+ // the kernel verbatim (it expands known /skills into their instructions).
+ const skillMatches =
+ draft.startsWith("/") && !draft.includes(" ")
+ ? session.skills.filter((skill) => skill.name.startsWith(draft.slice(1)))
+ : [];
+
+ return (
+ <>
+ {
+ client.setMode(session.id, modeId).then(() => session.store.setMode(modeId), reportError);
+ }}
+ onSetProfile={onSetProfile}
+ onCompact={() => {
+ client
+ .compact(session.id)
+ .then(() => session.store.addInfo("context compacted"), reportError);
+ }}
+ />
+
+
+
+ {vm.busy &&
Working… (Esc cancels)
}
+
+
+
+
+ {skillMatches.length > 0 ? (
+
+ {skillMatches.map((skill) => (
+ setDraft(`/${skill.name} `)}
+ title={skill.description}
+ className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground hover:bg-secondary"
+ >
+ /{skill.name}
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+ {
+ const next = !notifyMuted;
+ localStorage.setItem(NOTIFY_MUTED_KEY, next ? "1" : "0");
+ setNotifyMuted(next);
+ }}
+ title={
+ notifyMuted
+ ? "Notifications muted — click to enable"
+ : "Notify when a long turn finishes in the background"
+ }
+ className="rounded-md px-2 py-0.5 text-xs text-muted-foreground hover:bg-secondary"
+ >
+ {notifyMuted ? "🔕" : "🔔"}
+
+
+ ⚙ model
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/gui/src/components/ConfigDialog.tsx b/apps/gui/src/components/ConfigDialog.tsx
new file mode 100644
index 0000000..bde3777
--- /dev/null
+++ b/apps/gui/src/components/ConfigDialog.tsx
@@ -0,0 +1,227 @@
+import { splitModelRef } from "@minerva/client";
+import type {
+ ConfigProviderState,
+ ConfigSetModelParams,
+ ConfigStateResult,
+} from "@minerva/protocol";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { buildSetModelParams } from "../lib/config-form";
+
+const CUSTOM = "custom…";
+
+function keyHint(provider: ConfigProviderState | undefined): string {
+ if (!provider) return "";
+ if (provider.requiresApiKey === false) return "no key needed for this endpoint";
+ switch (provider.keySource) {
+ case "env":
+ return `key found in ${provider.keyVar} — leave blank to keep it`;
+ case "settings":
+ return "a key is stored in settings — leave blank to keep it";
+ default:
+ return `no key found (checked ${provider.keyVar} and settings)`;
+ }
+}
+
+/**
+ * Provider/key/model form, mirroring the TUI's /config panel over the same
+ * protocol call (minerva/config/set_model). One screen instead of wizard
+ * steps — a GUI can show all three decisions at once.
+ */
+export function ConfigDialog({
+ state,
+ onSubmit,
+ onClose,
+}: {
+ state: ConfigStateResult;
+ /** Persist + hot-swap. Rejections render inline; the dialog stays open. */
+ onSubmit: (params: ConfigSetModelParams) => Promise;
+ /** Absent on first run: there is nothing to fall back to yet. */
+ onClose?: (() => void) | undefined;
+}) {
+ // Provider = before the FIRST slash; the model may itself contain slashes.
+ const current = splitModelRef(state.model);
+ const currentProvider = current.provider ?? "anthropic";
+ const names = state.providers.map((p) => p.name);
+ const [providerName, setProviderName] = useState(
+ names.includes(currentProvider) ? currentProvider : (names[0] ?? CUSTOM),
+ );
+ const [customName, setCustomName] = useState("");
+ const [customBaseUrl, setCustomBaseUrl] = useState("");
+ const [apiKey, setApiKey] = useState("");
+ const selected = useMemo(
+ () => state.providers.find((p) => p.name === providerName),
+ [state.providers, providerName],
+ );
+ const [model, setModel] = useState(() => (currentProvider === providerName ? current.model : ""));
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Modal ownership: focus starts on the provider select, and Escape closes
+ // when there is something to fall back to (never on first run).
+ const providerSelectRef = useRef(null);
+ useEffect(() => {
+ const previous = document.activeElement;
+ providerSelectRef.current?.focus();
+ return () => {
+ if (previous instanceof HTMLElement && previous.isConnected) previous.focus();
+ };
+ }, []);
+ useEffect(() => {
+ if (!onClose) return;
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ event.stopPropagation();
+ onClose();
+ }
+ };
+ window.addEventListener("keydown", onKeyDown, true);
+ return () => window.removeEventListener("keydown", onKeyDown, true);
+ }, [onClose]);
+
+ const isCustom = providerName === CUSTOM;
+ const effectiveName = isCustom ? customName.trim() : providerName;
+ const effectiveModel = model.trim() || selected?.defaultModel || "";
+ const canSubmit =
+ !busy &&
+ effectiveName.length > 0 &&
+ effectiveModel.length > 0 &&
+ (!isCustom || customBaseUrl.trim());
+
+ const submit = () => {
+ if (!canSubmit) return;
+ const params = buildSetModelParams({
+ providerName: effectiveName,
+ isCustom,
+ baseUrl: customBaseUrl.trim(),
+ apiKey: apiKey.trim(),
+ model: effectiveModel,
+ });
+ setBusy(true);
+ setError(null);
+ onSubmit(params)
+ .catch((cause: unknown) => {
+ setError(cause instanceof Error ? cause.message : String(cause));
+ })
+ .finally(() => setBusy(false));
+ };
+
+ return (
+
+
+
Model & provider
+
+ current: {state.model} · stored in ~/.minerva/settings.json
+
+
+
+ Provider
+
+
{
+ setProviderName(event.target.value);
+ setModel("");
+ }}
+ className="mt-1 w-full rounded-md border bg-background px-2 py-1.5 text-sm"
+ >
+ {state.providers.map((p) => (
+
+ {p.name}
+ {p.keySource !== "none" ? " ✓" : ""}
+
+ ))}
+ {CUSTOM}
+
+
+ {isCustom ? (
+
+ ) : null}
+
+
+ API key
+
+
setApiKey(event.target.value)}
+ placeholder="sk-…"
+ className="mt-1 w-full rounded-md border bg-background px-2 py-1.5 font-mono text-sm"
+ />
+
{keyHint(selected)}
+
+
+ Model
+
+
setModel(event.target.value)}
+ placeholder={selected?.defaultModel ?? "model id"}
+ list="cfg-model-options"
+ className="mt-1 w-full rounded-md border bg-background px-2 py-1.5 text-sm"
+ />
+
+ {(selected?.models ?? []).map((m) => (
+
+ ))}
+
+
+ {error ?
✖ {error}
: null}
+
+
+ {onClose ? (
+
+ Cancel
+
+ ) : null}
+
+ {busy ? "Saving…" : "Save & use"}
+
+
+
+
+ );
+}
diff --git a/apps/gui/src/components/HeaderBar.tsx b/apps/gui/src/components/HeaderBar.tsx
new file mode 100644
index 0000000..fcc6173
--- /dev/null
+++ b/apps/gui/src/components/HeaderBar.tsx
@@ -0,0 +1,101 @@
+import type { SessionModeState } from "@minerva/protocol";
+
+/** Project + session controls: folder, session browser, new session, mode,
+ * profile, compact. The GUI's equivalent of the TUI's slash commands. */
+export function HeaderBar({
+ cwd,
+ modes,
+ currentModeId,
+ profiles,
+ profile,
+ busy,
+ onOpenFolder,
+ onOpenSessions,
+ onNewSession,
+ onSetMode,
+ onSetProfile,
+ onCompact,
+}: {
+ cwd: string;
+ modes: SessionModeState | null;
+ currentModeId: string | undefined;
+ profiles: string[];
+ profile: string | null;
+ busy: boolean;
+ onOpenFolder: () => void;
+ onOpenSessions: () => void;
+ onNewSession: () => void;
+ onSetMode: (modeId: string) => void;
+ onSetProfile: (profile: string | null) => void;
+ onCompact: () => void;
+}) {
+ const folder = cwd.split("/").filter(Boolean).at(-1) ?? cwd;
+ return (
+
+
+ 📁 {folder}
+
+
+ Sessions
+
+
+ New
+
+
+ {profiles.length > 0 ? (
+ onSetProfile(event.target.value || null)}
+ title="Profile"
+ className="rounded-md border bg-background px-1.5 py-1 text-xs"
+ >
+ no profile
+ {profiles.map((name) => (
+
+ {name}
+
+ ))}
+
+ ) : null}
+ {modes ? (
+ onSetMode(event.target.value)}
+ title="Session mode"
+ className="rounded-md border bg-background px-1.5 py-1 text-xs"
+ >
+ {modes.availableModes.map((mode) => (
+
+ {mode.name}
+
+ ))}
+
+ ) : null}
+
+ Compact
+
+
+
+ );
+}
diff --git a/apps/gui/src/components/PermissionDialog.tsx b/apps/gui/src/components/PermissionDialog.tsx
new file mode 100644
index 0000000..66e4522
--- /dev/null
+++ b/apps/gui/src/components/PermissionDialog.tsx
@@ -0,0 +1,158 @@
+import { firstLines } from "@minerva/client";
+import type {
+ PermissionOptionKind,
+ RequestPermissionParams,
+ RequestPermissionResult,
+} from "@minerva/protocol";
+import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
+import type { PermissionQueue } from "../lib/permission-queue";
+import { DiffView } from "./chat/DiffView";
+
+/** Same hotkeys as the TUI, keyed by option KIND so the kernel can rename
+ * or reorder options without breaking muscle memory. */
+const HOTKEYS: Record = {
+ y: "allow_once",
+ a: "allow_always",
+ n: "reject_once",
+};
+
+function hotkeyFor(kind: PermissionOptionKind): string | undefined {
+ return Object.entries(HOTKEYS).find(([, k]) => k === kind)?.[0];
+}
+
+const OPTION_STYLE: Record = {
+ allow_once: "bg-primary text-primary-foreground hover:opacity-90",
+ allow_always: "border hover:bg-secondary",
+ reject_once: "border text-destructive hover:bg-destructive/10",
+ reject_always: "border text-destructive hover:bg-destructive/10",
+};
+
+export function PermissionDialog({ queue }: { queue: PermissionQueue }) {
+ const subscribe = useCallback((listener: () => void) => queue.subscribe(listener), [queue]);
+ const { current, depth } = useSyncExternalStore(subscribe, () => queue.snapshot);
+
+ const respond = useCallback(
+ (result: RequestPermissionResult) => current?.resolve(result),
+ [current],
+ );
+
+ // Own the keyboard while open: focus lands on the first option so Enter
+ // confirms it, and handled keys never leak into whatever sat behind the
+ // modal (no preventDefault would insert the hotkey character into a
+ // focused textarea AND resolve the request at once).
+ const firstOptionRef = useRef(null);
+ useEffect(() => {
+ if (!current) return;
+ // Give focus back where it came from (usually the composer) once this
+ // request resolves — cleanup runs per request, so a stacked queue hands
+ // focus along correctly and the last resolve restores the original.
+ const previous = document.activeElement;
+ firstOptionRef.current?.focus();
+ return () => {
+ if (previous instanceof HTMLElement && previous.isConnected) previous.focus();
+ };
+ }, [current]);
+
+ useEffect(() => {
+ if (!current) return;
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ // ACP cancelled outcome: abandon the whole turn, not just this call.
+ event.preventDefault();
+ event.stopPropagation();
+ respond({ outcome: { outcome: "cancelled" } });
+ return;
+ }
+ // Hotkeys must never double as text input: if focus escaped back into
+ // an editable element (there is no focus trap), typing wins.
+ const target = event.target;
+ if (
+ target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement ||
+ (target instanceof HTMLElement && target.isContentEditable)
+ ) {
+ return;
+ }
+ const kind = HOTKEYS[event.key.toLowerCase()];
+ const option = kind && current.request.options.find((o) => o.kind === kind);
+ if (option) {
+ event.preventDefault();
+ event.stopPropagation();
+ respond({ outcome: { outcome: "selected", optionId: option.optionId } });
+ }
+ };
+ // Capture phase so the chat's own Esc-cancels-turn handler never races.
+ window.addEventListener("keydown", onKeyDown, true);
+ return () => window.removeEventListener("keydown", onKeyDown, true);
+ }, [current, respond]);
+
+ if (!current) return null;
+ const { toolCall, options, taskToolCallId } = current.request;
+
+ return (
+
+
+
+ Permission required{taskToolCallId ? " (from subagent)" : ""}
+ {depth > 1 ? ` · ${depth - 1} more waiting` : ""}
+
+
{toolCall.title}
+
+
+ {options.map((option, index) => {
+ const hotkey = hotkeyFor(option.kind);
+ return (
+
+ respond({ outcome: { outcome: "selected", optionId: option.optionId } })
+ }
+ className={`rounded-md px-3 py-1.5 text-sm ${OPTION_STYLE[option.kind]}`}
+ >
+ {option.name}
+ {hotkey ? ({hotkey}) : null}
+
+ );
+ })}
+
+
esc cancels the turn
+
+
+ );
+}
+
+/**
+ * What the call will actually do, from rawInput: the command for execute,
+ * the line diff for edits, the full (all-added) content for new files, the
+ * URL for fetches. Field-sniffed rather than tool-name-matched so MCP tools
+ * with the same shapes get previews for free (same rule as the TUI).
+ */
+function PermissionPreview({ toolCall }: { toolCall: RequestPermissionParams["toolCall"] }) {
+ const raw = toolCall.rawInput;
+ if (typeof raw !== "object" || raw === null) return null;
+ const input = raw as Record;
+ if (typeof input.command === "string") {
+ return (
+
+ {firstLines(input.command, 20)}
+
+ );
+ }
+ if (typeof input.old_string === "string" && typeof input.new_string === "string") {
+ return ;
+ }
+ if (toolCall.kind === "edit" && typeof input.content === "string") {
+ return ;
+ }
+ if (typeof input.url === "string") {
+ return {input.url}
;
+ }
+ return null;
+}
diff --git a/apps/gui/src/components/ProjectTabs.tsx b/apps/gui/src/components/ProjectTabs.tsx
new file mode 100644
index 0000000..72b5c27
--- /dev/null
+++ b/apps/gui/src/components/ProjectTabs.tsx
@@ -0,0 +1,69 @@
+import type { SessionStore } from "@minerva/client";
+import { useSessionStore } from "../hooks/use-session-store";
+import type { Tab } from "../lib/tabs";
+
+export function ProjectTabs({
+ tabs,
+ activeTabId,
+ stores,
+ onActivate,
+ onCloseTab,
+ onAddTab,
+}: {
+ tabs: Tab[];
+ activeTabId: string | null;
+ /** Live store per tab id, when that tab's session is materialized. */
+ stores: ReadonlyMap;
+ onActivate: (tabId: string) => void;
+ onCloseTab: (tabId: string) => void;
+ onAddTab: () => void;
+}) {
+ return (
+
+ {tabs.map((tab) => {
+ const store = stores.get(tab.id);
+ const active = tab.id === activeTabId;
+ return (
+
+ onActivate(tab.id)}
+ title={tab.cwd}
+ className="flex items-center gap-1.5"
+ >
+ {store ? : null}
+ {tab.cwd.split("/").filter(Boolean).at(-1) ?? tab.cwd}
+
+ onCloseTab(tab.id)}
+ title="Close tab"
+ className="rounded px-0.5 text-muted-foreground hover:bg-secondary hover:text-foreground"
+ >
+ ×
+
+
+ );
+ })}
+
+ +
+
+
+ );
+}
+
+function BusyDot({ store }: { store: SessionStore }) {
+ const vm = useSessionStore(store);
+ if (!vm.busy) return null;
+ return ;
+}
diff --git a/apps/gui/src/components/SessionBrowser.tsx b/apps/gui/src/components/SessionBrowser.tsx
new file mode 100644
index 0000000..b12f1e6
--- /dev/null
+++ b/apps/gui/src/components/SessionBrowser.tsx
@@ -0,0 +1,111 @@
+import type { MinervaClient } from "@minerva/client";
+import type { SessionSummary } from "@minerva/protocol";
+import { useEffect, useRef, useState } from "react";
+
+/** Pick a persisted session for a project — reads the kernel's JSONL index
+ * via minerva/sessions/list, so TUI-created sessions appear too. */
+export function SessionBrowser({
+ client,
+ cwd,
+ currentId,
+ onPick,
+ onClose,
+}: {
+ client: MinervaClient;
+ cwd: string;
+ currentId: string | null;
+ onPick: (summary: SessionSummary) => void;
+ onClose: () => void;
+}) {
+ const [sessions, setSessions] = useState(null);
+ const [error, setError] = useState(null);
+ const panelRef = useRef(null);
+
+ // Modal ownership: focus moves into the dialog on open (no trap) and
+ // returns to wherever it came from on close.
+ useEffect(() => {
+ const previous = document.activeElement;
+ panelRef.current?.focus();
+ return () => {
+ if (previous instanceof HTMLElement && previous.isConnected) previous.focus();
+ };
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+ client.listSessions(cwd).then(
+ (list) => {
+ // The kernel already returns newest-first (capped at 20).
+ if (!cancelled) setSessions(list);
+ },
+ (cause: unknown) => {
+ if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause));
+ },
+ );
+ return () => {
+ cancelled = true;
+ };
+ }, [client, cwd]);
+
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ onClose();
+ event.stopPropagation();
+ }
+ };
+ window.addEventListener("keydown", onKeyDown, true);
+ return () => window.removeEventListener("keydown", onKeyDown, true);
+ }, [onClose]);
+
+ return (
+
+
+
+ Sessions
+ {cwd}
+
+
+ {error ?
✖ {error}
: null}
+ {sessions === null && !error ? (
+
Loading…
+ ) : null}
+ {sessions?.length === 0 ? (
+
No sessions for this project yet.
+ ) : null}
+ {sessions?.map((summary) => (
+
onPick(summary)}
+ className="block w-full rounded-md px-2 py-1.5 text-left text-sm hover:bg-secondary disabled:opacity-50"
+ >
+ {summary.preview ?? "(no preview)"}
+
+ {new Date(summary.createdAt).toLocaleString()} · {summary.sessionId.slice(0, 12)}
+ {summary.sessionId === currentId ? " · current" : ""}
+
+
+ ))}
+
+
+
+ Close
+
+
+
+
+ );
+}
diff --git a/apps/gui/src/components/chat/DiffView.tsx b/apps/gui/src/components/chat/DiffView.tsx
new file mode 100644
index 0000000..c292c9c
--- /dev/null
+++ b/apps/gui/src/components/chat/DiffView.tsx
@@ -0,0 +1,129 @@
+import { clipDiff, type DiffLine, diffLines } from "@minerva/client";
+import { useMemo, useState } from "react";
+import { alignDiffRows, type SplitCell } from "../../lib/diff-rows";
+
+/** Cap on rendered diff lines — same budget as the TUI transcript. */
+const DIFF_LINE_CAP = 20;
+
+/** Shared viewer preference; each DiffView initializes from it on mount. */
+const VIEW_KEY = "minerva.diffView.v1";
+
+function loadPreference(): "unified" | "split" {
+ return localStorage.getItem(VIEW_KEY) === "split" ? "split" : "unified";
+}
+
+export function DiffView({ diff }: { diff: { oldText: string | null; newText: string } }) {
+ const [view, setView] = useState(loadPreference);
+ // The LCS diff is quadratic in the changed region — memoize it, or every
+ // streaming token re-renders re-diff every edit in the transcript.
+ const lines = useMemo(
+ () => clipDiff(diffLines(diff.oldText, diff.newText), DIFF_LINE_CAP),
+ [diff.oldText, diff.newText],
+ );
+
+ const toggle = (next: "unified" | "split") => {
+ localStorage.setItem(VIEW_KEY, next);
+ setView(next);
+ };
+
+ return (
+
+
+ {(["unified", "split"] as const).map((option) => (
+ toggle(option)}
+ className={`rounded px-1.5 py-0.5 text-[10px] ${
+ view === option
+ ? "bg-secondary text-foreground"
+ : "text-muted-foreground hover:bg-secondary/50"
+ }`}
+ >
+ {option}
+
+ ))}
+
+
+ {view === "split" ? : }
+
+
+ );
+}
+
+function UnifiedDiff({ lines }: { lines: DiffLine[] }) {
+ // Same key scheme as the TUI: character offset disambiguates equal lines.
+ let offset = 0;
+ return (
+ <>
+ {lines.map((line) => {
+ const key = `${offset}:${line.kind}`;
+ offset += line.text.length + 1;
+ return ;
+ })}
+ >
+ );
+}
+
+function UnifiedRow({ line }: { line: DiffLine }) {
+ switch (line.kind) {
+ case "add":
+ return (
+
+ + {line.text}
+
+ );
+ case "del":
+ return (
+
+ - {line.text}
+
+ );
+ case "gap":
+ case "note":
+ return {line.text}
;
+ default:
+ return {line.text}
;
+ }
+}
+
+const CELL_STYLE: Record = {
+ context: "text-muted-foreground",
+ add: "bg-green-500/10 text-green-700 dark:text-green-400",
+ del: "bg-red-500/10 text-red-700 dark:text-red-400",
+ empty: "bg-muted/40",
+};
+
+function SplitDiff({ lines }: { lines: DiffLine[] }) {
+ const rows = useMemo(() => alignDiffRows(lines), [lines]);
+ let offset = 0;
+ return (
+
+ {rows.flatMap((row) => {
+ const key = `${offset}`;
+ offset +=
+ row.kind === "band"
+ ? row.text.length + 1
+ : row.left.text.length + row.right.text.length + 2;
+ if (row.kind === "band") {
+ return [
+
+ {row.text}
+
,
+ ];
+ }
+ return [
+
+ {row.left.text || " "}
+
,
+
+ {row.right.text || " "}
+
,
+ ];
+ })}
+
+ );
+}
diff --git a/apps/gui/src/components/chat/Markdown.tsx b/apps/gui/src/components/chat/Markdown.tsx
new file mode 100644
index 0000000..8ed1183
--- /dev/null
+++ b/apps/gui/src/components/chat/Markdown.tsx
@@ -0,0 +1,19 @@
+import DOMPurify from "dompurify";
+import { marked } from "marked";
+import { useMemo } from "react";
+
+/**
+ * Markdown is for model output only (same rule as the TUI): info/user/tool
+ * text stays plain so command echoes are never reinterpreted. The model's
+ * HTML goes through DOMPurify — assistant output is untrusted input.
+ */
+export function Markdown({ text }: { text: string }) {
+ const html = useMemo(
+ () => DOMPurify.sanitize(marked.parse(text, { async: false }) as string),
+ [text],
+ );
+ return (
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized by DOMPurify above
+
+ );
+}
diff --git a/apps/gui/src/components/chat/PlanItem.tsx b/apps/gui/src/components/chat/PlanItem.tsx
new file mode 100644
index 0000000..8c944b3
--- /dev/null
+++ b/apps/gui/src/components/chat/PlanItem.tsx
@@ -0,0 +1,37 @@
+import type { ViewItem } from "@minerva/client";
+
+const MARK = { pending: "☐", in_progress: "◐", completed: "☑" } as const;
+
+export function PlanItem({ entries }: { entries: Extract["entries"] }) {
+ return (
+
+
Todos
+ {withUniqueKeys(entries).map(({ entry, key }) => (
+
+ {MARK[entry.status]} {entry.content}
+
+ ))}
+
+ );
+}
+
+/** Stable-ish keys from content, disambiguating duplicate entries. */
+function withUniqueKeys(
+ entries: T[],
+): Array<{ entry: T; key: string }> {
+ const seen = new Map();
+ return entries.map((entry) => {
+ const count = seen.get(entry.content) ?? 0;
+ seen.set(entry.content, count + 1);
+ return { entry, key: count === 0 ? entry.content : `${entry.content}#${count}` };
+ });
+}
diff --git a/apps/gui/src/components/chat/ThoughtItem.tsx b/apps/gui/src/components/chat/ThoughtItem.tsx
new file mode 100644
index 0000000..d846079
--- /dev/null
+++ b/apps/gui/src/components/chat/ThoughtItem.tsx
@@ -0,0 +1,28 @@
+import { formatTokens, thoughtTail, type ViewItem } from "@minerva/client";
+
+/** Character-budget width for the streaming tail — the GUI has no terminal
+ * columns, so use a fixed width comparable to an editor pane. */
+const TAIL_COLUMNS = 100;
+
+/**
+ * Model reasoning: a rolling tail while it streams (full thoughts run to
+ * thousands of chars), then a collapsed summary the reader can expand —
+ * the GUI upgrade over the TUI's summary-only line.
+ */
+export function ThoughtItem({ item }: { item: Extract }) {
+ if (item.streaming) {
+ return (
+
+ ✻ {thoughtTail(item.text, TAIL_COLUMNS)}
+
+ );
+ }
+ return (
+
+
+ ✻ thought · {formatTokens(item.text.length)} chars
+
+ {item.text}
+
+ );
+}
diff --git a/apps/gui/src/components/chat/ToolItem.tsx b/apps/gui/src/components/chat/ToolItem.tsx
new file mode 100644
index 0000000..43c5cb4
--- /dev/null
+++ b/apps/gui/src/components/chat/ToolItem.tsx
@@ -0,0 +1,53 @@
+import { firstLines, type ViewItem } from "@minerva/client";
+import { DiffView } from "./DiffView";
+
+type ToolViewItem = Extract;
+
+const STATUS_DOT: Record = {
+ pending: "text-yellow-500",
+ in_progress: "text-yellow-500 animate-pulse",
+ completed: "text-green-500",
+ failed: "text-red-500",
+};
+
+const PREVIEW_LINES = 4;
+
+export function ToolItem({ item }: { item: ToolViewItem }) {
+ const preview = item.output ? firstLines(item.output, PREVIEW_LINES) : null;
+ const outputLines = item.output ? item.output.trimEnd().split("\n").length : 0;
+ return (
+
+
+ ●
+ {item.title}
+ [{item.status}]
+
+ {item.task ? (
+
+ ↳ {item.task.toolCalls} tool call{item.task.toolCalls === 1 ? "" : "s"}
+ {item.task.failed > 0 ? ` (${item.task.failed} failed)` : ""}
+ {item.task.lastActivity ? ` · ${item.task.lastActivity}` : ""}
+
+ ) : null}
+ {item.diff ? (
+
+
+
+ ) : preview ? (
+
+
+ {preview}
+
+ {outputLines > PREVIEW_LINES && item.output ? (
+
+ show all {outputLines} lines
+
+ {item.output}
+
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/gui/src/components/chat/Transcript.tsx b/apps/gui/src/components/chat/Transcript.tsx
new file mode 100644
index 0000000..36437b3
--- /dev/null
+++ b/apps/gui/src/components/chat/Transcript.tsx
@@ -0,0 +1,50 @@
+import type { ViewItem } from "@minerva/client";
+import { memo } from "react";
+import { Markdown } from "./Markdown";
+import { PlanItem } from "./PlanItem";
+import { ThoughtItem } from "./ThoughtItem";
+import { ToolItem } from "./ToolItem";
+
+export function Transcript({ items }: { items: ViewItem[] }) {
+ return (
+
+ {items.map((item, index) => (
+
+ ))}
+
+ );
+}
+
+/** Tool items key by their real id; the plan list is a singleton; the rest
+ * only ever append or mutate in place, so the index is positional truth. */
+function itemKey(item: ViewItem, index: number): string {
+ if (item.kind === "tool") return `tool-${item.toolCallId}`;
+ if (item.kind === "plan") return "plan";
+ return `${item.kind}-${index}`;
+}
+
+// Memoized: the store rebuilds the items ARRAY per update but reuses the
+// untouched item objects, so identity lets streaming skip re-rendering (and
+// re-diffing) everything except the item actually changing.
+const TranscriptItem = memo(function TranscriptItem({ item }: { item: ViewItem }) {
+ switch (item.kind) {
+ case "user":
+ return (
+
+ {item.text}
+
+ );
+ case "assistant":
+ return ;
+ case "thought":
+ return ;
+ case "tool":
+ return ;
+ case "plan":
+ return ;
+ case "info":
+ return {item.text}
;
+ case "error":
+ return ✖ {item.text}
;
+ }
+});
diff --git a/apps/gui/src/components/chat/UsageFooter.tsx b/apps/gui/src/components/chat/UsageFooter.tsx
new file mode 100644
index 0000000..f8472d1
--- /dev/null
+++ b/apps/gui/src/components/chat/UsageFooter.tsx
@@ -0,0 +1,47 @@
+import { formatTokens, type SessionViewModel } from "@minerva/client";
+
+/** Status line matching the TUI footer: mode, token counts, context bar. */
+export function UsageFooter({
+ modeId,
+ usage,
+ context,
+}: {
+ modeId: string | undefined;
+ usage: SessionViewModel["usage"];
+ context: SessionViewModel["context"];
+}) {
+ const parts: string[] = [];
+ if (modeId && modeId !== "default") parts.push(`mode ${modeId}`);
+ if (usage) {
+ const { lastTurn, cumulative } = usage;
+ const cached =
+ cumulative.cacheReadTokens && cumulative.cacheReadTokens > 0
+ ? ` (${formatTokens(cumulative.cacheReadTokens)} cached)`
+ : "";
+ parts.push(
+ "tokens",
+ ...(lastTurn
+ ? [
+ `last ${formatTokens(lastTurn.inputTokens)} in / ${formatTokens(lastTurn.outputTokens)} out`,
+ ]
+ : []),
+ `session ${formatTokens(cumulative.inputTokens)} in / ${formatTokens(cumulative.outputTokens)} out${cached}`,
+ );
+ }
+ const pct = context && context.size > 0 ? Math.round((100 * context.used) / context.size) : null;
+ if (pct !== null) parts.push(`ctx ${pct}%`);
+ if (parts.length === 0) return null;
+ return (
+
+ {parts.join(" · ")}
+ {pct !== null ? (
+
+ = 80 ? "bg-red-500" : pct >= 60 ? "bg-yellow-500" : "bg-green-500"}`}
+ style={{ width: `${Math.min(100, pct)}%` }}
+ />
+
+ ) : null}
+
+ );
+}
diff --git a/apps/gui/src/hooks/use-session-store.ts b/apps/gui/src/hooks/use-session-store.ts
new file mode 100644
index 0000000..19fb196
--- /dev/null
+++ b/apps/gui/src/hooks/use-session-store.ts
@@ -0,0 +1,8 @@
+import type { SessionStore, SessionViewModel } from "@minerva/client";
+import { useCallback, useSyncExternalStore } from "react";
+
+/** Render from a SessionStore: snapshot is a stable reference per update. */
+export function useSessionStore(store: SessionStore): SessionViewModel {
+ const subscribe = useCallback((listener: () => void) => store.subscribe(listener), [store]);
+ return useSyncExternalStore(subscribe, () => store.snapshot);
+}
diff --git a/apps/gui/src/index.css b/apps/gui/src/index.css
new file mode 100644
index 0000000..d1ea053
--- /dev/null
+++ b/apps/gui/src/index.css
@@ -0,0 +1,152 @@
+@import "tailwindcss";
+
+@custom-variant dark (&:is(.dark *));
+
+/* shadcn/ui theme (neutral base) — components read these tokens via the
+ * @theme mapping below, so swapping the palette never touches components. */
+:root {
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --destructive-foreground: oklch(0.985 0 0);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --radius: 0.625rem;
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --destructive-foreground: oklch(0.985 0 0);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+/* Assistant-message markdown (see components/chat/Markdown.tsx). Hand-rolled
+ * instead of a typography plugin: the transcript needs one compact style. */
+@layer components {
+ .markdown > * + * {
+ margin-top: 0.5rem;
+ }
+ .markdown h1,
+ .markdown h2,
+ .markdown h3,
+ .markdown h4 {
+ font-weight: 600;
+ }
+ .markdown h1 {
+ font-size: 1.15rem;
+ }
+ .markdown h2 {
+ font-size: 1.05rem;
+ }
+ .markdown ul,
+ .markdown ol {
+ padding-left: 1.25rem;
+ }
+ .markdown ul {
+ list-style: disc;
+ }
+ .markdown ol {
+ list-style: decimal;
+ }
+ .markdown li + li {
+ margin-top: 0.125rem;
+ }
+ .markdown pre {
+ overflow-x: auto;
+ border-radius: var(--radius-md);
+ background: color-mix(in oklab, var(--color-muted) 60%, transparent);
+ padding: 0.5rem 0.75rem;
+ font-size: 0.75rem;
+ }
+ .markdown code {
+ font-family: var(--font-mono);
+ font-size: 0.8em;
+ }
+ .markdown :not(pre) > code {
+ border-radius: 0.25rem;
+ background: color-mix(in oklab, var(--color-muted) 60%, transparent);
+ padding: 0.1rem 0.3rem;
+ }
+ .markdown blockquote {
+ border-left: 3px solid var(--color-border);
+ padding-left: 0.75rem;
+ color: var(--color-muted-foreground);
+ }
+ .markdown a {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ }
+ .markdown table {
+ border-collapse: collapse;
+ font-size: 0.8rem;
+ }
+ .markdown th,
+ .markdown td {
+ border: 1px solid var(--color-border);
+ padding: 0.25rem 0.5rem;
+ }
+}
diff --git a/apps/gui/src/lib/config-form.ts b/apps/gui/src/lib/config-form.ts
new file mode 100644
index 0000000..9c66c3f
--- /dev/null
+++ b/apps/gui/src/lib/config-form.ts
@@ -0,0 +1,40 @@
+import type { ConfigSetModelParams } from "@minerva/protocol";
+
+export interface ConfigFormInput {
+ /** Effective provider name (builtin selection or trimmed custom name). */
+ providerName: string;
+ isCustom: boolean;
+ /** Trimmed; only meaningful for custom providers. */
+ baseUrl: string;
+ /** Trimmed; empty = keep the existing env/stored key. */
+ apiKey: string;
+ /** Trimmed model input; may contain slashes or a pasted full ref. */
+ model: string;
+}
+
+/**
+ * Build the config/set_model params. The ref is ALWAYS qualified with the
+ * selected provider (TUI parity) — no slash-sniffing, since a slash inside
+ * the model id is legitimate. A pasted full ref for the selected provider is
+ * tolerated by stripping its own prefix first. Custom providers persist
+ * `requiresApiKey: apiKey !== ""` (also TUI parity), so a keyless endpoint
+ * is recorded as such instead of defaulting to key-required.
+ */
+export function buildSetModelParams(input: ConfigFormInput): ConfigSetModelParams {
+ const model = input.model.startsWith(`${input.providerName}/`)
+ ? input.model.slice(input.providerName.length + 1)
+ : input.model;
+ return {
+ modelRef: `${input.providerName}/${model}`,
+ ...(input.isCustom
+ ? {
+ provider: {
+ name: input.providerName,
+ baseUrl: input.baseUrl,
+ requiresApiKey: input.apiKey !== "",
+ },
+ }
+ : {}),
+ ...(input.apiKey ? { apiKey: input.apiKey } : {}),
+ };
+}
diff --git a/apps/gui/src/lib/diff-rows.ts b/apps/gui/src/lib/diff-rows.ts
new file mode 100644
index 0000000..2a1ffb1
--- /dev/null
+++ b/apps/gui/src/lib/diff-rows.ts
@@ -0,0 +1,64 @@
+import type { DiffLine } from "@minerva/client";
+
+/**
+ * Side-by-side alignment over the shared unified diff: old file on the left,
+ * new on the right. Within each changed region, the i-th removed line faces
+ * the i-th added line (the classic split-view pairing); the longer side
+ * finishes against blanks. Gap/note lines become full-width bands.
+ */
+
+export interface SplitCell {
+ kind: "context" | "add" | "del" | "empty";
+ text: string;
+}
+
+export type SplitRow =
+ | { kind: "pair"; left: SplitCell; right: SplitCell }
+ | { kind: "band"; text: string };
+
+const EMPTY: SplitCell = { kind: "empty", text: "" };
+
+export function alignDiffRows(lines: DiffLine[]): SplitRow[] {
+ const rows: SplitRow[] = [];
+ let dels: string[] = [];
+ let adds: string[] = [];
+
+ const flush = () => {
+ for (let i = 0; i < Math.max(dels.length, adds.length); i++) {
+ const del = dels[i];
+ const add = adds[i];
+ rows.push({
+ kind: "pair",
+ left: del !== undefined ? { kind: "del", text: del } : EMPTY,
+ right: add !== undefined ? { kind: "add", text: add } : EMPTY,
+ });
+ }
+ dels = [];
+ adds = [];
+ };
+
+ for (const line of lines) {
+ switch (line.kind) {
+ case "del":
+ dels.push(line.text);
+ break;
+ case "add":
+ adds.push(line.text);
+ break;
+ case "context":
+ flush();
+ rows.push({
+ kind: "pair",
+ left: { kind: "context", text: line.text },
+ right: { kind: "context", text: line.text },
+ });
+ break;
+ default: // gap | note
+ flush();
+ rows.push({ kind: "band", text: line.text });
+ break;
+ }
+ }
+ flush();
+ return rows;
+}
diff --git a/apps/gui/src/lib/kernel-manager.ts b/apps/gui/src/lib/kernel-manager.ts
new file mode 100644
index 0000000..e9c4945
--- /dev/null
+++ b/apps/gui/src/lib/kernel-manager.ts
@@ -0,0 +1,167 @@
+import { MinervaClient } from "@minerva/client";
+import type { ConfigStateResult } from "@minerva/protocol";
+import { createPermissionQueue, type PermissionQueue } from "./permission-queue";
+import type { SidecarBridge } from "./sidecar-bridge";
+import { createSidecarTransport } from "./tauri-transport";
+
+type KernelPhase = "starting" | "ready" | "restarting" | "down";
+
+interface KernelSnapshot {
+ phase: KernelPhase;
+ /** Non-null only when ready. Identity changes on every restart — a dead
+ * Connection's pending state is unrecoverable, so recovery builds a fresh
+ * client and consumers must re-load sessions against it. */
+ client: MinervaClient | null;
+ config: ConfigStateResult | null;
+ /** The kernel's last exit code, while restarting/down. */
+ exitCode: number | null;
+ /** Startup failure detail, when down. */
+ error: string | null;
+}
+
+export interface KernelManager {
+ /** One queue across restarts: the dialog survives, requests don't. */
+ permissions: PermissionQueue;
+ subscribe(listener: () => void): () => void;
+ readonly snapshot: KernelSnapshot;
+ /** Begin connecting, or manually retry from "down". No-op while live. */
+ start(): void;
+}
+
+function withTimeout(promise: Promise, ms: number): Promise {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(`kernel did not respond within ${ms}ms`)), ms);
+ promise.then(
+ (value) => {
+ clearTimeout(timer);
+ resolve(value);
+ },
+ (cause: unknown) => {
+ clearTimeout(timer);
+ reject(cause);
+ },
+ );
+ });
+}
+
+/**
+ * Owns the kernel lifecycle on the webview side: spawn (via the bridge),
+ * client construction, and crash recovery. One automatic respawn per death —
+ * a kernel that dies twice in a row stays down until the user retries, so a
+ * crash loop can't spin. All policy is here; the Rust side is a dumb pipe.
+ */
+export function createKernelManager(
+ bridge: SidecarBridge,
+ options: { handshakeTimeoutMs?: number } = {},
+): KernelManager {
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 15_000;
+ const permissions = createPermissionQueue();
+ const listeners = new Set<() => void>();
+ let snapshot: KernelSnapshot = {
+ phase: "starting",
+ client: null,
+ config: null,
+ exitCode: null,
+ error: null,
+ };
+ let started = false;
+ let autoRestarts = 0;
+ let generation = 0;
+
+ const update = (patch: Partial) => {
+ snapshot = { ...snapshot, ...patch };
+ for (const listener of listeners) listener();
+ };
+
+ async function run(phase: "starting" | "restarting"): Promise {
+ const gen = ++generation;
+ update({ phase, client: null, error: null });
+ let client: MinervaClient | null = null;
+ try {
+ await bridge.start();
+ // An exit can arrive while start() is waiting (including one buffered by
+ // the generation-aware Tauri bridge). If it already launched recovery,
+ // this attempt must not attach a second client to the replacement.
+ if (gen !== generation) return;
+ const fresh = new MinervaClient(createSidecarTransport(bridge), {
+ onPermissionRequest: permissions.handler,
+ });
+ client = fresh;
+ // A kernel that spawns but never answers (bad build, protocol
+ // mismatch, deadlock) must not wedge the app in "starting" forever —
+ // bound the handshake, and treat expiry like any other dead start.
+ const config = await withTimeout(
+ (async () => {
+ await fresh.initialize();
+ return await fresh.getConfigState();
+ })(),
+ handshakeTimeoutMs,
+ );
+ if (gen !== generation) return; // superseded by a newer attempt
+ // Note: the auto-restart allowance is NOT replenished here — only a
+ // manual start() resets it. Replenishing on ready would let a kernel
+ // that dies shortly after every boot restart forever.
+ update({ phase: "ready", client, config, exitCode: null, error: null });
+ } catch (cause) {
+ if (gen !== generation) return; // the exit path already took over
+ // The half-connected state is unusable: drop the client and make sure
+ // no wedged process lingers behind the "down" banner. AWAIT the kill —
+ // it resolves only once the old kernel is fully gone (drain + reap),
+ // so the restart button can never spawn a successor that races the
+ // dying process. Intentional shutdowns emit no exit event (the Rust
+ // side is generation-guarded), so this cannot trigger crash recovery.
+ try {
+ client?.close();
+ } catch {
+ // Transport already closed.
+ }
+ await bridge.kill().catch(() => {});
+ if (gen !== generation) return;
+ update({
+ phase: "down",
+ client: null,
+ error: cause instanceof Error ? cause.message : String(cause),
+ });
+ }
+ }
+
+ bridge.onExit((code) => {
+ // Whoever was waiting on the dead kernel must not wait forever.
+ permissions.cancelAll();
+ try {
+ snapshot.client?.close();
+ } catch {
+ // Already closed by the transport's own exit handling.
+ }
+ if (autoRestarts < 1) {
+ autoRestarts++;
+ update({ client: null, exitCode: code });
+ void run("restarting");
+ } else {
+ generation++; // cancel any in-flight attempt
+ update({ phase: "down", client: null, exitCode: code });
+ }
+ });
+
+ return {
+ permissions,
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ get snapshot() {
+ return snapshot;
+ },
+ start() {
+ if (!started) {
+ started = true;
+ void run("starting");
+ return;
+ }
+ if (snapshot.phase === "down") {
+ autoRestarts = 0;
+ void run("restarting");
+ }
+ },
+ };
+}
diff --git a/apps/gui/src/lib/native.ts b/apps/gui/src/lib/native.ts
new file mode 100644
index 0000000..4d5481d
--- /dev/null
+++ b/apps/gui/src/lib/native.ts
@@ -0,0 +1,27 @@
+import { open } from "@tauri-apps/plugin-dialog";
+import {
+ isPermissionGranted,
+ requestPermission,
+ sendNotification,
+} from "@tauri-apps/plugin-notification";
+
+/** Native folder picker; null when the user cancels. */
+export async function pickFolder(): Promise {
+ const choice = await open({ directory: true, multiple: false, title: "Open project folder" });
+ return typeof choice === "string" ? choice : null;
+}
+
+/** Deliver a native notification, asking the OS for permission on first use.
+ * Best-effort: denial, an unsupported platform, or a plugin error are all
+ * silence, never a rejection — callers fire-and-forget this. */
+export async function notify(title: string, body: string): Promise {
+ try {
+ let granted = await isPermissionGranted();
+ if (!granted) {
+ granted = (await requestPermission()) === "granted";
+ }
+ if (granted) sendNotification({ title, body });
+ } catch {
+ // Silence by contract.
+ }
+}
diff --git a/apps/gui/src/lib/notify.ts b/apps/gui/src/lib/notify.ts
new file mode 100644
index 0000000..e5749c8
--- /dev/null
+++ b/apps/gui/src/lib/notify.ts
@@ -0,0 +1,35 @@
+import type { StopReason } from "@minerva/protocol";
+
+/** A turn shorter than this never notifies — the user hasn't left yet. */
+export const NOTIFY_MIN_TURN_MS = 5_000;
+
+export interface NotifyInput {
+ stopReason: StopReason;
+ /** Whether the window had focus when the turn finished. */
+ focused: boolean;
+ durationMs: number;
+ muted: boolean;
+ /** Project (tab) name for the body. */
+ project: string;
+}
+
+/**
+ * Whether a finished turn deserves a native notification, and its content.
+ * Pure so the matrix is unit-testable; the caller does the OS delivery.
+ * Cancelled turns never notify — the user caused them.
+ */
+export function decideNotification(input: NotifyInput): { title: string; body: string } | null {
+ if (input.muted || input.focused || input.stopReason === "cancelled") return null;
+ if (input.durationMs < NOTIFY_MIN_TURN_MS) return null;
+ const title =
+ input.stopReason === "end_turn" ? "Minerva finished a turn" : "Minerva needs attention";
+ const detail =
+ input.stopReason === "end_turn"
+ ? "The reply is ready."
+ : input.stopReason === "max_tokens"
+ ? "The turn hit the output-token limit."
+ : input.stopReason === "max_turn_requests"
+ ? "The turn hit the request limit."
+ : "The model refused the request.";
+ return { title, body: `${input.project} — ${detail}` };
+}
diff --git a/apps/gui/src/lib/permission-queue.ts b/apps/gui/src/lib/permission-queue.ts
new file mode 100644
index 0000000..b230e04
--- /dev/null
+++ b/apps/gui/src/lib/permission-queue.ts
@@ -0,0 +1,73 @@
+import type { PermissionHandler } from "@minerva/client";
+import type { RequestPermissionParams, RequestPermissionResult } from "@minerva/protocol";
+
+interface PendingPermission {
+ request: RequestPermissionParams;
+ resolve: (result: RequestPermissionResult) => void;
+}
+
+interface PermissionQueueSnapshot {
+ /** The request the dialog should show; null when nothing is pending. */
+ current: PendingPermission | null;
+ /** Total pending including current — parallel tool calls stack requests. */
+ depth: number;
+}
+
+export interface PermissionQueue {
+ /** Pass as MinervaClientOptions.onPermissionRequest. */
+ handler: PermissionHandler;
+ subscribe(listener: () => void): () => void;
+ readonly snapshot: PermissionQueueSnapshot;
+ /** Resolve everything as cancelled — used when the kernel connection dies:
+ * the requester is gone, and a modal must not outlive its kernel. */
+ cancelAll(): void;
+}
+
+/**
+ * The GUI's counterpart of the TUI's permission bridge: the client is
+ * constructed before React mounts, so kernel requests land in this queue and
+ * the mounted dialog resolves them. Unlike the TUI (one modal at a time by
+ * construction), parallel tool calls can stack — requests answer in FIFO
+ * order, and each resolve advances the snapshot to the next.
+ */
+export function createPermissionQueue(): PermissionQueue {
+ const pending: PendingPermission[] = [];
+ const listeners = new Set<() => void>();
+ let snapshot: PermissionQueueSnapshot = { current: null, depth: 0 };
+
+ const publish = () => {
+ snapshot = { current: pending[0] ?? null, depth: pending.length };
+ for (const listener of listeners) listener();
+ };
+
+ return {
+ handler: (request) =>
+ new Promise((resolve) => {
+ const entry: PendingPermission = {
+ request,
+ resolve: (result) => {
+ const index = pending.indexOf(entry);
+ if (index === -1) return; // already resolved — ignore double answers
+ pending.splice(index, 1);
+ resolve(result);
+ publish();
+ },
+ };
+ pending.push(entry);
+ publish();
+ }),
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ get snapshot() {
+ return snapshot;
+ },
+ cancelAll() {
+ // resolve() splices the entry out, so iterate over a copy.
+ for (const entry of [...pending]) {
+ entry.resolve({ outcome: { outcome: "cancelled" } });
+ }
+ },
+ };
+}
diff --git a/apps/gui/src/lib/session-slots.ts b/apps/gui/src/lib/session-slots.ts
new file mode 100644
index 0000000..166a54f
--- /dev/null
+++ b/apps/gui/src/lib/session-slots.ts
@@ -0,0 +1,43 @@
+/**
+ * Per-tab install tokens for async session results. Every install attempt
+ * (lazy ensure or a user-initiated switch) begins a token; a completion may
+ * commit only while its token is still the tab's current one. Anything can
+ * supersede it meanwhile — a newer switch, the tab closing, the kernel
+ * client being replaced — and the stale result must then be discarded (and
+ * its session closed) instead of overwriting the newer choice or leaking an
+ * invisible registration.
+ *
+ * Tokens come from a single monotonic counter that never resets: after
+ * invalidateAll() clears the map, an old token can never accidentally match
+ * a future one.
+ */
+export interface SessionSlots {
+ /** Start an install for this tab; supersedes any in-flight one. */
+ begin(tabId: string): number;
+ isCurrent(tabId: string, token: number): boolean;
+ /** The tab is gone — outstanding installs for it are stale. */
+ invalidate(tabId: string): void;
+ /** The kernel client was replaced — every outstanding install is stale. */
+ invalidateAll(): void;
+}
+
+export function createSessionSlots(): SessionSlots {
+ let counter = 0;
+ const current = new Map();
+ return {
+ begin(tabId) {
+ counter += 1;
+ current.set(tabId, counter);
+ return counter;
+ },
+ isCurrent(tabId, token) {
+ return current.get(tabId) === token;
+ },
+ invalidate(tabId) {
+ current.delete(tabId);
+ },
+ invalidateAll() {
+ current.clear();
+ },
+ };
+}
diff --git a/apps/gui/src/lib/session-switches.ts b/apps/gui/src/lib/session-switches.ts
new file mode 100644
index 0000000..2d926d4
--- /dev/null
+++ b/apps/gui/src/lib/session-switches.ts
@@ -0,0 +1,70 @@
+/**
+ * Coordinates user-initiated async switches independently from install
+ * tokens. Tokens answer "may this completion commit?"; this helper answers
+ * "is work for this target already running?" and "is this still the user's
+ * desired target?". Keeping those questions separate is what makes A → B → A
+ * safe: the last A reuses the first A promise but receives a fresh token.
+ */
+export interface SessionSwitches {
+ /** Mark target as the latest intent and return its one shared in-flight job. */
+ begin(tabId: string, target: string, start: () => Promise): Promise;
+ /** True while target is the user's latest switch intent for this tab. */
+ isDesired(tabId: string, target: string): boolean;
+ /** Clear a completed intent, but never erase a newer target. */
+ finish(tabId: string, target: string): void;
+ /** The tab is gone; completions should be discarded and detached. */
+ invalidate(tabId: string): void;
+ /** The client was replaced; no outstanding result belongs to it anymore. */
+ invalidateAll(): void;
+}
+
+export function createSessionSwitches(): SessionSwitches {
+ const desired = new Map();
+ const inFlight = new Map>>();
+
+ return {
+ begin(tabId, target, start) {
+ desired.set(tabId, target);
+ let targets = inFlight.get(tabId);
+ if (!targets) {
+ targets = new Map();
+ inFlight.set(tabId, targets);
+ }
+
+ const existing = targets.get(target);
+ if (existing) return existing;
+
+ let promise: Promise;
+ try {
+ promise = start();
+ } catch (cause) {
+ promise = Promise.reject(cause);
+ }
+ targets.set(target, promise);
+ const clean = () => {
+ if (targets?.get(target) === promise) targets.delete(target);
+ // A client reset may already have installed a fresh target map for
+ // this tab. Cleanup from the old client must never delete that map.
+ if (targets?.size === 0 && inFlight.get(tabId) === targets) inFlight.delete(tabId);
+ };
+ // Handle both outcomes on this derivative so cleanup cannot create an
+ // unhandled rejected promise. Callers still observe the original.
+ void promise.then(clean, clean);
+ return promise;
+ },
+ isDesired(tabId, target) {
+ return desired.get(tabId) === target;
+ },
+ finish(tabId, target) {
+ if (desired.get(tabId) === target) desired.delete(tabId);
+ },
+ invalidate(tabId) {
+ desired.delete(tabId);
+ inFlight.delete(tabId);
+ },
+ invalidateAll() {
+ desired.clear();
+ inFlight.clear();
+ },
+ };
+}
diff --git a/apps/gui/src/lib/sidecar-bridge.ts b/apps/gui/src/lib/sidecar-bridge.ts
new file mode 100644
index 0000000..7170dd7
--- /dev/null
+++ b/apps/gui/src/lib/sidecar-bridge.ts
@@ -0,0 +1,72 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen } from "@tauri-apps/api/event";
+import { createSidecarGenerationGate, type SidecarExitEvent } from "./sidecar-generation";
+
+/**
+ * The webview's only doorway to the kernel process. The Tauri implementation
+ * lives behind this interface so everything downstream (transport, client,
+ * UI) is testable with a fake — no other module may import @tauri-apps/api.
+ */
+export interface SidecarBridge {
+ /** Spawn the kernel (idempotent). Resolves once event listeners are live. */
+ start(): Promise;
+ /** Write one protocol frame (no trailing newline — the host appends it). */
+ send(line: string): Promise;
+ kill(): Promise;
+ onLine(handler: (line: string) => void): () => void;
+ onExit(handler: (code: number | null) => void): () => void;
+}
+
+export function createTauriSidecarBridge(): SidecarBridge {
+ const lineHandlers = new Set<(line: string) => void>();
+ const exitHandlers = new Set<(code: number | null) => void>();
+ const generations = createSidecarGenerationGate((code) => {
+ for (const handler of exitHandlers) handler(code);
+ });
+
+ // Subscribe immediately so no frame can slip between spawn and listen;
+ // start() awaits this before invoking the spawn command.
+ const subscribed = Promise.all([
+ listen("minerva://line", (event) => {
+ for (const handler of lineHandlers) handler(event.payload);
+ }),
+ listen("minerva://exit", (event) => {
+ generations.exit(event.payload);
+ }),
+ ]);
+
+ // The generation this bridge last started/attached to — kills are targeted
+ // at it, so a stale kill can never take down a replacement kernel that
+ // crash recovery spawned in the meantime (Rust no-ops on a mismatch).
+ let owned: number | null = null;
+
+ return {
+ async start() {
+ await subscribed;
+ const generation = await invoke("sidecar_start");
+ owned = generation;
+ generations.activate(generation);
+ },
+ async send(line: string) {
+ await invoke("sidecar_send", { line });
+ },
+ async kill() {
+ await invoke("sidecar_kill", { generation: owned });
+ owned = null;
+ generations.clear();
+ },
+ onLine(handler) {
+ lineHandlers.add(handler);
+ return () => lineHandlers.delete(handler);
+ },
+ onExit(handler) {
+ exitHandlers.add(handler);
+ return () => exitHandlers.delete(handler);
+ },
+ };
+}
+
+/** Project directory for the first session until the folder picker lands. */
+export function fetchDefaultCwd(): Promise {
+ return invoke("default_cwd");
+}
diff --git a/apps/gui/src/lib/sidecar-generation.ts b/apps/gui/src/lib/sidecar-generation.ts
new file mode 100644
index 0000000..dc84086
--- /dev/null
+++ b/apps/gui/src/lib/sidecar-generation.ts
@@ -0,0 +1,42 @@
+export interface SidecarExitEvent {
+ generation: number;
+ code: number | null;
+}
+
+/**
+ * Associates process-exit events with the sidecar generation returned by
+ * sidecar_start. A new webview subscribes before invoking start, so it can see
+ * the previous kernel's delayed exit; that event must not close the transport
+ * for the replacement. Conversely, a newly spawned kernel can die before the
+ * invoke resolves, so unmatched early exits are held until activate() reveals
+ * which generation this bridge actually owns.
+ */
+export function createSidecarGenerationGate(deliver: (code: number | null) => void) {
+ let active: number | null = null;
+ const pending = new Map();
+
+ return {
+ activate(generation: number) {
+ active = generation;
+ const hasPendingExit = pending.has(generation);
+ const code = pending.get(generation) ?? null;
+ pending.clear();
+ if (hasPendingExit) {
+ active = null;
+ deliver(code);
+ }
+ },
+ exit(event: SidecarExitEvent) {
+ if (event.generation === active) {
+ active = null;
+ deliver(event.code);
+ } else if (active === null) {
+ pending.set(event.generation, event.code);
+ }
+ },
+ clear() {
+ active = null;
+ pending.clear();
+ },
+ };
+}
diff --git a/apps/gui/src/lib/tab-session.ts b/apps/gui/src/lib/tab-session.ts
new file mode 100644
index 0000000..817ae9f
--- /dev/null
+++ b/apps/gui/src/lib/tab-session.ts
@@ -0,0 +1,51 @@
+import { RpcError } from "@minerva/protocol";
+import type { Tab } from "./tabs";
+
+/** The two session operations tab attachment needs, injected for testability. */
+export interface TabSessionOps {
+ load(sessionId: string, cwd: string): Promise;
+ create(cwd: string): Promise;
+}
+
+/**
+ * Whether a load failure means the persisted id itself is dead — the only
+ * class allowed to silently fall back to a fresh session. Matched against
+ * the kernel's REAL failure messages (session/load wraps Session.load
+ * errors verbatim into an RpcError): "no persisted session …" for a
+ * missing/deleted log and "invalid session id: …" for a corrupt persisted
+ * id; "unknown session: …" is the live-session-lookup family used by other
+ * methods, kept for completeness. Covered by a real-kernel round-trip test.
+ */
+export function isStaleSessionError(error: unknown): boolean {
+ if (!(error instanceof RpcError)) return false;
+ return (
+ error.message.startsWith("no persisted session") ||
+ error.message.startsWith("invalid session id") ||
+ error.message.startsWith("unknown session")
+ );
+}
+
+/**
+ * Materialize the session a tab should show: resume its persisted session,
+ * falling back to a fresh one ONLY when `isStale` says the id itself is dead
+ * (deleted log, session from another data dir). Any other load failure —
+ * a prompt still running in the session, pending writes that failed to
+ * flush, transient I/O — rethrows: the kernel throws those loudly precisely
+ * so the previous conversation is never silently replaced by a blank one.
+ * The caller surfaces the error, and re-activating the tab retries.
+ */
+export async function ensureTabSession(
+ ops: TabSessionOps,
+ tab: Tab,
+ isStale: (error: unknown) => boolean,
+): Promise<{ session: S; resumed: boolean }> {
+ if (tab.sessionId) {
+ try {
+ return { session: await ops.load(tab.sessionId, tab.cwd), resumed: true };
+ } catch (error) {
+ if (!isStale(error)) throw error;
+ // Dead id — fall through to a fresh session in the tab's project.
+ }
+ }
+ return { session: await ops.create(tab.cwd), resumed: false };
+}
diff --git a/apps/gui/src/lib/tabs.ts b/apps/gui/src/lib/tabs.ts
new file mode 100644
index 0000000..f6df899
--- /dev/null
+++ b/apps/gui/src/lib/tabs.ts
@@ -0,0 +1,96 @@
+/**
+ * Project-tab state: a pure reducer plus (de)serialization for restart
+ * persistence. Sessions themselves live elsewhere — a tab only records which
+ * cwd it shows and which persisted session it is (or should be) attached to,
+ * which is exactly what survives a kernel crash or an app restart.
+ */
+
+export interface Tab {
+ id: string;
+ cwd: string;
+ /** Persisted kernel session shown in this tab; null = fresh tab that will
+ * create one on first activation. */
+ sessionId: string | null;
+}
+
+export interface TabsState {
+ tabs: Tab[];
+ activeTabId: string | null;
+}
+
+export type TabsAction =
+ | { type: "open"; tabId: string; cwd: string }
+ | { type: "close"; tabId: string }
+ | { type: "activate"; tabId: string }
+ | { type: "attach-session"; tabId: string; sessionId: string | null };
+
+export const EMPTY_TABS: TabsState = { tabs: [], activeTabId: null };
+
+export function tabsReducer(state: TabsState, action: TabsAction): TabsState {
+ switch (action.type) {
+ case "open": {
+ const tab: Tab = { id: action.tabId, cwd: action.cwd, sessionId: null };
+ return { tabs: [...state.tabs, tab], activeTabId: tab.id };
+ }
+ case "close": {
+ const index = state.tabs.findIndex((tab) => tab.id === action.tabId);
+ if (index === -1) return state;
+ const tabs = state.tabs.filter((tab) => tab.id !== action.tabId);
+ // Closing the active tab activates its right neighbor (or the new last).
+ const activeTabId =
+ state.activeTabId === action.tabId
+ ? (tabs[Math.min(index, tabs.length - 1)]?.id ?? null)
+ : state.activeTabId;
+ return { tabs, activeTabId };
+ }
+ case "activate": {
+ if (!state.tabs.some((tab) => tab.id === action.tabId)) return state;
+ return { ...state, activeTabId: action.tabId };
+ }
+ case "attach-session": {
+ // One session, one tab: a store can only feed a single transcript view
+ // (the client refuses duplicate registrations for the same reason).
+ if (
+ action.sessionId !== null &&
+ state.tabs.some((tab) => tab.sessionId === action.sessionId && tab.id !== action.tabId)
+ ) {
+ return state;
+ }
+ return {
+ ...state,
+ tabs: state.tabs.map((tab) =>
+ tab.id === action.tabId ? { ...tab, sessionId: action.sessionId } : tab,
+ ),
+ };
+ }
+ }
+}
+
+export function serializeTabs(state: TabsState): string {
+ return JSON.stringify(state);
+}
+
+/** Parse persisted tab state, dropping anything malformed — a corrupt blob
+ * must degrade to a fresh start, never a crash loop. */
+export function deserializeTabs(raw: string | null): TabsState | null {
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw) as TabsState;
+ if (!Array.isArray(parsed.tabs)) return null;
+ const tabs = parsed.tabs.filter(
+ (tab): tab is Tab =>
+ typeof tab === "object" &&
+ tab !== null &&
+ typeof tab.id === "string" &&
+ typeof tab.cwd === "string" &&
+ (tab.sessionId === null || typeof tab.sessionId === "string"),
+ );
+ if (tabs.length === 0) return null;
+ const activeTabId = tabs.some((tab) => tab.id === parsed.activeTabId)
+ ? parsed.activeTabId
+ : (tabs[0]?.id ?? null);
+ return { tabs, activeTabId };
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/gui/src/lib/tauri-transport.ts b/apps/gui/src/lib/tauri-transport.ts
new file mode 100644
index 0000000..93cd50e
--- /dev/null
+++ b/apps/gui/src/lib/tauri-transport.ts
@@ -0,0 +1,54 @@
+import type { JsonRpcMessage, Transport } from "@minerva/protocol";
+import type { SidecarBridge } from "./sidecar-bridge";
+
+/**
+ * Protocol transport over the sidecar bridge. Framing (line splitting, UTF-8
+ * chunk boundaries) already happened on the Rust side, so this is a thin
+ * JSON codec with the same tolerance as the stdio transport: malformed lines
+ * are skipped (they carry no id to answer), close fires exactly once.
+ */
+export function createSidecarTransport(bridge: SidecarBridge): Transport {
+ const messageHandlers: Array<(message: JsonRpcMessage) => void> = [];
+ const closeHandlers: Array<() => void> = [];
+ let closed = false;
+
+ const emitClose = () => {
+ if (closed) return;
+ closed = true;
+ unsubscribeLine();
+ unsubscribeExit();
+ for (const handler of closeHandlers) handler();
+ };
+
+ const unsubscribeLine = bridge.onLine((line) => {
+ if (closed) return;
+ let message: JsonRpcMessage | null = null;
+ try {
+ message = JSON.parse(line) as JsonRpcMessage;
+ } catch {
+ // Malformed frame — skip it rather than kill the connection.
+ }
+ if (message) {
+ for (const handler of messageHandlers) handler(message);
+ }
+ });
+ const unsubscribeExit = bridge.onExit(() => emitClose());
+
+ return {
+ send(message: JsonRpcMessage) {
+ if (closed) return;
+ // Transport.send is fire-and-forget; a dead peer surfaces as the exit
+ // event → close, matching the stream transport's failure path.
+ bridge.send(JSON.stringify(message)).catch(() => emitClose());
+ },
+ onMessage(handler) {
+ messageHandlers.push(handler);
+ },
+ onClose(handler) {
+ closeHandlers.push(handler);
+ },
+ close() {
+ emitClose();
+ },
+ };
+}
diff --git a/apps/gui/src/main.tsx b/apps/gui/src/main.tsx
new file mode 100644
index 0000000..a251a81
--- /dev/null
+++ b/apps/gui/src/main.tsx
@@ -0,0 +1,19 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { App } from "./app";
+import "./index.css";
+
+// OS theme → the `.dark` class the palette keys off (index.css
+// @custom-variant). Without this the dark theme block is unreachable.
+const darkMedia = window.matchMedia("(prefers-color-scheme: dark)");
+const applyTheme = () => document.documentElement.classList.toggle("dark", darkMedia.matches);
+applyTheme();
+darkMedia.addEventListener("change", applyTheme);
+
+const root = document.getElementById("root");
+if (!root) throw new Error("index.html must provide a #root element");
+createRoot(root).render(
+
+
+ ,
+);
diff --git a/apps/gui/test/config-form.test.ts b/apps/gui/test/config-form.test.ts
new file mode 100644
index 0000000..b542461
--- /dev/null
+++ b/apps/gui/test/config-form.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, test } from "bun:test";
+import { buildSetModelParams } from "../src/lib/config-form";
+
+// splitModelRef moved to @minerva/client (shared frontend grammar); its
+// tests live in packages/client/test/model-ref.test.ts.
+
+describe("buildSetModelParams", () => {
+ test("always qualifies with the selected provider — slashes in the model survive", () => {
+ expect(
+ buildSetModelParams({
+ providerName: "openrouter",
+ isCustom: false,
+ baseUrl: "",
+ apiKey: "",
+ model: "meta-llama/llama-3",
+ }),
+ ).toEqual({ modelRef: "openrouter/meta-llama/llama-3" });
+ });
+
+ test("a pasted full ref for the selected provider is not double-prefixed", () => {
+ expect(
+ buildSetModelParams({
+ providerName: "bailian",
+ isCustom: false,
+ baseUrl: "",
+ apiKey: "",
+ model: "bailian/qwen-plus",
+ }),
+ ).toEqual({ modelRef: "bailian/qwen-plus" });
+ });
+
+ test("a keyed submission carries the key without a provider upsert for builtins", () => {
+ expect(
+ buildSetModelParams({
+ providerName: "bailian",
+ isCustom: false,
+ baseUrl: "",
+ apiKey: "sk-dashscope",
+ model: "qwen-plus",
+ }),
+ ).toEqual({ modelRef: "bailian/qwen-plus", apiKey: "sk-dashscope" });
+ });
+
+ test("a keyless custom provider persists requiresApiKey: false", () => {
+ expect(
+ buildSetModelParams({
+ providerName: "local-llm",
+ isCustom: true,
+ baseUrl: "http://localhost:8080/v1",
+ apiKey: "",
+ model: "llama4",
+ }),
+ ).toEqual({
+ modelRef: "local-llm/llama4",
+ provider: { name: "local-llm", baseUrl: "http://localhost:8080/v1", requiresApiKey: false },
+ });
+ });
+
+ test("a keyed custom provider persists requiresApiKey: true and the key", () => {
+ expect(
+ buildSetModelParams({
+ providerName: "acme",
+ isCustom: true,
+ baseUrl: "https://acme.test/v1",
+ apiKey: "sk-acme",
+ model: "acme-1",
+ }),
+ ).toEqual({
+ modelRef: "acme/acme-1",
+ provider: { name: "acme", baseUrl: "https://acme.test/v1", requiresApiKey: true },
+ apiKey: "sk-acme",
+ });
+ });
+});
diff --git a/apps/gui/test/diff-rows.test.ts b/apps/gui/test/diff-rows.test.ts
new file mode 100644
index 0000000..8f8db54
--- /dev/null
+++ b/apps/gui/test/diff-rows.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, test } from "bun:test";
+import type { DiffLine } from "@minerva/client";
+import { alignDiffRows } from "../src/lib/diff-rows";
+
+const del = (text: string): DiffLine => ({ kind: "del", text });
+const add = (text: string): DiffLine => ({ kind: "add", text });
+const ctx = (text: string): DiffLine => ({ kind: "context", text });
+
+describe("alignDiffRows", () => {
+ test("a modified line pairs del with add on one row", () => {
+ expect(alignDiffRows([ctx("a"), del("b"), add("B"), ctx("c")])).toEqual([
+ { kind: "pair", left: { kind: "context", text: "a" }, right: { kind: "context", text: "a" } },
+ { kind: "pair", left: { kind: "del", text: "b" }, right: { kind: "add", text: "B" } },
+ { kind: "pair", left: { kind: "context", text: "c" }, right: { kind: "context", text: "c" } },
+ ]);
+ });
+
+ test("pure additions leave the left side empty, pure removals the right", () => {
+ expect(alignDiffRows([add("new")])).toEqual([
+ { kind: "pair", left: { kind: "empty", text: "" }, right: { kind: "add", text: "new" } },
+ ]);
+ expect(alignDiffRows([del("old")])).toEqual([
+ { kind: "pair", left: { kind: "del", text: "old" }, right: { kind: "empty", text: "" } },
+ ]);
+ });
+
+ test("unequal runs finish against blanks", () => {
+ const rows = alignDiffRows([del("1"), del("2"), del("3"), add("x")]);
+ expect(rows).toEqual([
+ { kind: "pair", left: { kind: "del", text: "1" }, right: { kind: "add", text: "x" } },
+ { kind: "pair", left: { kind: "del", text: "2" }, right: { kind: "empty", text: "" } },
+ { kind: "pair", left: { kind: "del", text: "3" }, right: { kind: "empty", text: "" } },
+ ]);
+ });
+
+ test("interleaved del/add runs still pair index-wise until context", () => {
+ const rows = alignDiffRows([del("a"), add("A"), del("b"), add("B"), ctx("end")]);
+ expect(rows).toEqual([
+ { kind: "pair", left: { kind: "del", text: "a" }, right: { kind: "add", text: "A" } },
+ { kind: "pair", left: { kind: "del", text: "b" }, right: { kind: "add", text: "B" } },
+ {
+ kind: "pair",
+ left: { kind: "context", text: "end" },
+ right: { kind: "context", text: "end" },
+ },
+ ]);
+ });
+
+ test("gap and note lines become full-width bands and split runs", () => {
+ const rows = alignDiffRows([
+ del("x"),
+ { kind: "gap", text: "… 3 unchanged lines" },
+ add("y"),
+ { kind: "note", text: "\\ new file has no trailing newline" },
+ ]);
+ expect(rows).toEqual([
+ { kind: "pair", left: { kind: "del", text: "x" }, right: { kind: "empty", text: "" } },
+ { kind: "band", text: "… 3 unchanged lines" },
+ { kind: "pair", left: { kind: "empty", text: "" }, right: { kind: "add", text: "y" } },
+ { kind: "band", text: "\\ new file has no trailing newline" },
+ ]);
+ });
+
+ test("empty input yields no rows", () => {
+ expect(alignDiffRows([])).toEqual([]);
+ });
+});
diff --git a/apps/gui/test/kernel-manager.test.ts b/apps/gui/test/kernel-manager.test.ts
new file mode 100644
index 0000000..c01dd44
--- /dev/null
+++ b/apps/gui/test/kernel-manager.test.ts
@@ -0,0 +1,186 @@
+import { describe, expect, test } from "bun:test";
+import type { JsonRpcRequest } from "@minerva/protocol";
+import { createKernelManager } from "../src/lib/kernel-manager";
+import type { SidecarBridge } from "../src/lib/sidecar-bridge";
+
+/** Fake bridge that answers like a kernel: initialize + minerva/config/state.
+ * `alive` controls whether a (re)start boots a responsive kernel. */
+function createFakeKernelBridge() {
+ const lineHandlers = new Set<(line: string) => void>();
+ const exitHandlers = new Set<(code: number | null) => void>();
+ let nextStartGate: Promise | null = null;
+ const respond = (message: unknown) => {
+ queueMicrotask(() => {
+ for (const handler of lineHandlers) handler(JSON.stringify(message));
+ });
+ };
+ const bridge = {
+ starts: 0,
+ kills: 0,
+ alive: true,
+ /** True = accept writes but never answer (a wedged kernel). */
+ mute: false,
+ async start() {
+ bridge.starts++;
+ if (!bridge.alive) throw new Error("spawn failed");
+ const gate = nextStartGate;
+ nextStartGate = null;
+ if (gate) await gate;
+ },
+ async send(line: string) {
+ if (!bridge.alive) throw new Error("kernel is not running");
+ if (bridge.mute) return;
+ const message = JSON.parse(line) as JsonRpcRequest;
+ if (!("id" in message)) return;
+ if (message.method === "initialize") {
+ respond({
+ jsonrpc: "2.0",
+ id: message.id,
+ result: { protocolVersion: 1, agentCapabilities: { loadSession: true } },
+ });
+ } else if (message.method === "minerva/config/state") {
+ respond({
+ jsonrpc: "2.0",
+ id: message.id,
+ result: { model: "acme/one", needsApiKey: false, providers: [] },
+ });
+ } else if (message.method === "session/prompt") {
+ // Held open forever — lets tests park a request across a crash.
+ } else {
+ respond({ jsonrpc: "2.0", id: message.id, result: null });
+ }
+ },
+ async kill() {
+ // Contract with the real bridge: an intentional kill resolves once the
+ // child is gone and emits NO exit event (the Rust side is
+ // generation-guarded), so crash recovery never fires for it.
+ bridge.kills++;
+ },
+ onLine(handler: (line: string) => void) {
+ lineHandlers.add(handler);
+ return () => lineHandlers.delete(handler);
+ },
+ onExit(handler: (code: number | null) => void) {
+ exitHandlers.add(handler);
+ return () => exitHandlers.delete(handler);
+ },
+ crash(code: number) {
+ for (const handler of [...exitHandlers]) handler(code);
+ },
+ blockNextStart(gate: Promise) {
+ nextStartGate = gate;
+ },
+ lineHandlerCount() {
+ return lineHandlers.size;
+ },
+ } satisfies SidecarBridge & Record;
+ return bridge;
+}
+
+async function until(predicate: () => boolean, what: string): Promise {
+ for (let i = 0; i < 200; i++) {
+ if (predicate()) return;
+ await Bun.sleep(1);
+ }
+ throw new Error(`timed out waiting for ${what}`);
+}
+
+describe("createKernelManager", () => {
+ test("start reaches ready with a client and config", async () => {
+ const bridge = createFakeKernelBridge();
+ const manager = createKernelManager(bridge);
+ const phases: string[] = [];
+ manager.subscribe(() => phases.push(manager.snapshot.phase));
+ manager.start();
+ await until(() => manager.snapshot.phase === "ready", "ready");
+ expect(manager.snapshot.client).not.toBeNull();
+ expect(manager.snapshot.config?.model).toBe("acme/one");
+ expect(phases.at(-1)).toBe("ready");
+ // start() is a no-op while live — no second spawn.
+ manager.start();
+ expect(bridge.starts).toBe(1);
+ });
+
+ test("one crash auto-restarts with a fresh client; pending permissions cancel", async () => {
+ const bridge = createFakeKernelBridge();
+ const manager = createKernelManager(bridge);
+ manager.start();
+ await until(() => manager.snapshot.phase === "ready", "ready");
+ const firstClient = manager.snapshot.client;
+
+ const pending = manager.permissions.handler({
+ sessionId: "ses_x",
+ toolCall: { toolCallId: "t1", title: "write file", kind: "edit" },
+ options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }],
+ });
+
+ bridge.crash(1);
+ expect(await pending).toEqual({ outcome: { outcome: "cancelled" } });
+ await until(() => manager.snapshot.phase === "ready", "auto-restart ready");
+ expect(bridge.starts).toBe(2);
+ // Recovery must build a fresh client — the old Connection is dead.
+ expect(manager.snapshot.client).not.toBe(firstClient);
+ });
+
+ test("an exit during start supersedes the attempt before it creates a client", async () => {
+ const bridge = createFakeKernelBridge();
+ let releaseStart: (() => void) | undefined;
+ bridge.blockNextStart(
+ new Promise((resolve) => {
+ releaseStart = resolve;
+ }),
+ );
+ const manager = createKernelManager(bridge);
+ manager.start();
+ await until(() => bridge.starts === 1, "blocked first start");
+
+ bridge.crash(1);
+ await until(() => manager.snapshot.phase === "ready", "replacement ready");
+ releaseStart?.();
+ await Bun.sleep(0);
+
+ // Only the replacement attempt owns a transport. Without the post-start
+ // generation check, the superseded attempt attaches a leaked second one.
+ expect(bridge.lineHandlerCount()).toBe(1);
+ });
+
+ test("a second crash stays down until a manual start", async () => {
+ const bridge = createFakeKernelBridge();
+ const manager = createKernelManager(bridge);
+ manager.start();
+ await until(() => manager.snapshot.phase === "ready", "ready");
+ bridge.crash(1);
+ await until(() => manager.snapshot.phase === "ready", "auto-restart");
+ bridge.crash(9);
+ expect(manager.snapshot.phase).toBe("down");
+ expect(manager.snapshot.exitCode).toBe(9);
+
+ manager.start();
+ await until(() => manager.snapshot.phase === "ready", "manual restart");
+ expect(bridge.starts).toBe(3);
+ });
+
+ test("a spawn failure lands in down with the error", async () => {
+ const bridge = createFakeKernelBridge();
+ bridge.alive = false;
+ const manager = createKernelManager(bridge);
+ manager.start();
+ await until(() => manager.snapshot.phase === "down", "down");
+ expect(manager.snapshot.error).toContain("spawn failed");
+ });
+
+ test("a hung handshake times out to down and kills the wedged kernel", async () => {
+ const bridge = createFakeKernelBridge();
+ bridge.mute = true;
+ const manager = createKernelManager(bridge, { handshakeTimeoutMs: 20 });
+ manager.start();
+ await until(() => manager.snapshot.phase === "down", "handshake timeout");
+ expect(manager.snapshot.error).toContain("did not respond");
+ expect(bridge.kills).toBe(1);
+
+ // The manual restart escape hatch works once the kernel behaves.
+ bridge.mute = false;
+ manager.start();
+ await until(() => manager.snapshot.phase === "ready", "recovery after timeout");
+ });
+});
diff --git a/apps/gui/test/notify.test.ts b/apps/gui/test/notify.test.ts
new file mode 100644
index 0000000..dd552c6
--- /dev/null
+++ b/apps/gui/test/notify.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from "bun:test";
+import { decideNotification, NOTIFY_MIN_TURN_MS } from "../src/lib/notify";
+
+const BASE = {
+ stopReason: "end_turn" as const,
+ focused: false,
+ durationMs: NOTIFY_MIN_TURN_MS + 1,
+ muted: false,
+ project: "minerva",
+};
+
+describe("decideNotification", () => {
+ test("a long unfocused completed turn notifies with the project name", () => {
+ expect(decideNotification(BASE)).toEqual({
+ title: "Minerva finished a turn",
+ body: "minerva — The reply is ready.",
+ });
+ });
+
+ test("suppressed when focused, muted, quick, or user-cancelled", () => {
+ expect(decideNotification({ ...BASE, focused: true })).toBeNull();
+ expect(decideNotification({ ...BASE, muted: true })).toBeNull();
+ expect(decideNotification({ ...BASE, durationMs: NOTIFY_MIN_TURN_MS - 1 })).toBeNull();
+ expect(decideNotification({ ...BASE, stopReason: "cancelled" })).toBeNull();
+ });
+
+ test("abnormal stops escalate the title", () => {
+ for (const stopReason of ["max_tokens", "max_turn_requests", "refusal"] as const) {
+ const result = decideNotification({ ...BASE, stopReason });
+ expect(result?.title).toBe("Minerva needs attention");
+ }
+ expect(decideNotification({ ...BASE, stopReason: "max_tokens" })?.body).toContain(
+ "output-token limit",
+ );
+ });
+});
diff --git a/apps/gui/test/permission-queue.test.ts b/apps/gui/test/permission-queue.test.ts
new file mode 100644
index 0000000..c8c5ea1
--- /dev/null
+++ b/apps/gui/test/permission-queue.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, test } from "bun:test";
+import type { RequestPermissionParams } from "@minerva/protocol";
+import { createPermissionQueue } from "../src/lib/permission-queue";
+
+function request(id: string): RequestPermissionParams {
+ return {
+ sessionId: "ses_test",
+ toolCall: { toolCallId: id, title: `tool ${id}`, kind: "execute" },
+ options: [
+ { optionId: "allow", name: "Allow", kind: "allow_once" },
+ { optionId: "deny", name: "Deny", kind: "reject_once" },
+ ],
+ };
+}
+
+describe("createPermissionQueue", () => {
+ test("stacked requests answer in FIFO order", async () => {
+ const queue = createPermissionQueue();
+ const first = queue.handler(request("a"));
+ const second = queue.handler(request("b"));
+
+ expect(queue.snapshot.depth).toBe(2);
+ expect(queue.snapshot.current?.request.toolCall.toolCallId).toBe("a");
+
+ queue.snapshot.current?.resolve({ outcome: { outcome: "selected", optionId: "allow" } });
+ expect(await first).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
+ expect(queue.snapshot.depth).toBe(1);
+ expect(queue.snapshot.current?.request.toolCall.toolCallId).toBe("b");
+
+ queue.snapshot.current?.resolve({ outcome: { outcome: "cancelled" } });
+ expect(await second).toEqual({ outcome: { outcome: "cancelled" } });
+ expect(queue.snapshot).toEqual({ current: null, depth: 0 });
+ });
+
+ test("notifies subscribers on enqueue and resolve, with fresh snapshots", () => {
+ const queue = createPermissionQueue();
+ let notified = 0;
+ const seen: Array = [];
+ const unsubscribe = queue.subscribe(() => {
+ notified++;
+ seen.push(queue.snapshot.depth);
+ });
+
+ void queue.handler(request("a"));
+ const before = queue.snapshot;
+ queue.snapshot.current?.resolve({ outcome: { outcome: "cancelled" } });
+ expect(notified).toBe(2);
+ expect(seen).toEqual([1, 0]);
+ // Snapshot identity changes per transition (useSyncExternalStore contract).
+ expect(queue.snapshot).not.toBe(before);
+
+ unsubscribe();
+ void queue.handler(request("b"));
+ expect(notified).toBe(2);
+ });
+
+ test("double-resolving one entry is ignored", async () => {
+ const queue = createPermissionQueue();
+ const result = queue.handler(request("a"));
+ const entry = queue.snapshot.current;
+ entry?.resolve({ outcome: { outcome: "selected", optionId: "allow" } });
+ entry?.resolve({ outcome: { outcome: "cancelled" } });
+ expect(await result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
+ expect(queue.snapshot.depth).toBe(0);
+ });
+});
diff --git a/apps/gui/test/session-slots.test.ts b/apps/gui/test/session-slots.test.ts
new file mode 100644
index 0000000..80ef4be
--- /dev/null
+++ b/apps/gui/test/session-slots.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, test } from "bun:test";
+import { createSessionSlots } from "../src/lib/session-slots";
+
+describe("createSessionSlots", () => {
+ test("a newer begin supersedes the older in-flight install", () => {
+ const slots = createSessionSlots();
+ const first = slots.begin("tab");
+ const second = slots.begin("tab");
+ // Completion order no longer matters: only the latest may commit.
+ expect(slots.isCurrent("tab", first)).toBe(false);
+ expect(slots.isCurrent("tab", second)).toBe(true);
+ });
+
+ test("tabs are independent", () => {
+ const slots = createSessionSlots();
+ const a = slots.begin("a");
+ const b = slots.begin("b");
+ expect(slots.isCurrent("a", a)).toBe(true);
+ expect(slots.isCurrent("b", b)).toBe(true);
+ slots.begin("a");
+ expect(slots.isCurrent("a", a)).toBe(false);
+ expect(slots.isCurrent("b", b)).toBe(true);
+ });
+
+ test("closing a tab invalidates its outstanding install", () => {
+ const slots = createSessionSlots();
+ const token = slots.begin("tab");
+ slots.invalidate("tab");
+ expect(slots.isCurrent("tab", token)).toBe(false);
+ });
+
+ test("a client swap invalidates everything, and old tokens never resurrect", () => {
+ const slots = createSessionSlots();
+ const a = slots.begin("a");
+ const b = slots.begin("b");
+ slots.invalidateAll();
+ expect(slots.isCurrent("a", a)).toBe(false);
+ expect(slots.isCurrent("b", b)).toBe(false);
+ // The counter is global and monotonic: post-clear begins can never hand
+ // out a number an outstanding pre-clear completion still holds.
+ const fresh = slots.begin("a");
+ expect(fresh).toBeGreaterThan(a);
+ expect(fresh).toBeGreaterThan(b);
+ expect(slots.isCurrent("a", a)).toBe(false);
+ expect(slots.isCurrent("a", fresh)).toBe(true);
+ });
+});
diff --git a/apps/gui/test/session-switches.test.ts b/apps/gui/test/session-switches.test.ts
new file mode 100644
index 0000000..3b77e53
--- /dev/null
+++ b/apps/gui/test/session-switches.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, test } from "bun:test";
+import { createSessionSlots } from "../src/lib/session-slots";
+import { createSessionSwitches } from "../src/lib/session-switches";
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (cause: unknown) => void;
+ const promise = new Promise((yes, no) => {
+ resolve = yes;
+ reject = no;
+ });
+ return { promise, resolve, reject };
+}
+
+describe("createSessionSwitches", () => {
+ test("A → B → A reuses A and lets the last intent commit", async () => {
+ const switches = createSessionSwitches<{ id: string }>();
+ const slots = createSessionSlots();
+ const a = deferred<{ id: string }>();
+ const b = deferred<{ id: string }>();
+ const starts = { a: 0, b: 0 };
+ const committed: string[] = [];
+ const closed: string[] = [];
+
+ const select = (target: "a" | "b") => {
+ const token = slots.begin("tab");
+ const pending = switches.begin("tab", target, () => {
+ starts[target] += 1;
+ return target === "a" ? a.promise : b.promise;
+ });
+ void pending.then((session) => {
+ if (!slots.isCurrent("tab", token)) {
+ // An older handler for a REUSED target must not detach the result
+ // before the latest handler commits that same store.
+ if (!switches.isDesired("tab", target)) closed.push(session.id);
+ return;
+ }
+ switches.finish("tab", target);
+ committed.push(session.id);
+ });
+ return pending;
+ };
+
+ const firstA = select("a");
+ select("b");
+ const lastA = select("a");
+ expect(lastA).toBe(firstA);
+ expect(starts).toEqual({ a: 1, b: 1 });
+
+ a.resolve({ id: "a" });
+ await a.promise;
+ await Promise.resolve();
+ expect(committed).toEqual(["a"]);
+ expect(closed).toEqual([]);
+
+ b.resolve({ id: "b" });
+ await b.promise;
+ await Promise.resolve();
+ expect(closed).toEqual(["b"]);
+ });
+
+ test("invalidating a tab makes its reused result disposable", async () => {
+ const switches = createSessionSwitches();
+ const work = deferred();
+ const first = switches.begin("tab", "session", () => work.promise);
+ const reused = switches.begin("tab", "session", () => Promise.resolve("duplicate"));
+ expect(reused).toBe(first);
+
+ switches.invalidate("tab");
+ expect(switches.isDesired("tab", "session")).toBe(false);
+ work.resolve("session");
+ await work.promise;
+ });
+
+ test("a client reset never reuses old work or lets its cleanup erase new work", async () => {
+ const switches = createSessionSwitches();
+ const oldWork = deferred();
+ const newWork = deferred();
+ const oldPromise = switches.begin("tab", "session", () => oldWork.promise);
+
+ switches.invalidateAll();
+ expect(switches.isDesired("tab", "session")).toBe(false);
+ const newPromise = switches.begin("tab", "session", () => newWork.promise);
+ expect(newPromise).not.toBe(oldPromise);
+
+ oldWork.resolve("old");
+ await oldWork.promise;
+ await Promise.resolve();
+ const reusedNew = switches.begin("tab", "session", () => Promise.resolve("duplicate"));
+ expect(reusedNew).toBe(newPromise);
+
+ newWork.resolve("new");
+ await newWork.promise;
+ });
+
+ test("a synchronous start failure becomes the shared rejection", async () => {
+ const switches = createSessionSwitches();
+ const failure = new Error("boom");
+ const first = switches.begin("tab", "session", () => {
+ throw failure;
+ });
+ const reused = switches.begin("tab", "session", () => Promise.resolve("duplicate"));
+ expect(reused).toBe(first);
+ await expect(first).rejects.toBe(failure);
+ });
+});
diff --git a/apps/gui/test/sidecar-generation.test.ts b/apps/gui/test/sidecar-generation.test.ts
new file mode 100644
index 0000000..60c0d45
--- /dev/null
+++ b/apps/gui/test/sidecar-generation.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, test } from "bun:test";
+import { createSidecarGenerationGate } from "../src/lib/sidecar-generation";
+
+describe("createSidecarGenerationGate", () => {
+ test("delivers only the active generation's exit", () => {
+ const delivered: Array = [];
+ const gate = createSidecarGenerationGate((code) => delivered.push(code));
+ gate.activate(2);
+ gate.exit({ generation: 1, code: 1 });
+ gate.exit({ generation: 2, code: 2 });
+ expect(delivered).toEqual([2]);
+ });
+
+ test("an old exit received before activation is discarded", () => {
+ const delivered: Array = [];
+ const gate = createSidecarGenerationGate((code) => delivered.push(code));
+ gate.exit({ generation: 4, code: 4 });
+ gate.activate(5);
+ expect(delivered).toEqual([]);
+ });
+
+ test("an immediate exit is delivered once start identifies its generation", () => {
+ const delivered: Array = [];
+ const gate = createSidecarGenerationGate((code) => delivered.push(code));
+ gate.exit({ generation: 7, code: null });
+ gate.activate(7);
+ expect(delivered).toEqual([null]);
+ });
+
+ test("clear forgets ownership and buffered exits", () => {
+ const delivered: Array = [];
+ const gate = createSidecarGenerationGate((code) => delivered.push(code));
+ gate.exit({ generation: 9, code: 9 });
+ gate.clear();
+ gate.activate(9);
+ expect(delivered).toEqual([]);
+ });
+});
diff --git a/apps/gui/test/stale-session.test.ts b/apps/gui/test/stale-session.test.ts
new file mode 100644
index 0000000..cc057af
--- /dev/null
+++ b/apps/gui/test/stale-session.test.ts
@@ -0,0 +1,69 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { MinervaClient } from "@minerva/client";
+import { createKernel, type MinervaKernel } from "@minerva/kernel";
+import { createInProcTransportPair } from "@minerva/protocol";
+import { createScriptedProvider } from "@minerva/providers";
+import { ensureTabSession, isStaleSessionError } from "../src/lib/tab-session";
+
+/**
+ * The stale predicate is only as good as the errors the kernel actually
+ * throws — an earlier version matched a message the load path never
+ * produces, silently disabling the self-heal. These tests run the REAL
+ * kernel so the predicate can't drift from the wire again.
+ */
+describe("isStaleSessionError against the real kernel", () => {
+ const kernels: MinervaKernel[] = [];
+ const clients: MinervaClient[] = [];
+ afterEach(async () => {
+ for (const client of clients.splice(0)) client.close();
+ await Promise.all(kernels.splice(0).map((kernel) => kernel.close()));
+ });
+
+ async function setup() {
+ const cwd = mkdtempSync(join(tmpdir(), "minerva-gui-stale-proj-"));
+ const dataDir = mkdtempSync(join(tmpdir(), "minerva-gui-stale-data-"));
+ const [clientTransport, kernelTransport] = createInProcTransportPair();
+ kernels.push(createKernel(kernelTransport, { dataDir, provider: createScriptedProvider([]) }));
+ const client = new MinervaClient(clientTransport);
+ clients.push(client);
+ await client.initialize();
+ return { client, cwd };
+ }
+
+ const caught = (promise: Promise) =>
+ promise.then(
+ () => null,
+ (cause: unknown) => cause,
+ );
+
+ test("a missing persisted session is stale (deleted log, other data dir)", async () => {
+ const { client, cwd } = await setup();
+ const error = await caught(client.loadSession(`ses_${crypto.randomUUID()}`, cwd));
+ expect(error).not.toBeNull();
+ expect(isStaleSessionError(error)).toBe(true);
+ });
+
+ test("a corrupt persisted id is stale", async () => {
+ const { client, cwd } = await setup();
+ const error = await caught(client.loadSession("../../etc/passwd", cwd));
+ expect(error).not.toBeNull();
+ expect(isStaleSessionError(error)).toBe(true);
+ });
+
+ test("ensureTabSession self-heals a dead tab id end to end", async () => {
+ const { client, cwd } = await setup();
+ const { session, resumed } = await ensureTabSession(
+ {
+ load: (sessionId, dir) => client.loadSession(sessionId, dir),
+ create: (dir) => client.newSession(dir),
+ },
+ { id: "t", cwd, sessionId: `ses_${crypto.randomUUID()}` },
+ isStaleSessionError,
+ );
+ expect(resumed).toBe(false);
+ expect(session.sessionId).toStartWith("ses_");
+ });
+});
diff --git a/apps/gui/test/tab-session.test.ts b/apps/gui/test/tab-session.test.ts
new file mode 100644
index 0000000..3c29906
--- /dev/null
+++ b/apps/gui/test/tab-session.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, test } from "bun:test";
+import { RpcError } from "@minerva/protocol";
+import { ensureTabSession, isStaleSessionError as isStale } from "../src/lib/tab-session";
+
+describe("ensureTabSession", () => {
+ test("resumes the tab's persisted session", async () => {
+ const calls: string[] = [];
+ const result = await ensureTabSession(
+ {
+ load: async (id, cwd) => {
+ calls.push(`load:${id}:${cwd}`);
+ return "loaded";
+ },
+ create: async () => {
+ calls.push("create");
+ return "created";
+ },
+ },
+ { id: "t", cwd: "/proj", sessionId: "ses_1" },
+ isStale,
+ );
+ expect(result).toEqual({ session: "loaded", resumed: true });
+ expect(calls).toEqual(["load:ses_1:/proj"]);
+ });
+
+ test("falls back to a fresh session only for a stale id", async () => {
+ // The real message family for a missing log; the full round-trip against
+ // an actual kernel lives in stale-session.test.ts.
+ const result = await ensureTabSession(
+ {
+ load: async () => {
+ throw new RpcError(-32602, "no persisted session ses_gone for /proj");
+ },
+ create: async (cwd) => `created:${cwd}`,
+ },
+ { id: "t", cwd: "/proj", sessionId: "ses_gone" },
+ isStale,
+ );
+ expect(result).toEqual({ session: "created:/proj", resumed: false });
+ });
+
+ test("non-stale load failures surface instead of replacing the conversation", async () => {
+ const failures = [
+ new RpcError(-32600, "cannot load a session while a prompt is running in it"),
+ new RpcError(-32603, "cannot reload session: pending writes failed (EIO)"),
+ new Error("session ses_1 is already open in this client"),
+ ];
+ for (const failure of failures) {
+ let created = false;
+ await expect(
+ ensureTabSession(
+ {
+ load: async () => {
+ throw failure;
+ },
+ create: async () => {
+ created = true;
+ return "created";
+ },
+ },
+ { id: "t", cwd: "/proj", sessionId: "ses_1" },
+ isStale,
+ ),
+ ).rejects.toBe(failure);
+ expect(created).toBe(false);
+ }
+ });
+
+ test("creates directly for a fresh tab", async () => {
+ const result = await ensureTabSession(
+ {
+ load: async () => {
+ throw new Error("must not be called");
+ },
+ create: async (cwd) => `created:${cwd}`,
+ },
+ { id: "t", cwd: "/proj", sessionId: null },
+ isStale,
+ );
+ expect(result).toEqual({ session: "created:/proj", resumed: false });
+ });
+
+ test("a failing create propagates — the caller owns that error", async () => {
+ await expect(
+ ensureTabSession(
+ {
+ load: async () => "unused",
+ create: async () => {
+ throw new Error("kernel gone");
+ },
+ },
+ { id: "t", cwd: "/proj", sessionId: null },
+ isStale,
+ ),
+ ).rejects.toThrow("kernel gone");
+ });
+});
diff --git a/apps/gui/test/tabs.test.ts b/apps/gui/test/tabs.test.ts
new file mode 100644
index 0000000..5afc8f0
--- /dev/null
+++ b/apps/gui/test/tabs.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, test } from "bun:test";
+import {
+ deserializeTabs,
+ EMPTY_TABS,
+ serializeTabs,
+ type TabsState,
+ tabsReducer,
+} from "../src/lib/tabs";
+
+const open = (state: TabsState, id: string, cwd: string) =>
+ tabsReducer(state, { type: "open", tabId: id, cwd });
+
+describe("tabsReducer", () => {
+ test("open appends and activates", () => {
+ let state = open(EMPTY_TABS, "a", "/one");
+ state = open(state, "b", "/two");
+ expect(state.tabs.map((t) => t.id)).toEqual(["a", "b"]);
+ expect(state.activeTabId).toBe("b");
+ });
+
+ test("closing the active tab activates its right neighbor, then the last", () => {
+ let state = open(open(open(EMPTY_TABS, "a", "/1"), "b", "/2"), "c", "/3");
+ state = tabsReducer(state, { type: "activate", tabId: "b" });
+ state = tabsReducer(state, { type: "close", tabId: "b" });
+ expect(state.activeTabId).toBe("c");
+ state = tabsReducer(state, { type: "close", tabId: "c" });
+ expect(state.activeTabId).toBe("a");
+ state = tabsReducer(state, { type: "close", tabId: "a" });
+ expect(state).toEqual({ tabs: [], activeTabId: null });
+ });
+
+ test("closing a background tab keeps the active one", () => {
+ let state = open(open(EMPTY_TABS, "a", "/1"), "b", "/2");
+ state = tabsReducer(state, { type: "close", tabId: "a" });
+ expect(state.activeTabId).toBe("b");
+ });
+
+ test("activate ignores unknown tabs", () => {
+ const state = open(EMPTY_TABS, "a", "/1");
+ expect(tabsReducer(state, { type: "activate", tabId: "nope" })).toBe(state);
+ });
+
+ test("attach-session refuses a session already shown in another tab", () => {
+ let state = open(open(EMPTY_TABS, "a", "/1"), "b", "/2");
+ state = tabsReducer(state, { type: "attach-session", tabId: "a", sessionId: "ses_1" });
+ const rejected = tabsReducer(state, { type: "attach-session", tabId: "b", sessionId: "ses_1" });
+ expect(rejected).toBe(state);
+ // Re-attaching to the same tab and clearing are both fine.
+ state = tabsReducer(state, { type: "attach-session", tabId: "a", sessionId: "ses_1" });
+ state = tabsReducer(state, { type: "attach-session", tabId: "a", sessionId: null });
+ expect(state.tabs[0]?.sessionId).toBeNull();
+ });
+});
+
+describe("tab persistence", () => {
+ test("round-trips through serialize/deserialize", () => {
+ let state = open(open(EMPTY_TABS, "a", "/1"), "b", "/2");
+ state = tabsReducer(state, { type: "attach-session", tabId: "a", sessionId: "ses_1" });
+ state = tabsReducer(state, { type: "activate", tabId: "a" });
+ expect(deserializeTabs(serializeTabs(state))).toEqual(state);
+ });
+
+ test("rejects garbage and heals a stale active id", () => {
+ expect(deserializeTabs(null)).toBeNull();
+ expect(deserializeTabs("not json")).toBeNull();
+ expect(deserializeTabs(JSON.stringify({ tabs: "nope" }))).toBeNull();
+ expect(deserializeTabs(JSON.stringify({ tabs: [], activeTabId: null }))).toBeNull();
+ const healed = deserializeTabs(
+ JSON.stringify({
+ tabs: [{ id: "a", cwd: "/1", sessionId: null }, { bogus: true }],
+ activeTabId: "gone",
+ }),
+ );
+ expect(healed).toEqual({ tabs: [{ id: "a", cwd: "/1", sessionId: null }], activeTabId: "a" });
+ });
+});
diff --git a/apps/gui/test/tauri-transport.test.ts b/apps/gui/test/tauri-transport.test.ts
new file mode 100644
index 0000000..99e9c03
--- /dev/null
+++ b/apps/gui/test/tauri-transport.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, test } from "bun:test";
+import type { JsonRpcMessage } from "@minerva/protocol";
+import type { SidecarBridge } from "../src/lib/sidecar-bridge";
+import { createSidecarTransport } from "../src/lib/tauri-transport";
+
+interface FakeBridge extends SidecarBridge {
+ sent: string[];
+ emitLine(line: string): void;
+ emitExit(code: number | null): void;
+ failSends: boolean;
+}
+
+function createFakeBridge(): FakeBridge {
+ const lineHandlers = new Set<(line: string) => void>();
+ const exitHandlers = new Set<(code: number | null) => void>();
+ const bridge: FakeBridge = {
+ sent: [],
+ failSends: false,
+ emitLine(line) {
+ for (const handler of lineHandlers) handler(line);
+ },
+ emitExit(code) {
+ for (const handler of exitHandlers) handler(code);
+ },
+ async start() {},
+ async send(line) {
+ if (bridge.failSends) throw new Error("kernel is not running");
+ bridge.sent.push(line);
+ },
+ async kill() {},
+ onLine(handler) {
+ lineHandlers.add(handler);
+ return () => lineHandlers.delete(handler);
+ },
+ onExit(handler) {
+ exitHandlers.add(handler);
+ return () => exitHandlers.delete(handler);
+ },
+ };
+ return bridge;
+}
+
+const REQUEST: JsonRpcMessage = { jsonrpc: "2.0", id: 1, method: "initialize", params: {} };
+
+describe("createSidecarTransport", () => {
+ test("round-trips messages as single-line JSON frames", () => {
+ const bridge = createFakeBridge();
+ const transport = createSidecarTransport(bridge);
+ const received: JsonRpcMessage[] = [];
+ transport.onMessage((message) => received.push(message));
+
+ transport.send(REQUEST);
+ expect(bridge.sent).toEqual([JSON.stringify(REQUEST)]);
+ expect(bridge.sent[0]).not.toContain("\n");
+
+ bridge.emitLine(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { ok: true } }));
+ expect(received).toEqual([{ jsonrpc: "2.0", id: 1, result: { ok: true } }]);
+ });
+
+ test("skips malformed frames without dropping the connection", () => {
+ const bridge = createFakeBridge();
+ const transport = createSidecarTransport(bridge);
+ const received: JsonRpcMessage[] = [];
+ let closed = 0;
+ transport.onMessage((message) => received.push(message));
+ transport.onClose(() => closed++);
+
+ bridge.emitLine("not json at all {{{");
+ bridge.emitLine(JSON.stringify(REQUEST));
+ expect(received).toEqual([REQUEST]);
+ expect(closed).toBe(0);
+ });
+
+ test("kernel exit closes the transport exactly once and stops delivery", () => {
+ const bridge = createFakeBridge();
+ const transport = createSidecarTransport(bridge);
+ const received: JsonRpcMessage[] = [];
+ let closed = 0;
+ transport.onMessage((message) => received.push(message));
+ transport.onClose(() => closed++);
+
+ bridge.emitExit(1);
+ bridge.emitExit(1);
+ bridge.emitLine(JSON.stringify(REQUEST));
+ transport.send(REQUEST);
+
+ expect(closed).toBe(1);
+ expect(received).toEqual([]);
+ expect(bridge.sent).toEqual([]);
+ });
+
+ test("close() detaches from the bridge", () => {
+ const bridge = createFakeBridge();
+ const transport = createSidecarTransport(bridge);
+ let closed = 0;
+ transport.onClose(() => closed++);
+
+ transport.close();
+ expect(closed).toBe(1);
+ bridge.emitExit(0);
+ expect(closed).toBe(1);
+ });
+
+ test("a failed send closes the transport", async () => {
+ const bridge = createFakeBridge();
+ const transport = createSidecarTransport(bridge);
+ let closed = 0;
+ transport.onClose(() => closed++);
+
+ bridge.failSends = true;
+ transport.send(REQUEST);
+ // send() failure propagates through a promise rejection handler.
+ await Bun.sleep(0);
+ expect(closed).toBe(1);
+ });
+});
diff --git a/apps/gui/tsconfig.json b/apps/gui/tsconfig.json
new file mode 100644
index 0000000..3b7c923
--- /dev/null
+++ b/apps/gui/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "types": ["bun", "vite/client"],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src/**/*", "test/**/*", "scripts/**/*", "vite.config.ts"],
+ // Override the inherited root exclude — it lists "apps/gui" (this project).
+ "exclude": ["node_modules", "dist", "src-tauri"]
+}
diff --git a/apps/gui/vite.config.ts b/apps/gui/vite.config.ts
new file mode 100644
index 0000000..6651317
--- /dev/null
+++ b/apps/gui/vite.config.ts
@@ -0,0 +1,15 @@
+import { fileURLToPath } from "node:url";
+import tailwindcss from "@tailwindcss/vite";
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+// Port 1420 is Tauri's conventional dev-server port; strictPort because the
+// Tauri shell (added in a later slice) hardcodes the devUrl it attaches to.
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
+ },
+ clearScreen: false,
+ server: { port: 1420, strictPort: true },
+});
diff --git a/biome.json b/biome.json
index 0fd1f1c..f3c4f2b 100644
--- a/biome.json
+++ b/biome.json
@@ -1,7 +1,7 @@
{
"files": {
"ignoreUnknown": true,
- "includes": ["**", "!**/node_modules", "!**/dist"]
+ "includes": ["**", "!**/node_modules", "!**/dist", "!**/src-tauri/target", "!**/src-tauri/gen"]
},
"formatter": {
"enabled": true,
@@ -21,5 +21,10 @@
"formatter": {
"quoteStyle": "double"
}
+ },
+ "css": {
+ "parser": {
+ "tailwindDirectives": true
+ }
}
}
diff --git a/bun.lock b/bun.lock
index 6d6191b..d2f8779 100644
--- a/bun.lock
+++ b/bun.lock
@@ -12,9 +12,35 @@
"typescript": "^7.0.2",
},
},
+ "apps/gui": {
+ "name": "@minerva/gui",
+ "version": "0.3.0",
+ "dependencies": {
+ "@minerva/client": "workspace:*",
+ "@minerva/protocol": "workspace:*",
+ "@tauri-apps/api": "^2",
+ "@tauri-apps/plugin-dialog": "^2",
+ "@tauri-apps/plugin-notification": "^2",
+ "dompurify": "^3",
+ "marked": "^16",
+ "react": "^19",
+ "react-dom": "^19",
+ },
+ "devDependencies": {
+ "@minerva/kernel": "workspace:*",
+ "@minerva/providers": "workspace:*",
+ "@tailwindcss/vite": "^4.1.11",
+ "@tauri-apps/cli": "^2",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "@vitejs/plugin-react": "^5",
+ "tailwindcss": "^4.1.11",
+ "vite": "^7",
+ },
+ },
"packages/cli": {
"name": "@minerva/cli",
- "version": "0.1.0",
+ "version": "0.3.0",
"bin": {
"minerva": "./src/index.tsx",
},
@@ -36,7 +62,7 @@
},
"packages/client": {
"name": "@minerva/client",
- "version": "0.1.0",
+ "version": "0.3.0",
"dependencies": {
"@minerva/protocol": "workspace:*",
},
@@ -47,7 +73,7 @@
},
"packages/kernel": {
"name": "@minerva/kernel",
- "version": "0.1.0",
+ "version": "0.3.0",
"dependencies": {
"@minerva/protocol": "workspace:*",
"@minerva/providers": "workspace:*",
@@ -60,11 +86,11 @@
},
"packages/protocol": {
"name": "@minerva/protocol",
- "version": "0.1.0",
+ "version": "0.3.0",
},
"packages/providers": {
"name": "@minerva/providers",
- "version": "0.1.0",
+ "version": "0.3.0",
"dependencies": {
"@ai-sdk/anthropic": "^4",
"@ai-sdk/openai": "^4",
@@ -92,6 +118,44 @@
"@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="],
+ "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
+
+ "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
+
+ "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
+
+ "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
+
+ "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
+
+ "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
+
+ "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
+
+ "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
+
+ "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
+
+ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
+
+ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
+
+ "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
+
+ "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
+
+ "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
+
+ "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
+
+ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
+
+ "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
+
+ "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
+
+ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
+
"@biomejs/biome": ["@biomejs/biome@2.5.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.3", "@biomejs/cli-darwin-x64": "2.5.3", "@biomejs/cli-linux-arm64": "2.5.3", "@biomejs/cli-linux-arm64-musl": "2.5.3", "@biomejs/cli-linux-x64": "2.5.3", "@biomejs/cli-linux-x64-musl": "2.5.3", "@biomejs/cli-win32-arm64": "2.5.3", "@biomejs/cli-win32-x64": "2.5.3" }, "bin": { "biome": "bin/biome" } }, "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g=="],
@@ -116,12 +180,76 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
+
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
"@minerva/cli": ["@minerva/cli@workspace:packages/cli"],
"@minerva/client": ["@minerva/client@workspace:packages/client"],
+ "@minerva/gui": ["@minerva/gui@workspace:apps/gui"],
+
"@minerva/kernel": ["@minerva/kernel@workspace:packages/kernel"],
"@minerva/protocol": ["@minerva/protocol@workspace:packages/protocol"],
@@ -212,16 +340,142 @@
"@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.21.3", "", { "os": "win32", "cpu": "x64" }, "sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q=="],
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
+
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
+
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
+
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="],
+
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="],
+
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="],
+
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="],
+
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="],
+
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="],
+
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="],
+
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="],
+
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="],
+
+ "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="],
+
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="],
+
+ "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="],
+
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="],
+
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="],
+
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="],
+
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
+
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="],
+
+ "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="],
+
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="],
+
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="],
+
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="],
+
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="],
+
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
+
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+ "@tailwindcss/node": ["@tailwindcss/node@4.3.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.2" } }, "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg=="],
+
+ "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.2", "@tailwindcss/oxide-darwin-arm64": "4.3.2", "@tailwindcss/oxide-darwin-x64": "4.3.2", "@tailwindcss/oxide-freebsd-x64": "4.3.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", "@tailwindcss/oxide-linux-x64-musl": "4.3.2", "@tailwindcss/oxide-wasm32-wasi": "4.3.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag=="],
+
+ "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.2", "", { "os": "android", "cpu": "arm64" }, "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA=="],
+
+ "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w=="],
+
+ "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ=="],
+
+ "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA=="],
+
+ "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w=="],
+
+ "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw=="],
+
+ "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA=="],
+
+ "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w=="],
+
+ "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw=="],
+
+ "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.2", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw=="],
+
+ "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ=="],
+
+ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ=="],
+
+ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.2", "", { "dependencies": { "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "tailwindcss": "4.3.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA=="],
+
+ "@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="],
+
+ "@tauri-apps/cli": ["@tauri-apps/cli@2.11.4", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.4", "@tauri-apps/cli-darwin-x64": "2.11.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", "@tauri-apps/cli-linux-arm64-musl": "2.11.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-musl": "2.11.4", "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", "@tauri-apps/cli-win32-x64-msvc": "2.11.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ=="],
+
+ "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ=="],
+
+ "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A=="],
+
+ "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.4", "", { "os": "linux", "cpu": "arm" }, "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ=="],
+
+ "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA=="],
+
+ "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw=="],
+
+ "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.4", "", { "os": "linux", "cpu": "none" }, "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ=="],
+
+ "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ=="],
+
+ "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A=="],
+
+ "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ=="],
+
+ "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA=="],
+
+ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="],
+
+ "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.1", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="],
+
+ "@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="],
+
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
+ "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
+
+ "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
+
+ "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
+
+ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
+
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
+ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
+
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
+ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
+
+ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
+
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
@@ -264,6 +518,8 @@
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
+ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
+
"@vscode/ripgrep": ["@vscode/ripgrep@1.18.0", "", { "optionalDependencies": { "@vscode/ripgrep-darwin-arm64": "1.18.0", "@vscode/ripgrep-darwin-x64": "1.18.0", "@vscode/ripgrep-linux-arm": "1.18.0", "@vscode/ripgrep-linux-arm64": "1.18.0", "@vscode/ripgrep-linux-ia32": "1.18.0", "@vscode/ripgrep-linux-ppc64": "1.18.0", "@vscode/ripgrep-linux-riscv64": "1.18.0", "@vscode/ripgrep-linux-s390x": "1.18.0", "@vscode/ripgrep-linux-x64": "1.18.0", "@vscode/ripgrep-win32-arm64": "1.18.0", "@vscode/ripgrep-win32-ia32": "1.18.0", "@vscode/ripgrep-win32-x64": "1.18.0" } }, "sha512-ns5lWe44tSfbTMbVUsyB+I1819PVSw4AdpgK0RNkzfWfwy6+3IUNSxwSrfTno1/oWaS/hERNz+XLWVyga2aJBQ=="],
"@vscode/ripgrep-darwin-arm64": ["@vscode/ripgrep-darwin-arm64@1.18.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q=="],
@@ -308,8 +564,12 @@
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="],
+
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
+ "browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="],
+
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
@@ -318,6 +578,8 @@
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
+ "caniuse-lite": ["caniuse-lite@1.0.30001805", "", {}, "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA=="],
+
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"cli-boxes": ["cli-boxes@4.0.1", "", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="],
@@ -332,6 +594,8 @@
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
+ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
+
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
@@ -348,12 +612,20 @@
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "dompurify": ["dompurify@3.4.12", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg=="],
+
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
+ "electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="],
+
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
+ "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
+
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
@@ -364,6 +636,10 @@
"es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="],
+ "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
+
+ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
+
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
@@ -394,8 +670,12 @@
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
+
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@@ -406,6 +686,8 @@
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
@@ -442,14 +724,48 @@
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
+ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+
+ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
+
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
+ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
+
"knip": ["knip@6.26.0", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.7.0", "oxc-parser": "^0.137.0", "oxc-resolver": "11.21.3", "picomatch": "^4.0.4", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.1", "yaml": "^2.9.0", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-e9eELEEpBpGTd4H4HB7818/DYj9dMzMyUqAddfYwUN/EbSkgIjOuWEF96W/xHsmV0SDrsdXjIM+oZ2xpPzPsBA=="],
+ "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
+
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
+
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
+
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
+
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
+
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
+
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
+
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
+
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
+
+ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
"marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -466,8 +782,12 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+ "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
+
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
+ "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
+
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
@@ -490,10 +810,14 @@
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
+ "postcss": ["postcss@8.5.18", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ=="],
+
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
@@ -506,20 +830,28 @@
"react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
+ "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
+
"react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
+ "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
+
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
+ "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
+
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
+ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
@@ -546,6 +878,8 @@
"smol-toml": ["smol-toml@1.7.0", "", {}, "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ=="],
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
@@ -558,6 +892,10 @@
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
+ "tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="],
+
+ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
+
"terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
@@ -578,8 +916,12 @@
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
+ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
+
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
+ "vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
+
"walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -592,6 +934,8 @@
"ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="],
+ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
@@ -604,6 +948,18 @@
"@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="],
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
"body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"ink/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index f5b7e3c..22a50e3 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -1,9 +1,10 @@
# Minerva — Design Record
Minerva is a cross-platform, model-agnostic code agent: a headless kernel with
-multiple frontends (CLI now, GUI later). It is a daily-driver tool first and the
-foundation for an enterprise product later. This document records the v0.1
-design decisions and the reasoning behind them.
+multiple frontends (Ink CLI and Tauri desktop GUI). It is a daily-driver tool
+first and the foundation for an enterprise product later. This document records
+the design decisions and the reasoning behind them, starting from v0.1; later
+milestones append to it (see M2 below).
The central constraint that shaped the architecture: **Tauri 2's backend is
Rust, so a TypeScript kernel can never run in-process in the GUI** — the kernel
@@ -49,7 +50,7 @@ minerva/
client/ # frontend-agnostic protocol client + session view-model reducer
cli/ # Ink app: REPL, streaming render, approval prompts, slash commands
apps/
- gui/ # Tauri 2 + React + Vite (M2)
+ gui/ # Tauri 2 + React + Vite desktop app (kernel as stdio sidecar)
```
Config: `~/.minerva/settings.json` (global) + `.minerva/settings.json`
@@ -81,8 +82,27 @@ streaming, permission round-trip, subagent degradation)*; two providers switchab
`kill -9` the CLI → resume restores the session ✅ (tested, including torn log
lines); every side effect traceable in the audit log ✅.
-**M2 (post-v0.1):** Tauri 2 GUI — kernel as bundled sidecar, React UI consuming
-`@minerva/client` view-models; session browser reads the JSONL index.
+**M2 (shipped post-v0.3):** Tauri 2 GUI — kernel as stdio sidecar, React UI
+consuming `@minerva/client` view-models; session browser reads the JSONL
+index. Decisions recorded from the build:
+
+- **Rust owns the kernel child** (~200 lines in `src-tauri/src/sidecar.rs`),
+ not the webview: a webview-held child would be orphaned on every HMR
+ reload. Rust does its own byte-level line framing and forwards one frame
+ per event; all restart/reconnect policy lives in TS
+ (`apps/gui/src/lib/kernel-manager.ts` — one auto-respawn per death, then a
+ manual restart button). No `shell:*` capability is granted to the webview.
+- **One kernel, many project tabs**: `session/new` takes cwd per session, so
+ tabs multiplex over the single connection. Only the active tab holds a live
+ store; background tabs rebuild from JSONL replay on activation — which also
+ makes crash recovery "drop the stores, keep tab state".
+- **First-run config over the protocol**: `minerva acp --allow-unconfigured`
+ starts the host keyless and the GUI drives `minerva/config/state` (new read
+ method) + `minerva/config/set_model`, mirroring the TUI's /config panel.
+- **Packaging**: `prepare-sidecar.ts` feeds the release pair
+ `dist/{minerva, rg}` to Tauri as externalBin; Tauri co-locates both beside
+ the app binary, so `resolveRgPath` works unchanged. Local `tauri build`
+ only for now — release-CI bundling/signing is deferred.
## Development workflow
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 3f95e33..a6016d3 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -1,8 +1,8 @@
# Minerva Wire Protocol
The kernel is protocol-fronted: every frontend — the Ink CLI (in-process), an
-editor speaking ACP over stdio, the planned Tauri sidecar and WebSocket
-remote — exchanges the same JSON-RPC 2.0 messages. This document is the
+editor speaking ACP over stdio, the Tauri GUI's sidecar, and the planned
+WebSocket remote — exchanges the same JSON-RPC 2.0 messages. This document is the
reference for that wire surface (`@minerva/protocol` is the source of truth in
code; `PROTOCOL_VERSION = 1`).
@@ -21,7 +21,7 @@ transport spec — `JSON.stringify` guarantees this).
| Transport | Factory | Used by |
|---|---|---|
| In-process | `createInProcTransportPair()` | CLI embedding the kernel |
-| Stream (stdio) | `createStreamTransport(input, output)` | `minerva acp`, future Tauri sidecar |
+| Stream (stdio) | `createStreamTransport(input, output)` | `minerva acp` — editors and the Tauri GUI (which spawns `minerva acp --allow-unconfigured` and bridges the pipes into the webview) |
| WebSocket | — | planned (remote kernels) |
Error codes follow JSON-RPC 2.0 (`-32600` invalid request, `-32601` method not
@@ -212,6 +212,19 @@ resolveProvider`), keeping the kernel free of AI SDK knowledge; hosts without
a resolver reject the method. Open sessions log a `session.model_changed`
audit event, which replay ignores.
+### `minerva/config/state`
+Params `{}` → `{ model, needsApiKey, providers }`. The read half of
+`config/set_model`, for frontends on the far side of a pipe (the TUI computes
+this host-side; the GUI sidecar can't). `model` is the live provider's id
+(the model ref the kernel actually runs). `needsApiKey` is true when the live
+provider requires a key and none was found — the signal to open a first-run
+config flow. Each provider row carries `{ name, defaultModel?, keyVar,
+keySource: "env" | "settings" | "none", baseUrl?, models?, requiresApiKey? }`;
+key detection is blank-aware, matching `resolveApiKey`. Settings are re-read
+per call, so a key stored moments ago is reflected. Pairs with
+`minerva acp --allow-unconfigured`, which lets the stdio host start keyless so
+this flow can run before any key exists.
+
## Versioning
`PROTOCOL_VERSION` is exchanged in `initialize`. Additive changes (new
diff --git a/knip.json b/knip.json
index b610ed1..200c913 100644
--- a/knip.json
+++ b/knip.json
@@ -10,6 +10,9 @@
},
"packages/cli": {
"entry": ["test/fixtures/*.{ts,tsx}", "scripts/*.ts"]
+ },
+ "apps/gui": {
+ "entry": ["scripts/*.ts"]
}
},
"ignoreBinaries": ["expect"]
diff --git a/package.json b/package.json
index e439286..25fe781 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
],
"scripts": {
"test": "bun test",
- "typecheck": "tsc --noEmit",
+ "typecheck": "tsc --noEmit && tsc --noEmit -p apps/gui",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"verify": "bun run typecheck && bun run lint && bun test",
diff --git a/packages/cli/src/app.tsx b/packages/cli/src/app.tsx
index 418001b..bbb3733 100644
--- a/packages/cli/src/app.tsx
+++ b/packages/cli/src/app.tsx
@@ -1,4 +1,15 @@
-import type { MinervaClient, SessionStore, SessionViewModel, ViewItem } from "@minerva/client";
+import {
+ clipDiff,
+ type DiffLine,
+ diffLines,
+ firstLines,
+ formatTokens,
+ type MinervaClient,
+ type SessionStore,
+ type SessionViewModel,
+ thoughtTail,
+ type ViewItem,
+} from "@minerva/client";
import type {
InstructionsInfo,
PermissionOption,
@@ -11,7 +22,6 @@ import { Box, Text, useApp, useInput, useStderr, useStdout } from "ink";
import TextInput from "ink-text-input";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { ConfigPanel, type ConfigResult, type ProviderChoice } from "./config-panel";
-import { clipDiff, type DiffLine, diffLines } from "./diff";
import { establishSession } from "./establish";
import { InputHistory } from "./history";
import { Markdown } from "./markdown";
@@ -617,16 +627,6 @@ function StatusFooter({
return {parts.join(" · ")} ;
}
-function formatTokens(count: number): string {
- if (count >= 1_000_000) return `${trimDecimal(count / 1_000_000)}M`;
- if (count >= 1_000) return `${trimDecimal(count / 1_000)}k`;
- return String(count);
-}
-
-function trimDecimal(value: number): string {
- return value.toFixed(1).replace(/\.0$/, "");
-}
-
function itemKey(item: ViewItem, index: number): string {
if (item.kind === "tool") return `tool-${item.toolCallId}`;
if (item.kind === "plan") return "plan";
@@ -773,32 +773,6 @@ function ThoughtView({ item }: { item: Extract })
);
}
-/** Keep the first or last `count` lines, marking the truncation. */
-export function clipLines(text: string, count: number, keep: "first" | "last"): string {
- const lines = text.trimEnd().split("\n");
- if (lines.length <= count) return lines.join("\n");
- return keep === "first"
- ? `${lines.slice(0, count).join("\n")}\n… (${lines.length - count} more lines)`
- : `… ${lines.slice(-count).join("\n")}`;
-}
-
-const firstLines = (text: string, count: number) => clipLines(text, count, "first");
-
-/**
- * Rolling tail for a streaming thought: the last few lines, but also capped by
- * a character budget from the terminal width — a long reasoning paragraph with
- * no newlines (routine for Qwen/Chinese reasoning) is under the line cap yet
- * would still flood the live region.
- */
-export function thoughtTail(text: string, columns: number): string {
- const clipped = clipLines(text, 4, "last");
- const budget = 4 * Math.max(20, columns - 4);
- if (clipped.length <= budget) return clipped;
- // Strip any leading ellipsis clipLines added so we don't double it.
- const body = clipped.startsWith("… ") ? clipped.slice(2) : clipped;
- return `… ${body.slice(-budget)}`;
-}
-
function PlanView({ entries }: { entries: Extract["entries"] }) {
return (
diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts
index c054929..292767b 100644
--- a/packages/cli/src/args.ts
+++ b/packages/cli/src/args.ts
@@ -15,6 +15,9 @@ interface CliArgs {
print: { prompt: string | null } | null;
/** --mode for print mode's non-interactive permission story. */
mode: string | null;
+ /** acp only: start without an API key — a GUI host drives configuration
+ * over the protocol (minerva/config/set_model) instead of exiting. */
+ allowUnconfigured: boolean;
}
export type ParsedCli =
@@ -31,6 +34,8 @@ Usage: minerva [command] [options]
Commands:
(default) Interactive terminal UI
acp Host the kernel on stdio (ACP framing) for editors
+ (--allow-unconfigured starts without an API key so a
+ GUI host can drive setup over the protocol)
Options:
-p, --print [text] One-shot mode: run the prompt (or stdin when piped),
@@ -63,6 +68,7 @@ export function parseCliArgs(argv: string[]): ParsedCli {
let print: CliArgs["print"] = null;
let mode: string | null = null;
let command: CliArgs["command"] = "tui";
+ let allowUnconfigured = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
@@ -83,6 +89,8 @@ export function parseCliArgs(argv: string[]): ParsedCli {
return { kind: "error", message: "--mode requires a mode id" };
}
mode = value;
+ } else if (arg === "--allow-unconfigured") {
+ allowUnconfigured = true;
} else if (arg === "--continue" || arg === "-c") {
resume = "latest";
} else if (arg === "--resume" || arg === "-r") {
@@ -119,5 +127,10 @@ export function parseCliArgs(argv: string[]): ParsedCli {
if (print !== null && command === "acp") {
return { kind: "error", message: "acp and --print are mutually exclusive" };
}
- return { kind: "run", args: { command, model, resume, profile, print, mode } };
+ // Only the stdio host may start keyless: the TUI has its own config panel
+ // and print mode has no way to configure anything mid-run.
+ if (allowUnconfigured && command !== "acp") {
+ return { kind: "error", message: "--allow-unconfigured requires the acp command" };
+ }
+ return { kind: "run", args: { command, model, resume, profile, print, mode, allowUnconfigured } };
}
diff --git a/packages/cli/src/config-panel.tsx b/packages/cli/src/config-panel.tsx
index c8117c9..2c91667 100644
--- a/packages/cli/src/config-panel.tsx
+++ b/packages/cli/src/config-panel.tsx
@@ -1,3 +1,4 @@
+import { splitModelRef } from "@minerva/client";
import { Box, Text, useInput } from "ink";
import TextInput from "ink-text-input";
import { useRef, useState } from "react";
@@ -88,10 +89,9 @@ export function ConfigPanel({
if (!choice) return;
// Re-configuring the current provider keeps its model; otherwise prefill
// the provider's default.
- const slash = currentModel.indexOf("/");
- const currentProvider = slash === -1 ? "anthropic" : currentModel.slice(0, slash);
- const currentModelId = slash === -1 ? currentModel : currentModel.slice(slash + 1);
- const prefill = currentProvider === choice.name ? currentModelId : (choice.defaultModel ?? "");
+ const current = splitModelRef(currentModel);
+ const currentProvider = current.provider ?? "anthropic";
+ const prefill = currentProvider === choice.name ? current.model : (choice.defaultModel ?? "");
setModel(prefill);
const known = (choice.models ?? []).indexOf(prefill);
modelIndexRef.current = known === -1 ? 0 : known;
diff --git a/packages/cli/src/index.tsx b/packages/cli/src/index.tsx
index 7db89c4..208fd9c 100644
--- a/packages/cli/src/index.tsx
+++ b/packages/cli/src/index.tsx
@@ -16,6 +16,7 @@ import {
DEFAULT_ANTHROPIC_MODEL,
type ModelProvider,
parseModelRef,
+ providerKeyStatuses,
resolveApiKey,
} from "@minerva/providers";
import { render } from "ink";
@@ -132,8 +133,9 @@ function requireKeyOrExit(): void {
}
if (command === "acp") {
- // stdout carries the protocol — no UI, so a missing key stays a hard exit.
- requireKeyOrExit();
+ // stdout carries the protocol — no UI, so a missing key stays a hard exit
+ // unless a GUI host asked to drive configuration over the protocol.
+ if (!parsed.args.allowUnconfigured) requireKeyOrExit();
// runAcpHost awaits kernel.close() on disconnect; a durability failure
// surfaces here as a nonzero exit rather than a silent success.
try {
@@ -170,23 +172,13 @@ if (parsed.args.print) {
process.exit(code);
}
-// Rows for the /config panel: every registry provider plus where (if
-// anywhere) a usable key was found for it.
-const providerChoices: ProviderChoice[] = Object.entries(registry).map(([name, def]) => ({
- name,
- defaultModel: def.defaultModel,
- keyVar: def.apiKeyEnv,
- // Blank-aware, matching resolveApiKey — an exported-but-empty env var
- // must not display as a usable key.
- keySource: process.env[def.apiKeyEnv]?.trim()
- ? ("env" as const)
- : settings.providers[name]?.apiKey?.trim()
- ? ("settings" as const)
- : ("none" as const),
- baseUrl: def.baseURL,
- models: def.models,
- requiresApiKey: def.requiresApiKey,
-}));
+// Rows for the /config panel — providerKeyStatuses is the shared policy
+// home, also consumed by the kernel's minerva/config/state for the GUI.
+const providerChoices: ProviderChoice[] = providerKeyStatuses(
+ registry,
+ process.env,
+ storedKeys(settings),
+);
// Input history is frontend-local state (like Ink itself), so it lives here
// rather than behind the protocol: one JSONL file per data dir, best-effort,
diff --git a/packages/cli/test/acp.test.ts b/packages/cli/test/acp.test.ts
index dec24cc..809132a 100644
--- a/packages/cli/test/acp.test.ts
+++ b/packages/cli/test/acp.test.ts
@@ -101,4 +101,37 @@ describe("minerva acp over a real process boundary", () => {
connection.close();
}, 20_000);
+
+ // Every provider key scrubbed: blank env values count as absent.
+ const NO_KEYS = { ANTHROPIC_API_KEY: "", OPENAI_API_KEY: "", DASHSCOPE_API_KEY: "" };
+
+ test("real entrypoint: keyless `acp --allow-unconfigured` still hosts the protocol", async () => {
+ const dataDir = mkdtempSync(join(tmpdir(), "minerva-acp3-data-"));
+ const transport = spawnHost("src/index.tsx", ["acp", "--allow-unconfigured"], {
+ MINERVA_DATA_DIR: dataDir,
+ ...NO_KEYS,
+ });
+ const connection = new Connection(transport);
+
+ // The GUI host's first-run flow: the kernel must be reachable before any
+ // key exists so the config dialog can drive minerva/config/set_model.
+ const init = await connection.request<{ protocolVersion: number }>(AGENT_METHODS.initialize, {
+ protocolVersion: PROTOCOL_VERSION,
+ });
+ expect(init.protocolVersion).toBe(PROTOCOL_VERSION);
+
+ connection.close();
+ }, 20_000);
+
+ test("real entrypoint: keyless `acp` without the flag exits nonzero", async () => {
+ const dataDir = mkdtempSync(join(tmpdir(), "minerva-acp4-data-"));
+ const child = spawn("bun", ["run", "src/index.tsx", "acp"], {
+ cwd: CLI_DIR,
+ env: { ...process.env, MINERVA_DATA_DIR: dataDir, ...NO_KEYS },
+ stdio: ["pipe", "pipe", "ignore"],
+ });
+ children.push(child);
+ const code = await new Promise((resolve) => child.on("exit", resolve));
+ expect(code).toBe(1);
+ }, 20_000);
});
diff --git a/packages/cli/test/app.test.tsx b/packages/cli/test/app.test.tsx
index dcb44b8..4f13ec1 100644
--- a/packages/cli/test/app.test.tsx
+++ b/packages/cli/test/app.test.tsx
@@ -10,7 +10,7 @@ import { createScriptedProvider, type TurnEvent } from "@minerva/providers";
// no compatible major exists yet — these tests are the compatibility proof,
// so an unreviewed 4.x bump must not slip in via install.
import { render } from "ink-testing-library";
-import { App, clipLines, thoughtTail } from "../src/app";
+import { App } from "../src/app";
import type { ProviderChoice } from "../src/config-panel";
import { createPermissionBridge } from "../src/permission-bridge";
@@ -22,35 +22,6 @@ afterEach(async () => {
await Promise.all(kernels.splice(0).map((kernel) => kernel.close()));
});
-describe("line clipping helpers", () => {
- test("clipLines keeps the first lines with a remaining-count marker", () => {
- const text = "a\nb\nc\nd\ne";
- expect(clipLines(text, 2, "first")).toBe("a\nb\n… (3 more lines)");
- expect(clipLines(text, 10, "first")).toBe(text);
- });
-
- test("clipLines keeps the last lines with a leading ellipsis", () => {
- const text = "a\nb\nc\nd\ne";
- expect(clipLines(text, 2, "last")).toBe("… d\ne");
- expect(clipLines(text, 10, "last")).toBe(text);
- });
-
- test("thoughtTail caps a long unbroken paragraph by terminal width", () => {
- const paragraph = "x".repeat(5000); // no newlines: under the line cap
- const columns = 80;
- const tail = thoughtTail(paragraph, columns);
- const budget = 4 * (columns - 4);
- // Bounded to the width budget plus the "… " prefix, keeping the tail end.
- expect(tail.length).toBeLessThanOrEqual(budget + 2);
- expect(tail.startsWith("… ")).toBe(true);
- expect(paragraph.endsWith(tail.slice(2))).toBe(true);
- });
-
- test("thoughtTail leaves a short thought untouched", () => {
- expect(thoughtTail("brief thought", 80)).toBe("brief thought");
- });
-});
-
/** Full-stack TUI harness: real App + client + kernel, scripted provider. */
function renderTui(
turns: TurnEvent[][],
diff --git a/packages/cli/test/args.test.ts b/packages/cli/test/args.test.ts
index 470d91a..d275eda 100644
--- a/packages/cli/test/args.test.ts
+++ b/packages/cli/test/args.test.ts
@@ -5,7 +5,15 @@ describe("parseCliArgs", () => {
test("no arguments runs the TUI with no model override", () => {
expect(parseCliArgs([])).toEqual({
kind: "run",
- args: { command: "tui", model: null, resume: null, profile: null, print: null, mode: null },
+ args: {
+ command: "tui",
+ model: null,
+ resume: null,
+ profile: null,
+ print: null,
+ mode: null,
+ allowUnconfigured: false,
+ },
});
});
@@ -17,11 +25,31 @@ describe("parseCliArgs", () => {
profile: null,
print: null,
mode: null,
+ allowUnconfigured: false,
};
expect(parseCliArgs(["acp", "-m", "openai/gpt-5.2"])).toEqual({ kind: "run", args });
expect(parseCliArgs(["-m", "openai/gpt-5.2", "acp"])).toEqual({ kind: "run", args });
});
+ test("--allow-unconfigured is acp-only", () => {
+ expect(parseCliArgs(["acp", "--allow-unconfigured"])).toMatchObject({
+ kind: "run",
+ args: { command: "acp", allowUnconfigured: true },
+ });
+ expect(parseCliArgs(["--allow-unconfigured", "acp"])).toMatchObject({
+ kind: "run",
+ args: { command: "acp", allowUnconfigured: true },
+ });
+ expect(parseCliArgs(["--allow-unconfigured"])).toEqual({
+ kind: "error",
+ message: "--allow-unconfigured requires the acp command",
+ });
+ expect(parseCliArgs(["-p", "hi", "--allow-unconfigured"])).toEqual({
+ kind: "error",
+ message: "--allow-unconfigured requires the acp command",
+ });
+ });
+
test("-p captures a prompt, or defers to stdin when followed by a flag", () => {
expect(parseCliArgs(["-p", "say hi"])).toMatchObject({
args: { print: { prompt: "say hi" }, mode: null },
@@ -106,7 +134,15 @@ describe("parseCliArgs", () => {
const text = usage(DEFAULT);
expect(text).toContain("Usage: minerva");
expect(text).toContain(DEFAULT);
- for (const flag of ["--continue", "--resume", "--model", "--help", "--version", "acp"]) {
+ for (const flag of [
+ "--continue",
+ "--resume",
+ "--model",
+ "--help",
+ "--version",
+ "acp",
+ "--allow-unconfigured",
+ ]) {
expect(text).toContain(flag);
}
for (const keyVar of ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "DASHSCOPE_API_KEY"]) {
diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts
index 9c3b218..5eb4055 100644
--- a/packages/client/src/client.ts
+++ b/packages/client/src/client.ts
@@ -3,6 +3,7 @@ import {
CLIENT_METHODS,
type ConfigSetModelParams,
type ConfigSetModelResult,
+ type ConfigStateResult,
Connection,
type InitializeResult,
type InstructionsInfo,
@@ -14,6 +15,7 @@ import {
type SessionCompactedParams,
type SessionCompactResult,
type SessionLoadResult,
+ type SessionModeState,
type SessionNewResult,
type SessionPromptResult,
type SessionSummary,
@@ -115,6 +117,9 @@ export class MinervaClient {
store: SessionStore;
instructions?: InstructionsInfo;
profile?: string;
+ /** Mode state as reported by the kernel — mode-picker UIs need the
+ * available list, not just the current id the store tracks. */
+ modes?: SessionModeState;
}> {
const { sessionId, modes, instructions, profile } =
await this.#connection.request(AGENT_METHODS.sessionNew, {
@@ -129,6 +134,7 @@ export class MinervaClient {
store,
...(instructions ? { instructions } : {}),
...(profile !== undefined ? { profile } : {}),
+ ...(modes ? { modes } : {}),
};
}
@@ -145,6 +151,7 @@ export class MinervaClient {
store: SessionStore;
instructions?: InstructionsInfo;
profile?: string;
+ modes?: SessionModeState;
}> {
// Overwriting a live registration would detach that session's store from
// the update stream (and the failure path would delete it outright).
@@ -167,6 +174,7 @@ export class MinervaClient {
store,
...(instructions ? { instructions } : {}),
...(profile !== undefined ? { profile } : {}),
+ ...(modes ? { modes } : {}),
};
} catch (error) {
this.#stores.delete(sessionId);
@@ -226,6 +234,11 @@ export class MinervaClient {
return result.providerId;
}
+ /** Current model + selectable providers with key status (first-run flows). */
+ async getConfigState(): Promise {
+ return await this.#connection.request(MINERVA_METHODS.configState, {});
+ }
+
/** Summarize and reset the model context; returns the summary. */
async compact(sessionId: string): Promise {
// Same guard as prompt(): a rejected overlapping call must not clear the
diff --git a/packages/cli/src/diff.ts b/packages/client/src/diff.ts
similarity index 100%
rename from packages/cli/src/diff.ts
rename to packages/client/src/diff.ts
diff --git a/packages/client/src/format.ts b/packages/client/src/format.ts
new file mode 100644
index 0000000..c7ec033
--- /dev/null
+++ b/packages/client/src/format.ts
@@ -0,0 +1,41 @@
+/**
+ * Display formatting shared by frontends (design decision #8): pure text
+ * helpers with no UI imports, so the Ink CLI and the GUI render identical
+ * token counts, output previews, and streaming-thought tails.
+ */
+
+export function formatTokens(count: number): string {
+ if (count >= 1_000_000) return `${trimDecimal(count / 1_000_000)}M`;
+ if (count >= 1_000) return `${trimDecimal(count / 1_000)}k`;
+ return String(count);
+}
+
+function trimDecimal(value: number): string {
+ return value.toFixed(1).replace(/\.0$/, "");
+}
+
+/** Keep the first or last `count` lines, marking the truncation. */
+export function clipLines(text: string, count: number, keep: "first" | "last"): string {
+ const lines = text.trimEnd().split("\n");
+ if (lines.length <= count) return lines.join("\n");
+ return keep === "first"
+ ? `${lines.slice(0, count).join("\n")}\n… (${lines.length - count} more lines)`
+ : `… ${lines.slice(-count).join("\n")}`;
+}
+
+export const firstLines = (text: string, count: number): string => clipLines(text, count, "first");
+
+/**
+ * Rolling tail for a streaming thought: the last few lines, but also capped by
+ * a character budget from the display width — a long reasoning paragraph with
+ * no newlines (routine for Qwen/Chinese reasoning) is under the line cap yet
+ * would still flood the live region.
+ */
+export function thoughtTail(text: string, columns: number): string {
+ const clipped = clipLines(text, 4, "last");
+ const budget = 4 * Math.max(20, columns - 4);
+ if (clipped.length <= budget) return clipped;
+ // Strip any leading ellipsis clipLines added so we don't double it.
+ const body = clipped.startsWith("… ") ? clipped.slice(2) : clipped;
+ return `… ${body.slice(-budget)}`;
+}
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
index e3c9b16..8ab4e53 100644
--- a/packages/client/src/index.ts
+++ b/packages/client/src/index.ts
@@ -1,2 +1,5 @@
export * from "./client";
+export * from "./diff";
+export * from "./format";
+export * from "./model-ref";
export * from "./store";
diff --git a/packages/client/src/model-ref.ts b/packages/client/src/model-ref.ts
new file mode 100644
index 0000000..04c32d5
--- /dev/null
+++ b/packages/client/src/model-ref.ts
@@ -0,0 +1,13 @@
+/**
+ * The model-ref grammar shared by frontend config UIs: the provider is
+ * everything before the FIRST slash, the model everything after — model ids
+ * may themselves contain slashes (openrouter-style `meta-llama/llama-3`).
+ * Mirrors the provider registry's parseModelRef contract; that function
+ * additionally validates against a live registry and stays server-side
+ * (the providers package must not enter a webview bundle).
+ */
+export function splitModelRef(ref: string): { provider: string | null; model: string } {
+ const slash = ref.indexOf("/");
+ if (slash === -1) return { provider: null, model: ref };
+ return { provider: ref.slice(0, slash), model: ref.slice(slash + 1) };
+}
diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts
index e02208d..0b38d79 100644
--- a/packages/client/test/client.test.ts
+++ b/packages/client/test/client.test.ts
@@ -83,6 +83,20 @@ describe("MinervaClient against a real kernel", () => {
]);
});
+ test("newSession surfaces the kernel's mode state for picker UIs", async () => {
+ const cwd = mkdtempSync(join(tmpdir(), "minerva-cli-proj-"));
+ const dataDir = mkdtempSync(join(tmpdir(), "minerva-cli-data-"));
+ const [clientTransport, kernelTransport] = createInProcTransportPair();
+ createKernel(kernelTransport, { dataDir, provider: createScriptedProvider([]) });
+ const client = new MinervaClient(clientTransport);
+ await client.initialize();
+ const { modes, store } = await client.newSession(cwd);
+ expect(modes?.currentModeId).toBe("default");
+ // The available list is what a picker renders — it must survive the trip.
+ expect(modes?.availableModes.map((mode) => mode.id)).toContain("plan");
+ expect(store.snapshot.currentModeId).toBe("default");
+ });
+
test("newSession surfaces AGENTS.md instructions from the kernel result", async () => {
const cwd = mkdtempSync(join(tmpdir(), "minerva-cli-proj-"));
const dataDir = mkdtempSync(join(tmpdir(), "minerva-cli-data-"));
diff --git a/packages/cli/test/diff.test.ts b/packages/client/test/diff.test.ts
similarity index 100%
rename from packages/cli/test/diff.test.ts
rename to packages/client/test/diff.test.ts
diff --git a/packages/client/test/format.test.ts b/packages/client/test/format.test.ts
new file mode 100644
index 0000000..e95262d
--- /dev/null
+++ b/packages/client/test/format.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, test } from "bun:test";
+import { clipLines, firstLines, formatTokens, thoughtTail } from "../src/format";
+
+describe("clipLines", () => {
+ test("keeps the first lines with a remaining-count marker", () => {
+ const text = "a\nb\nc\nd\ne";
+ expect(clipLines(text, 2, "first")).toBe("a\nb\n… (3 more lines)");
+ expect(clipLines(text, 10, "first")).toBe(text);
+ });
+
+ test("keeps the last lines with a leading ellipsis", () => {
+ const text = "a\nb\nc\nd\ne";
+ expect(clipLines(text, 2, "last")).toBe("… d\ne");
+ expect(clipLines(text, 10, "last")).toBe(text);
+ });
+
+ test("firstLines is the first-keeping shorthand", () => {
+ expect(firstLines("a\nb\nc", 2)).toBe("a\nb\n… (1 more lines)");
+ });
+});
+
+describe("thoughtTail", () => {
+ test("caps a long unbroken paragraph by display width", () => {
+ const paragraph = "x".repeat(5000); // no newlines: under the line cap
+ const columns = 80;
+ const tail = thoughtTail(paragraph, columns);
+ const budget = 4 * (columns - 4);
+ // Bounded to the width budget plus the "… " prefix, keeping the tail end.
+ expect(tail.length).toBeLessThanOrEqual(budget + 2);
+ expect(tail.startsWith("… ")).toBe(true);
+ expect(paragraph.endsWith(tail.slice(2))).toBe(true);
+ });
+
+ test("leaves a short thought untouched", () => {
+ expect(thoughtTail("brief thought", 80)).toBe("brief thought");
+ });
+});
+
+describe("formatTokens", () => {
+ test("abbreviates thousands and millions, trimming .0", () => {
+ expect(formatTokens(999)).toBe("999");
+ expect(formatTokens(1_000)).toBe("1k");
+ expect(formatTokens(1_536)).toBe("1.5k");
+ expect(formatTokens(2_000_000)).toBe("2M");
+ expect(formatTokens(2_450_000)).toBe("2.5M");
+ });
+});
diff --git a/packages/client/test/model-ref.test.ts b/packages/client/test/model-ref.test.ts
new file mode 100644
index 0000000..64a823c
--- /dev/null
+++ b/packages/client/test/model-ref.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, test } from "bun:test";
+import { splitModelRef } from "../src/model-ref";
+
+describe("splitModelRef", () => {
+ test("splits on the FIRST slash — model ids may contain slashes", () => {
+ expect(splitModelRef("openrouter/meta-llama/llama-3")).toEqual({
+ provider: "openrouter",
+ model: "meta-llama/llama-3",
+ });
+ expect(splitModelRef("bailian/qwen-plus")).toEqual({ provider: "bailian", model: "qwen-plus" });
+ });
+
+ test("a bare id has no provider", () => {
+ expect(splitModelRef("claude-opus-4-8")).toEqual({ provider: null, model: "claude-opus-4-8" });
+ });
+});
diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts
index 4b80b84..0631996 100644
--- a/packages/kernel/src/kernel.ts
+++ b/packages/kernel/src/kernel.ts
@@ -2,8 +2,10 @@ import { join } from "node:path";
import {
AGENT_METHODS,
CLIENT_METHODS,
+ type ConfigProviderState,
type ConfigSetModelParams,
type ConfigSetModelResult,
+ type ConfigStateResult,
Connection,
type InitializeParams,
type InitializeResult,
@@ -27,7 +29,13 @@ import {
type SkillsListResult,
type Transport,
} from "@minerva/protocol";
-import { buildProviderRegistry, type ModelProvider } from "@minerva/providers";
+import {
+ buildProviderRegistry,
+ type ModelProvider,
+ parseModelRef,
+ providerKeyStatuses,
+ resolveApiKey,
+} from "@minerva/providers";
import { type LoopContext, runPrompt } from "./agent-loop";
import { runCompact } from "./compact";
import { now } from "./events";
@@ -128,6 +136,9 @@ export class MinervaKernel {
#dataDir: string;
#tools: KernelTool[];
#systemPrompt: (cwd: string) => string;
+ /** Profile mutations can await settings I/O. Queue them per session so two
+ * rapid selections still apply in request-arrival order (last choice wins). */
+ #profileMutations = new Map>();
/** Operations running now; drained on shutdown so their trailing events are
* flushed before the process exits. */
#inFlight = new Set>();
@@ -158,6 +169,7 @@ export class MinervaKernel {
this.#register(MINERVA_METHODS.sessionsList, (params) => this.#sessionsList(params));
this.#register(MINERVA_METHODS.sessionCompact, (params) => this.#sessionCompact(params));
this.#register(MINERVA_METHODS.configSetModel, (params) => this.#configSetModel(params));
+ this.#register(MINERVA_METHODS.configState, () => this.#configState());
this.#register(MINERVA_METHODS.skillsList, (params) => this.#skillsList(params));
this.#register(MINERVA_METHODS.profilesList, (params) => this.#profilesList(params));
this.#register(MINERVA_METHODS.sessionSetProfile, (params) => this.#sessionSetProfile(params));
@@ -412,6 +424,27 @@ export class MinervaKernel {
"profile must be a string or null (to clear)",
);
}
+ const previous = this.#profileMutations.get(session.id) ?? Promise.resolve();
+ const operation = previous
+ // A rejected selection must not prevent later user choices from running.
+ .catch(() => {})
+ .then(() => this.#applySessionProfile(session, profile));
+ const tail = operation.then(
+ () => {},
+ () => {},
+ );
+ this.#profileMutations.set(session.id, tail);
+ try {
+ await operation;
+ return null;
+ } finally {
+ if (this.#profileMutations.get(session.id) === tail) {
+ this.#profileMutations.delete(session.id);
+ }
+ }
+ }
+
+ async #applySessionProfile(session: Session, profile: string | null): Promise {
// Resolve BEFORE the promptActive guard: loadSettings awaits, and a
// prompt claiming the lease during that await would otherwise see the
// session mutated despite the guard (same no-await-between-guard-and-
@@ -446,7 +479,6 @@ export class MinervaKernel {
// Settle the write before acknowledging, like set_mode: a persona switch
// that vanishes on restart is a policy surprise.
await session.flush();
- return null;
}
async #sessionSetMode(params: unknown): Promise {
@@ -773,6 +805,34 @@ export class MinervaKernel {
}
}
+ /**
+ * Current model + selectable providers with key status — the read half of
+ * config/set_model, for frontends on the far side of a pipe. The rows come
+ * from providerKeyStatuses, the same helper the TUI's /config panel uses,
+ * so the two frontends can never disagree about whether a key exists.
+ * Settings are re-read per call so a key stored moments ago is reflected.
+ */
+ async #configState(): Promise {
+ const settings = await loadSettings(this.#runtime, this.#dataDir, process.cwd());
+ const registry = buildProviderRegistry(settings.providers);
+ const storedKeys = Object.fromEntries(
+ Object.entries(settings.providers).map(([name, entry]) => [name, entry.apiKey]),
+ );
+ const providers: ConfigProviderState[] = providerKeyStatuses(registry, process.env, storedKeys);
+
+ let needsApiKey = false;
+ try {
+ const { provider } = parseModelRef(this.#provider.id, registry);
+ needsApiKey =
+ registry[provider]?.requiresApiKey !== false &&
+ !resolveApiKey(provider, registry, { env: process.env, storedKeys });
+ } catch {
+ // Live provider outside the registry (tests, exotic hosts): nothing a
+ // config dialog could usefully demand a key for.
+ }
+ return { model: this.#provider.id, needsApiKey, providers };
+ }
+
async #configSetModel(params: unknown): Promise {
const { modelRef, provider, apiKey } = (params ?? {}) as Partial;
if (typeof modelRef !== "string" || modelRef.length === 0) {
diff --git a/packages/kernel/test/config.test.ts b/packages/kernel/test/config.test.ts
index d400bcf..8d04133 100644
--- a/packages/kernel/test/config.test.ts
+++ b/packages/kernel/test/config.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
-import { mkdtempSync, readFileSync, statSync } from "node:fs";
+import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
@@ -177,3 +177,87 @@ describe("minerva/config/set_model", () => {
);
});
});
+
+describe("minerva/config/state", () => {
+ /** Like setup(), but with global settings seeded before the kernel exists. */
+ async function setupWithSettings(providerId: string, settings: Partial) {
+ const cwd = mkdtempSync(join(tmpdir(), "minerva-cfgstate-proj-"));
+ const dataDir = mkdtempSync(join(tmpdir(), "minerva-cfgstate-data-"));
+ writeFileSync(globalSettingsPath(dataDir), JSON.stringify(settings), { mode: 0o600 });
+ const [clientTransport, kernelTransport] = createInProcTransportPair();
+ const kernel = createKernel(kernelTransport, {
+ provider: scripted(providerId, [textTurn("unused")]),
+ dataDir,
+ });
+ const client = new Connection(clientTransport);
+ await client.request(AGENT_METHODS.initialize, { protocolVersion: PROTOCOL_VERSION });
+ return { client, cwd, dataDir, kernel };
+ }
+
+ const ACME = {
+ baseUrl: "https://acme.test/v1",
+ apiKeyEnv: "MINERVA_TEST_ACME_KEY",
+ defaultModel: "acme-1",
+ };
+
+ test("reports the live model, provider rows, and stored-key sources", async () => {
+ const h = await setupWithSettings("acme/acme-1", {
+ providers: { acme: { ...ACME, apiKey: "sk-stored" } },
+ });
+ const state = await h.client.request<{
+ model: string;
+ needsApiKey: boolean;
+ providers: Array<{ name: string; keyVar: string; keySource: string; baseUrl?: string }>;
+ }>(MINERVA_METHODS.configState, {});
+
+ expect(state.model).toBe("acme/acme-1");
+ // The stored key satisfies the live provider.
+ expect(state.needsApiKey).toBe(false);
+ const acme = state.providers.find((p) => p.name === "acme");
+ expect(acme).toMatchObject({
+ keyVar: "MINERVA_TEST_ACME_KEY",
+ keySource: "settings",
+ baseUrl: "https://acme.test/v1",
+ });
+ // Builtins are always selectable alongside custom providers.
+ for (const name of ["anthropic", "openai", "bailian", "ollama"]) {
+ expect(state.providers.some((p) => p.name === name)).toBe(true);
+ }
+ });
+
+ test("needsApiKey flags a keyless required provider; env keys count (blank-aware)", async () => {
+ const h = await setupWithSettings("acme/acme-1", { providers: { acme: ACME } });
+ const read = () =>
+ h.client.request<{
+ needsApiKey: boolean;
+ providers: Array<{ name: string; keySource: string }>;
+ }>(MINERVA_METHODS.configState, {});
+
+ process.env.MINERVA_TEST_ACME_KEY = " ";
+ try {
+ // Blank env value counts as absent — the first-run signal fires.
+ let state = await read();
+ expect(state.needsApiKey).toBe(true);
+ expect(state.providers.find((p) => p.name === "acme")?.keySource).toBe("none");
+
+ process.env.MINERVA_TEST_ACME_KEY = "sk-env";
+ state = await read();
+ expect(state.needsApiKey).toBe(false);
+ expect(state.providers.find((p) => p.name === "acme")?.keySource).toBe("env");
+ } finally {
+ delete process.env.MINERVA_TEST_ACME_KEY;
+ }
+ });
+
+ test("a live provider outside the registry never demands a key", async () => {
+ // A namespaced ref whose provider isn't in the registry (bare ids like
+ // "scripted" instead default to anthropic, whose key state is env-truth).
+ const h = await setupWithSettings("not-in-registry/model-x", {});
+ const state = await h.client.request<{ model: string; needsApiKey: boolean }>(
+ MINERVA_METHODS.configState,
+ {},
+ );
+ expect(state.model).toBe("not-in-registry/model-x");
+ expect(state.needsApiKey).toBe(false);
+ });
+});
diff --git a/packages/kernel/test/profiles.test.ts b/packages/kernel/test/profiles.test.ts
index d7d5657..6ff888b 100644
--- a/packages/kernel/test/profiles.test.ts
+++ b/packages/kernel/test/profiles.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test";
-import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
+import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
@@ -12,7 +12,7 @@ import {
type SessionNewResult,
} from "@minerva/protocol";
import type { ModelProvider, TurnRequest } from "@minerva/providers";
-import { createKernel, type MinervaKernel } from "../src";
+import { createKernel, defaultRuntime, type MinervaKernel, projectDir, type Runtime } from "../src";
const tmp = (prefix: string) => mkdtempSync(join(tmpdir(), prefix));
@@ -168,6 +168,68 @@ describe("named profiles through the kernel", () => {
expect(captured[2]).toContain("You are Minerva");
}, 15_000);
+ test("concurrent profile changes apply in request order", async () => {
+ const cwd = tmp("minerva-prof-proj-");
+ writeProjectSettings(cwd, PROFILE_SETTINGS);
+ let releaseSettings: (() => void) | undefined;
+ let settingsReadStarted: (() => void) | undefined;
+ const settingsGate = new Promise((resolve) => {
+ releaseSettings = resolve;
+ });
+ const enteredSettingsRead = new Promise((resolve) => {
+ settingsReadStarted = resolve;
+ });
+ let delayNextSettingsRead = false;
+ const runtime: Runtime = {
+ ...defaultRuntime,
+ async readTextFile(path) {
+ if (delayNextSettingsRead && path.endsWith("/.minerva/settings.json")) {
+ delayNextSettingsRead = false;
+ settingsReadStarted?.();
+ await settingsGate;
+ }
+ return defaultRuntime.readTextFile(path);
+ },
+ };
+ const [clientTransport, kernelTransport] = createInProcTransportPair();
+ const dataDir = tmp("minerva-prof-data-");
+ const kernel = createKernel(kernelTransport, {
+ dataDir,
+ provider: capturingProvider([]),
+ runtime,
+ });
+ kernels.push(kernel);
+ const client = new Connection(clientTransport);
+ await client.request(AGENT_METHODS.initialize, { protocolVersion: 1 });
+ const { sessionId } = await client.request(AGENT_METHODS.sessionNew, { cwd });
+
+ delayNextSettingsRead = true;
+ const chooseWriter = client.request(MINERVA_METHODS.sessionSetProfile, {
+ sessionId,
+ profile: "writer",
+ });
+ await enteredSettingsRead;
+ const clearProfile = client.request(MINERVA_METHODS.sessionSetProfile, {
+ sessionId,
+ profile: null,
+ });
+ // Let the second request reach the kernel while the first is blocked. The
+ // pre-fix implementation applies this clear immediately, then lets the
+ // older writer request overwrite it when the gate opens.
+ await Bun.sleep(0);
+ releaseSettings?.();
+ await Promise.all([chooseWriter, clearProfile]);
+
+ expect(kernel.getSession(sessionId)?.profile).toBeUndefined();
+ const events = readFileSync(join(projectDir(dataDir, cwd), `${sessionId}.jsonl`), "utf8")
+ .trim()
+ .split("\n")
+ .map((line) => JSON.parse(line) as { type?: string; profile?: string | null })
+ .filter((event) => event.type === "session.profile_changed")
+ .map((event) => event.profile);
+ expect(events).toEqual(["writer", null]);
+ }, 15_000);
+
test("resume restores the profile by re-resolving its name against settings", async () => {
const cwd = tmp("minerva-prof-proj-");
const dataDir = tmp("minerva-prof-data-");
diff --git a/packages/protocol/src/stdio.ts b/packages/protocol/src/stdio.ts
index 65a3d07..35f68fa 100644
--- a/packages/protocol/src/stdio.ts
+++ b/packages/protocol/src/stdio.ts
@@ -1,4 +1,3 @@
-import { StringDecoder } from "node:string_decoder";
import type { JsonRpcMessage, Transport } from "./jsonrpc";
/** Drop the connection if a single frame grows past this — a peer that sends a
@@ -30,8 +29,11 @@ export function createStreamTransport(
const messageHandlers: Array<(message: JsonRpcMessage) => void> = [];
const closeHandlers: Array<() => void> = [];
// Decode across chunk boundaries: a multibyte UTF-8 char split between two
- // reads would otherwise be corrupted by a per-chunk toString.
- const decoder = new StringDecoder("utf8");
+ // reads would otherwise be corrupted by a per-chunk toString. TextDecoder
+ // (not node:string_decoder) because this module also loads in the GUI
+ // webview, where node: builtins don't exist — Vite externalizes them and
+ // the import throws at module eval.
+ const decoder = new TextDecoder("utf-8");
let buffer = "";
let closed = false;
@@ -53,7 +55,7 @@ export function createStreamTransport(
const onData = (chunk: Buffer | string) => {
if (closed) return; // stop processing once the transport is torn down
- buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
+ buffer += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
// A single frame (framed OR not-yet-framed) larger than the cap is a
// runaway/hostile peer — enforce the byte limit before parsing so a valid
// 20 MB JSON line can't slip through.
diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts
index 9bfef17..32b95c6 100644
--- a/packages/protocol/src/types.ts
+++ b/packages/protocol/src/types.ts
@@ -32,6 +32,10 @@ export const MINERVA_METHODS = {
sessionCompact: "minerva/session/compact",
/** Frontend → kernel: persist model/provider config and swap the live provider. */
configSetModel: "minerva/config/set_model",
+ /** Frontend → kernel: current model + selectable providers with key status.
+ * Exists for hosts on the far side of a pipe (GUI sidecar): the TUI computes
+ * this host-side, a remote frontend can't. */
+ configState: "minerva/config/state",
/** Frontend → kernel: list the skills available for a project. */
skillsList: "minerva/skills/list",
/** Frontend → kernel: list the named profiles defined in settings. */
@@ -228,6 +232,32 @@ export interface ConfigSetModelResult {
providerId: string;
}
+// --- minerva/config/state ----------------------------------------------
+
+/** One selectable provider: registry definition plus where (if anywhere) a
+ * usable API key was found. Blank env/settings values count as absent. */
+export interface ConfigProviderState {
+ name: string;
+ defaultModel?: string | undefined;
+ /** Env var the provider reads (e.g. DASHSCOPE_API_KEY). */
+ keyVar: string;
+ keySource: "env" | "settings" | "none";
+ baseUrl?: string | undefined;
+ /** Known model ids — selector suggestions; any id remains valid free text. */
+ models?: string[] | undefined;
+ /** false = keyless endpoint; an empty key entry is fine, not a gap. */
+ requiresApiKey?: boolean | undefined;
+}
+
+export interface ConfigStateResult {
+ /** The live provider's id, i.e. the model ref the kernel actually runs. */
+ model: string;
+ /** True when the live provider requires a key and none was found — the
+ * signal for a frontend to open its first-run config flow. */
+ needsApiKey: boolean;
+ providers: ConfigProviderState[];
+}
+
// --- minerva/sessions/list ---------------------------------------------
export interface SessionsListParams {
diff --git a/packages/providers/src/registry.ts b/packages/providers/src/registry.ts
index ff1c365..67d7288 100644
--- a/packages/providers/src/registry.ts
+++ b/packages/providers/src/registry.ts
@@ -246,6 +246,46 @@ export function apiKeyEnvVar(
return providerDef(provider, providers).apiKeyEnv;
}
+export interface ProviderKeyStatus {
+ name: string;
+ defaultModel?: string | undefined;
+ /** Env var the provider reads (e.g. DASHSCOPE_API_KEY). */
+ keyVar: string;
+ /** Where a usable key was found, if anywhere. */
+ keySource: "env" | "settings" | "none";
+ baseUrl?: string | undefined;
+ models?: string[] | undefined;
+ requiresApiKey?: boolean | undefined;
+}
+
+/**
+ * Rows for config UIs: every registry provider plus where (if anywhere) a
+ * usable key was found. Blank-aware, matching resolveApiKey — an
+ * exported-but-empty env var must not display as a usable key. This is the
+ * single home of that policy: the kernel's minerva/config/state handler and
+ * the TUI's /config panel both consume it, so they can never disagree about
+ * whether a key exists.
+ */
+export function providerKeyStatuses(
+ registry: ProviderRegistry,
+ env: Record,
+ storedKeys: Record,
+): ProviderKeyStatus[] {
+ return Object.entries(registry).map(([name, def]) => ({
+ name,
+ ...(def.defaultModel !== undefined ? { defaultModel: def.defaultModel } : {}),
+ keyVar: def.apiKeyEnv,
+ keySource: env[def.apiKeyEnv]?.trim()
+ ? ("env" as const)
+ : storedKeys[name]?.trim()
+ ? ("settings" as const)
+ : ("none" as const),
+ ...(def.baseURL !== undefined ? { baseUrl: def.baseURL } : {}),
+ ...(def.models !== undefined ? { models: def.models } : {}),
+ ...(def.requiresApiKey !== undefined ? { requiresApiKey: def.requiresApiKey } : {}),
+ }));
+}
+
export interface ResolveApiKeyOptions {
/** Key passed programmatically (e.g. just entered in the config panel). */
explicit?: string | undefined;
diff --git a/tsconfig.json b/tsconfig.json
index c51dfbb..01d39c8 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -20,5 +20,7 @@
"types": ["bun"]
},
"include": ["packages/**/*", "apps/**/*"],
- "exclude": ["**/node_modules", "**/dist"]
+ // apps/gui needs the DOM lib (webview code), which would poison the Bun-typed
+ // root project — it typechecks as its own project: `tsc -p apps/gui`.
+ "exclude": ["**/node_modules", "**/dist", "apps/gui"]
}