diff --git a/.npmrc b/.npmrc index b973752..d264c12 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ -tag-version-prefix="" \ No newline at end of file +tag-version-prefix="" +legacy-peer-deps=true \ No newline at end of file diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 1f442f1..cc3933e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -73,11 +73,13 @@ export default defineConfig({ { text: "Model Selection", link: "/usage/model-selection" }, { text: "Session History", link: "/usage/session-history" }, { text: "Multi-Session Chat", link: "/usage/multi-session" }, + { text: "Session Manager", link: "/usage/session-manager" }, { text: "Floating Chat", link: "/usage/floating-chat" }, { text: "Editing", link: "/usage/editing" }, { text: "Chat Export", link: "/usage/chat-export" }, { text: "Commands & Hotkeys", link: "/usage/commands" }, { text: "Context Files", link: "/usage/context-files" }, + { text: "Prompt Injection", link: "/usage/prompt-injection" }, { text: "MCP Tools", link: "/usage/mcp-tools" }, ], }, diff --git a/docs/agent-setup/claude-code.md b/docs/agent-setup/claude-code.md index 2c9f0d2..2db4e9f 100644 --- a/docs/agent-setup/claude-code.md +++ b/docs/agent-setup/claude-code.md @@ -37,7 +37,23 @@ Choose one of the following methods: ### Option A: API Key 1. Get your API key from [Anthropic Console](https://console.anthropic.com/) -2. Enter the API key in **Settings → Agent Client → Claude Code → API key** +2. Open **Settings → Agent Client → Built-in agents → Claude Code → API key** +3. Click the **Link...** button next to the API key field +4. In the **Select secret** dialog: + - To use an existing secret: select it from the list and click **Save** + - To create a new one: click **Add secret...**, enter an ID (lowercase letters, numbers, and dashes only — e.g., `claude-api-key`), paste your API key, then click **Save** + +Once linked, the field shows the masked secret value with a **Change** button to swap secrets. + +::: tip Managing secrets +API keys are stored in **Obsidian's Keychain** (Settings → Keychain). You can rename, edit, or delete secrets there at any time. The same secret can be shared across plugins by referencing the same ID. +::: + +::: info Upgrading from a previous version +If you previously stored your Claude API key in this plugin (v0.10.x or earlier), it is automatically migrated to Obsidian's Keychain as `claude-api-key` the first time you load the upgraded plugin. A one-time notification confirms the migration. + +If `claude-api-key` is already in use by another plugin with a different value, your key is preserved under `agent-client-claude-api-key` instead. You can rename it from **Settings → Keychain** if you prefer. +::: ### Option B: Account Login diff --git a/docs/agent-setup/codex.md b/docs/agent-setup/codex.md index b46e593..6139c45 100644 --- a/docs/agent-setup/codex.md +++ b/docs/agent-setup/codex.md @@ -37,7 +37,23 @@ Choose one of the following methods: ### Option A: API Key 1. Get your API key from [OpenAI Platform](https://platform.openai.com/api-keys) -2. Enter the API key in **Settings → Agent Client → Codex → API key** +2. Open **Settings → Agent Client → Built-in agents → Codex → API key** +3. Click the **Link...** button next to the API key field +4. In the **Select secret** dialog: + - To use an existing secret: select it from the list and click **Save** + - To create a new one: click **Add secret...**, enter an ID (lowercase letters, numbers, and dashes only — e.g., `openai-api-key`), paste your API key, then click **Save** + +Once linked, the field shows the masked secret value with a **Change** button to swap secrets. + +::: tip Managing secrets +API keys are stored in **Obsidian's Keychain** (Settings → Keychain). You can rename, edit, or delete secrets there at any time. The same secret can be shared across plugins by referencing the same ID. +::: + +::: info Upgrading from a previous version +If you previously stored your OpenAI API key in this plugin (v0.10.x or earlier), it is automatically migrated to Obsidian's Keychain as `openai-api-key` the first time you load the upgraded plugin. A one-time notification confirms the migration. + +If `openai-api-key` is already in use by another plugin with a different value, your key is preserved under `agent-client-openai-api-key` instead. You can rename it from **Settings → Keychain** if you prefer. +::: ### Option B: Account Login diff --git a/docs/agent-setup/gemini-cli.md b/docs/agent-setup/gemini-cli.md index d6c549c..b982004 100644 --- a/docs/agent-setup/gemini-cli.md +++ b/docs/agent-setup/gemini-cli.md @@ -67,7 +67,23 @@ If you have a Gemini Code Assist License from your organization, add `GOOGLE_CLO If you prefer to use an API key for authentication: 1. Get your API key from [Google AI Studio](https://aistudio.google.com/apikey) -2. Enter the API key in **Settings → Agent Client → Gemini CLI → API key** +2. Open **Settings → Agent Client → Built-in agents → Gemini CLI → API key** +3. Click the **Link...** button next to the API key field +4. In the **Select secret** dialog: + - To use an existing secret: select it from the list and click **Save** + - To create a new one: click **Add secret...**, enter an ID (lowercase letters, numbers, and dashes only — e.g., `gemini-api-key`), paste your API key, then click **Save** + +Once linked, the field shows the masked secret value with a **Change** button to swap secrets. + +::: tip Managing secrets +API keys are stored in **Obsidian's Keychain** (Settings → Keychain). You can rename, edit, or delete secrets there at any time. The same secret can be shared across plugins by referencing the same ID. +::: + +::: info Upgrading from a previous version +If you previously stored your Gemini API key in this plugin (v0.10.x or earlier), it is automatically migrated to Obsidian's Keychain as `gemini-api-key` the first time you load the upgraded plugin. A one-time notification confirms the migration. + +If `gemini-api-key` is already in use by another plugin with a different value, your key is preserved under `agent-client-gemini-api-key` instead. You can rename it from **Settings → Keychain** if you prefer. +::: ::: tip If the key isn't picked up If authentication still fails, set the key on the Gemini CLI side instead: run `gemini` in your terminal, type `/auth`, choose **Use Gemini API Key**, and paste your key (it's stored in your system keychain). Then leave the **API key field empty** in Agent Client. @@ -77,7 +93,7 @@ If authentication still fails, set the key on the Gemini CLI side instead: run ` If you are using Vertex AI for enterprise workloads: -1. In **Settings → Agent Client → Gemini CLI → Environment variables**, add: +1. In **Settings → Agent Client → Built-in agents → Gemini CLI → Environment variables**, add: ``` GOOGLE_API_KEY=YOUR_API_KEY diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 2fad401..10b3439 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -37,6 +37,14 @@ To try pre-release versions before they are published to Community Plugins: ## Prerequisites +### Obsidian Version + +Agent Client requires **Obsidian 1.11.4 or later**. The plugin uses Obsidian's Keychain (introduced in 1.11.4) to store agent API keys securely. + +::: tip +Check your version under **Settings → General → Version**. If you are on an older version, update Obsidian from the [official installer](https://obsidian.md/download) or your package manager. +::: + ### Node.js ::: tip Not always required diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index 6edad7b..10093aa 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -42,14 +42,14 @@ The agent executable cannot be found at the specified path. The agent requires authentication before processing requests. **For Claude Code:** -- **API key**: Set in **Settings → Agent Client → Claude Code (ACP) → API key** +- **API key**: Open **Settings → Agent Client → Built-in agents → Claude Code → API key**, click **Link...**, and link or create a secret. See [Claude Code Setup](/agent-setup/claude-code#authentication). - **Account login**: Run `claude` in Terminal first and complete the login flow **For Codex:** -- Set your OpenAI API key in **Settings → Agent Client → Codex → API key** +- Open **Settings → Agent Client → Built-in agents → Codex → API key**, click **Link...**, and link or create a secret. See [Codex Setup](/agent-setup/codex#authentication). **For Gemini CLI:** -- Set your Google API key in **Settings → Agent Client → Gemini CLI → API key** +- Open **Settings → Agent Client → Built-in agents → Gemini CLI → API key**, click **Link...**, and link or create a secret. See [Gemini CLI Setup](/agent-setup/gemini-cli#authentication). - Or run `gemini` in Terminal first to authenticate with your Google account ### "No Authentication Methods" error @@ -143,6 +143,11 @@ If you sync your vault (e.g., via Nextcloud, Syncthing, or iCloud) across machin - Enable **Settings → Agent Client → Windows Subsystem for Linux → Enable WSL mode** - Optionally specify your distribution in **WSL distribution** +**Using a Node version manager (nvm/fnm) inside WSL:** +- These tools usually add `node` to `PATH` only in **interactive** shells (e.g. from `~/.bashrc`), so the agent — launched non-interactively — may not see your nvm-managed `node` and can fall back to an old system `node` (or none). +- Fix: set **Node.js path** to the absolute path of your nvm `node` directory, e.g. `/home//.nvm/versions/node//bin`. The plugin then injects it into `PATH` for the agent. +- API keys: in WSL mode you can enter them directly in the agent's API key field — the plugin forwards them into WSL automatically (no need to put them in `~/.profile`). + ### Agent works in Terminal but not in Obsidian The PATH environment may differ between Terminal and Obsidian. diff --git a/docs/public/images/prompt-injection-settings.webp b/docs/public/images/prompt-injection-settings.webp new file mode 100644 index 0000000..c4c3f21 Binary files /dev/null and b/docs/public/images/prompt-injection-settings.webp differ diff --git a/docs/public/images/session-manager-context-menu.webp b/docs/public/images/session-manager-context-menu.webp new file mode 100644 index 0000000..f9b52ad Binary files /dev/null and b/docs/public/images/session-manager-context-menu.webp differ diff --git a/docs/public/images/session-manager-view.webp b/docs/public/images/session-manager-view.webp new file mode 100644 index 0000000..17639d5 Binary files /dev/null and b/docs/public/images/session-manager-view.webp differ diff --git a/docs/public/images/status-busy.webp b/docs/public/images/status-busy.webp new file mode 100644 index 0000000..f1a839f Binary files /dev/null and b/docs/public/images/status-busy.webp differ diff --git a/docs/public/images/status-error.webp b/docs/public/images/status-error.webp new file mode 100644 index 0000000..7507d3a Binary files /dev/null and b/docs/public/images/status-error.webp differ diff --git a/docs/public/images/status-permission.webp b/docs/public/images/status-permission.webp new file mode 100644 index 0000000..5d396ba Binary files /dev/null and b/docs/public/images/status-permission.webp differ diff --git a/docs/public/images/status-ready.webp b/docs/public/images/status-ready.webp new file mode 100644 index 0000000..3d5b3d3 Binary files /dev/null and b/docs/public/images/status-ready.webp differ diff --git a/docs/usage/prompt-injection.md b/docs/usage/prompt-injection.md new file mode 100644 index 0000000..9b7cb16 --- /dev/null +++ b/docs/usage/prompt-injection.md @@ -0,0 +1,63 @@ +# Prompt Injection + +Guide agents to produce **Obsidian-flavored Markdown** by injecting short formatting instructions into the first message of each session. + +## Why? + +AI agents are trained on standard Markdown, but Obsidian uses extended syntax for some elements. Without guidance, agents may produce output that misses out on Obsidian features: + +- Notes referenced as `[Note](note.md)` instead of `[[Note Name]]` wikilinks, which are how Obsidian links notes by name and builds the backlink graph +- Math formulas wrapped in `\(...\)` or `\[...\]` instead of `$...$` or `$$...$$`, which Obsidian does not render as math +- Markdown tables placed without a leading blank line, causing them to render as plain text + +Prompt injection adds short, focused instructions so the agent produces output that looks right in Obsidian from the start. + +## How it works + +When you send the **first message** of a new session, the plugin appends one or more short instruction sentences to your prompt. The agent sees both your message and the formatting guidance, and applies it across the rest of the session. + +::: info +Instructions are only injected on the **first message** of each session, not on every message. They live for the duration of the agent's context window. +::: + +## Settings + +Configure in **Settings → Agent Client → Prompt injection**: + +

+ Prompt injection settings +

+ +| Setting | Default | Description | +|---------|---------|-------------| +| **Inject Obsidian Markdown instructions** | On | Master toggle — turn off to disable all injection | +| **Wikilink formatting** | On | Instruct agents to use `[[Note Name]]` syntax when referencing notes | +| **Markdown table spacing** | On | Instruct agents to leave a blank line before Markdown tables | +| **LaTeX math formatting** | On | Instruct agents to use `$...$` and `$$...$$` delimiters for math | + +When the master toggle is off, no instructions are injected regardless of the sub-toggle states. When the master toggle is on, only the enabled sub-toggles are injected. + +## Instruction Text + +For reference, the exact strings injected when each toggle is on: + +- **Wikilink**: *"When referencing notes in this vault, use [[Note Name]] wikilink syntax so they become clickable links."* +- **Tables**: *"Always leave a blank line before Markdown tables; without it Obsidian renders them as plain text."* +- **LaTeX math**: *"This client uses Obsidian Flavored Markdown. For math, use $...$ for inline and $$...$$ for display (not \\(...\\) or \\[...\\])."* + +## When to Disable + +You may want to turn off some or all toggles if: + +- You are writing prose that doesn't reference notes, math, or tables +- The agent already follows Obsidian conventions reliably for your use case +- You want to minimize the size of the first prompt for performance or cost reasons + +::: tip +If an agent ignores some instructions despite the toggle being on, that is an agent limitation rather than a plugin bug. Try a different model or agent, or rephrase the instruction in your own message. +::: + +## See Also + +- [Context Files](/usage/context-files) — agent-side guidance via `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` +- [Note Mentions](/usage/mentions) — sharing note content with the agent diff --git a/docs/usage/session-manager.md b/docs/usage/session-manager.md new file mode 100644 index 0000000..130c9e5 --- /dev/null +++ b/docs/usage/session-manager.md @@ -0,0 +1,77 @@ +# Session Manager + +A dedicated sidebar view that lists all open chat sessions with live status, so you can keep track of multiple conversations at a glance. + +

+ Session Manager view in the left sidebar +

+ +## Overview + +The Session Manager shows every chat view (sidebar and floating) currently open, with: + +- **Session title** — derived from the first message, or the saved title if you renamed it +- **Agent name** — which agent the session is using +- **Status icon** — live indicator of what the session is doing right now +- **Focus highlight** — the currently focused chat view is marked as active + +::: tip +This is especially useful when you have many chat views open and want to see which one is generating, awaiting permission, or idle. +::: + +## Opening the Session Manager + +You can open the Session Manager in several ways: + +- **Chat header menu**: Click the **⋮** (more) menu in any chat view's header and select **"Open session manager"** +- **Command palette**: Open `Cmd/Ctrl + P` and search for **"Open session manager"** + +The view opens in the left sidebar by default. You can drag it to a different location like any other Obsidian view. + +::: tip +Assign a keyboard shortcut to **"Open session manager"** in **Settings → Hotkeys** for quick access. +::: + +## Status Icons + +Each session entry shows an icon reflecting its current state: + +| Icon | Status | Meaning | +|------|--------|---------| +| Ready | **Ready** | The session is connected and idle, waiting for your next message | +| Busy | **Busy** | The agent is processing or generating a response | +| Permission | **Permission** | The agent is waiting for you to approve or reject an action | +| Error | **Error** | The session encountered an error | + +## Actions + +### Switch to a Session + +Click any session entry to focus that chat view. If the view is in the sidebar, Obsidian reveals it. If it is a floating window, it is brought to the front. + +### Session Menu + +Click the **⋮** (more) button on the right of any session entry, or right-click the entry, to open the action menu: + +

+ Session entry context menu showing Rename and Close +

+ +| Action | Description | +|--------|-------------| +| **Rename** | Edit the session title. The new title is shown both in the Session Manager and on the chat view's tab | +| **Close** | Close the chat view (the underlying session remains in History) | + +::: tip +Renaming is also available from the chat header (**⋮** menu → **Rename session**) and from [Session History](/usage/session-history). +::: + +## Empty State + +If no chat views are open, the Session Manager shows **"No active sessions"**. Open a chat view (ribbon icon or the **"Open chat view"** command) to populate the list. + +## See Also + +- [Multi-Session Chat](/usage/multi-session) for opening multiple chat views and broadcasting prompts +- [Session History](/usage/session-history) for resuming or forking past sessions +- [Floating Chat](/usage/floating-chat) for floating chat windows diff --git a/eslint.config.mjs b/eslint.config.mjs index a6f152a..8a04682 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,7 +13,7 @@ export default defineConfig([ files: ["**/*.ts", "**/*.tsx"], languageOptions: { parser: tsparser, - parserOptions: { project: "./tsconfig.json" }, + parserOptions: { project: "./tsconfig.eslint.json" }, }, rules: { // Preserve existing rules diff --git a/manifest.json b/manifest.json index 3cacbe9..6f54dfb 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,11 @@ { "id": "agent-client", "name": "Agent Client", - "version": "0.10.6", - "minAppVersion": "1.7.2", + "version": "0.11.0", + "minAppVersion": "1.11.4", "description": "Chat with AI agents via the Agent Client Protocol directly from your vault.", "author": "RAIT-09", "authorUrl": "https://github.com/RAIT-09", "fundingUrl": "https://www.buymeacoffee.com/rait09", "isDesktopOnly": true -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7f86970..e43078f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,26 @@ { "name": "obsidian-agent-client", - "version": "0.10.6", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-agent-client", - "version": "0.10.6", + "version": "0.11.0", "license": "Apache-2.0", "dependencies": { - "@agentclientprotocol/sdk": "^0.14.1", + "@agentclientprotocol/sdk": "^0.28.1", "@codemirror/state": "6.5.0", "@codemirror/view": "^6.35.0", "@tanstack/react-virtual": "^3.13.23", "diff": "^8.0.2", "react": "18.3.1", "react-dom": "18.3.1", - "semver": "^7.7.3" + "semver": "^7.7.3", + "zod": "^4.4.3" }, "devDependencies": { + "@eslint/json": "^0.14.0", "@types/node": "^16.11.6", "@types/react": "18.3.29", "@types/react-dom": "18.3.7", @@ -33,13 +35,14 @@ "tslib": "2.4.0", "typescript": "^5.9.3", "typescript-eslint": "^8.0.0", - "vitepress": "^1.6.4" + "vitepress": "^1.6.4", + "vitest": "^4.1.8" } }, "node_modules/@agentclientprotocol/sdk": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.14.1.tgz", - "integrity": "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.28.1.tgz", + "integrity": "sha512-Z2Frs6YtPhnZZ+XwFXyQkRDXY0fn8FjCalEs0W4yUhQnY4TztmNq0/RnfzWdFN3vqT3h0jTz5klzYbZHGxCDyQ==", "license": "Apache-2.0", "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" @@ -425,6 +428,40 @@ } } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -991,7 +1028,6 @@ "integrity": "sha512-rvR/EZtvUG3p9uqrSmcDJPYSH7atmWr0RnFWN6m917MAPx82+zQgPUmDu0whPFG6XTyM0vB/hR6c1Q63OaYtCQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/core": "^0.17.0", "@eslint/plugin-kit": "^0.4.1", @@ -1084,7 +1120,6 @@ "integrity": "sha512-KWiFQpSAqEIyrTXko3hFNLeQvSK8zXlJQzhhxsyVn58WFRYXST99b3Nqnu+ttOtjds2Pl2grUHGpe2NzhPynuQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18" } @@ -1104,9 +1139,9 @@ } }, "node_modules/@iconify-json/simple-icons": { - "version": "1.2.84", - "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.84.tgz", - "integrity": "sha512-v4JVu6xIewGoETD4mm2k6UAdFAbTlY1duw5ZNSxYORfs2yFsHDhoU9Omn/BgrV0nR/ptWkF3ZIr/ZHoYXI/6Jw==", + "version": "1.2.85", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.85.tgz", + "integrity": "sha512-Hp5LXvd3LRk+e+1558wtonA7c1Z0/Phmi7xCqpgtb8bs8cuyGnP34GDbt5uhhUXxKlzacnnhAcXgcDxe9bUa1w==", "dev": true, "license": "CC0-1.0", "dependencies": { @@ -1171,6 +1206,35 @@ "ret": "~0.1.10" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pkgr/core": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.2.tgz", @@ -1184,10 +1248,292 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", "cpu": [ "arm" ], @@ -1199,9 +1545,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", "cpu": [ "arm64" ], @@ -1213,9 +1559,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", "cpu": [ "arm64" ], @@ -1227,9 +1573,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", "cpu": [ "x64" ], @@ -1241,9 +1587,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", "cpu": [ "arm64" ], @@ -1255,9 +1601,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", "cpu": [ "x64" ], @@ -1269,9 +1615,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", "cpu": [ "arm" ], @@ -1286,9 +1632,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", "cpu": [ "arm" ], @@ -1303,9 +1649,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", "cpu": [ "arm64" ], @@ -1320,9 +1666,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", "cpu": [ "arm64" ], @@ -1337,9 +1683,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", "cpu": [ "loong64" ], @@ -1354,9 +1700,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", "cpu": [ "loong64" ], @@ -1371,9 +1717,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", "cpu": [ "ppc64" ], @@ -1388,9 +1734,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", "cpu": [ "ppc64" ], @@ -1405,9 +1751,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", "cpu": [ "riscv64" ], @@ -1422,9 +1768,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", "cpu": [ "riscv64" ], @@ -1439,9 +1785,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", "cpu": [ "s390x" ], @@ -1456,9 +1802,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", "cpu": [ "x64" ], @@ -1473,9 +1819,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", "cpu": [ "x64" ], @@ -1490,9 +1836,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", "cpu": [ "x64" ], @@ -1504,9 +1850,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", "cpu": [ "arm64" ], @@ -1518,9 +1864,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", "cpu": [ "arm64" ], @@ -1532,9 +1878,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", "cpu": [ "ia32" ], @@ -1546,9 +1892,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", "cpu": [ "x64" ], @@ -1560,9 +1906,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", "cpu": [ "x64" ], @@ -1667,13 +2013,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tanstack/react-virtual": { - "version": "3.13.26", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.26.tgz", - "integrity": "sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", + "integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.16.0" + "@tanstack/virtual-core": "3.17.0" }, "funding": { "type": "github", @@ -1685,15 +2038,37 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.16.0", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.16.0.tgz", - "integrity": "sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", + "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/codemirror": { "version": "5.60.8", "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", @@ -1704,6 +2079,13 @@ "@types/tern": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "8.56.2", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz", @@ -1848,17 +2230,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", - "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", + "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/type-utils": "8.60.0", - "@typescript-eslint/utils": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/type-utils": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1871,7 +2253,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.60.0", + "@typescript-eslint/parser": "^8.60.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1887,16 +2269,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", - "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", + "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3" }, "engines": { @@ -1912,14 +2294,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", - "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", + "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.60.0", - "@typescript-eslint/types": "^8.60.0", + "@typescript-eslint/tsconfig-utils": "^8.60.1", + "@typescript-eslint/types": "^8.60.1", "debug": "^4.4.3" }, "engines": { @@ -1934,14 +2316,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", - "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", + "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0" + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1952,9 +2334,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", - "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", + "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", "dev": true, "license": "MIT", "engines": { @@ -1969,15 +2351,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", - "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", + "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1994,9 +2376,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", - "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", + "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", "dev": true, "license": "MIT", "engines": { @@ -2008,16 +2390,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", - "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", + "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.60.0", - "@typescript-eslint/tsconfig-utils": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/project-service": "8.60.1", + "@typescript-eslint/tsconfig-utils": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2036,16 +2418,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", - "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", + "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0" + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2060,13 +2442,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", - "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", + "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/types": "8.60.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2097,6 +2479,92 @@ "dev": true, "license": "ISC" }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.35", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz", @@ -2597,6 +3065,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -2727,6 +3205,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2804,6 +3292,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/copy-anything": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", @@ -2973,6 +3468,16 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -3042,9 +3547,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.22.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", - "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", + "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", "dev": true, "license": "MIT", "dependencies": { @@ -3185,6 +3690,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -4013,6 +4525,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4981,10 +5503,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5138,6 +5670,279 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "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" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5573,6 +6378,17 @@ "@codemirror/view": "6.38.6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/oniguruma-to-es": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", @@ -5693,6 +6509,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -5809,9 +6632,9 @@ } }, "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", "dev": true, "license": "MIT", "funding": { @@ -6013,14 +6836,48 @@ "dev": true, "license": "MIT" }, - "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -6030,41 +6887,34 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", "fsevents": "~2.3.2" } }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -6160,18 +7010,10 @@ "loose-envify": "^1.1.0" } }, - "node_modules/search-insights": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", - "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6345,6 +7187,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6376,6 +7225,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6616,10 +7479,27 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -6633,6 +7513,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toml-eslint-parser": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/toml-eslint-parser/-/toml-eslint-parser-0.9.3.tgz", @@ -6812,16 +7702,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", - "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", + "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.60.0", - "@typescript-eslint/parser": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/utils": "8.60.0" + "@typescript-eslint/eslint-plugin": "8.60.1", + "@typescript-eslint/parser": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7390,16 +8280,6 @@ "node": ">=12" } }, - "node_modules/vitepress/node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" - } - }, "node_modules/vitepress/node_modules/@vitejs/plugin-vue": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", @@ -7453,13 +8333,6 @@ "@esbuild/win32-x64": "0.21.5" } }, - "node_modules/vitepress/node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "extraneous": true, - "license": "MIT" - }, "node_modules/vitepress/node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -7520,6 +8393,211 @@ } } }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.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" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/vue": { "version": "3.5.35", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz", @@ -7653,6 +8731,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7714,7 +8809,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 4f29206..b27de48 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,13 @@ { "name": "obsidian-agent-client", - "version": "0.10.6", + "version": "0.11.0", "description": "Use AI coding agents via the Agent Client Protocol directly inside Obsidian", "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "test": "vitest run", + "test:watch": "vitest", "version": "node version-bump.mjs && git add manifest.json versions.json", "format": "prettier --write \"src/**/*.{ts,tsx}\" \"*.css\"", "format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"*.css\"", @@ -19,6 +21,7 @@ "author": "", "license": "Apache-2.0", "devDependencies": { + "@eslint/json": "^0.14.0", "@types/node": "^16.11.6", "@types/react": "18.3.29", "@types/react-dom": "18.3.7", @@ -33,16 +36,18 @@ "tslib": "2.4.0", "typescript": "^5.9.3", "typescript-eslint": "^8.0.0", - "vitepress": "^1.6.4" + "vitepress": "^1.6.4", + "vitest": "^4.1.8" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.14.1", + "@agentclientprotocol/sdk": "^0.28.1", "@codemirror/state": "6.5.0", "@codemirror/view": "^6.35.0", "@tanstack/react-virtual": "^3.13.23", "diff": "^8.0.2", "react": "18.3.1", "react-dom": "18.3.1", - "semver": "^7.7.3" + "semver": "^7.7.3", + "zod": "^4.4.3" } } diff --git a/src/acp/acp-client.ts b/src/acp/acp-client.ts index d4165b7..a6fc93d 100644 --- a/src/acp/acp-client.ts +++ b/src/acp/acp-client.ts @@ -21,6 +21,7 @@ import { convertWindowsPathToWsl, getEnhancedWindowsEnv, prepareShellCommand, + buildWslEnv, } from "../utils/platform"; import { resolveNodeDirectory } from "../utils/paths"; import { @@ -42,6 +43,19 @@ export interface AgentConfig { args: string[]; env?: Record; workingDirectory: string; + /** + * Optional API key injection intent. + * When present, AcpClient.initialize() resolves the secret value from + * Obsidian's secret storage and injects it into the spawn environment + * as `envVarName`. Custom agents typically don't set this (they use + * env vars directly). + */ + apiKey?: { + /** Secret storage ID to look up at spawn time */ + secretId: string; + /** Environment variable name to inject the resolved value into */ + envVarName: string; + }; } /** @@ -61,7 +75,7 @@ export interface TerminalOutputResult { */ export class AcpClient { // Connection & process - private connection: acp.ClientSideConnection | null = null; + private connection: acp.ClientConnection | null = null; private agentProcess: ChildProcess | null = null; private currentConfig: AgentConfig | null = null; private isInitializedFlag = false; @@ -110,7 +124,7 @@ export class AcpClient { async initialize(config: AgentConfig): Promise { this.logger.log( "[AcpClient] Starting initialization with config:", - config, + this.getSafeConfigForLog(config), ); this.logger.log( `[AcpClient] Current state - process: ${!!this.agentProcess}, PID: ${this.agentProcess?.pid}`, @@ -180,6 +194,30 @@ export class AcpClient { ); } + // Resolve API key secret just before spawn so the latest value is used. + // Custom agents don't set config.apiKey and inject keys via env directly. + if (config.apiKey) { + const secretValue = + this.plugin.app.secretStorage.getSecret( + config.apiKey.secretId, + ) ?? ""; + baseEnv[config.apiKey.envVarName] = secretValue; + } + + // In WSL mode, forward the configured env var NAMES into WSL via WSLENV + // (Windows env vars are otherwise invisible to the Linux agent process, + // so the plugin's API key field would have no effect in WSL). Built-in + // agents resolve the API key into baseEnv above — not into config.env — + // so its var name must be added explicitly, or the key would never cross + // into WSL. Must run AFTER the secret is injected into baseEnv. (#312) + if (Platform.isWin && this.plugin.settings.windowsWslMode) { + const wslEnvNames = Object.keys(config.env || {}); + if (config.apiKey?.envVarName) { + wslEnvNames.push(config.apiKey.envVarName); + } + baseEnv = buildWslEnv(baseEnv, wslEnvNames); + } + this.logger.log( "[AcpClient] Starting agent process in directory:", config.workingDirectory, @@ -341,15 +379,44 @@ export class AcpClient { ); const stream = acp.ndJsonStream(input, output); - this.connection = new acp.ClientSideConnection( - () => this.handler, - stream, - ); + // Build the client app by registering handlers by ACP method name, then + // hold the persistent connection. This is the same builder the deprecated + // ClientSideConnection constructed internally (legacyClientApp), inlined. + const app = acp + .client({ name: "obsidian-agent-client" }) + .onNotification("session/update", (ctx) => + this.handler.sessionUpdate(ctx.params), + ) + .onRequest("session/request_permission", (ctx) => + this.handler.requestPermission(ctx.params), + ) + .onRequest("fs/read_text_file", (ctx) => + this.handler.readTextFile(ctx.params), + ) + .onRequest("fs/write_text_file", (ctx) => + this.handler.writeTextFile(ctx.params), + ) + .onRequest("terminal/create", (ctx) => + this.handler.createTerminal(ctx.params), + ) + .onRequest("terminal/output", (ctx) => + this.handler.terminalOutput(ctx.params), + ) + .onRequest("terminal/wait_for_exit", (ctx) => + this.handler.waitForTerminalExit(ctx.params), + ) + .onRequest("terminal/kill", (ctx) => + this.handler.killTerminal(ctx.params), + ) + .onRequest("terminal/release", (ctx) => + this.handler.releaseTerminal(ctx.params), + ); + this.connection = app.connect(stream); try { this.logger.log("[AcpClient] Starting ACP initialization..."); - const initResult = await this.connection.initialize({ + const initResult = await this.connection.agent.request("initialize", { protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: { fs: { @@ -384,6 +451,35 @@ export class AcpClient { } } + private getSafeConfigForLog(config: AgentConfig): { + id: string; + displayName: string; + command: string; + args: string[]; + envKeys: string[]; + workingDirectory: string; + apiKeyEnvVar?: string; + } { + return { + id: config.id, + displayName: config.displayName, + command: config.command, + args: [...config.args], + envKeys: Object.keys(config.env || {}), + workingDirectory: config.workingDirectory, + // Log env var name only — never the secret ID or value + apiKeyEnvVar: config.apiKey?.envVarName, + }; + } + + /** + * Update the auto-allow permission setting on a live client. + * Called by the plugin when the setting changes at runtime. + */ + updateAutoAllow(autoAllow: boolean): void { + this.permissionManager.setAutoAllow(autoAllow); + } + /** * Create a new chat session with the agent. */ @@ -393,7 +489,7 @@ export class AcpClient { try { this.logger.log("[AcpClient] Creating new session..."); - const response = await connection.newSession({ + const response = await connection.agent.request("session/new", { cwd: this.toSessionCwd(workingDirectory), mcpServers: [], }); @@ -420,7 +516,7 @@ export class AcpClient { const connection = this.requireConnection(); try { - await connection.authenticate({ methodId }); + await connection.agent.request("authenticate", { methodId }); this.logger.log("[AcpClient] ✅ authenticate ok:", methodId); return true; } catch (error: unknown) { @@ -451,7 +547,7 @@ export class AcpClient { `[AcpClient] Sending prompt with ${content.length} content blocks`, ); - const promptResult = await connection.prompt({ + const promptResult = await connection.agent.request("session/prompt", { sessionId: sessionId, prompt: acpContent, }); @@ -506,7 +602,7 @@ export class AcpClient { this.logger.log( "[AcpClient] Sending session/cancel notification...", ); - await this.connection.cancel({ sessionId }); + await this.connection.agent.notify("session/cancel", { sessionId }); this.logger.log( "[AcpClient] Cancellation request sent successfully", ); @@ -608,7 +704,7 @@ export class AcpClient { ); try { - await connection.setSessionMode({ + await connection.agent.request("session/set_mode", { sessionId, modeId, }); @@ -619,31 +715,6 @@ export class AcpClient { } } - /** - * DEPRECATED: Use setSessionConfigOption instead. - */ - async setSessionModel(sessionId: string, modelId: string): Promise { - const connection = this.requireConnection(); - - this.logger.log( - `[AcpClient] Setting session model to: ${modelId} for session: ${sessionId}`, - ); - - try { - await connection.unstable_setSessionModel({ - sessionId, - modelId, - }); - this.logger.log(`[AcpClient] Session model set to: ${modelId}`); - } catch (error) { - this.logger.error( - "[AcpClient] Failed to set session model:", - error, - ); - throw error; - } - } - /** * Set a session configuration option. * @@ -663,11 +734,14 @@ export class AcpClient { ); try { - const response = await connection.setSessionConfigOption({ - sessionId, - configId, - value, - }); + const response = await connection.agent.request( + "session/set_config_option", + { + sessionId, + configId, + value, + }, + ); this.logger.log( `[AcpClient] Config option set. Updated options:`, response.configOptions, @@ -722,7 +796,7 @@ export class AcpClient { * Assert that the ACP connection is initialized and return it. * @throws Error if connection is not available */ - private requireConnection(): acp.ClientSideConnection { + private requireConnection(): acp.ClientConnection { if (!this.connection) { throw new Error( "Connection not initialized. Call initialize() first.", @@ -781,7 +855,7 @@ export class AcpClient { const filterCwd = cwd ? this.toSessionCwd(cwd) : undefined; - const response = await connection.unstable_listSessions({ + const response = await connection.agent.request("session/list", { cwd: filterCwd ?? null, cursor: cursor ?? null, }); @@ -824,7 +898,7 @@ export class AcpClient { try { this.logger.log(`[AcpClient] Loading session: ${sessionId}...`); - const response = await connection.loadSession({ + const response = await connection.agent.request("session/load", { sessionId, cwd: this.toSessionCwd(cwd), mcpServers: [], @@ -864,7 +938,7 @@ export class AcpClient { try { this.logger.log(`[AcpClient] Resuming session: ${sessionId}...`); - const response = await connection.unstable_resumeSession({ + const response = await connection.agent.request("session/resume", { sessionId, cwd: this.toSessionCwd(cwd), mcpServers: [], @@ -898,7 +972,7 @@ export class AcpClient { try { this.logger.log(`[AcpClient] Forking session: ${sessionId}...`); - const response = await connection.unstable_forkSession({ + const response = await connection.agent.request("session/fork", { sessionId, cwd: this.toSessionCwd(cwd), mcpServers: [], diff --git a/src/acp/acp-handler.ts b/src/acp/acp-handler.ts index ac3bb7e..371ae25 100644 --- a/src/acp/acp-handler.ts +++ b/src/acp/acp-handler.ts @@ -9,9 +9,9 @@ import type { Logger } from "../utils/logger"; /** * Handles incoming ACP protocol events from the agent. * - * Implements the acp.Client interface to receive session updates, - * permission requests, and terminal operations from the SDK's - * ClientSideConnection dispatch. + * These methods are registered by ACP method name on the client app + * (acp.client(...)) in AcpClient, and receive session updates, permission + * requests, and terminal operations dispatched by the SDK connection. * * This class does not initiate communication — that is AcpClient's role. * It only reacts to events from the agent side. @@ -61,14 +61,17 @@ export class AcpHandler { } // ==================================================================== - // ACP Client Protocol Handlers (called by ClientSideConnection) + // ACP Client Protocol Handlers (registered on the client app) // ==================================================================== sessionUpdate(params: acp.SessionNotification): Promise { const update = params.update; const sessionId = params.sessionId; this.promptSessionUpdateCount++; - this.logger.log("[AcpHandler] sessionUpdate:", { sessionId, update }); + this.logger.log("[AcpHandler] sessionUpdate:", { + sessionId, + type: update.sessionUpdate, + }); switch (update.sessionUpdate) { case "agent_message_chunk": @@ -164,20 +167,6 @@ export class AcpHandler { return this.permissionManager.request(params); } - // ==================================================================== - // ACP Extension Handlers - // ==================================================================== - - async extNotification( - method: string, - params: Record, - ): Promise { - this.logger.log( - `[AcpHandler] Extension notification received: ${method}`, - params, - ); - } - // ==================================================================== // File System Stubs // ==================================================================== @@ -191,7 +180,7 @@ export class AcpHandler { } // ==================================================================== - // Terminal Operations (called by ClientSideConnection) + // Terminal Operations (registered on the client app) // ==================================================================== createTerminal( @@ -229,8 +218,8 @@ export class AcpHandler { } killTerminal( - params: acp.KillTerminalCommandRequest, - ): Promise { + params: acp.KillTerminalRequest, + ): Promise { const success = this.terminalManager.killTerminal(params.terminalId); if (!success) { throw new Error(`Terminal ${params.terminalId} not found`); diff --git a/src/acp/permission-handler.ts b/src/acp/permission-handler.ts index f2abe66..5939d75 100644 --- a/src/acp/permission-handler.ts +++ b/src/acp/permission-handler.ts @@ -112,12 +112,10 @@ export class PermissionManager { const normalizedOptions: PermissionOption[] = params.options.map( (option) => { - const normalizedKind = - option.kind === "reject_always" - ? "reject_once" - : option.kind; - const kind: PermissionOption["kind"] = normalizedKind - ? normalizedKind + // Use the agent-provided kind as-is. Only infer from the + // option name when a non-conforming agent omits kind. + const kind: PermissionOption["kind"] = option.kind + ? option.kind : option.name.toLowerCase().includes("allow") ? "allow_once" : "reject_once"; diff --git a/src/acp/terminal-handler.ts b/src/acp/terminal-handler.ts index 780bb03..6b0cc7c 100644 --- a/src/acp/terminal-handler.ts +++ b/src/acp/terminal-handler.ts @@ -3,7 +3,11 @@ import type AgentClientPlugin from "../plugin"; import { getLogger, Logger } from "../utils/logger"; import { Platform } from "obsidian"; import { resolveNodeDirectory } from "../utils/paths"; -import { getEnhancedWindowsEnv, prepareShellCommand } from "../utils/platform"; +import { + getEnhancedWindowsEnv, + prepareShellCommand, + buildWslEnv, +} from "../utils/platform"; /** * Parameters for creating a terminal process. @@ -70,6 +74,15 @@ export class TerminalManager { } } + // In WSL mode, forward the tool-provided env vars into WSL via WSLENV + // (Windows env is otherwise not visible to the Linux process). + if (Platform.isWin && this.plugin.settings.windowsWslMode && params.env) { + env = buildWslEnv( + env, + params.env.map((e) => e.name), + ); + } + // Handle command parsing let command = params.command; let args = params.args || []; @@ -97,9 +110,13 @@ export class TerminalManager { cwd: params.cwd, }); - // Spawn the process + // Spawn the process. + // In WSL mode the working directory is applied inside the launcher + // (cd ''); the wsl.exe process must NOT receive a Linux path as + // its Windows cwd (CreateProcess would fail), so omit cwd there. + const useWsl = Platform.isWin && this.plugin.settings.windowsWslMode; const spawnOptions: SpawnOptions = { - cwd: params.cwd || undefined, + cwd: useWsl ? undefined : params.cwd || undefined, env, stdio: ["pipe", "pipe", "pipe"], shell: needsShell, diff --git a/src/acp/type-converter.ts b/src/acp/type-converter.ts index 18574ff..9b7049a 100644 --- a/src/acp/type-converter.ts +++ b/src/acp/type-converter.ts @@ -21,7 +21,6 @@ import type { interface AcpSessionResponse { sessionId?: string; modes?: acp.SessionModeState | null; - models?: acp.SessionModelState | null; configOptions?: acp.SessionConfigOption[] | null; } @@ -100,15 +99,29 @@ export class AcpTypeConverter { static toSessionConfigOptions( acpOptions: acp.SessionConfigOption[], ): SessionConfigOption[] { - return acpOptions.map((opt) => ({ - id: opt.id, - name: opt.name, - description: opt.description ?? undefined, - category: opt.category ?? undefined, - type: opt.type, - currentValue: opt.currentValue, - options: this.toSessionConfigSelectOptions(opt.options), - })); + return acpOptions.map((opt): SessionConfigOption => { + const base = { + id: opt.id, + name: opt.name, + description: opt.description ?? undefined, + category: opt.category ?? undefined, + }; + // boolean options (ACP 0.28+) are carried as data; the UI renders + // and sets only select options for now. + if (opt.type === "boolean") { + return { + ...base, + type: "boolean", + currentValue: opt.currentValue, + }; + } + return { + ...base, + type: "select", + currentValue: opt.currentValue, + options: this.toSessionConfigSelectOptions(opt.options), + }; + }); } private static toSessionConfigSelectOptions( @@ -166,18 +179,6 @@ export class AcpTypeConverter { }; } - let models: SessionResult["models"]; - if (response.models) { - models = { - availableModels: response.models.availableModels.map((m) => ({ - modelId: m.modelId, - name: m.name, - description: m.description ?? undefined, - })), - currentModelId: response.models.currentModelId, - }; - } - const configOptions = response.configOptions ? this.toSessionConfigOptions(response.configOptions) : undefined; @@ -185,7 +186,6 @@ export class AcpTypeConverter { return { sessionId, modes, - models, configOptions, }; } diff --git a/src/hooks/useAgent.ts b/src/hooks/useAgent.ts index 3a8c2b0..8f02d86 100644 --- a/src/hooks/useAgent.ts +++ b/src/hooks/useAgent.ts @@ -29,7 +29,6 @@ import type { ChatMessage, ActivePermission } from "../types/chat"; import type { ChatSession, SessionModeState, - SessionModelState, SessionConfigOption, } from "../types/session"; import type { AgentDisplayInfo } from "../services/session-helpers"; @@ -66,13 +65,11 @@ export interface UseAgentReturn { updateSessionFromLoad: ( sessionId: string, modes?: SessionModeState, - models?: SessionModelState, configOptions?: SessionConfigOption[], ) => Promise; // Config setMode: (modeId: string) => Promise; - setModel: (modelId: string) => Promise; setConfigOption: (configId: string, value: string) => Promise; // Message operations @@ -199,7 +196,6 @@ export function useAgent( // Config setMode: agentSession.setMode, - setModel: agentSession.setModel, setConfigOption: agentSession.setConfigOption, // Message operations @@ -232,7 +228,6 @@ export function useAgent( agentSession.getAvailableAgents, agentSession.updateSessionFromLoad, agentSession.setMode, - agentSession.setModel, agentSession.setConfigOption, agentMessages.sendMessage, agentMessages.clearMessages, diff --git a/src/hooks/useAgentMessages.ts b/src/hooks/useAgentMessages.ts index cf63695..41f905d 100644 --- a/src/hooks/useAgentMessages.ts +++ b/src/hooks/useAgentMessages.ts @@ -49,6 +49,8 @@ export interface SendMessageOptions { images?: ImagePromptContent[]; /** Attached file references (resource links) */ resourceLinks?: ResourceLinkPromptContent[]; + /** Whether this is the first message in the session */ + isFirstMessage?: boolean; } export interface UseAgentMessagesReturn { @@ -181,10 +183,41 @@ export function useAgentMessages( ignoreUpdatesRef.current = ignore; }, []); - /** Discard any pending RAF updates and reset the streaming flag. */ + /** + * Cancel-time cleanup. Discards in-flight streaming updates so they don't + * bleed into the next reply (#200), but still applies any queued permission + * lifecycle updates (cancel/response) so the permission banner clears + * instead of staying stuck (#326). + */ const clearPendingUpdates = useCallback((): void => { + const queued = pendingUpdatesRef.current; pendingUpdatesRef.current = []; flushScheduledRef.current = false; + + // Streaming deltas are dropped; only terminal permission updates + // (cancelled or answered) are applied so `findActivePermission` stops + // returning the request. Active/pending permission updates are not + // replayed, so a cancel can never re-surface an active banner. + const permissionUpdates = queued.filter( + (u) => + (u.type === "tool_call" || u.type === "tool_call_update") && + (u.permissionRequest?.isCancelled === true || + u.permissionRequest?.selectedOptionId !== undefined), + ); + if (permissionUpdates.length > 0) { + setMessages((prev) => { + let result = prev; + for (const update of permissionUpdates) { + result = applySingleUpdate( + result, + update, + toolCallIndexRef.current, + ); + } + return result; + }); + } + setIsSending(false); }, []); @@ -275,6 +308,14 @@ export function useAgentMessages( maxNoteLength: settings.displaySettings.maxNoteLength, maxSelectionLength: settings.displaySettings.maxSelectionLength, + isFirstMessage: options.isFirstMessage, + promptInjection: settings.promptInjection.enabled + ? { + latex: settings.promptInjection.latex, + wikiLinks: settings.promptInjection.wikiLinks, + tables: settings.promptInjection.tables, + } + : undefined, }, vaultAccess, vaultAccess, // IMentionService (same object) diff --git a/src/hooks/useAgentSession.ts b/src/hooks/useAgentSession.ts index 301325c..2f335f8 100644 --- a/src/hooks/useAgentSession.ts +++ b/src/hooks/useAgentSession.ts @@ -11,7 +11,6 @@ const { useState, useCallback, useRef } = React; import type { ChatSession, SessionModeState, - SessionModelState, SessionUpdate, SessionConfigOption, } from "../types/session"; @@ -19,6 +18,7 @@ import type { AcpClient } from "../acp/acp-client"; import type { ISettingsAccess } from "../services/settings-service"; import type { ErrorInfo } from "../types/errors"; import { extractErrorMessage } from "../utils/error-utils"; +import { getLogger } from "../utils/logger"; import { type AgentDisplayInfo, getDefaultAgentId, @@ -31,6 +31,7 @@ import { import { applyLegacyValue, tryRestoreConfigOption, + restoreSavedConfigOptions, restoreLegacyConfig, } from "../services/session-state"; @@ -58,13 +59,11 @@ export interface UseAgentSessionReturn { updateSessionFromLoad: ( sessionId: string, modes?: SessionModeState, - models?: SessionModelState, configOptions?: SessionConfigOption[], ) => Promise; // Config setMode: (modeId: string) => Promise; - setModel: (modelId: string) => Promise; setConfigOption: (configId: string, value: string) => Promise; /** Handle session-level updates (commands, mode, config, usage, error) */ @@ -182,7 +181,6 @@ export function useAgentSession( authMethods: [], availableCommands: undefined, modes: undefined, - models: undefined, configOptions: undefined, usage: undefined, promptCapabilities: prev.promptCapabilities, @@ -223,29 +221,23 @@ export function useAgentSession( const sessionResult = await agentClient.newSession(effectiveCwd); - setSession((prev) => ({ - ...prev, - sessionId: sessionResult.sessionId, - state: "ready", - authMethods: initResult?.authMethods ?? [], - modes: sessionResult.modes, - models: sessionResult.models, - configOptions: sessionResult.configOptions, - promptCapabilities: initResult - ? initResult.promptCapabilities - : prev.promptCapabilities, - agentCapabilities: initResult - ? initResult.agentCapabilities - : prev.agentCapabilities, - agentInfo: initResult - ? initResult.agentInfo - : prev.agentInfo, - lastActivityAt: new Date(), - })); + // Pre-compute restored modes/configOptions BEFORE + // marking state as "ready" to avoid a UI race: without this, + // the dropdowns briefly show the agent's default values and + // a message sent during the window hits the agent in the + // wrong mode. With this, the first render after session + // creation already shows the user's saved selection. + let finalModes = sessionResult.modes; + let finalConfigOptions = sessionResult.configOptions; - // Restore last used config (model/mode) if (sessionResult.configOptions && sessionResult.sessionId) { let configOptions = sessionResult.configOptions; + configOptions = await restoreSavedConfigOptions( + agentClient, + sessionResult.sessionId, + configOptions, + settings.lastUsedConfigOptions[agentId], + ); configOptions = await tryRestoreConfigOption( agentClient, sessionResult.sessionId, @@ -260,21 +252,34 @@ export function useAgentSession( "mode", settings.lastUsedModes[agentId], ); - if (configOptions !== sessionResult.configOptions) { - setSession((prev) => ({ - ...prev, - configOptions, - })); - } + finalConfigOptions = configOptions; } else if (sessionResult.sessionId) { - await restoreLegacyConfig( + const restored = await restoreLegacyConfig( agentClient, sessionResult, - settings.lastUsedModels[agentId], settings.lastUsedModes[agentId], - setSession, ); + finalModes = restored.modes; } + + setSession((prev) => ({ + ...prev, + sessionId: sessionResult.sessionId, + state: "ready", + authMethods: initResult?.authMethods ?? [], + modes: finalModes, + configOptions: finalConfigOptions, + promptCapabilities: initResult + ? initResult.promptCapabilities + : prev.promptCapabilities, + agentCapabilities: initResult + ? initResult.agentCapabilities + : prev.agentCapabilities, + agentInfo: initResult + ? initResult.agentInfo + : prev.agentInfo, + lastActivityAt: new Date(), + })); } catch (error) { setSession((prev) => ({ ...prev, state: "error" })); setErrorInfo({ @@ -301,13 +306,13 @@ export function useAgentSession( try { await agentClient.cancel(s.sessionId); } catch (error) { - console.warn("Failed to cancel session:", error); + getLogger().warn("Failed to cancel session:", error); } } try { await agentClient.disconnect(); } catch (error) { - console.warn("Failed to disconnect:", error); + getLogger().warn("Failed to disconnect:", error); } setSession((prev) => ({ ...prev, @@ -329,7 +334,7 @@ export function useAgentSession( await agentClient.cancel(s.sessionId); setSession((prev) => ({ ...prev, state: "ready" })); } catch (error) { - console.warn("Failed to cancel operation:", error); + getLogger().warn("Failed to cancel operation:", error); setSession((prev) => ({ ...prev, state: "ready" })); } }, [agentClient]); @@ -343,26 +348,27 @@ export function useAgentSession( async ( sessionId: string, modes?: SessionModeState, - models?: SessionModelState, configOptions?: SessionConfigOption[], ) => { - setSession((prev) => ({ - ...prev, - sessionId, - state: "ready", - modes: modes ?? prev.modes, - models: models ?? prev.models, - configOptions: configOptions ?? prev.configOptions, - lastActivityAt: new Date(), - })); - - // Restore last used config (model/mode) — same logic as createSession + // Pre-compute restored config BEFORE marking ready to avoid a UI + // race where the dropdowns briefly show the agent's current values + // before the user's saved selection is re-applied. See the matching + // refactor in createSession for the rationale. const s = sessionRef.current; const settings = settingsAccess.getSnapshot(); const agentId = s.agentId; + let finalModes = modes; + let finalConfigOptions = configOptions; + if (configOptions && sessionId) { let restored = configOptions; + restored = await restoreSavedConfigOptions( + agentClient, + sessionId, + restored, + settings.lastUsedConfigOptions[agentId], + ); restored = await tryRestoreConfigOption( agentClient, sessionId, @@ -377,21 +383,24 @@ export function useAgentSession( "mode", settings.lastUsedModes[agentId], ); - if (restored !== configOptions) { - setSession((prev) => ({ - ...prev, - configOptions: restored, - })); - } + finalConfigOptions = restored; } else if (sessionId && modes) { - await restoreLegacyConfig( + const restored = await restoreLegacyConfig( agentClient, - { sessionId, modes, models, configOptions: undefined }, - settings.lastUsedModels[agentId], + { sessionId, modes, configOptions: undefined }, settings.lastUsedModes[agentId], - setSession, ); + finalModes = restored.modes; } + + setSession((prev) => ({ + ...prev, + sessionId, + state: "ready", + modes: finalModes ?? prev.modes, + configOptions: finalConfigOptions ?? prev.configOptions, + lastActivityAt: new Date(), + })); }, [agentClient, settingsAccess], ); @@ -401,44 +410,33 @@ export function useAgentSession( // ============================================================ const setLegacyConfigValue = useCallback( - async (kind: "mode" | "model", value: string) => { + async (value: string) => { const s = sessionRef.current; if (!s.sessionId) { - console.warn(`Cannot set ${kind}: no active session`); + getLogger().debug("Cannot set mode: no active session"); return; } - const previousValue = - kind === "mode" - ? s.modes?.currentModeId - : s.models?.currentModelId; + const previousValue = s.modes?.currentModeId; - setSession((prev) => applyLegacyValue(prev, kind, value)); + setSession((prev) => applyLegacyValue(prev, value)); try { - if (kind === "mode") { - await agentClient.setSessionMode(s.sessionId, value); - } else { - await agentClient.setSessionModel(s.sessionId, value); - } + await agentClient.setSessionMode(s.sessionId, value); if (s.agentId) { - const persistKey = - kind === "mode" ? "lastUsedModes" : "lastUsedModels"; const currentSettings = settingsAccess.getSnapshot(); void settingsAccess.updateSettings({ - [persistKey]: { - ...currentSettings[persistKey], + lastUsedModes: { + ...currentSettings.lastUsedModes, [s.agentId]: value, }, }); } } catch (error) { - console.error(`Failed to set ${kind}:`, error); + getLogger().error("Failed to set mode:", error); if (previousValue) { - setSession((prev) => - applyLegacyValue(prev, kind, previousValue), - ); + setSession((prev) => applyLegacyValue(prev, previousValue)); } } }, @@ -446,12 +444,7 @@ export function useAgentSession( ); const setMode = useCallback( - (modeId: string) => setLegacyConfigValue("mode", modeId), - [setLegacyConfigValue], - ); - - const setModel = useCallback( - (modelId: string) => setLegacyConfigValue("model", modelId), + (modeId: string) => setLegacyConfigValue(modeId), [setLegacyConfigValue], ); @@ -459,7 +452,9 @@ export function useAgentSession( async (configId: string, value: string) => { const s = sessionRef.current; if (!s.sessionId) { - console.warn("Cannot set config option: no active session"); + getLogger().debug( + "Cannot set config option: no active session", + ); return; } @@ -470,7 +465,7 @@ export function useAgentSession( return { ...prev, configOptions: prev.configOptions.map((opt) => - opt.id === configId + opt.id === configId && opt.type === "select" ? { ...opt, currentValue: value } : opt, ), @@ -491,26 +486,42 @@ export function useAgentSession( const changedOption = updatedOptions.find( (o) => o.id === configId, ); - if (changedOption?.category === "model" && s.agentId) { + if (changedOption && s.agentId) { const currentSettings = settingsAccess.getSnapshot(); - void settingsAccess.updateSettings({ - lastUsedModels: { - ...currentSettings.lastUsedModels, - [s.agentId]: value, - }, - }); - } - if (changedOption?.category === "mode" && s.agentId) { - const currentSettings = settingsAccess.getSnapshot(); - void settingsAccess.updateSettings({ - lastUsedModes: { - ...currentSettings.lastUsedModes, - [s.agentId]: value, - }, - }); + if (changedOption.category === "model") { + void settingsAccess.updateSettings({ + lastUsedModels: { + ...currentSettings.lastUsedModels, + [s.agentId]: value, + }, + }); + } else if (changedOption.category === "mode") { + void settingsAccess.updateSettings({ + lastUsedModes: { + ...currentSettings.lastUsedModes, + [s.agentId]: value, + }, + }); + } else if ( + configId !== "__proto__" && + configId !== "constructor" + ) { + const allOptions = + currentSettings.lastUsedConfigOptions; + const agentOptions = allOptions[s.agentId] ?? {}; + void settingsAccess.updateSettings({ + lastUsedConfigOptions: { + ...allOptions, + [s.agentId]: { + ...agentOptions, + [configId]: value, + }, + }, + }); + } } } catch (error) { - console.error("Failed to set config option:", error); + getLogger().error("Failed to set config option:", error); if (previousConfigOptions) { setSession((prev) => ({ ...prev, @@ -537,7 +548,6 @@ export function useAgentSession( getAvailableAgents, updateSessionFromLoad, setMode, - setModel, setConfigOption, handleSessionUpdate, }; diff --git a/src/hooks/useChatActions.ts b/src/hooks/useChatActions.ts index 16de1c8..583bb56 100644 --- a/src/hooks/useChatActions.ts +++ b/src/hooks/useChatActions.ts @@ -44,7 +44,6 @@ export interface UseChatActionsReturn { // Config actions handleSetMode: (modeId: string) => Promise; - handleSetModel: (modelId: string) => Promise; handleSetConfigOption: (configId: string, value: string) => Promise; // UI state actions @@ -178,15 +177,14 @@ export function useChatActions( } await agent.sendMessage(content, { - activeNote: settings.autoMentionActiveNote - ? suggestions.mentions.activeNote - : null, + activeNote: suggestions.mentions.activeNote, vaultBasePath: vaultPath, isAutoMentionDisabled: suggestions.mentions.isAutoMentionDisabled, images: images.length > 0 ? images : undefined, resourceLinks: resourceLinks.length > 0 ? resourceLinks : undefined, + isFirstMessage, }); // Save session metadata locally on first message @@ -207,7 +205,6 @@ export function useChatActions( session.sessionId, sessionHistory.saveSessionLocally, logger, - settings.autoMentionActiveNote, suggestions.mentions.activeNote, suggestions.mentions.isAutoMentionDisabled, shouldConvertToWsl, @@ -345,13 +342,6 @@ export function useChatActions( [agent.setMode], ); - const handleSetModel = useCallback( - async (modelId: string) => { - await agent.setModel(modelId); - }, - [agent.setModel], - ); - const handleSetConfigOption = useCallback( async (configId: string, value: string) => { await agent.setConfigOption(configId, value); @@ -387,7 +377,6 @@ export function useChatActions( handleSwitchAgent, handleRestartAgent, handleSetMode, - handleSetModel, handleSetConfigOption, handleClearError, handleClearAgentUpdate, diff --git a/src/hooks/useSessionHistory.ts b/src/hooks/useSessionHistory.ts index ab24216..60b3611 100644 --- a/src/hooks/useSessionHistory.ts +++ b/src/hooks/useSessionHistory.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, useMemo } from "react"; +import { useState, useCallback, useRef, useMemo, useEffect } from "react"; import type { AcpClient } from "../acp/acp-client"; import type { ISettingsAccess } from "../services/settings-service"; import type { @@ -7,12 +7,12 @@ import type { SavedSessionInfo, ChatSession, SessionModeState, - SessionModelState, SessionConfigOption, AgentCapabilities, } from "../types/session"; import type { ChatMessage } from "../types/chat"; import { extractErrorMessage } from "../utils/error-utils"; +import { truncateTitle } from "../utils/text"; // ============================================================================ // Session Capability Helpers (from session-capability-utils.ts) @@ -56,13 +56,11 @@ export interface SessionLoadCallback { /** * @param sessionId - ID of the session (new session ID for fork) * @param modes - Available modes from the session - * @param models - Available models from the session * @param configOptions - Config options from the session */ ( sessionId: string, modes?: SessionModeState, - models?: SessionModelState, configOptions?: SessionConfigOption[], ): void; } @@ -300,6 +298,36 @@ export function useSessionHistory( const cacheRef = useRef(null); const currentCwdRef = useRef(undefined); + // External rename detection: when `savedSessions` changes (e.g. via Session + // Manager's Rename), re-merge titles into the currently displayed list + // without re-fetching from the agent. Subscribe to settings directly (rather + // than via useSettings) so we don't re-render on unrelated settings slices + // (windowsWslMode, fontSize, etc.). Reference comparison is sufficient + // because SessionStorage always writes a fresh `savedSessions` array. + const lastSavedSessionsRef = useRef(null); + + useEffect(() => { + return settingsAccess.subscribe(() => { + const next = settingsAccess.getSnapshot().savedSessions ?? []; + if (next === lastSavedSessionsRef.current) return; + lastSavedSessionsRef.current = next; + + const localSessions = settingsAccess.getSavedSessions( + session.agentId, + ); + setSessions((prev) => { + if (prev.length === 0) return prev; + const merged = mergeWithLocalTitles(prev, localSessions); + // Skip render if no title actually changed + const unchanged = + merged.length === prev.length && + merged.every((s, i) => s.title === prev[i].title); + return unchanged ? prev : merged; + }); + setLocalSessionIds(new Set(localSessions.map((s) => s.sessionId))); + }); + }, [settingsAccess, session.agentId]); + /** * Check if cache is valid. */ @@ -509,7 +537,7 @@ export function useSessionHistory( try { // IMPORTANT: Update session.sessionId BEFORE calling restore // so that session/update notifications are not ignored - onSessionLoad(sessionId, undefined, undefined, undefined); + onSessionLoad(sessionId, undefined, undefined); if (capabilities.canLoad) { // Check local messages first to decide whether to use them or agent replay @@ -528,7 +556,6 @@ export function useSessionHistory( onSessionLoad( result.sessionId, result.modes, - result.models, result.configOptions, ); onMessagesRestore(localMessages); @@ -544,7 +571,6 @@ export function useSessionHistory( onSessionLoad( result.sessionId, result.modes, - result.models, result.configOptions, ); } @@ -557,7 +583,6 @@ export function useSessionHistory( onSessionLoad( result.sessionId, result.modes, - result.models, result.configOptions, ); @@ -608,7 +633,6 @@ export function useSessionHistory( onSessionLoad( result.sessionId, result.modes, - result.models, result.configOptions, ); @@ -626,15 +650,9 @@ export function useSessionHistory( ); const originalTitle = originalSession?.title ?? "Session"; - // Truncate title to 50 characters - const maxTitleLength = 50; + // Keep "Fork: " prefix intact; truncate only the base. const prefix = "Fork: "; - const maxBaseLength = maxTitleLength - prefix.length; - const truncatedTitle = - originalTitle.length > maxBaseLength - ? originalTitle.substring(0, maxBaseLength) + "..." - : originalTitle; - const newTitle = `${prefix}${truncatedTitle}`; + const newTitle = `${prefix}${truncateTitle(originalTitle, 50 - prefix.length)}`; const now = new Date().toISOString(); @@ -710,7 +728,6 @@ export function useSessionHistory( */ const updateSessionTitle = useCallback( async (sessionId: string, newTitle: string, sessionCwd: string) => { - // Read current title for potential rollback const savedSessions = settingsAccess.getSavedSessions(); const existing = savedSessions.find( (s) => s.sessionId === sessionId, @@ -725,28 +742,13 @@ export function useSessionHistory( ); try { - if (existing) { - await settingsAccess.saveSession({ - ...existing, - title: newTitle, - updatedAt: new Date().toISOString(), - }); - } else { - // Session exists only on agent side — create local entry - // Use sessionCwd (from SessionInfo) instead of hook's cwd - await settingsAccess.saveSession({ - sessionId, - agentId: session.agentId, - cwd: sessionCwd, - title: newTitle, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - } - + await settingsAccess.updateSessionTitle(sessionId, newTitle, { + agentId: session.agentId, + cwd: sessionCwd, + }); invalidateCache(); } catch (err) { - // Rollback optimistic update + // Rollback setSessions((prev) => prev.map((s) => s.sessionId === sessionId @@ -770,10 +772,7 @@ export function useSessionHistory( async (sessionId: string, messageContent: string) => { if (!session.agentId) return; - const title = - messageContent.length > 50 - ? messageContent.substring(0, 50) + "..." - : messageContent; + const title = truncateTitle(messageContent); await settingsAccess.saveSession({ sessionId, @@ -791,6 +790,10 @@ export function useSessionHistory( * Save session messages locally. * Called when a turn ends (agent response complete). * Fire-and-forget (does not block UI). + * + * Also bumps the session's `updatedAt` metadata so that + * `getSavedSessions()` (sorted by updatedAt desc) reflects actual + * activity rather than just session-creation time (#257). */ const saveSessionMessages = useCallback( ( @@ -799,12 +802,20 @@ export function useSessionHistory( ) => { if (!session.agentId || messages.length === 0) return; - // Fire-and-forget + // Persist message content (fire-and-forget) void settingsAccess.saveSessionMessages( sessionId, session.agentId, messages, ); + + // Bump updatedAt on session metadata so "last used" ordering + // reflects real activity. `updateSession` is a no-op if the entry + // hasn't landed yet — saveSessionLocally will create it on the + // first-message path. + void settingsAccess.updateSession(sessionId, { + updatedAt: new Date().toISOString(), + }); }, [session.agentId, settingsAccess], ); diff --git a/src/hooks/useSuggestions.ts b/src/hooks/useSuggestions.ts index 51291ee..05598dd 100644 --- a/src/hooks/useSuggestions.ts +++ b/src/hooks/useSuggestions.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useEffect } from "react"; import type { NoteMetadata, IVaultAccess } from "../services/vault-service"; import { detectMention, @@ -90,6 +90,7 @@ export function useSuggestions( vaultAccess: IVaultAccess, plugin: AgentClientPlugin, availableCommands: SlashCommand[], + autoMentionDefault: boolean, ): UseSuggestionsReturn { // ============================================================ // Mention State @@ -103,7 +104,14 @@ export function useSuggestions( null, ); const [activeNote, setActiveNote] = useState(null); - const [isAutoMentionDisabled, setIsAutoMentionDisabled] = useState(false); + const [isAutoMentionDisabled, setIsAutoMentionDisabled] = useState( + !autoMentionDefault, + ); + + // Sync toggle when the setting changes at runtime (e.g. from plugin settings) + useEffect(() => { + setIsAutoMentionDisabled(!autoMentionDefault); + }, [autoMentionDefault]); const mentionIsOpen = mentionSuggestions.length > 0 && mentionContext !== null; @@ -208,14 +216,8 @@ export function useSuggestions( const commandUpdateSuggestions = useCallback( (input: string, cursorPosition: number) => { - const wasOpen = commandSuggestions.length > 0; - // Slash commands only trigger at the very beginning of input if (!input.startsWith("/")) { - // Re-enable auto-mention only if dropdown was showing - if (wasOpen) { - toggleAutoMention(false); - } setCommandSuggestions([]); setCommandSelectedIndex(0); return; @@ -229,8 +231,6 @@ export function useSuggestions( if (afterSlash.includes(" ")) { setCommandSuggestions([]); setCommandSelectedIndex(0); - // Keep auto-mention disabled (slash command is still active) - toggleAutoMention(true); return; } @@ -243,10 +243,8 @@ export function useSuggestions( setCommandSuggestions(filtered); setCommandSelectedIndex(0); - // Disable auto-mention when slash command is detected - toggleAutoMention(true); }, - [availableCommands, toggleAutoMention, commandSuggestions.length], + [availableCommands], ); const commandSelectSuggestion = useCallback( diff --git a/src/plugin.ts b/src/plugin.ts index 82efaec..31f3710 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -6,6 +6,10 @@ import { } from "obsidian"; import * as semver from "semver"; import { ChatView, VIEW_TYPE_CHAT } from "./ui/ChatView"; +import { + SessionManagerView, + VIEW_TYPE_SESSION_MANAGER, +} from "./ui/SessionManagerView"; import { createFloatingChat, FloatingViewContainer, @@ -30,6 +34,7 @@ import { enumVal, obj, strRecord, + nestedStrRecord, xyPoint, } from "./services/settings-normalizer"; import { @@ -40,7 +45,7 @@ import { CustomAgentSettings, } from "./types/agent"; import type { SavedSessionInfo } from "./types/session"; -import { initializeLogger } from "./utils/logger"; +import { initializeLogger, getLogger } from "./utils/logger"; // Re-export for backward compatibility export type { AgentEnvVar, CustomAgentSettings }; @@ -76,6 +81,17 @@ export interface AgentClientPluginSettings { autoMentionActiveNote: boolean; /** Show OS system notifications on response completion and permission requests */ enableSystemNotifications: boolean; + /** Prompt injection settings for Obsidian-flavored Markdown guidance */ + promptInjection: { + /** Master toggle for prompt injection */ + enabled: boolean; + /** Inject LaTeX math formatting instructions ($...$ and $$...$$) */ + latex: boolean; + /** Instruct agents to use [[Note Name]] wikilink syntax */ + wikiLinks: boolean; + /** Instruct agents to leave a blank line before Markdown tables */ + tables: boolean; + }; debugMode: boolean; nodePath: string; exportSettings: { @@ -111,6 +127,8 @@ export interface AgentClientPluginSettings { lastUsedModels: Record; // Last used mode per agent (agentId → modeId) lastUsedModes: Record; + // Last used non-model/mode config options per agent (agentId → {optionId → value}) + lastUsedConfigOptions: Record>; // Floating chat settings enableFloatingChat: boolean; floatingButtonImage: string; @@ -123,7 +141,7 @@ const DEFAULT_SETTINGS: AgentClientPluginSettings = { claude: { id: "claude-code-acp", displayName: "Claude Code", - apiKey: "", + apiKeySecretId: "", command: "claude-agent-acp", args: [], env: [], @@ -131,7 +149,7 @@ const DEFAULT_SETTINGS: AgentClientPluginSettings = { codex: { id: "codex-acp", displayName: "Codex", - apiKey: "", + apiKeySecretId: "", command: "codex-acp", args: [], env: [], @@ -139,7 +157,7 @@ const DEFAULT_SETTINGS: AgentClientPluginSettings = { gemini: { id: "gemini-cli", displayName: "Gemini CLI", - apiKey: "", + apiKeySecretId: "", command: "gemini", args: ["--experimental-acp"], env: [], @@ -149,6 +167,12 @@ const DEFAULT_SETTINGS: AgentClientPluginSettings = { autoAllowPermissions: false, autoMentionActiveNote: true, enableSystemNotifications: true, + promptInjection: { + enabled: true, + latex: true, + wikiLinks: true, + tables: true, + }, debugMode: false, nodePath: "", exportSettings: { @@ -177,6 +201,7 @@ const DEFAULT_SETTINGS: AgentClientPluginSettings = { savedSessions: [], lastUsedModels: {}, lastUsedModes: {}, + lastUsedConfigOptions: {}, enableFloatingChat: false, floatingButtonImage: "", floatingWindowSize: { width: 400, height: 500 }, @@ -206,8 +231,18 @@ export default class AgentClientPlugin extends Plugin { // Initialize settings store this.settingsService = createSettingsService(this.settings, this); + // Detach stale leaves from a previous plugin instance to prevent + // "Attempting to register an existing view type" when Obsidian's + // hot-reload races onunload/onload (e.g. rapid toggle or npm run dev). + this.app.workspace.detachLeavesOfType(VIEW_TYPE_CHAT); this.registerView(VIEW_TYPE_CHAT, (leaf) => new ChatView(leaf, this)); + this.app.workspace.detachLeavesOfType(VIEW_TYPE_SESSION_MANAGER); + this.registerView( + VIEW_TYPE_SESSION_MANAGER, + (leaf) => new SessionManagerView(leaf, this), + ); + const ribbonIconEl = this.addRibbonIcon( "bot-message-square", "Open agent client", @@ -251,6 +286,14 @@ export default class AgentClientPlugin extends Plugin { }, }); + this.addCommand({ + id: "open-session-manager", + name: "Open session manager", + callback: () => { + void this.activateSessionManager(); + }, + }); + // Register agent-specific commands this.registerAgentCommands(); this.registerPermissionCommands(); @@ -333,7 +376,7 @@ export default class AgentClientPlugin extends Plugin { // Fire and forget - don't block Obsidian from quitting for (const [viewId, client] of this._acpClients) { client.disconnect().catch((error) => { - console.warn( + getLogger().warn( `[AgentClient] Quit cleanup error for view ${viewId}:`, error, ); @@ -342,6 +385,19 @@ export default class AgentClientPlugin extends Plugin { this._acpClients.clear(); }), ); + + // Keep the focused chat view in sync when the active leaf changes + // (e.g. clicking a chat tab in the tab bar). ChatPanel's DOM + // focus/click listeners only fire on interaction inside the view, so a + // tab-bar switch would otherwise leave the Session Manager highlight on + // the previous view until the user clicks into the new one. + this.registerEvent( + this.app.workspace.on("active-leaf-change", (leaf) => { + if (leaf?.view instanceof ChatView) { + this.setLastActiveChatViewId(leaf.view.viewId); + } + }), + ); } onunload() { @@ -379,6 +435,16 @@ export default class AgentClientPlugin extends Plugin { return client; } + /** + * Update auto-allow permission setting on all live AcpClient instances. + * Called when the setting changes at runtime. + */ + updateAllAutoAllow(autoAllow: boolean): void { + for (const client of this._acpClients.values()) { + client.updateAutoAllow(autoAllow); + } + } + /** * Remove and disconnect the AcpClient for a specific view. * Called when a ChatView is closed. @@ -389,7 +455,7 @@ export default class AgentClientPlugin extends Plugin { try { await client.disconnect(); } catch (error) { - console.warn( + getLogger().warn( `[AgentClient] Failed to disconnect client for view ${viewId}:`, error, ); @@ -450,6 +516,34 @@ export default class AgentClientPlugin extends Plugin { } } + async activateSessionManager(): Promise { + const { workspace } = this.app; + + const leaves = workspace.getLeavesOfType(VIEW_TYPE_SESSION_MANAGER); + if (leaves.length > 0) { + await workspace.revealLeaf(leaves[0]); + return; + } + + const leaf = workspace.getLeftLeaf(false); + if (leaf) { + await leaf.setViewState({ + type: VIEW_TYPE_SESSION_MANAGER, + active: true, + }); + await workspace.revealLeaf(leaf); + } + } + + /** + * Close a specific chat view (sidebar or floating). + * Dispatch is via IChatViewContainer.closeContainer(); plugin does not + * need to know the concrete container class. + */ + closeView(viewId: string): void { + this.viewRegistry.get(viewId)?.closeContainer(); + } + /** * Focus the textarea in a ChatView leaf. */ @@ -542,7 +636,7 @@ export default class AgentClientPlugin extends Plugin { async openNewChatViewWithAgent(agentId: string): Promise { const leaf = this.createNewChatLeaf(true); if (!leaf) { - console.warn("[AgentClient] Failed to create new leaf"); + getLogger().warn("[AgentClient] Failed to create new leaf"); return; } @@ -829,6 +923,7 @@ export default class AgentClientPlugin extends Plugin { async loadSettings() { const raw = ((await this.loadData()) ?? {}) as Record; const D = DEFAULT_SETTINGS; + let migratedSecrets = false; // Extract agent sub-objects const rc = obj(raw.claude) ?? {}; @@ -864,7 +959,16 @@ export default class AgentClientPlugin extends Plugin { claude: { id: D.claude.id, // Fixed — never from raw displayName: str(rc.displayName, D.claude.displayName), - apiKey: str(rc.apiKey, D.claude.apiKey), + apiKeySecretId: this.migrateLegacyApiKey( + "claude-api-key", + "agent-client-claude-api-key", + str(rc.apiKeySecretId, D.claude.apiKeySecretId), + str(rc.apiKey, ""), + "Claude", + () => { + migratedSecrets = true; + }, + ), // Migration: claude.command ← claudeCodeAcpCommandPath (old name) command: str(rc.command, "") || @@ -876,7 +980,16 @@ export default class AgentClientPlugin extends Plugin { codex: { id: D.codex.id, displayName: str(rk.displayName, D.codex.displayName), - apiKey: str(rk.apiKey, D.codex.apiKey), + apiKeySecretId: this.migrateLegacyApiKey( + "openai-api-key", + "agent-client-openai-api-key", + str(rk.apiKeySecretId, D.codex.apiKeySecretId), + str(rk.apiKey, ""), + "Codex", + () => { + migratedSecrets = true; + }, + ), command: str(rk.command, "") || D.codex.command, args: sanitizeArgs(rk.args), env: normalizeEnvVars(rk.env), @@ -884,7 +997,16 @@ export default class AgentClientPlugin extends Plugin { gemini: { id: D.gemini.id, displayName: str(rg.displayName, D.gemini.displayName), - apiKey: str(rg.apiKey, D.gemini.apiKey), + apiKeySecretId: this.migrateLegacyApiKey( + "gemini-api-key", + "agent-client-gemini-api-key", + str(rg.apiKeySecretId, D.gemini.apiKeySecretId), + str(rg.apiKey, ""), + "Gemini", + () => { + migratedSecrets = true; + }, + ), // Migration: gemini.command ← geminiCommandPath (old name) command: str(rg.command, "") || @@ -910,6 +1032,15 @@ export default class AgentClientPlugin extends Plugin { raw.enableSystemNotifications, D.enableSystemNotifications, ), + promptInjection: (() => { + const rp = obj(raw.promptInjection) ?? {}; + return { + enabled: bool(rp.enabled, D.promptInjection.enabled), + latex: bool(rp.latex, D.promptInjection.latex), + wikiLinks: bool(rp.wikiLinks, D.promptInjection.wikiLinks), + tables: bool(rp.tables, D.promptInjection.tables), + }; + })(), debugMode: bool(raw.debugMode, D.debugMode), nodePath: str(raw.nodePath, D.nodePath), exportSettings: { @@ -994,6 +1125,7 @@ export default class AgentClientPlugin extends Plugin { : D.savedSessions, lastUsedModels: strRecord(raw.lastUsedModels), lastUsedModes: strRecord(raw.lastUsedModes), + lastUsedConfigOptions: nestedStrRecord(raw.lastUsedConfigOptions), // Migration: enableFloatingChat ← showFloatingButton (old name) enableFloatingChat: bool( raw.enableFloatingChat, @@ -1016,6 +1148,10 @@ export default class AgentClientPlugin extends Plugin { }; this.ensureDefaultAgentId(); + + if (migratedSecrets) { + await this.saveSettings(); + } } async saveSettings() { @@ -1026,6 +1162,80 @@ export default class AgentClientPlugin extends Plugin { await this.settingsService.updateSettings(nextSettings); } + /** + * Migrate legacy plaintext apiKey (v0.10.x) to secretStorage. + * + * Returns the secretId to use for this agent. + * + * Behavior: + * - If apiKeySecretId is already set, return it as-is. If a legacy + * plaintext apiKey still lingers in data.json (orphaned from prior + * experimental state), trigger onMigrate to schedule a save that + * cleans it up. + * - If legacy apiKey is empty, return empty string (no migration needed). + * - Otherwise, migrate to secretStorage: + * - Use defaultSecretId (e.g. "claude-api-key") for cross-plugin sharing. + * - On collision (defaultSecretId exists with a different value, e.g. + * from another plugin), fall back to fallbackSecretId + * (e.g. "agent-client-claude-api-key") to preserve the user's key + * and notify them. + * + * This method is for upgrading from v0.10.x or experimental builds and + * can be removed in a future major version once we're confident no + * users have legacy plaintext apiKey fields in data.json. + */ + private migrateLegacyApiKey( + defaultSecretId: string, + fallbackSecretId: string, + currentSecretId: string, + legacyApiKey: string, + agentLabel: string, + onMigrate: () => void, + ): string { + const trimmed = legacyApiKey.trim(); + + // Already migrated + if (currentSecretId.length > 0) { + // Clean up orphaned plaintext apiKey if still in data.json + if (trimmed.length > 0) { + onMigrate(); + } + return currentSecretId; + } + + if (trimmed.length === 0) { + return ""; + } + + const existing = this.app.secretStorage.getSecret(defaultSecretId); + + if (existing === null) { + // No collision — create the secret with the preferred ID + this.app.secretStorage.setSecret(defaultSecretId, trimmed); + new Notice( + `[Agent Client] Your ${agentLabel} API key has been migrated to Obsidian's Keychain as "${defaultSecretId}".`, + ); + onMigrate(); + return defaultSecretId; + } + + if (existing === trimmed) { + // Idempotent re-migration (same value already stored) + onMigrate(); + return defaultSecretId; + } + + // Collision: defaultSecretId exists with a different value (likely + // another plugin). Fall back to a plugin-prefixed ID to preserve + // the user's key without overwriting other plugins' secrets. + this.app.secretStorage.setSecret(fallbackSecretId, trimmed); + new Notice( + `[Agent Client] "${defaultSecretId}" was already in use. Your ${agentLabel} API key was migrated to "${fallbackSecretId}". You can rename it in Obsidian's Keychain settings.`, + ); + onMigrate(); + return fallbackSecretId; + } + /** * Fetch the latest stable release version from GitHub. */ diff --git a/src/services/message-sender.ts b/src/services/message-sender.ts index 0019237..91afb9e 100644 --- a/src/services/message-sender.ts +++ b/src/services/message-sender.ts @@ -13,6 +13,7 @@ */ import type { AcpClient } from "../acp/acp-client"; +import { getLogger } from "../utils/logger"; import type { IVaultAccess, NoteMetadata, @@ -75,6 +76,16 @@ export interface PreparePromptInput { /** Maximum characters for selection (default: 10000) */ maxSelectionLength?: number; + + /** Whether this is the first message in the session */ + isFirstMessage?: boolean; + + /** Prompt injection settings (undefined = disabled) */ + promptInjection?: { + latex?: boolean; + wikiLinks?: boolean; + tables?: boolean; + }; } /** @@ -144,6 +155,12 @@ export interface SendPromptResult { const DEFAULT_MAX_NOTE_LENGTH = 10000; // Default maximum characters per note const DEFAULT_MAX_SELECTION_LENGTH = 10000; // Default maximum characters for selection +const LATEX_MATH_INSTRUCTION = + "This client uses Obsidian Flavored Markdown. For math, use $...$ for inline and $$...$$ for display (not \\(...\\) or \\[...\\])."; +const WIKI_LINK_INSTRUCTION = + "When referencing notes in this vault, use [[Note Name]] wikilink syntax so they become clickable links."; +const TABLE_INSTRUCTION = + "Always leave a blank line before Markdown tables; without it Obsidian renders them as plain text."; // ============================================================================ // Shared Helper Functions @@ -196,7 +213,7 @@ async function processNote( originalLength: content.length, }; } catch (error) { - console.error(`Failed to read note ${file.path}:`, error); + getLogger().error(`Failed to read note ${file.path}:`, error); return null; } } @@ -232,7 +249,7 @@ async function readSelection( originalLength: fullText.length, }; } catch (error) { - console.error(`Failed to read selection from ${notePath}:`, error); + getLogger().error(`Failed to read selection from ${notePath}:`, error); return null; } } @@ -240,18 +257,78 @@ async function readSelection( /** * Build auto-mention prefix string for session/load recovery. * Format: "@[[note name]]:from-to\n" or "@[[note name]]\n" + * + * Returns empty string when the user message starts with "/" to avoid + * corrupting ACP slash-command recognition at the text-block level. + * Agents detect slash commands by matching "/" at character 0 of a + * text ContentBlock's text field; the prefix would push "/" off char 0. + * The resource-block channel (autoMentionBlocks) still carries the + * note context for slash-command turns — see the ACP spec at + * https://agentclientprotocol.com/protocol/slash-commands which allows + * resource blocks alongside slash-command text blocks. */ function buildAutoMentionPrefix( activeNote: NoteMetadata | null | undefined, isDisabled: boolean | undefined, + message: string, ): string { if (!activeNote || isDisabled) return ""; + if (message.startsWith("/")) return ""; if (activeNote.selection) { return `@[[${activeNote.name}]]:${activeNote.selection.from.line + 1}-${activeNote.selection.to.line + 1}\n`; } return `@[[${activeNote.name}]]\n`; } +/** + * Build system prompt instruction strings for Obsidian-flavored Markdown. + * Returns an array of instruction strings to inject. + * Empty array if not first message or no instructions enabled. + */ +function buildSystemInstructions(input: PreparePromptInput): string[] { + if (!input.isFirstMessage) return []; + if (!input.promptInjection) return []; + + const instructions: string[] = []; + + if (input.promptInjection.wikiLinks) { + instructions.push(WIKI_LINK_INSTRUCTION); + } + + if (input.promptInjection.tables) { + instructions.push(TABLE_INSTRUCTION); + } + + if (input.promptInjection.latex) { + instructions.push(LATEX_MATH_INSTRUCTION); + } + + return instructions; +} + +function buildAgentMessageText( + message: string, + autoMentionPrefix: string, + contextBlocks?: string[], +): string { + const userMessage = autoMentionPrefix + message; + + // Skip context blocks on slash-command turns to avoid colliding with + // the ACP command-detection rule (agents detect '/' at the start of + // a text ContentBlock). This is the fallback path for agents that + // don't support embeddedContext resource blocks; agents with + // embeddedContext receive note context through the separate resource + // ContentBlock path in preparePrompt, which stays spec-compliant + // alongside slash commands. + const isSlashCommand = message.startsWith("/"); + const includeContext = !isSlashCommand && contextBlocks && contextBlocks.length > 0; + + return [ + ...(includeContext ? [contextBlocks.join("\n")] : []), + ...(userMessage ? [userMessage] : []), + ].join("\n\n"); +} + /** * Build display content array (message + images + resource links). */ @@ -267,6 +344,14 @@ function buildDisplayContent(input: PreparePromptInput): PromptContent[] { /** * Build auto-mention context metadata for UI. + * + * Returns metadata describing the auto-mentioned note for display as a + * UI badge. The caller is responsible for deciding whether the badge + * should reflect what was actually sent: agents that support + * embeddedContext receive the auto-mention as a Resource block even on + * slash-command turns (badge applies), while the text-context fallback + * path drops the note context on slash commands (badge should be + * skipped at the call site). */ function buildAutoMentionContext( activeNote: NoteMetadata | null | undefined, @@ -380,7 +465,12 @@ async function preparePromptWithEmbeddedContext( }); } - // Build auto-mention Resource block + // Build auto-mention Resource block. Sent on slash-command turns too + // because Resource blocks are separate ContentBlocks — they don't + // interfere with ACP's "/" at char 0 detection on the user-message + // text block. Only buildAutoMentionPrefix needs the slash guard. + // See https://agentclientprotocol.com/protocol/slash-commands which + // allows resource blocks alongside slash-command text blocks. const autoMentionBlocks: PromptContent[] = []; if (input.activeNote && !input.isAutoMentionDisabled) { const autoMentionResource = await buildAutoMentionResource( @@ -396,9 +486,18 @@ async function preparePromptWithEmbeddedContext( const autoMentionPrefix = buildAutoMentionPrefix( input.activeNote, input.isAutoMentionDisabled, + input.message, ); + // Build system prompt instructions (first message only) + const systemInstructions = buildSystemInstructions(input); + const systemBlocks: PromptContent[] = systemInstructions.map((text) => ({ + type: "text" as const, + text, + })); + const agentContent: PromptContent[] = [ + ...systemBlocks, ...resourceBlocks, ...autoMentionBlocks, ...(input.message || autoMentionPrefix @@ -472,19 +571,25 @@ async function preparePromptWithTextContext( contextBlocks.push(autoMentionContextBlock); } + // Build system prompt instructions (first message only) + const systemInstructions = buildSystemInstructions(input); + for (const instruction of systemInstructions) { + contextBlocks.push( + `\n${instruction}\n`, + ); + } + const autoMentionPrefix = buildAutoMentionPrefix( input.activeNote, input.isAutoMentionDisabled, + input.message, ); - // Build agent message text (context blocks + auto-mention prefix + original message) - const agentMessageText = - contextBlocks.length > 0 - ? contextBlocks.join("\n") + - "\n\n" + - autoMentionPrefix + - input.message - : autoMentionPrefix + input.message; + const agentMessageText = buildAgentMessageText( + input.message, + autoMentionPrefix, + contextBlocks, + ); const agentContent: PromptContent[] = [ ...(agentMessageText @@ -497,10 +602,17 @@ async function preparePromptWithTextContext( return { displayContent: buildDisplayContent(input), agentContent, - autoMentionContext: buildAutoMentionContext( - input.activeNote, - input.isAutoMentionDisabled, - ), + // Skip the badge on slash-command turns because the text-context + // block was also dropped (see buildAgentMessageText). Keeping the + // badge state consistent with what was actually sent to the agent + // avoids a misleading "context attached" indicator on a turn that + // shipped no context. + autoMentionContext: input.message.startsWith("/") + ? undefined + : buildAutoMentionContext( + input.activeNote, + input.isAutoMentionDisabled, + ), }; } diff --git a/src/services/session-helpers.ts b/src/services/session-helpers.ts index 34ba459..f375df4 100644 --- a/src/services/session-helpers.ts +++ b/src/services/session-helpers.ts @@ -10,8 +10,10 @@ import type { GeminiAgentSettings, CodexAgentSettings, } from "../types/agent"; -import type { ChatSession } from "../types/session"; +import type { ChatSession, SavedSessionInfo } from "../types/session"; +import type { ChatMessage } from "../types/chat"; import { toAgentConfig } from "./settings-normalizer"; +import { truncateTitle } from "../utils/text"; import type { AgentUpdateNotification } from "./update-checker"; // ============================================================================ @@ -111,7 +113,13 @@ export function findAgentSettings( } /** - * Build AgentConfig with API key injection for known agents. + * Build AgentConfig with API key injection intent for known agents. + * + * For built-in agents, attaches an `apiKey` intent (secretId + envVarName) + * to the config. AcpClient.initialize() resolves the secret value from + * Obsidian's secret storage just before spawn. + * + * Custom agents pass through unchanged (they manage env vars directly). */ export function buildAgentConfigWithApiKey( settings: AgentClientPluginSettings, @@ -121,14 +129,13 @@ export function buildAgentConfigWithApiKey( ) { const baseConfig = toAgentConfig(agentSettings, workingDirectory); - // Add API keys to environment for Claude, Codex, and Gemini if (agentId === settings.claude.id) { const claudeSettings = agentSettings as ClaudeAgentSettings; return { ...baseConfig, - env: { - ...baseConfig.env, - ANTHROPIC_API_KEY: claudeSettings.apiKey, + apiKey: { + secretId: claudeSettings.apiKeySecretId, + envVarName: "ANTHROPIC_API_KEY", }, }; } @@ -136,9 +143,9 @@ export function buildAgentConfigWithApiKey( const codexSettings = agentSettings as CodexAgentSettings; return { ...baseConfig, - env: { - ...baseConfig.env, - OPENAI_API_KEY: codexSettings.apiKey, + apiKey: { + secretId: codexSettings.apiKeySecretId, + envVarName: "OPENAI_API_KEY", }, }; } @@ -146,14 +153,14 @@ export function buildAgentConfigWithApiKey( const geminiSettings = agentSettings as GeminiAgentSettings; return { ...baseConfig, - env: { - ...baseConfig.env, - GEMINI_API_KEY: geminiSettings.apiKey, + apiKey: { + secretId: geminiSettings.apiKeySecretId, + envVarName: "GEMINI_API_KEY", }, }; } - // Custom agents - no API key injection + // Custom agents — no API key injection return baseConfig; } @@ -177,13 +184,38 @@ export function createInitialSession( authMethods: [], availableCommands: undefined, modes: undefined, - models: undefined, createdAt: new Date(), lastActivityAt: new Date(), workingDirectory, }; } +// ============================================================================ +// Session Title Derivation +// ============================================================================ + +/** Derive the session display title (saved title > first user message > "New session"). */ +export function computeSessionTitle( + sessionId: string | null, + savedSessions: SavedSessionInfo[], + messages: ChatMessage[], +): string { + if (sessionId) { + const saved = savedSessions.find((s) => s.sessionId === sessionId); + if (saved?.title) return saved.title; + } + const firstUserMessage = messages.find((m) => m.role === "user"); + if (firstUserMessage) { + const textContent = firstUserMessage.content.find( + (c) => c.type === "text" || c.type === "text_with_context", + ); + if (textContent && "text" in textContent) { + return truncateTitle(textContent.text); + } + } + return "New session"; +} + // ============================================================================ // Gemini CLI Deprecation Notice // ============================================================================ diff --git a/src/services/session-state.ts b/src/services/session-state.ts index 81cdb44..5ad69c0 100644 --- a/src/services/session-state.ts +++ b/src/services/session-state.ts @@ -24,15 +24,10 @@ import type { AcpClient } from "../acp/acp-client"; */ export function applyLegacyValue( prev: ChatSession, - kind: "mode" | "model", value: string, ): ChatSession { - if (kind === "mode") { - if (!prev.modes) return prev; - return { ...prev, modes: { ...prev.modes, currentModeId: value } }; - } - if (!prev.models) return prev; - return { ...prev, models: { ...prev.models, currentModelId: value } }; + if (!prev.modes) return prev; + return { ...prev, modes: { ...prev.modes, currentModeId: value } }; } // ============================================================================ @@ -53,7 +48,7 @@ export async function tryRestoreConfigOption( if (!savedValue) return configOptions; const option = configOptions.find((o) => o.category === category); - if (!option) return configOptions; + if (!option || option.type !== "select") return configOptions; if (savedValue === option.currentValue) return configOptions; if ( !flattenConfigSelectOptions(option.options).some( @@ -73,58 +68,85 @@ export async function tryRestoreConfigOption( } } +/** + * Restore saved config option values by option id (non-legacy config options; + * model/mode use the category-based path above + lastUsedModels/lastUsedModes). + * Applies each saved value that still maps to a currently-available choice; + * unknown ids and stale/unavailable values are skipped. Mirrors the validation + * in tryRestoreConfigOption but keyed by id instead of category. + */ +export async function restoreSavedConfigOptions( + agentClient: AcpClient, + sessionId: string, + configOptions: SessionConfigOption[], + savedById: Record | undefined, +): Promise { + if (!savedById) return configOptions; + + let result = configOptions; + for (const [optionId, savedValue] of Object.entries(savedById)) { + const option = result.find((o) => o.id === optionId); + if (!option || option.type !== "select") continue; + if (savedValue === option.currentValue) continue; + if ( + !flattenConfigSelectOptions(option.options).some( + (o) => o.value === savedValue, + ) + ) + continue; + try { + result = await agentClient.setSessionConfigOption( + sessionId, + optionId, + savedValue, + ); + } catch { + // Keep current value on failure. + } + } + return result; +} + /** * Restore last used mode/model via legacy APIs. * Only called when configOptions is not available. + * + * Returns the final modes/models state after restoration (or the originals + * if no restoration was needed or if the agent-side calls failed). + * The caller is responsible for applying these to session state. + * This function has no side effects on React state so callers can sequence + * the restore BEFORE marking the session as "ready", avoiding a UI race + * where the dropdown briefly shows the agent's default mode/model before + * the user's saved selection is re-applied. */ export async function restoreLegacyConfig( agentClient: AcpClient, sessionResult: SessionResult, - savedModelId: string | undefined, savedModeId: string | undefined, - setSession: (updater: (prev: ChatSession) => ChatSession) => void, -): Promise { - if (!sessionResult.sessionId) return; +): Promise<{ + modes: SessionResult["modes"]; +}> { + let modes = sessionResult.modes; - // Legacy model restore - if (sessionResult.models && savedModelId) { - if ( - savedModelId !== sessionResult.models.currentModelId && - sessionResult.models.availableModels.some( - (m) => m.modelId === savedModelId, - ) - ) { - try { - await agentClient.setSessionModel( - sessionResult.sessionId, - savedModelId, - ); - setSession((prev) => - applyLegacyValue(prev, "model", savedModelId), - ); - } catch { - // Agent default is fine as fallback - } - } - } + if (!sessionResult.sessionId) return { modes }; // Legacy mode restore - if (sessionResult.modes && savedModeId) { + if (modes && savedModeId) { if ( - savedModeId !== sessionResult.modes.currentModeId && - sessionResult.modes.availableModes.some((m) => m.id === savedModeId) + savedModeId !== modes.currentModeId && + modes.availableModes.some((m) => m.id === savedModeId) ) { try { await agentClient.setSessionMode( sessionResult.sessionId, savedModeId, ); - setSession((prev) => - applyLegacyValue(prev, "mode", savedModeId), - ); + modes = { ...modes, currentModeId: savedModeId }; } catch { // Agent default is fine as fallback } } } + + return { modes }; } diff --git a/src/services/session-storage.ts b/src/services/session-storage.ts index 6729e6a..423e618 100644 --- a/src/services/session-storage.ts +++ b/src/services/session-storage.ts @@ -13,6 +13,7 @@ import type AgentClientPlugin from "../plugin"; import type { ChatMessage, MessageContent } from "../types/chat"; import type { SavedSessionInfo } from "../types/session"; import { convertWindowsPathToWsl } from "../utils/platform"; +import { getLogger } from "../utils/logger"; // ============================================================================ // Types @@ -154,6 +155,75 @@ export class SessionStorage { await this.sessionLock; } + /** + * Update the title of a saved session. + * If createIfMissing is provided and session doesn't exist, creates a new entry. + */ + async updateSessionTitle( + sessionId: string, + newTitle: string, + createIfMissing?: { agentId: string; cwd: string }, + ): Promise { + this.sessionLock = this.sessionLock.then(async () => { + const state = this.settingsAccess.getSnapshot(); + const sessions = [...(state.savedSessions || [])]; + const idx = sessions.findIndex((s) => s.sessionId === sessionId); + + if (idx >= 0) { + // Immutable update: replace the object instead of mutating it, + // matching saveSession's pattern and keeping state objects stable. + sessions[idx] = { + ...sessions[idx], + title: newTitle, + updatedAt: new Date().toISOString(), + }; + } else if (createIfMissing) { + sessions.unshift({ + sessionId, + agentId: createIfMissing.agentId, + cwd: createIfMissing.cwd, + title: newTitle, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } else { + return; + } + + await this.settingsAccess.updateSettings({ + savedSessions: sessions, + }); + }); + await this.sessionLock; + } + + /** + * Update fields of an existing saved session. + * Silently no-op if the session does not exist (no create). + * `updatedAt` is set to now unless explicitly provided in `patch`. + */ + async updateSession( + sessionId: string, + patch: Partial>, + ): Promise { + this.sessionLock = this.sessionLock.then(async () => { + const state = this.settingsAccess.getSnapshot(); + const sessions = [...(state.savedSessions || [])]; + const idx = sessions.findIndex((s) => s.sessionId === sessionId); + if (idx < 0) return; + + sessions[idx] = { + ...sessions[idx], + ...patch, + updatedAt: patch.updatedAt ?? new Date().toISOString(), + }; + await this.settingsAccess.updateSettings({ + savedSessions: sessions, + }); + }); + await this.sessionLock; + } + // ============================================================ // Session Message History Methods // ============================================================ @@ -227,14 +297,14 @@ export class SessionStorage { typeof data.version !== "number" || !Array.isArray(data.messages) ) { - console.warn( + getLogger().debug( `[SessionStorage] Invalid session file structure: ${filePath}`, ); return null; } if (data.version !== 1) { - console.warn( + getLogger().debug( `[SessionStorage] Unknown session file version: ${data.version}`, ); return null; @@ -245,7 +315,7 @@ export class SessionStorage { timestamp: new Date(msg.timestamp), })); } catch (error) { - console.error( + getLogger().error( `[SessionStorage] Failed to load session messages: ${error}`, ); return null; diff --git a/src/services/settings-normalizer.ts b/src/services/settings-normalizer.ts index eefded9..b452e5d 100644 --- a/src/services/settings-normalizer.ts +++ b/src/services/settings-normalizer.ts @@ -247,6 +247,26 @@ export function strRecord(raw: unknown): Record { return result; } +/** Normalize a nested string record, e.g. agentId → { optionId → value }. */ +export function nestedStrRecord( + raw: unknown, +): Record> { + const result: Record> = {}; + const o = obj(raw); + if (!o) return result; + for (const [key, value] of Object.entries(o)) { + if ( + typeof key === "string" && + key.length > 0 && + key !== "__proto__" && + key !== "constructor" + ) { + result[key] = strRecord(value); + } + } + return result; +} + /** Extract an {x, y} point, or return null if invalid */ export function xyPoint(raw: unknown): { x: number; y: number } | null { const o = obj(raw); diff --git a/src/services/settings-service.ts b/src/services/settings-service.ts index 9725baa..bd89e6c 100644 --- a/src/services/settings-service.ts +++ b/src/services/settings-service.ts @@ -8,6 +8,7 @@ import type { AgentClientPluginSettings } from "../plugin"; import type AgentClientPlugin from "../plugin"; +import { updateDebugMode } from "../utils/logger"; import type { ChatMessage } from "../types/chat"; import type { SavedSessionInfo } from "../types/session"; import { SessionStorage } from "./session-storage"; @@ -92,6 +93,25 @@ export interface ISettingsAccess { */ deleteSession(sessionId: string): Promise; + /** + * Update the title of a saved session. + * If createIfMissing is provided and session doesn't exist, creates a new entry. + */ + updateSessionTitle( + sessionId: string, + newTitle: string, + createIfMissing?: { agentId: string; cwd: string }, + ): Promise; + + /** + * Update fields of an existing saved session. + * Silently no-op if the session does not exist. + */ + updateSession( + sessionId: string, + patch: Partial>, + ): Promise; + // ============================================================ // Session Message History Methods // ============================================================ @@ -200,6 +220,9 @@ export class SettingsService implements ISettingsAccess { // Sync with plugin.settings (required for saveSettings to persist correctly) this.plugin.settings = next; + // Keep logger in sync with debug mode toggle + updateDebugMode(next.debugMode); + // Notify all subscribers for (const listener of this.listeners) { listener(); @@ -253,6 +276,25 @@ export class SettingsService implements ISettingsAccess { return this.sessionStorage.deleteSession(sessionId); } + async updateSessionTitle( + sessionId: string, + newTitle: string, + createIfMissing?: { agentId: string; cwd: string }, + ): Promise { + return this.sessionStorage.updateSessionTitle( + sessionId, + newTitle, + createIfMissing, + ); + } + + async updateSession( + sessionId: string, + patch: Partial>, + ): Promise { + return this.sessionStorage.updateSession(sessionId, patch); + } + async saveSessionMessages( sessionId: string, agentId: string, diff --git a/src/services/vault-service.ts b/src/services/vault-service.ts index 2000046..f7f1821 100644 --- a/src/services/vault-service.ts +++ b/src/services/vault-service.ts @@ -371,11 +371,7 @@ export class VaultService implements IVaultAccess { const { editor, file } = view; const filePath = file.path; - if ( - this.lastSelectionKey && - !this.lastSelectionKey.startsWith(`${filePath}:`) - ) { - // Clear previous file selection when switching files + if (!this.lastSelectionKey.startsWith(`${filePath}:`)) { this.handleSelectionChange(filePath, null); } @@ -418,7 +414,7 @@ export class VaultService implements IVaultAccess { // 1. Obsidian changes its internal implementation // 2. A future Obsidian version removes the 'cm' property // 3. The editor is in a different mode (e.g., legacy editor) - console.warn( + getLogger().debug( "[VaultService] CodeMirror 6 API not available. " + "Selection change tracking will not work. " + "This may be due to an Obsidian version change.", @@ -498,7 +494,7 @@ export class VaultService implements IVaultAccess { try { listener(); } catch (error) { - console.error("[VaultService] Selection listener error", error); + getLogger().error("[VaultService] Selection listener error", error); } } } diff --git a/src/services/view-registry.ts b/src/services/view-registry.ts index d266514..b182b7d 100644 --- a/src/services/view-registry.ts +++ b/src/services/view-registry.ts @@ -28,6 +28,28 @@ import { getLogger } from "../utils/logger"; */ export type ChatViewType = "sidebar" | "floating"; +/** + * Simplified session status for display in session lists. + * Derived from session state, permission state, and sending state. + */ +export type SessionStatus = + | "ready" + | "busy" + | "permission" + | "error" + | "disconnected"; + +/** + * Reactive snapshot of the view registry for `useSyncExternalStore`. + * Includes both the view list and the focused view ID so that consumers + * (e.g. SessionManagerView) can derive UI state from a single source — + * mirrors SettingsService's whole-state snapshot pattern. + */ +export interface ViewRegistrySnapshot { + views: IChatViewContainer[]; + focusedId: string | null; +} + /** * Interface that all chat view containers must implement. * Enables the plugin to manage views uniformly regardless of their implementation. @@ -136,6 +158,42 @@ export interface IChatViewContainer { */ cancelOperation(): Promise; + // ============================================================ + // Session Info + // ============================================================ + + /** + * Get the current session status for display in session lists. + * Combines session state and permission status into a simplified status. + */ + getSessionStatus(): SessionStatus; + + /** + * Get the session title for display in session lists. + * Returns "New session" before the first message, then the first user message (truncated). + */ + getSessionTitle(): string; + + /** + * Get the current ACP session ID. + * Returns null if no session has been created yet. + */ + getSessionId(): string | null; + + /** + * Close this view permanently. + * - Sidebar (`ChatView`): detaches the workspace leaf + * - Floating (`FloatingViewContainer`): unmounts the React root + * Implementations are responsible for triggering proper cleanup + * (onClose / unmount), which transitively unregisters from the registry. + * + * NOTE: Named `closeContainer` (not `close`) to avoid colliding with + * Obsidian's internal `View.close()` — invoked by `leaf.detach()` during + * cleanup. Overriding it caused infinite recursion in ChatView, since + * our impl calls `leaf.detach()` which calls `view.close()` again. + */ + closeContainer(): void; + // ============================================================ // Container Access // ============================================================ @@ -151,6 +209,8 @@ export class ChatViewRegistry { private views = new Map(); private focusedViewId: string | null = null; private logger = getLogger(); + private changeListeners = new Set<() => void>(); + private snapshotCache: ViewRegistrySnapshot | null = null; // ============================================================ // Registration @@ -166,9 +226,13 @@ export class ChatViewRegistry { ); this.views.set(view.viewId, view); - // First view becomes focused by default + // First view becomes focused by default. setFocused() already calls + // notifyChange(), so only emit an explicit notify on subsequent + // registrations to keep this method's contract at "exactly one notify". if (this.views.size === 1) { this.setFocused(view.viewId); + } else { + this.notifyChange(); } } @@ -192,6 +256,7 @@ export class ChatViewRegistry { this.views.get(this.focusedViewId)?.onActivate(); } } + this.notifyChange(); } /** @@ -206,6 +271,8 @@ export class ChatViewRegistry { } this.views.clear(); this.focusedViewId = null; + this.changeListeners.clear(); + this.snapshotCache = null; } // ============================================================ @@ -244,6 +311,7 @@ export class ChatViewRegistry { this.focusedViewId = viewId; this.views.get(viewId)?.onActivate(); this.logger.log(`[ChatViewRegistry] Focus changed to: ${viewId}`); + this.notifyChange(); } /** @@ -342,4 +410,44 @@ export class ChatViewRegistry { get size(): number { return this.views.size; } + + // ============================================================ + // Change Notification (for Session Manager) + // ============================================================ + + /** + * Subscribe to registry changes (view register/unregister, focus change, state change). + * Pattern: same as SettingsService.subscribe (for useSyncExternalStore). + */ + subscribe = (listener: () => void): (() => void) => { + this.changeListeners.add(listener); + return () => this.changeListeners.delete(listener); + }; + + /** + * Notify all subscribers that something changed. + * Called from: register, unregister, setFocused, and externally by ChatPanel on state change. + */ + notifyChange(): void { + this.snapshotCache = null; + for (const listener of this.changeListeners) { + listener(); + } + } + + /** + * Get a stable snapshot of the registry for useSyncExternalStore. + * Returns the same object reference until notifyChange() invalidates it. + * Includes both views and focusedId so consumers derive UI state from + * a single source (mirrors SettingsService's whole-state snapshot pattern). + */ + getSnapshot = (): ViewRegistrySnapshot => { + if (!this.snapshotCache) { + this.snapshotCache = { + views: Array.from(this.views.values()), + focusedId: this.focusedViewId, + }; + } + return this.snapshotCache; + }; } diff --git a/src/types/agent.ts b/src/types/agent.ts index 818b6f4..3064deb 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -57,30 +57,36 @@ export interface BaseAgentSettings { * Configuration for Gemini CLI agent. * * Extends base settings with Gemini-specific requirements. + * The API key (GEMINI_API_KEY) is stored in Obsidian's secret storage + * and referenced by ID. Empty string means no API key is configured. */ export interface GeminiAgentSettings extends BaseAgentSettings { - /** Gemini API key (GEMINI_API_KEY) */ - apiKey: string; + /** Secret storage ID containing the Gemini API key (GEMINI_API_KEY) */ + apiKeySecretId: string; } /** * Configuration for Claude Code agent. * * Extends base settings with Claude-specific requirements. + * The API key (ANTHROPIC_API_KEY) is stored in Obsidian's secret storage + * and referenced by ID. Empty string means no API key is configured. */ export interface ClaudeAgentSettings extends BaseAgentSettings { - /** Anthropic API key for Claude (ANTHROPIC_API_KEY) */ - apiKey: string; + /** Secret storage ID containing the Anthropic API key (ANTHROPIC_API_KEY) */ + apiKeySecretId: string; } /** * Configuration for Codex CLI agent. * * Extends base settings with Codex-specific requirements. + * The API key (OPENAI_API_KEY) is stored in Obsidian's secret storage + * and referenced by ID. Empty string means no API key is configured. */ export interface CodexAgentSettings extends BaseAgentSettings { - /** OpenAI API key for Codex (OPENAI_API_KEY) */ - apiKey: string; + /** Secret storage ID containing the OpenAI API key (OPENAI_API_KEY) */ + apiKeySecretId: string; } /** diff --git a/src/types/session.ts b/src/types/session.ts index 4b572af..7288632 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -122,44 +122,6 @@ export interface SessionModeState { currentModeId: string; } -// ============================================================================ -// Model (Experimental) -// ============================================================================ - -/** - * Represents an AI model available in a session. - * - * Models determine which AI model is used for responses. - * This is an experimental feature and may change. - */ -/** DEPRECATED: Use SessionConfigOption instead. Kept for backward compatibility. */ -export interface SessionModel { - /** Unique identifier for this model (e.g., "claude-sonnet-4") */ - modelId: string; - - /** Human-readable name for display */ - name: string; - - /** Optional description of this model */ - description?: string; -} - -/** - * State of available models in a session. - * - * Contains both the list of available models and the currently active model. - * Updated via NewSessionResponse initially. - * Note: Unlike modes, there is no dedicated notification for model changes. - */ -/** DEPRECATED: Use SessionConfigOption instead. Kept for backward compatibility. */ -export interface SessionModelState { - /** List of models available in this session */ - availableModels: SessionModel[]; - - /** ID of the currently active model */ - currentModelId: string; -} - /** * Context window usage and cost information for a session. * Reported by the agent via `usage_update` session notifications. @@ -218,12 +180,6 @@ export interface ChatSession { */ modes?: SessionModeState; - /** - * DEPRECATED: Use configOptions instead. Kept for backward compatibility - * with agents that don't support configOptions. - */ - models?: SessionModelState; - /** * Session configuration options (mode, model, thought_level, etc.). * Supersedes legacy modes/models fields. @@ -454,20 +410,31 @@ export interface ProcessErrorUpdate extends SessionUpdateBase { // Config Option Types // ============================================================================ -/** - * A session configuration option (e.g. mode, model, thought_level). - * Part of the ACP configOptions API that supersedes legacy modes/models. - */ -export interface SessionConfigOption { +interface SessionConfigOptionBase { id: string; name: string; description?: string | null; category?: string | null; - type: "select"; - currentValue: string; - options: SessionConfigSelectOption[] | SessionConfigSelectGroup[]; } +/** + * A session configuration option (e.g. mode, model, thought_level). + * Part of the ACP configOptions API that supersedes legacy modes/models. + * + * `select` carries a string value + choices. `boolean` (ACP 0.28+) is held as + * data for future support — it is not yet rendered or settable in the UI. + */ +export type SessionConfigOption = + | (SessionConfigOptionBase & { + type: "select"; + currentValue: string; + options: SessionConfigSelectOption[] | SessionConfigSelectGroup[]; + }) + | (SessionConfigOptionBase & { + type: "boolean"; + currentValue: boolean; + }); + export interface SessionConfigSelectOption { value: string; name: string; @@ -571,9 +538,6 @@ export interface SessionResult { /** DEPRECATED: Use configOptions instead. Kept for backward compatibility. */ modes?: SessionModeState; - /** DEPRECATED: Use configOptions instead. Kept for backward compatibility. */ - models?: SessionModelState; - /** Session config options (supersedes modes/models) */ configOptions?: SessionConfigOption[]; } diff --git a/src/ui/ChatPanel.tsx b/src/ui/ChatPanel.tsx index e75c2b8..00f1dd1 100644 --- a/src/ui/ChatPanel.tsx +++ b/src/ui/ChatPanel.tsx @@ -9,11 +9,13 @@ import { type MenuItem, } from "obsidian"; -import type { AttachedFile, ChatInputState } from "../types/chat"; +import type { AttachedFile, ChatInputState, ChatMessage } from "../types/chat"; import { isSameDirectory } from "../utils/platform"; +import { computeSessionTitle } from "../services/session-helpers"; import { useHistoryModal } from "../hooks/useHistoryModal"; import { useChatActions } from "../hooks/useChatActions"; import { ChangeDirectoryModal } from "./ChangeDirectoryModal"; +import { addRenameSessionMenuItem } from "./EditTitleModal"; // Service imports import { getLogger } from "../utils/logger"; @@ -35,10 +37,10 @@ import { flattenConfigSelectOptions, type SlashCommand, type SessionModeState, - type SessionModelState, type SessionConfigOption, } from "../types/session"; import { checkAgentUpdate } from "../services/update-checker"; +import type { SessionStatus } from "../services/view-registry"; import { buildGeminiDeprecationNotice } from "../services/session-helpers"; /** Stable empty array for useSuggestions when no commands available */ @@ -61,6 +63,9 @@ import type { IChatViewHost } from "./view-host"; */ export interface ChatPanelCallbacks { getDisplayName: () => string; + getSessionStatus: () => SessionStatus; + getSessionTitle: () => string; + getSessionId: () => string | null; getInputState: () => ChatInputState | null; setInputState: (state: ChatInputState) => void; canSend: () => boolean; @@ -81,6 +86,11 @@ export interface ChatPanelProps { onRegisterCallbacks?: (callbacks: ChatPanelCallbacks) => void; /** Called when agent ID changes (sidebar only — persists in Obsidian state) */ onAgentIdChanged?: (agentId: string) => void; + /** + * Called when the derived session title may have changed (sidebar only — + * triggers Obsidian to re-read getDisplayText() and update the tab header). + */ + onSessionTitleChanged?: () => void; // Floating-specific onMinimize?: () => void; onClose?: () => void; @@ -109,6 +119,9 @@ interface AppWithSettings { // ChatPanel Component // ============================================================================ +/** Debounce (ms) for re-saving when trailing chunks arrive after a turn ends (#320). */ +const TRAILING_SAVE_DEBOUNCE_MS = 800; + /** * Core chat panel component that encapsulates all chat logic. * @@ -125,6 +138,7 @@ export function ChatPanel({ config, onRegisterCallbacks, onAgentIdChanged, + onSessionTitleChanged, onMinimize, onClose, onOpenNewWindow, @@ -190,6 +204,7 @@ export function ChatPanel({ vaultService, plugin, session.availableCommands || EMPTY_COMMANDS, + settings.autoMentionActiveNote, ); // Session history hook with callback for session load @@ -197,23 +212,16 @@ export function ChatPanel({ ( sessionId: string, modes?: SessionModeState, - models?: SessionModelState, configOptions?: SessionConfigOption[], ) => { logger.log( `[ChatPanel] Session loaded/resumed/forked: ${sessionId}`, { modes, - models, configOptions, }, ); - void agent.updateSessionFromLoad( - sessionId, - modes, - models, - configOptions, - ); + void agent.updateSessionFromLoad(sessionId, modes, configOptions); }, [logger, agent.updateSessionFromLoad], ); @@ -296,7 +304,6 @@ export function ChatPanel({ handleSwitchAgent, handleRestartAgent, handleSetMode, - handleSetModel, handleSetConfigOption, handleClearError, handleClearAgentUpdate, @@ -423,6 +430,16 @@ export function ChatPanel({ menu.addSeparator(); // -- Actions section -- + addRenameSessionMenuItem( + menu, + plugin, + session.sessionId, + plugin.settingsService + .getSavedSessions() + .find((s) => s.sessionId === session.sessionId)?.title ?? + "New session", + ); + menu.addItem((item: MenuItem) => { item.setTitle("Open new view") .setIcon("copy-plus") @@ -456,6 +473,14 @@ export function ChatPanel({ }); }); + menu.addItem((item: MenuItem) => { + item.setTitle("Open session manager") + .setIcon("layout-list") + .onClick(() => { + void plugin.activateSessionManager(); + }); + }); + menu.addSeparator(); menu.addItem((item: MenuItem) => { @@ -510,6 +535,16 @@ export function ChatPanel({ menu.addSeparator(); + addRenameSessionMenuItem( + menu, + plugin, + session.sessionId, + plugin.settingsService + .getSavedSessions() + .find((s) => s.sessionId === session.sessionId)?.title ?? + "New session", + ); + if (onOpenNewWindow) { menu.addItem((item: MenuItem) => { item.setTitle("Open new floating chat") @@ -543,6 +578,14 @@ export function ChatPanel({ }); }); + menu.addItem((item: MenuItem) => { + item.setTitle("Open session manager") + .setIcon("layout-list") + .onClick(() => { + void plugin.activateSessionManager(); + }); + }); + menu.addSeparator(); menu.addItem((item: MenuItem) => { @@ -587,14 +630,14 @@ export function ChatPanel({ // Floating: create a shim with listener tracking return { app: plugin.app, - registerDomEvent: (( + registerDomEvent: ( target: Window | Document | HTMLElement, type: string, callback: EventListenerOrEventListenerObject, ) => { target.addEventListener(type, callback); registeredListenersRef.current.push({ target, type, callback }); - }), + }, }; }, [viewHostProp, plugin.app]); @@ -621,16 +664,20 @@ export function ChatPanel({ void agent.createSession(config?.agent || initialAgentId); }, [agent.createSession, config?.agent, initialAgentId]); - // Apply configured model when session is ready + // Apply configured model (a select config option with category "model") + // when session is ready. useEffect(() => { if (!config?.model || !isSessionReady) return; - // Prefer configOptions if available if (session.configOptions) { const modelOption = session.configOptions.find( (o) => o.category === "model", ); - if (modelOption && modelOption.currentValue !== config.model) { + if ( + modelOption && + modelOption.type === "select" && + modelOption.currentValue !== config.model + ) { const valueExists = flattenConfigSelectOptions( modelOption.options, ).some((o) => o.value === config.model); @@ -642,29 +689,12 @@ export function ChatPanel({ void agent.setConfigOption(modelOption.id, config.model); } } - return; - } - - // Fallback to legacy models - if (session.models) { - const modelExists = session.models.availableModels.some( - (m) => m.modelId === config.model, - ); - if (modelExists && session.models.currentModelId !== config.model) { - logger.log( - "[ChatPanel] Applying configured model:", - config.model, - ); - void agent.setModel(config.model); - } } }, [ config?.model, isSessionReady, session.configOptions, - session.models, agent.setConfigOption, - agent.setModel, logger, ]); @@ -673,19 +703,43 @@ export function ChatPanel({ const sessionRef = useRef(session); const autoExportRef = useRef(autoExportIfEnabled); const closeSessionRef = useRef(agent.closeSession); + const saveSessionMessagesRef = useRef(sessionHistory.saveSessionMessages); + // True once the user has actually run a turn in THIS session. The + // trailing-chunk re-save and close-time flush are armed only after a real + // turn, so messages that were merely loaded/replayed are never re-saved + // (which would bump updatedAt and corrupt "last used" ordering). (#320 review) + const sentThisSessionRef = useRef(false); + // Reference identity of the messages array last persisted to disk. Used to + // de-duplicate the turn-end save, the trailing-chunk re-save, and the + // close-time flush. + const lastSavedMessagesRef = useRef(null); messagesRef.current = messages; sessionRef.current = session; autoExportRef.current = autoExportIfEnabled; closeSessionRef.current = agent.closeSession; + saveSessionMessagesRef.current = sessionHistory.saveSessionMessages; // Cleanup on unmount only - auto-export and close session useEffect(() => { return () => { logger.log("[ChatPanel] Cleanup: auto-export and close session"); + // Flush trailing-chunk content the debounced save may not have + // persisted yet (view closed within the debounce window). Only when a + // real turn ran this session and there is unsaved content. (#320 review) + const latest = messagesRef.current; + const sid = sessionRef.current.sessionId; + if ( + sentThisSessionRef.current && + sid && + latest.length > 0 && + lastSavedMessagesRef.current !== latest + ) { + saveSessionMessagesRef.current(sid, latest); + } void (async () => { await autoExportRef.current( "closeChat", - messagesRef.current, + latest, sessionRef.current, ); await closeSessionRef.current(); @@ -727,6 +781,13 @@ export function ChatPanel({ // ============================================================ const prevIsSendingRef = useRef(false); + // Re-loading/switching sessions disarms the trailing-chunk save & flush, so + // freshly loaded/replayed messages are never re-saved (which would bump + // updatedAt and corrupt "last used" ordering). (#320 review) + useEffect(() => { + sentThisSessionRef.current = false; + }, [session.sessionId]); + useEffect(() => { const wasSending = prevIsSendingRef.current; prevIsSendingRef.current = isSending; @@ -738,13 +799,18 @@ export function ChatPanel({ session.sessionId && messages.length > 0 ) { + sentThisSessionRef.current = true; + lastSavedMessagesRef.current = messages; sessionHistory.saveSessionMessages(session.sessionId, messages); logger.log( `[ChatPanel] Session messages saved: ${session.sessionId}`, ); // System notification on response completion - if (settings.enableSystemNotifications && !activeDocument.hasFocus()) { + if ( + settings.enableSystemNotifications && + !activeDocument.hasFocus() + ) { new Notification("Agent Client", { body: `${activeAgentLabel} has completed the response.`, }); @@ -760,6 +826,64 @@ export function ChatPanel({ logger, ]); + // Some agents (e.g. OpenCode) emit trailing message chunks *after* end_turn, + // so the turn-end save above runs before they arrive and the persisted copy + // is truncated. Re-save when messages change while idle, debounced so rapid + // trailing updates coalesce into a single write (avoids racing the file). (#320) + useEffect(() => { + const sessionId = session.sessionId; + if (isSending || !sessionId || messages.length === 0) return; + if (!sentThisSessionRef.current) return; + if (lastSavedMessagesRef.current === messages) return; + const timer = window.setTimeout(() => { + lastSavedMessagesRef.current = messages; + sessionHistory.saveSessionMessages(sessionId, messages); + }, TRAILING_SAVE_DEBOUNCE_MS); + return () => window.clearTimeout(timer); + }, [ + isSending, + session.sessionId, + messages, + sessionHistory.saveSessionMessages, + ]); + + // ============================================================ + // Effects - Notify ViewRegistry of State Changes + // ============================================================ + // `hasMessages` flips false → true on first message and then stays stable + // for the rest of the conversation. The Session Manager's title and + // status only depend on this boolean transition, not on per-chunk growth, + // so we avoid notifying on every streamed token. + const hasMessages = messages.length > 0; + useEffect(() => { + plugin.viewRegistry.notifyChange(); + }, [ + plugin.viewRegistry, + session.state, + session.sessionId, + isSending, + agent.hasActivePermission, + sessionHistory.loading, + hasMessages, + ]); + + // ============================================================ + // Effects - Notify Sidebar Container of Session Title Changes + // ============================================================ + const sessionTitle = useMemo( + () => + computeSessionTitle( + session.sessionId, + settings.savedSessions ?? [], + messages, + ), + [session.sessionId, settings.savedSessions, messages], + ); + // Fires on initial mount + every sessionTitle change so the tab reflects the current title. + useEffect(() => { + onSessionTitleChanged?.(); + }, [onSessionTitleChanged, sessionTitle]); + // ============================================================ // Effects - System Notification on Permission Request // ============================================================ @@ -951,18 +1075,41 @@ export function ChatPanel({ const attachedFilesRef = useRef(attachedFiles); const isSessionReadyRef = useRef(isSessionReady); const isSendingRef = useRef(isSending); + const sessionStateRef = useRef(session.state); + const sessionIdRef = useRef(session.sessionId); + const hasActivePermissionRef = useRef(agent.hasActivePermission); const sessionHistoryLoadingRef = useRef(sessionHistory.loading); const handleSendMessageRef = useRef(handleSendMessage); inputValueRef.current = inputValue; attachedFilesRef.current = attachedFiles; isSessionReadyRef.current = isSessionReady; isSendingRef.current = isSending; + sessionStateRef.current = session.state; + sessionIdRef.current = session.sessionId; + hasActivePermissionRef.current = agent.hasActivePermission; sessionHistoryLoadingRef.current = sessionHistory.loading; handleSendMessageRef.current = handleSendMessage; useEffect(() => { onRegisterCallbacks?.({ getDisplayName: () => activeAgentLabel, + getSessionStatus: () => { + const state = sessionStateRef.current; + if (state === "error") return "error"; + if (state === "disconnected") return "disconnected"; + if (hasActivePermissionRef.current) return "permission"; + if (isSendingRef.current || sessionHistoryLoadingRef.current) + return "busy"; + if (state === "ready") return "ready"; + return "busy"; + }, + getSessionTitle: () => + computeSessionTitle( + sessionIdRef.current, + plugin.settingsService.getSnapshot().savedSessions ?? [], + messagesRef.current, + ), + getSessionId: () => sessionIdRef.current, getInputState: () => ({ text: inputValueRef.current, files: attachedFilesRef.current, @@ -1097,8 +1244,6 @@ export function ChatPanel({ onRestoredMessageConsumed={handleRestoredMessageConsumed} modes={session.modes} onModeChange={(modeId) => void handleSetMode(modeId)} - models={session.models} - onModelChange={(modelId) => void handleSetModel(modelId)} configOptions={session.configOptions} onConfigOptionChange={(configId, value) => void handleSetConfigOption(configId, value) diff --git a/src/ui/ChatView.tsx b/src/ui/ChatView.tsx index 9200bf2..e9f7b07 100644 --- a/src/ui/ChatView.tsx +++ b/src/ui/ChatView.tsx @@ -2,9 +2,10 @@ import { ItemView, WorkspaceLeaf } from "obsidian"; import type { IChatViewContainer, ChatViewType, + SessionStatus, } from "../services/view-registry"; import * as React from "react"; -const { useState, useEffect, useMemo } = React; +const { useState, useEffect, useMemo, useCallback } = React; import { createRoot, Root } from "react-dom/client"; import type AgentClientPlugin from "../plugin"; @@ -64,6 +65,12 @@ function ChatComponent({ return unsubscribe; }, [view]); + // Stable so ChatPanel's title-update effect deps are value-stable. + const handleSessionTitleChanged = useCallback( + () => view.refreshDisplayText(), + [view], + ); + // ============================================================ // Render // ============================================================ @@ -78,6 +85,7 @@ function ChatComponent({ view.setCallbacks(callbacks) } onAgentIdChanged={(agentId) => view.setAgentId(agentId)} + onSessionTitleChanged={handleSessionTitleChanged} /> ); @@ -126,7 +134,8 @@ export class ChatView extends ItemView implements IChatViewContainer { } getDisplayText() { - return "Agent client"; + // Tab title == Session Manager title; fallback lives in getSessionTitle(). + return this.getSessionTitle(); } getIcon() { @@ -207,6 +216,28 @@ export class ChatView extends ItemView implements IChatViewContainer { return this.callbacks?.getDisplayName() ?? "Chat"; } + getSessionStatus(): SessionStatus { + return this.callbacks?.getSessionStatus() ?? "disconnected"; + } + + getSessionTitle(): string { + return this.callbacks?.getSessionTitle() ?? "New session"; + } + + getSessionId(): string | null { + return this.callbacks?.getSessionId() ?? null; + } + + closeContainer(): void { + this.leaf.detach(); + } + + refreshDisplayText(): void { + // Undocumented WorkspaceLeaf.updateHeader() — Obsidian core uses the same internal method to refresh tab headers. + const leaf = this.leaf as unknown as { updateHeader?: () => void }; + leaf.updateHeader?.(); + } + /** * Get current input state (text + images). * Returns null if React component not mounted. diff --git a/src/ui/EditTitleModal.ts b/src/ui/EditTitleModal.ts new file mode 100644 index 0000000..9fc9727 --- /dev/null +++ b/src/ui/EditTitleModal.ts @@ -0,0 +1,145 @@ +/** + * Modal for editing a session title. + * + * Displays a text input pre-filled with the current title. + * Calls onSave callback with the new title when user clicks Save. + */ + +import { Modal, App, Menu } from "obsidian"; +import type AgentClientPlugin from "../plugin"; + +export class EditTitleModal extends Modal { + private currentTitle: string; + private onSave: (newTitle: string) => void | Promise; + + constructor( + app: App, + currentTitle: string, + onSave: (newTitle: string) => void | Promise, + ) { + super(app); + this.currentTitle = currentTitle; + this.onSave = onSave; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl("h2", { text: "Edit session title" }); + + const inputEl = contentEl.createEl("input", { + type: "text", + cls: "agent-client-edit-title-input", + attr: { maxlength: "100" }, + }); + // createEl sets HTML attribute; explicit assignment sets DOM property (displayed value) + inputEl.value = this.currentTitle; + + // Focus and select all text for easy replacement + window.setTimeout(() => { + inputEl.focus(); + inputEl.select(); + }, 10); + + // Enter key to save + inputEl.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + this.saveAndClose(inputEl.value); + } + }); + + const buttonContainer = contentEl.createDiv({ + cls: "agent-client-edit-title-buttons", + }); + + buttonContainer + .createEl("button", { text: "Cancel" }) + .addEventListener("click", () => { + this.close(); + }); + + buttonContainer + .createEl("button", { + text: "Save", + cls: "mod-cta", + }) + .addEventListener("click", () => { + this.saveAndClose(inputEl.value); + }); + } + + private saveAndClose(rawValue: string) { + const value = rawValue.trim(); + if (!value) return; + this.close(); + void this.onSave(value); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} + +/** + * Open the EditTitleModal for renaming a saved session. + * Used by ChatPanel's More menu and SessionManagerView's item context menu. + */ +export function openRenameSessionModal( + plugin: AgentClientPlugin, + sessionId: string, + currentTitle: string, +): void { + const modal = new EditTitleModal( + plugin.app, + currentTitle, + async (newTitle) => { + await plugin.settingsService.updateSessionTitle( + sessionId, + newTitle, + ); + }, + ); + modal.open(); +} + +/** + * Add a "Rename session" menu item that opens the rename modal. + * Centralizes the disabled/enabled label so all call sites stay in sync. + * + * @param menu - The menu to which the item is added. + * @param plugin - The plugin instance. + * @param sessionId - The session ID to rename (null when not yet saved). + * @param currentTitle - The current title (used as the modal's initial value). + * @param options.label - Override the menu item label (default: "Rename session"). + */ +export function addRenameSessionMenuItem( + menu: Menu, + plugin: AgentClientPlugin, + sessionId: string | null, + currentTitle: string, + options?: { label?: string }, +): void { + const baseLabel = options?.label ?? "Rename session"; + const hasSavedSession = sessionId + ? plugin.settingsService + .getSavedSessions() + .some((s) => s.sessionId === sessionId) + : false; + + menu.addItem((item) => { + item.setTitle( + hasSavedSession + ? baseLabel + : `${baseLabel} (send a message first)`, + ) + .setIcon("pencil") + .setDisabled(!hasSavedSession) + .onClick(() => { + if (!sessionId || !hasSavedSession) return; + openRenameSessionModal(plugin, sessionId, currentTitle); + }); + }); +} diff --git a/src/ui/FloatingChatView.tsx b/src/ui/FloatingChatView.tsx index 17901c7..457a592 100644 --- a/src/ui/FloatingChatView.tsx +++ b/src/ui/FloatingChatView.tsx @@ -6,6 +6,7 @@ import type AgentClientPlugin from "../plugin"; import type { IChatViewContainer, ChatViewType, + SessionStatus, } from "../services/view-registry"; import type { ChatInputState } from "../types/chat"; @@ -142,6 +143,22 @@ export class FloatingViewContainer implements IChatViewContainer { return this.callbacks?.getDisplayName() ?? "Chat"; } + getSessionStatus(): SessionStatus { + return this.callbacks?.getSessionStatus() ?? "disconnected"; + } + + getSessionTitle(): string { + return this.callbacks?.getSessionTitle() ?? "New session"; + } + + getSessionId(): string | null { + return this.callbacks?.getSessionId() ?? null; + } + + closeContainer(): void { + this.unmount(); + } + onActivate(): void { this.containerEl.classList.add("is-focused"); } diff --git a/src/ui/InputArea.tsx b/src/ui/InputArea.tsx index 4429d28..1e8717f 100644 --- a/src/ui/InputArea.tsx +++ b/src/ui/InputArea.tsx @@ -8,7 +8,6 @@ import type { NoteMetadata } from "../services/vault-service"; import type { SlashCommand, SessionModeState, - SessionModelState, SessionUsage, SessionConfigOption, } from "../types/session"; @@ -212,10 +211,6 @@ export interface InputAreaProps { modes?: SessionModeState; /** Callback when mode is changed */ onModeChange?: (modeId: string) => void; - /** Session model state (available models and current model) - experimental */ - models?: SessionModelState; - /** Callback when model is changed */ - onModelChange?: (modelId: string) => void; /** Session config options (supersedes modes/models when present) */ configOptions?: SessionConfigOption[]; /** Callback when a config option is changed */ @@ -279,8 +274,6 @@ export function InputArea({ onRestoredMessageConsumed, modes, onModeChange, - models, - onModelChange, configOptions, onConfigOptionChange, usage, @@ -310,7 +303,8 @@ export function InputArea({ // Unofficial Obsidian API (see src/types/obsidian-internals.d.ts) const obsidianSpellcheck = - (plugin.app.vault.getConfig("spellcheck") as boolean | undefined) ?? true; + (plugin.app.vault.getConfig("spellcheck") as boolean | undefined) ?? + true; // Local state (hint and command are still local - not needed for broadcast) const [hintText, setHintText] = useState(null); @@ -362,6 +356,7 @@ export function InputArea({ const removeFile = useCallback( (id: string) => { onAttachedFilesChange(attachedFiles.filter((f) => f.id !== id)); + textareaRef.current?.focus(); }, [attachedFiles, onAttachedFilesChange], ); @@ -406,7 +401,7 @@ export function InputArea({ mimeType: file.type, }); } catch (error) { - console.error("Failed to convert image:", error); + getLogger().error("Failed to convert image:", error); new Notice("[Agent Client] Failed to attach image"); } } @@ -1011,7 +1006,7 @@ export function InputArea({ onDrop={(e) => void handleDrop(e)} > {/* Auto-mention Badge */} - {autoMentionEnabled && mentions.activeNote && ( + {mentions.activeNote && (