Merge branch 'dev' into experimental/wsl-launch-env-overhaul
2
LICENSE
|
|
@ -187,7 +187,7 @@
|
|||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2025 RAIT-09
|
||||
Copyright 2025-2026 RAIT-09
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
|
|
|||
2
NOTICE
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
obsidian-agent-client
|
||||
Copyright 2025-2026 RAIT-09
|
||||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
docs/public/images/prompt-injection-settings.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/public/images/session-manager-context-menu.webp
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
docs/public/images/session-manager-view.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
docs/public/images/status-busy.webp
Normal file
|
After Width: | Height: | Size: 280 B |
BIN
docs/public/images/status-error.webp
Normal file
|
After Width: | Height: | Size: 434 B |
BIN
docs/public/images/status-permission.webp
Normal file
|
After Width: | Height: | Size: 358 B |
BIN
docs/public/images/status-ready.webp
Normal file
|
After Width: | Height: | Size: 286 B |
63
docs/usage/prompt-injection.md
Normal file
|
|
@ -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**:
|
||||
|
||||
<p align="center">
|
||||
<img src="/images/prompt-injection-settings.webp" alt="Prompt injection settings" width="600" />
|
||||
</p>
|
||||
|
||||
| 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
|
||||
77
docs/usage/session-manager.md
Normal file
|
|
@ -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.
|
||||
|
||||
<p align="center">
|
||||
<img src="/images/session-manager-view.webp" alt="Session Manager view in the left sidebar" width="400" />
|
||||
</p>
|
||||
|
||||
## 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 |
|
||||
|------|--------|---------|
|
||||
| <img src="/images/status-ready.webp" alt="Ready" width="32" /> | **Ready** | The session is connected and idle, waiting for your next message |
|
||||
| <img src="/images/status-busy.webp" alt="Busy" width="32" /> | **Busy** | The agent is processing or generating a response |
|
||||
| <img src="/images/status-permission.webp" alt="Permission" width="32" /> | **Permission** | The agent is waiting for you to approve or reject an action |
|
||||
| <img src="/images/status-error.webp" alt="Error" width="32" /> | **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:
|
||||
|
||||
<p align="center">
|
||||
<img src="/images/session-manager-context-menu.webp" alt="Session entry context menu showing Rename and Close" width="400" />
|
||||
</p>
|
||||
|
||||
| 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
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"id": "agent-client",
|
||||
"name": "Agent Client",
|
||||
"version": "0.10.6",
|
||||
"minAppVersion": "1.7.2",
|
||||
"version": "0.11.0-preview.1",
|
||||
"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",
|
||||
|
|
|
|||
395
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "obsidian-agent-client",
|
||||
"version": "0.10.6",
|
||||
"version": "0.11.0-preview.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-agent-client",
|
||||
"version": "0.10.6",
|
||||
"version": "0.11.0-preview.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.14.1",
|
||||
|
|
@ -1139,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": {
|
||||
|
|
@ -1531,9 +1531,9 @@
|
|||
"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"
|
||||
],
|
||||
|
|
@ -1545,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"
|
||||
],
|
||||
|
|
@ -1559,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"
|
||||
],
|
||||
|
|
@ -1573,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"
|
||||
],
|
||||
|
|
@ -1587,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"
|
||||
],
|
||||
|
|
@ -1601,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"
|
||||
],
|
||||
|
|
@ -1615,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"
|
||||
],
|
||||
|
|
@ -1632,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"
|
||||
],
|
||||
|
|
@ -1649,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"
|
||||
],
|
||||
|
|
@ -1666,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"
|
||||
],
|
||||
|
|
@ -1683,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"
|
||||
],
|
||||
|
|
@ -1700,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"
|
||||
],
|
||||
|
|
@ -1717,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"
|
||||
],
|
||||
|
|
@ -1734,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"
|
||||
],
|
||||
|
|
@ -1751,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"
|
||||
],
|
||||
|
|
@ -1768,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"
|
||||
],
|
||||
|
|
@ -1785,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"
|
||||
],
|
||||
|
|
@ -1802,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"
|
||||
],
|
||||
|
|
@ -1819,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"
|
||||
],
|
||||
|
|
@ -1836,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"
|
||||
],
|
||||
|
|
@ -1850,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"
|
||||
],
|
||||
|
|
@ -1864,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"
|
||||
],
|
||||
|
|
@ -1878,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"
|
||||
],
|
||||
|
|
@ -1892,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"
|
||||
],
|
||||
|
|
@ -1906,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"
|
||||
],
|
||||
|
|
@ -2021,12 +2021,12 @@
|
|||
"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",
|
||||
|
|
@ -2038,9 +2038,9 @@
|
|||
}
|
||||
},
|
||||
"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",
|
||||
|
|
@ -2230,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"
|
||||
|
|
@ -2253,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"
|
||||
}
|
||||
|
|
@ -2269,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": {
|
||||
|
|
@ -2294,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": {
|
||||
|
|
@ -2316,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"
|
||||
|
|
@ -2334,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": {
|
||||
|
|
@ -2351,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"
|
||||
},
|
||||
|
|
@ -2376,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": {
|
||||
|
|
@ -2390,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",
|
||||
|
|
@ -2418,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"
|
||||
|
|
@ -2442,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": {
|
||||
|
|
@ -3547,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": {
|
||||
|
|
@ -5503,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"
|
||||
|
|
@ -6622,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": {
|
||||
|
|
@ -6861,13 +6871,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
|
||||
"integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
|
||||
"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.8"
|
||||
"@types/estree": "1.0.9"
|
||||
},
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
|
|
@ -6877,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",
|
||||
|
|
@ -7016,9 +7019,9 @@
|
|||
"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"
|
||||
|
|
@ -7707,16 +7710,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"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-agent-client",
|
||||
"version": "0.10.6",
|
||||
"version": "0.11.0-preview.1",
|
||||
"description": "Use AI coding agents via the Agent Client Protocol directly inside Obsidian",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,19 @@ export interface AgentConfig {
|
|||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -111,7 +124,7 @@ export class AcpClient {
|
|||
async initialize(config: AgentConfig): Promise<InitializeResult> {
|
||||
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}`,
|
||||
|
|
@ -189,6 +202,16 @@ 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;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
"[AcpClient] Starting agent process in directory:",
|
||||
config.workingDirectory,
|
||||
|
|
@ -393,6 +416,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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -68,7 +68,10 @@ export class AcpHandler {
|
|||
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":
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,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 +32,7 @@ import {
|
|||
import {
|
||||
applyLegacyValue,
|
||||
tryRestoreConfigOption,
|
||||
restoreSavedConfigOptions,
|
||||
restoreLegacyConfig,
|
||||
} from "../services/session-state";
|
||||
|
||||
|
|
@ -223,29 +225,24 @@ 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/models/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 finalModels = sessionResult.models;
|
||||
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 +257,37 @@ 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;
|
||||
finalModels = restored.models;
|
||||
}
|
||||
|
||||
setSession((prev) => ({
|
||||
...prev,
|
||||
sessionId: sessionResult.sessionId,
|
||||
state: "ready",
|
||||
authMethods: initResult?.authMethods ?? [],
|
||||
modes: finalModes,
|
||||
models: finalModels,
|
||||
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 +314,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 +342,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]);
|
||||
|
|
@ -346,23 +359,26 @@ export function useAgentSession(
|
|||
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 finalModels = models;
|
||||
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 +393,27 @@ 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],
|
||||
settings.lastUsedModes[agentId],
|
||||
setSession,
|
||||
);
|
||||
finalModes = restored.modes;
|
||||
finalModels = restored.models;
|
||||
}
|
||||
|
||||
setSession((prev) => ({
|
||||
...prev,
|
||||
sessionId,
|
||||
state: "ready",
|
||||
modes: finalModes ?? prev.modes,
|
||||
models: finalModels ?? prev.models,
|
||||
configOptions: finalConfigOptions ?? prev.configOptions,
|
||||
lastActivityAt: new Date(),
|
||||
}));
|
||||
},
|
||||
[agentClient, settingsAccess],
|
||||
);
|
||||
|
|
@ -404,7 +426,7 @@ export function useAgentSession(
|
|||
async (kind: "mode" | "model", value: string) => {
|
||||
const s = sessionRef.current;
|
||||
if (!s.sessionId) {
|
||||
console.warn(`Cannot set ${kind}: no active session`);
|
||||
getLogger().debug(`Cannot set ${kind}: no active session`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -434,7 +456,7 @@ export function useAgentSession(
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to set ${kind}:`, error);
|
||||
getLogger().error(`Failed to set ${kind}:`, error);
|
||||
if (previousValue) {
|
||||
setSession((prev) =>
|
||||
applyLegacyValue(prev, kind, previousValue),
|
||||
|
|
@ -459,7 +481,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;
|
||||
}
|
||||
|
||||
|
|
@ -491,26 +515,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,
|
||||
|
|
|
|||
|
|
@ -178,15 +178,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 +206,6 @@ export function useChatActions(
|
|||
session.sessionId,
|
||||
sessionHistory.saveSessionLocally,
|
||||
logger,
|
||||
settings.autoMentionActiveNote,
|
||||
suggestions.mentions.activeNote,
|
||||
suggestions.mentions.isAutoMentionDisabled,
|
||||
shouldConvertToWsl,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
} 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)
|
||||
|
|
@ -300,6 +301,38 @@ export function useSessionHistory(
|
|||
const cacheRef = useRef<SessionCache | null>(null);
|
||||
const currentCwdRef = useRef<string | undefined>(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<SavedSessionInfo[] | null>(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.
|
||||
*/
|
||||
|
|
@ -626,15 +659,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 +737,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 +751,14 @@ 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 +782,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 +800,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 +812,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],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<NoteMetadata | null>(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(
|
||||
|
|
|
|||
230
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<string, string>;
|
||||
// Last used mode per agent (agentId → modeId)
|
||||
lastUsedModes: Record<string, string>;
|
||||
// Last used non-model/mode config options per agent (agentId → {optionId → value})
|
||||
lastUsedConfigOptions: Record<string, Record<string, string>>;
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<string, unknown>;
|
||||
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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
`<obsidian_system_instruction>\n${instruction}\n</obsidian_system_instruction>`,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -184,6 +191,32 @@ export function createInitialSession(
|
|||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -73,35 +73,83 @@ 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<string, string> | undefined,
|
||||
): Promise<SessionConfigOption[]> {
|
||||
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) 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<void> {
|
||||
if (!sessionResult.sessionId) return;
|
||||
): Promise<{
|
||||
modes: SessionResult["modes"];
|
||||
models: SessionResult["models"];
|
||||
}> {
|
||||
let modes = sessionResult.modes;
|
||||
let models = sessionResult.models;
|
||||
|
||||
if (!sessionResult.sessionId) return { modes, models };
|
||||
|
||||
// Legacy model restore
|
||||
if (sessionResult.models && savedModelId) {
|
||||
if (models && savedModelId) {
|
||||
if (
|
||||
savedModelId !== sessionResult.models.currentModelId &&
|
||||
sessionResult.models.availableModels.some(
|
||||
(m) => m.modelId === savedModelId,
|
||||
)
|
||||
savedModelId !== models.currentModelId &&
|
||||
models.availableModels.some((m) => m.modelId === savedModelId)
|
||||
) {
|
||||
try {
|
||||
await agentClient.setSessionModel(
|
||||
sessionResult.sessionId,
|
||||
savedModelId,
|
||||
);
|
||||
setSession((prev) =>
|
||||
applyLegacyValue(prev, "model", savedModelId),
|
||||
);
|
||||
models = { ...models, currentModelId: savedModelId };
|
||||
} catch {
|
||||
// Agent default is fine as fallback
|
||||
}
|
||||
|
|
@ -109,22 +157,22 @@ export async function restoreLegacyConfig(
|
|||
}
|
||||
|
||||
// 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, models };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<Omit<SavedSessionInfo, "sessionId" | "createdAt">>,
|
||||
): Promise<void> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -247,6 +247,26 @@ export function strRecord(raw: unknown): Record<string, string> {
|
|||
return result;
|
||||
}
|
||||
|
||||
/** Normalize a nested string record, e.g. agentId → { optionId → value }. */
|
||||
export function nestedStrRecord(
|
||||
raw: unknown,
|
||||
): Record<string, Record<string, string>> {
|
||||
const result: Record<string, Record<string, string>> = {};
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
/**
|
||||
* Update fields of an existing saved session.
|
||||
* Silently no-op if the session does not exist.
|
||||
*/
|
||||
updateSession(
|
||||
sessionId: string,
|
||||
patch: Partial<Omit<SavedSessionInfo, "sessionId" | "createdAt">>,
|
||||
): Promise<void>;
|
||||
|
||||
// ============================================================
|
||||
// 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<void> {
|
||||
return this.sessionStorage.updateSessionTitle(
|
||||
sessionId,
|
||||
newTitle,
|
||||
createIfMissing,
|
||||
);
|
||||
}
|
||||
|
||||
async updateSession(
|
||||
sessionId: string,
|
||||
patch: Partial<Omit<SavedSessionInfo, "sessionId" | "createdAt">>,
|
||||
): Promise<void> {
|
||||
return this.sessionStorage.updateSession(sessionId, patch);
|
||||
}
|
||||
|
||||
async saveSessionMessages(
|
||||
sessionId: string,
|
||||
agentId: string,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
|
||||
// ============================================================
|
||||
// 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<string, IChatViewContainer>();
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -39,6 +41,7 @@ import {
|
|||
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 +64,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 +87,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 +120,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 +139,7 @@ export function ChatPanel({
|
|||
config,
|
||||
onRegisterCallbacks,
|
||||
onAgentIdChanged,
|
||||
onSessionTitleChanged,
|
||||
onMinimize,
|
||||
onClose,
|
||||
onOpenNewWindow,
|
||||
|
|
@ -190,6 +205,7 @@ export function ChatPanel({
|
|||
vaultService,
|
||||
plugin,
|
||||
session.availableCommands || EMPTY_COMMANDS,
|
||||
settings.autoMentionActiveNote,
|
||||
);
|
||||
|
||||
// Session history hook with callback for session load
|
||||
|
|
@ -423,6 +439,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 +482,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 +544,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 +587,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) => {
|
||||
|
|
@ -673,19 +725,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<ChatMessage[] | null>(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 +803,13 @@ export function ChatPanel({
|
|||
// ============================================================
|
||||
const prevIsSendingRef = useRef<boolean>(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,6 +821,8 @@ 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}`,
|
||||
|
|
@ -760,6 +845,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 +1094,40 @@ 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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</ChatContextProvider>
|
||||
);
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
145
src/ui/EditTitleModal.ts
Normal file
|
|
@ -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<void>;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
currentTitle: string,
|
||||
onSave: (newTitle: string) => void | Promise<void>,
|
||||
) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -362,6 +362,7 @@ export function InputArea({
|
|||
const removeFile = useCallback(
|
||||
(id: string) => {
|
||||
onAttachedFilesChange(attachedFiles.filter((f) => f.id !== id));
|
||||
textareaRef.current?.focus();
|
||||
},
|
||||
[attachedFiles, onAttachedFilesChange],
|
||||
);
|
||||
|
|
@ -406,7 +407,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 +1012,7 @@ export function InputArea({
|
|||
onDrop={(e) => void handleDrop(e)}
|
||||
>
|
||||
{/* Auto-mention Badge */}
|
||||
{autoMentionEnabled && mentions.activeNote && (
|
||||
{mentions.activeNote && (
|
||||
<button
|
||||
className="agent-client-auto-mention-inline"
|
||||
onClick={() =>
|
||||
|
|
@ -1062,7 +1063,7 @@ export function InputArea({
|
|||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => void handlePaste(e)}
|
||||
placeholder={placeholder}
|
||||
className={`agent-client-chat-input-textarea ${autoMentionEnabled && mentions.activeNote ? "has-auto-mention" : ""}`}
|
||||
className={`agent-client-chat-input-textarea ${mentions.activeNote ? "has-auto-mention" : ""}`}
|
||||
rows={1}
|
||||
spellCheck={obsidianSpellcheck}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from "react";
|
||||
const { useRef, useEffect, useCallback } = React;
|
||||
import { setIcon, DropdownComponent } from "obsidian";
|
||||
const { useRef, useEffect, useCallback, useMemo } = React;
|
||||
import { setIcon, Menu } from "obsidian";
|
||||
|
||||
import {
|
||||
flattenConfigSelectOptions,
|
||||
|
|
@ -12,65 +12,114 @@ import {
|
|||
} from "../types/session";
|
||||
|
||||
// ============================================================================
|
||||
// Obsidian Dropdown Hook
|
||||
// ToolbarDropdown — themed dropdown using Obsidian's Menu
|
||||
// ============================================================================
|
||||
|
||||
interface ToolbarDropdownItem {
|
||||
value: string;
|
||||
label: string;
|
||||
groupName?: string;
|
||||
}
|
||||
|
||||
interface ToolbarDropdownProps {
|
||||
label: string;
|
||||
title: string;
|
||||
items: ToolbarDropdownItem[];
|
||||
currentValue: string | undefined;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing an Obsidian DropdownComponent lifecycle.
|
||||
* Handles creation, option population, value sync, and cleanup.
|
||||
* Themed dropdown trigger. Uses Obsidian's Menu instead of a native <select>
|
||||
* so the open state respects Obsidian theme tokens, supports keyboard nav,
|
||||
* and can be positioned above the trigger to avoid covering the input.
|
||||
*/
|
||||
function useObsidianDropdown(
|
||||
containerRef: React.RefObject<HTMLDivElement | null>,
|
||||
options: Array<{ value: string; label: string }> | undefined,
|
||||
currentValue: string | undefined,
|
||||
onChangeRef: React.RefObject<((value: string) => void) | undefined>,
|
||||
): void {
|
||||
const instanceRef = useRef<DropdownComponent | null>(null);
|
||||
function ToolbarDropdown({
|
||||
label,
|
||||
title,
|
||||
items,
|
||||
currentValue,
|
||||
onChange,
|
||||
className,
|
||||
}: ToolbarDropdownProps) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const chevronRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
// Create/destroy dropdown when options change
|
||||
useEffect(() => {
|
||||
const containerEl = containerRef.current;
|
||||
if (!containerEl) return;
|
||||
|
||||
if (!options || options.length <= 1) {
|
||||
if (instanceRef.current) {
|
||||
containerEl.empty();
|
||||
instanceRef.current = null;
|
||||
}
|
||||
return;
|
||||
if (chevronRef.current) {
|
||||
setIcon(chevronRef.current, "chevron-down");
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!instanceRef.current) {
|
||||
const dropdown = new DropdownComponent(containerEl);
|
||||
instanceRef.current = dropdown;
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
for (const opt of options) {
|
||||
dropdown.addOption(opt.value, opt.label);
|
||||
}
|
||||
const menu = new Menu();
|
||||
|
||||
if (currentValue) {
|
||||
dropdown.setValue(currentValue);
|
||||
}
|
||||
|
||||
dropdown.onChange((value) => {
|
||||
onChangeRef.current?.(value);
|
||||
menu.addItem((menuItem) => {
|
||||
menuItem.setTitle(title).setIsLabel(true);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (instanceRef.current) {
|
||||
containerEl.empty();
|
||||
instanceRef.current = null;
|
||||
let lastGroupName: string | undefined;
|
||||
for (const item of items) {
|
||||
if (
|
||||
item.groupName &&
|
||||
item.groupName !== lastGroupName &&
|
||||
lastGroupName !== undefined
|
||||
) {
|
||||
menu.addSeparator();
|
||||
}
|
||||
lastGroupName = item.groupName;
|
||||
|
||||
menu.addItem((menuItem) => {
|
||||
menuItem
|
||||
.setTitle(item.label)
|
||||
.setChecked(item.value === currentValue)
|
||||
.onClick(() => {
|
||||
onChange(item.value);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [options, containerRef, onChangeRef, currentValue]);
|
||||
|
||||
// Sync value when it changes externally
|
||||
useEffect(() => {
|
||||
if (instanceRef.current && currentValue) {
|
||||
instanceRef.current.setValue(currentValue);
|
||||
}
|
||||
}, [currentValue]);
|
||||
menu.showAtMouseEvent(e.nativeEvent);
|
||||
buttonRef.current?.blur();
|
||||
},
|
||||
[items, currentValue, onChange],
|
||||
);
|
||||
|
||||
const wrapperClass = `agent-client-toolbar-dropdown${className ? ` ${className}` : ""}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={wrapperClass}
|
||||
title={title}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="agent-client-toolbar-dropdown-label-area">
|
||||
{items.map((item) => (
|
||||
<span
|
||||
key={item.value}
|
||||
className="agent-client-toolbar-dropdown-sizer"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
<span className="agent-client-toolbar-dropdown-label">
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
ref={chevronRef}
|
||||
className="agent-client-toolbar-dropdown-chevron"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -125,28 +174,8 @@ export function InputToolbar({
|
|||
usage,
|
||||
isSessionReady,
|
||||
}: InputToolbarProps) {
|
||||
// Refs
|
||||
const sendButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const modeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const modelDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const configOptionsRef = useRef<HTMLDivElement>(null);
|
||||
const configDropdownInstances = useRef<Map<string, DropdownComponent>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
// Stable callback refs
|
||||
const onModeChangeRef = useRef(onModeChange);
|
||||
onModeChangeRef.current = onModeChange;
|
||||
|
||||
const onModelChangeRef = useRef(onModelChange);
|
||||
onModelChangeRef.current = onModelChange;
|
||||
|
||||
const onConfigOptionChangeRef = useRef(onConfigOptionChange);
|
||||
onConfigOptionChangeRef.current = onConfigOptionChange;
|
||||
|
||||
/**
|
||||
* Update send button icon color based on state.
|
||||
*/
|
||||
const updateIconColor = useCallback(
|
||||
(svg: SVGElement) => {
|
||||
svg.classList.remove(
|
||||
|
|
@ -168,7 +197,6 @@ export function InputToolbar({
|
|||
[isSending, hasContent],
|
||||
);
|
||||
|
||||
// Update send button icon based on sending state
|
||||
useEffect(() => {
|
||||
if (sendButtonRef.current) {
|
||||
const iconName = isSending ? "square" : "send-horizontal";
|
||||
|
|
@ -180,7 +208,6 @@ export function InputToolbar({
|
|||
}
|
||||
}, [isSending, updateIconColor]);
|
||||
|
||||
// Update icon color when hasContent changes
|
||||
useEffect(() => {
|
||||
if (sendButtonRef.current) {
|
||||
const svg = sendButtonRef.current.querySelector("svg");
|
||||
|
|
@ -190,101 +217,40 @@ export function InputToolbar({
|
|||
}
|
||||
}, [updateIconColor]);
|
||||
|
||||
// Mode dropdown
|
||||
const modeOptions = modes?.availableModes?.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.name,
|
||||
}));
|
||||
useObsidianDropdown(
|
||||
modeDropdownRef,
|
||||
modeOptions,
|
||||
modes?.currentModeId,
|
||||
onModeChangeRef,
|
||||
);
|
||||
// ----- Build dropdown item lists (memoized) -----
|
||||
|
||||
// Model dropdown
|
||||
const modelOptions = models?.availableModels?.map((m) => ({
|
||||
value: m.modelId,
|
||||
label: m.name,
|
||||
}));
|
||||
useObsidianDropdown(
|
||||
modelDropdownRef,
|
||||
modelOptions,
|
||||
models?.currentModelId,
|
||||
onModelChangeRef,
|
||||
);
|
||||
const modeItems = useMemo<ToolbarDropdownItem[]>(() => {
|
||||
if (!modes?.availableModes) return [];
|
||||
return modes.availableModes.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.name,
|
||||
}));
|
||||
}, [modes]);
|
||||
|
||||
// Initialize configOptions dropdowns (dynamic, replaces mode/model when present)
|
||||
useEffect(() => {
|
||||
const containerEl = configOptionsRef.current;
|
||||
if (!containerEl) return;
|
||||
const modelItems = useMemo<ToolbarDropdownItem[]>(() => {
|
||||
if (!models?.availableModels) return [];
|
||||
return models.availableModels.map((m) => ({
|
||||
value: m.modelId,
|
||||
label: m.name,
|
||||
}));
|
||||
}, [models]);
|
||||
|
||||
// Clean up existing dropdowns
|
||||
containerEl.empty();
|
||||
configDropdownInstances.current.clear();
|
||||
const currentModeLabel = useMemo(() => {
|
||||
const id = modes?.currentModeId;
|
||||
return (
|
||||
modes?.availableModes?.find((m) => m.id === id)?.name ?? "Mode"
|
||||
);
|
||||
}, [modes]);
|
||||
|
||||
if (!configOptions || configOptions.length === 0) return;
|
||||
const currentModelLabel = useMemo(() => {
|
||||
const id = models?.currentModelId;
|
||||
return (
|
||||
models?.availableModels?.find((m) => m.modelId === id)?.name ??
|
||||
"Model"
|
||||
);
|
||||
}, [models]);
|
||||
|
||||
for (const option of configOptions) {
|
||||
// Flatten options (handle both flat and grouped)
|
||||
const flatOptions = flattenConfigSelectOptions(option.options);
|
||||
|
||||
// Only show if there are multiple values
|
||||
if (flatOptions.length <= 1) continue;
|
||||
|
||||
// Create wrapper div with appropriate class based on category
|
||||
const categoryClass = option.category
|
||||
? `agent-client-config-selector-${option.category}`
|
||||
: "agent-client-config-selector";
|
||||
const wrapperEl = containerEl.createDiv({
|
||||
cls: `agent-client-config-selector ${categoryClass}`,
|
||||
attr: { title: option.description ?? option.name },
|
||||
});
|
||||
|
||||
const dropdownContainer = wrapperEl.createDiv();
|
||||
const dropdown = new DropdownComponent(dropdownContainer);
|
||||
|
||||
// Add options (with group prefix for grouped options)
|
||||
if (option.options.length > 0 && "group" in option.options[0]) {
|
||||
for (const group of option.options as SessionConfigSelectGroup[]) {
|
||||
for (const opt of group.options) {
|
||||
dropdown.addOption(
|
||||
opt.value,
|
||||
`${group.name} / ${opt.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const opt of flatOptions) {
|
||||
dropdown.addOption(opt.value, opt.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Set current value
|
||||
dropdown.setValue(option.currentValue);
|
||||
|
||||
// Handle change
|
||||
const configId = option.id;
|
||||
dropdown.onChange((value) => {
|
||||
if (onConfigOptionChangeRef.current) {
|
||||
onConfigOptionChangeRef.current(configId, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Add chevron icon
|
||||
const iconEl = wrapperEl.createSpan({
|
||||
cls: "agent-client-config-selector-icon",
|
||||
});
|
||||
setIcon(iconEl, "chevron-down");
|
||||
|
||||
configDropdownInstances.current.set(option.id, dropdown);
|
||||
}
|
||||
|
||||
return () => {
|
||||
containerEl.empty();
|
||||
configDropdownInstances.current.clear();
|
||||
};
|
||||
}, [configOptions]);
|
||||
// ----- Render -----
|
||||
|
||||
return (
|
||||
<div className="agent-client-chat-input-actions">
|
||||
|
|
@ -303,54 +269,97 @@ export function InputToolbar({
|
|||
)}
|
||||
|
||||
{/* Config Options (supersedes legacy mode/model selectors) */}
|
||||
{configOptions && configOptions.length > 0 ? (
|
||||
<div
|
||||
ref={configOptionsRef}
|
||||
className="agent-client-config-options-container"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Legacy Mode Selector */}
|
||||
{modes && modes.availableModes.length > 1 && (
|
||||
<div
|
||||
className="agent-client-mode-selector"
|
||||
title={
|
||||
modes.availableModes.find(
|
||||
(m) => m.id === modes.currentModeId,
|
||||
)?.description ?? "Select mode"
|
||||
}
|
||||
>
|
||||
<div ref={modeDropdownRef} />
|
||||
<span
|
||||
className="agent-client-mode-selector-icon"
|
||||
ref={(el) => {
|
||||
if (el) setIcon(el, "chevron-down");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{configOptions && configOptions.length > 0
|
||||
? configOptions.map((option) => {
|
||||
const flatOptions = flattenConfigSelectOptions(
|
||||
option.options,
|
||||
);
|
||||
if (flatOptions.length <= 1) return null;
|
||||
|
||||
{/* Legacy Model Selector */}
|
||||
{models && models.availableModels.length > 1 && (
|
||||
<div
|
||||
className="agent-client-model-selector"
|
||||
title={
|
||||
models.availableModels.find(
|
||||
(m) => m.modelId === models.currentModelId,
|
||||
)?.description ?? "Select model"
|
||||
const isGrouped =
|
||||
option.options.length > 0 &&
|
||||
"group" in option.options[0];
|
||||
|
||||
let items: ToolbarDropdownItem[];
|
||||
if (isGrouped) {
|
||||
items = [];
|
||||
for (const group of option.options as SessionConfigSelectGroup[]) {
|
||||
for (const opt of group.options) {
|
||||
items.push({
|
||||
value: opt.value,
|
||||
label: `${group.name} / ${opt.name}`,
|
||||
groupName: group.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
>
|
||||
<div ref={modelDropdownRef} />
|
||||
<span
|
||||
className="agent-client-model-selector-icon"
|
||||
ref={(el) => {
|
||||
if (el) setIcon(el, "chevron-down");
|
||||
} else {
|
||||
items = flatOptions.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.name,
|
||||
}));
|
||||
}
|
||||
|
||||
const currentItem = items.find(
|
||||
(it) => it.value === option.currentValue,
|
||||
);
|
||||
const label = currentItem?.label ?? option.name;
|
||||
const title = option.description ?? option.name;
|
||||
|
||||
return (
|
||||
<ToolbarDropdown
|
||||
key={option.id}
|
||||
label={label}
|
||||
title={title}
|
||||
items={items}
|
||||
currentValue={option.currentValue}
|
||||
onChange={(value) => {
|
||||
onConfigOptionChange?.(option.id, value);
|
||||
}}
|
||||
className={
|
||||
option.category
|
||||
? `agent-client-config-selector-${option.category}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
);
|
||||
})
|
||||
: (
|
||||
<>
|
||||
{modes && modes.availableModes.length > 1 && onModeChange && (
|
||||
<ToolbarDropdown
|
||||
label={currentModeLabel}
|
||||
title={
|
||||
modes.availableModes.find(
|
||||
(m) => m.id === modes.currentModeId,
|
||||
)?.description ?? "Select mode"
|
||||
}
|
||||
items={modeItems}
|
||||
currentValue={modes.currentModeId ?? undefined}
|
||||
onChange={onModeChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{models &&
|
||||
models.availableModels.length > 1 &&
|
||||
onModelChange && (
|
||||
<ToolbarDropdown
|
||||
label={currentModelLabel}
|
||||
title={
|
||||
models.availableModels.find(
|
||||
(m) =>
|
||||
m.modelId ===
|
||||
models.currentModelId,
|
||||
)?.description ?? "Select model"
|
||||
}
|
||||
items={modelItems}
|
||||
currentValue={
|
||||
models.currentModelId ?? undefined
|
||||
}
|
||||
onChange={onModelChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Send/Stop Button */}
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ import { setIcon } from "obsidian";
|
|||
import { MessageBubble } from "./MessageBubble";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
// How long (ms) after a tab is re-shown we refuse to shrink measured item
|
||||
// sizes. Right after re-show the items briefly re-measure small while their
|
||||
// markdown re-lays-out; recording those shrinks collapses the virtualizer's
|
||||
// total size, which clamps scrollTop to 0 and loses the position. Riding out
|
||||
// this window keeps total stable so the scroll position is preserved. (#321)
|
||||
const SHOW_SETTLE_MS = 500;
|
||||
|
||||
/**
|
||||
* Props for MessageList component
|
||||
*/
|
||||
|
|
@ -66,6 +73,16 @@ export function MessageList({
|
|||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const isAtBottomRef = useRef(true);
|
||||
const prevIsSendingRef = useRef(false);
|
||||
// Last measured height per message id. Used to keep the virtualizer's total
|
||||
// size stable while the tab is hidden (display:none) so scrollTop isn't
|
||||
// clamped to 0 and the position survives a tab switch. (#321)
|
||||
const sizeCacheRef = useRef<Map<string, number>>(new Map());
|
||||
// Whether the view was last seen hidden (display:none), and the end of the
|
||||
// post-show "settle" window during which we refuse to shrink the size cache
|
||||
// (see SHOW_SETTLE_MS). Together these suppress the transient total-size
|
||||
// collapse that would otherwise clamp scrollTop to 0 on re-show. (#321)
|
||||
const wasHiddenRef = useRef(false);
|
||||
const settleUntilRef = useRef(0);
|
||||
|
||||
// ============================================================
|
||||
// Virtualizer
|
||||
|
|
@ -75,6 +92,40 @@ export function MessageList({
|
|||
getScrollElement: () => containerRef.current,
|
||||
estimateSize: () => 80,
|
||||
overscan: 5,
|
||||
getItemKey: (index) => messages[index]?.id ?? String(index),
|
||||
measureElement: (element) => {
|
||||
const el = element as HTMLElement;
|
||||
const id = el.getAttribute("data-msg-id");
|
||||
const cached = id ? sizeCacheRef.current.get(id) : undefined;
|
||||
// Hidden (display:none): the item is detached from layout
|
||||
// (offsetParent === null) and would measure 0. Remember we were
|
||||
// hidden and return the last known size so the total size doesn't
|
||||
// collapse on the hidden side. (#321)
|
||||
if (el.offsetParent === null) {
|
||||
wasHiddenRef.current = true;
|
||||
return cached || 80;
|
||||
}
|
||||
// First measure after re-show: open the settle window. Opening it
|
||||
// here (rather than from a separate observer) makes this very call
|
||||
// guarded too, regardless of observer firing order. (#321)
|
||||
if (wasHiddenRef.current) {
|
||||
wasHiddenRef.current = false;
|
||||
settleUntilRef.current = performance.now() + SHOW_SETTLE_MS;
|
||||
}
|
||||
const measured = el.getBoundingClientRect().height;
|
||||
// Inside the settle window, never shrink: return the larger of the
|
||||
// fresh and cached heights and don't record it, so getTotalSize()
|
||||
// stays stable and scrollTop isn't clamped to 0 on re-show. Genuine
|
||||
// shrinks are accepted again once the window expires. (#321)
|
||||
if (
|
||||
cached !== undefined &&
|
||||
performance.now() < settleUntilRef.current
|
||||
) {
|
||||
return Math.max(measured, cached);
|
||||
}
|
||||
if (id && measured > 0) sizeCacheRef.current.set(id, measured);
|
||||
return measured || cached || 80;
|
||||
},
|
||||
});
|
||||
|
||||
// Suppress scroll position correction when user has scrolled up.
|
||||
|
|
@ -106,11 +157,15 @@ export function MessageList({
|
|||
return isNearBottom;
|
||||
}, []);
|
||||
|
||||
// Reset scroll state when messages are cleared (new chat)
|
||||
// Reset scroll state and drop the per-message size cache when messages are
|
||||
// cleared (new chat / restore / fork / restart all funnel through an empty
|
||||
// array first). Prevents stale msgId→height entries from accumulating
|
||||
// across sessions in this long-lived view. (#321)
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) {
|
||||
setIsAtBottom(true);
|
||||
isAtBottomRef.current = true;
|
||||
sizeCacheRef.current.clear();
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
|
|
@ -203,6 +258,7 @@ export function MessageList({
|
|||
key={message.id}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={virtualItem.index}
|
||||
data-msg-id={message.id}
|
||||
className="agent-client-virtual-item"
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import { getLogger } from "../utils/logger";
|
||||
import type { PermissionOption } from "../types/chat";
|
||||
import { LucideIcon } from "./shared/IconButton";
|
||||
|
||||
/** Kind semantics are conveyed by icon shape + color, not button background. */
|
||||
const KIND_ICONS: Record<PermissionOption["kind"], string> = {
|
||||
allow_always: "check-check",
|
||||
allow_once: "check",
|
||||
reject_once: "x",
|
||||
reject_always: "ban",
|
||||
};
|
||||
|
||||
interface PermissionBannerProps {
|
||||
permissionRequest: {
|
||||
|
|
@ -9,6 +18,8 @@ interface PermissionBannerProps {
|
|||
isCancelled?: boolean;
|
||||
isActive?: boolean;
|
||||
};
|
||||
/** Whether to show kind icons (follows displaySettings.showEmojis) */
|
||||
showEmojis: boolean;
|
||||
/** Callback to approve a permission request */
|
||||
onApprovePermission?: (
|
||||
requestId: string,
|
||||
|
|
@ -19,6 +30,7 @@ interface PermissionBannerProps {
|
|||
|
||||
export function PermissionBanner({
|
||||
permissionRequest,
|
||||
showEmojis,
|
||||
onApprovePermission,
|
||||
onOptionSelected,
|
||||
}: PermissionBannerProps) {
|
||||
|
|
@ -35,7 +47,8 @@ export function PermissionBanner({
|
|||
{permissionRequest.options.map((option) => (
|
||||
<button
|
||||
key={option.optionId}
|
||||
className={`agent-client-permission-option ${option.kind ? `agent-client-permission-kind-${option.kind}` : ""}`}
|
||||
className={`agent-client-permission-option agent-client-permission-kind-${option.kind}`}
|
||||
title={option.name}
|
||||
onClick={() => {
|
||||
if (onOptionSelected) {
|
||||
onOptionSelected(option.optionId);
|
||||
|
|
@ -53,7 +66,15 @@ export function PermissionBanner({
|
|||
}
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
{showEmojis && (
|
||||
<LucideIcon
|
||||
name={KIND_ICONS[option.kind]}
|
||||
className="agent-client-permission-option-icon"
|
||||
/>
|
||||
)}
|
||||
<span className="agent-client-permission-option-label">
|
||||
{option.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@
|
|||
*/
|
||||
|
||||
import { Modal, App, setIcon } from "obsidian";
|
||||
import { EditTitleModal } from "./EditTitleModal";
|
||||
import * as React from "react";
|
||||
const { useState, useCallback } = React;
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
import type { SessionInfo } from "../types/session";
|
||||
import { truncateTitle } from "../utils/text";
|
||||
|
||||
// ============================================================
|
||||
// ConfirmDeleteModal (internal)
|
||||
|
|
@ -84,91 +86,6 @@ class ConfirmDeleteModal extends Modal {
|
|||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EditTitleModal (internal)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
class EditTitleModal extends Modal {
|
||||
private currentTitle: string;
|
||||
private onSave: (newTitle: string) => void | Promise<void>;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
currentTitle: string,
|
||||
onSave: (newTitle: string) => void | Promise<void>,
|
||||
) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SessionHistoryContent (internal)
|
||||
// ============================================================
|
||||
|
|
@ -293,16 +210,6 @@ function formatRelativeTime(date: Date): string {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate session title to 50 characters with ellipsis.
|
||||
*/
|
||||
function truncateTitle(title: string): string {
|
||||
if (title.length <= 50) {
|
||||
return title;
|
||||
}
|
||||
return title.slice(0, 50) + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug form for manual session input.
|
||||
*/
|
||||
|
|
|
|||
233
src/ui/SessionManagerView.tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import { ItemView, WorkspaceLeaf, setIcon, Menu } from "obsidian";
|
||||
import * as React from "react";
|
||||
const { useRef, useEffect, useCallback } = React;
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
|
||||
import type AgentClientPlugin from "../plugin";
|
||||
import type {
|
||||
IChatViewContainer,
|
||||
SessionStatus,
|
||||
} from "../services/view-registry";
|
||||
import { addRenameSessionMenuItem } from "./EditTitleModal";
|
||||
import { useSettings } from "../hooks/useSettings";
|
||||
|
||||
export const VIEW_TYPE_SESSION_MANAGER = "agent-client-session-manager";
|
||||
|
||||
// ============================================================================
|
||||
// React Components
|
||||
// ============================================================================
|
||||
|
||||
function SessionStatusIcon({ status }: { status: SessionStatus }) {
|
||||
const iconRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
const iconName = ((s: SessionStatus): string => {
|
||||
switch (s) {
|
||||
case "ready":
|
||||
return "circle-check";
|
||||
case "busy":
|
||||
return "loader";
|
||||
case "permission":
|
||||
return "shield-alert";
|
||||
case "error":
|
||||
return "circle-x";
|
||||
case "disconnected":
|
||||
return "circle-off";
|
||||
}
|
||||
})(status);
|
||||
|
||||
useEffect(() => {
|
||||
if (iconRef.current) setIcon(iconRef.current, iconName);
|
||||
}, [iconName]);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={iconRef}
|
||||
className={`agent-client-session-status-icon agent-client-session-status-${status}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const SessionItem = React.memo(function SessionItem({
|
||||
view,
|
||||
isFocused,
|
||||
plugin,
|
||||
status,
|
||||
title,
|
||||
agentName,
|
||||
}: {
|
||||
view: IChatViewContainer;
|
||||
isFocused: boolean;
|
||||
plugin: AgentClientPlugin;
|
||||
status: SessionStatus;
|
||||
title: string;
|
||||
agentName: string;
|
||||
}) {
|
||||
const moreRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (moreRef.current) setIcon(moreRef.current, "more-horizontal");
|
||||
}, []);
|
||||
|
||||
// `view` is stable for the same viewId (registry holds the same instance),
|
||||
// so this callback is stable across renders — keeping React.memo effective.
|
||||
const handleClick = useCallback(() => view.focus(), [view]);
|
||||
|
||||
const showMenu = useCallback(
|
||||
(position: { x: number; y: number }) => {
|
||||
const menu = new Menu();
|
||||
|
||||
addRenameSessionMenuItem(
|
||||
menu,
|
||||
plugin,
|
||||
view.getSessionId(),
|
||||
view.getSessionTitle(),
|
||||
{ label: "Rename" },
|
||||
);
|
||||
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Close")
|
||||
.setIcon("x")
|
||||
.onClick(() => {
|
||||
plugin.closeView(view.viewId);
|
||||
});
|
||||
});
|
||||
|
||||
menu.showAtPosition(position);
|
||||
},
|
||||
[plugin, view],
|
||||
);
|
||||
|
||||
const handleMoreClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
showMenu({ x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[showMenu],
|
||||
);
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showMenu({ x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[showMenu],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="tree-item">
|
||||
<div
|
||||
className={`tree-item-self is-clickable ${isFocused ? "is-active" : ""}`}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<SessionStatusIcon status={status} />
|
||||
<div className="tree-item-inner agent-client-session-item-text">
|
||||
<div
|
||||
className="agent-client-session-item-title"
|
||||
aria-label={title}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
className="agent-client-session-item-agent"
|
||||
aria-label={agentName}
|
||||
>
|
||||
{agentName}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
ref={moreRef}
|
||||
type="button"
|
||||
className="agent-client-session-item-more clickable-icon"
|
||||
aria-label="Session actions"
|
||||
onClick={handleMoreClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function SessionManagerComponent({
|
||||
plugin,
|
||||
}: {
|
||||
plugin: AgentClientPlugin;
|
||||
}) {
|
||||
const { views, focusedId } = useSyncExternalStore(
|
||||
plugin.viewRegistry.subscribe,
|
||||
plugin.viewRegistry.getSnapshot,
|
||||
plugin.viewRegistry.getSnapshot,
|
||||
);
|
||||
|
||||
// Subscribe to settings changes so renamed titles are reflected immediately
|
||||
useSettings(plugin);
|
||||
|
||||
if (views.length === 0) {
|
||||
return (
|
||||
<div className="agent-client-session-manager-empty">
|
||||
No active sessions
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="agent-client-session-manager">
|
||||
{views.map((view) => (
|
||||
<SessionItem
|
||||
key={view.viewId}
|
||||
view={view}
|
||||
isFocused={view.viewId === focusedId}
|
||||
plugin={plugin}
|
||||
status={view.getSessionStatus()}
|
||||
title={view.getSessionTitle()}
|
||||
agentName={view.getDisplayName()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Obsidian ItemView
|
||||
// ============================================================================
|
||||
|
||||
export class SessionManagerView extends ItemView {
|
||||
private root: Root | null = null;
|
||||
private plugin: AgentClientPlugin;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: AgentClientPlugin) {
|
||||
super(leaf);
|
||||
this.plugin = plugin;
|
||||
this.navigation = false;
|
||||
}
|
||||
|
||||
getViewType() {
|
||||
return VIEW_TYPE_SESSION_MANAGER;
|
||||
}
|
||||
|
||||
getDisplayText() {
|
||||
return "Agent sessions";
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return "layout-list";
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const container = this.containerEl.children[1];
|
||||
container.empty();
|
||||
this.root = createRoot(container);
|
||||
this.root.render(
|
||||
<SessionManagerComponent plugin={this.plugin} />,
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
Setting,
|
||||
DropdownComponent,
|
||||
Platform,
|
||||
SecretComponent,
|
||||
} from "obsidian";
|
||||
import type AgentClientPlugin from "../plugin";
|
||||
import type {
|
||||
|
|
@ -76,13 +77,15 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
text.setPlaceholder("Leave blank (login shell auto-resolves)")
|
||||
.setValue(this.plugin.settings.nodePath)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.nodePath = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
nodePath: value.trim(),
|
||||
});
|
||||
});
|
||||
});
|
||||
this.addAutoDetectButton(nodePathSetting, "node", async (path) => {
|
||||
this.plugin.settings.nodePath = path;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
nodePath: path,
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
|
|
@ -102,10 +105,11 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
)
|
||||
.setValue(this.plugin.settings.sendMessageShortcut)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.sendMessageShortcut = value as
|
||||
| "enter"
|
||||
| "cmd-enter";
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
sendMessageShortcut: value as
|
||||
| "enter"
|
||||
| "cmd-enter",
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -124,8 +128,9 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
toggle
|
||||
.setValue(this.plugin.settings.autoMentionActiveNote)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.autoMentionActiveNote = value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
autoMentionActiveNote: value,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -145,9 +150,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
.onChange(async (value) => {
|
||||
const num = parseInt(value, 10);
|
||||
if (!isNaN(num) && num >= 1) {
|
||||
this.plugin.settings.displaySettings.maxNoteLength =
|
||||
num;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
displaySettings: {
|
||||
...this.plugin.settings.displaySettings,
|
||||
maxNoteLength: num,
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
@ -169,9 +177,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
.onChange(async (value) => {
|
||||
const num = parseInt(value, 10);
|
||||
if (!isNaN(num) && num >= 1) {
|
||||
this.plugin.settings.displaySettings.maxSelectionLength =
|
||||
num;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
displaySettings: {
|
||||
...this.plugin.settings.displaySettings,
|
||||
maxSelectionLength: num,
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
@ -193,9 +204,9 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
.addOption("editor-split", "Editor area (split)")
|
||||
.setValue(this.plugin.settings.chatViewLocation)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.chatViewLocation =
|
||||
value as ChatViewLocation;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
chatViewLocation: value as ChatViewLocation,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -305,8 +316,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
toggle
|
||||
.setValue(this.plugin.settings.displaySettings.showEmojis)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.displaySettings.showEmojis = value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
displaySettings: {
|
||||
...this.plugin.settings.displaySettings,
|
||||
showEmojis: value,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -321,9 +336,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.displaySettings.autoCollapseDiffs,
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.displaySettings.autoCollapseDiffs =
|
||||
value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
displaySettings: {
|
||||
...this.plugin.settings.displaySettings,
|
||||
autoCollapseDiffs: value,
|
||||
},
|
||||
});
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
|
@ -346,9 +364,15 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
.onChange(async (value) => {
|
||||
const num = parseInt(value, 10);
|
||||
if (!isNaN(num) && num > 0) {
|
||||
this.plugin.settings.displaySettings.diffCollapseThreshold =
|
||||
num;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings(
|
||||
{
|
||||
displaySettings: {
|
||||
...this.plugin.settings
|
||||
.displaySettings,
|
||||
diffCollapseThreshold: num,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
@ -400,8 +424,9 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
.setPlaceholder("https://example.com/avatar.png")
|
||||
.setValue(this.plugin.settings.floatingButtonImage)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.floatingButtonImage = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
floatingButtonImage: value.trim(),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -420,8 +445,11 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
toggle
|
||||
.setValue(this.plugin.settings.autoAllowPermissions)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.autoAllowPermissions = value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
autoAllowPermissions: value,
|
||||
});
|
||||
// Propagate to all live AcpClient instances
|
||||
this.plugin.updateAllAutoAllow(value);
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -440,11 +468,80 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
toggle
|
||||
.setValue(this.plugin.settings.enableSystemNotifications)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.enableSystemNotifications = value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
enableSystemNotifications: value,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Prompt injection
|
||||
// ───────────<E29480><E29480>─────────────────────────────────────────────────────────
|
||||
|
||||
new Setting(containerEl).setName("Prompt injection").setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Inject Obsidian Markdown instructions")
|
||||
.setDesc(
|
||||
"Include formatting guidance in the first message of each session so agents produce Obsidian-compatible Markdown.",
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.promptInjection.enabled)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.promptInjection.enabled = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
if (this.plugin.settings.promptInjection.enabled) {
|
||||
new Setting(containerEl)
|
||||
.setName("Wikilink formatting")
|
||||
.setDesc(
|
||||
"Instruct agents to use [[Note Name]] wikilink syntax when referencing notes.",
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
this.plugin.settings.promptInjection.wikiLinks,
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.promptInjection.wikiLinks =
|
||||
value;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Markdown table spacing")
|
||||
.setDesc(
|
||||
"Instruct agents to leave a blank line before Markdown tables so Obsidian renders them correctly.",
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.promptInjection.tables)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.promptInjection.tables = value;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("LaTeX math formatting")
|
||||
.setDesc(
|
||||
"Instruct agents to use $...$ and $$...$$ delimiters for math expressions.",
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.promptInjection.latex)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.promptInjection.latex = value;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Windows WSL Settings (Windows only)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -463,8 +560,9 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
toggle
|
||||
.setValue(this.plugin.settings.windowsWslMode)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.windowsWslMode = value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
windowsWslMode: value,
|
||||
});
|
||||
this.display(); // Refresh to show/hide distribution setting
|
||||
}),
|
||||
);
|
||||
|
|
@ -483,9 +581,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
"",
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.windowsWslDistribution =
|
||||
value.trim() || undefined;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings(
|
||||
{
|
||||
windowsWslDistribution:
|
||||
value.trim() || undefined,
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -519,9 +620,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
.setPlaceholder("Agent Client")
|
||||
.setValue(this.plugin.settings.exportSettings.defaultFolder)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.exportSettings.defaultFolder =
|
||||
value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
exportSettings: {
|
||||
...this.plugin.settings.exportSettings,
|
||||
defaultFolder: value,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -537,9 +641,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.exportSettings.filenameTemplate,
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.exportSettings.filenameTemplate =
|
||||
value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
exportSettings: {
|
||||
...this.plugin.settings.exportSettings,
|
||||
filenameTemplate: value,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -555,9 +662,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.exportSettings.frontmatterTag,
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.exportSettings.frontmatterTag =
|
||||
value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
exportSettings: {
|
||||
...this.plugin.settings.exportSettings,
|
||||
frontmatterTag: value,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -568,9 +678,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
toggle
|
||||
.setValue(this.plugin.settings.exportSettings.includeImages)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.exportSettings.includeImages =
|
||||
value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
exportSettings: {
|
||||
...this.plugin.settings.exportSettings,
|
||||
includeImages: value,
|
||||
},
|
||||
});
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
|
@ -594,9 +707,15 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.exportSettings.imageLocation,
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.exportSettings.imageLocation =
|
||||
value as "obsidian" | "custom" | "base64";
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
exportSettings: {
|
||||
...this.plugin.settings.exportSettings,
|
||||
imageLocation: value as
|
||||
| "obsidian"
|
||||
| "custom"
|
||||
| "base64",
|
||||
},
|
||||
});
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
|
@ -617,9 +736,15 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
.imageCustomFolder,
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.exportSettings.imageCustomFolder =
|
||||
value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings(
|
||||
{
|
||||
exportSettings: {
|
||||
...this.plugin.settings
|
||||
.exportSettings,
|
||||
imageCustomFolder: value,
|
||||
},
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -636,9 +761,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.exportSettings.autoExportOnNewChat,
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.exportSettings.autoExportOnNewChat =
|
||||
value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
exportSettings: {
|
||||
...this.plugin.settings.exportSettings,
|
||||
autoExportOnNewChat: value,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -654,9 +782,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
.autoExportOnCloseChat,
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.exportSettings.autoExportOnCloseChat =
|
||||
value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
exportSettings: {
|
||||
...this.plugin.settings.exportSettings,
|
||||
autoExportOnCloseChat: value,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -669,9 +800,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.exportSettings.openFileAfterExport,
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.exportSettings.openFileAfterExport =
|
||||
value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
exportSettings: {
|
||||
...this.plugin.settings.exportSettings,
|
||||
openFileAfterExport: value,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -690,8 +824,9 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
toggle
|
||||
.setValue(this.plugin.settings.debugMode)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.debugMode = value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
debugMode: value,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -813,17 +948,20 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
new Setting(sectionEl)
|
||||
.setName("API key")
|
||||
.setDesc(
|
||||
"Gemini API key. Required if not logging in with a Google account. (Stored as plain text)",
|
||||
"Gemini API key. Required if not logging in with a Google account. Select from Obsidian's Keychain or create a new secret.",
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("Enter your Gemini API key")
|
||||
.setValue(gemini.apiKey)
|
||||
.addComponent((el) =>
|
||||
new SecretComponent(this.app, el)
|
||||
.setValue(gemini.apiKeySecretId)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.gemini.apiKey = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
text.inputEl.type = "password";
|
||||
});
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
gemini: {
|
||||
...this.plugin.settings.gemini,
|
||||
apiKeySecretId: value,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const geminiPathSetting = new Setting(sectionEl)
|
||||
.setName("Path")
|
||||
|
|
@ -834,13 +972,21 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
text.setPlaceholder("gemini")
|
||||
.setValue(gemini.command)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.gemini.command = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
gemini: {
|
||||
...this.plugin.settings.gemini,
|
||||
command: value.trim(),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
this.addAutoDetectButton(geminiPathSetting, "gemini", async (path) => {
|
||||
this.plugin.settings.gemini.command = path;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
gemini: {
|
||||
...this.plugin.settings.gemini,
|
||||
command: path,
|
||||
},
|
||||
});
|
||||
});
|
||||
this.addInstallHint(sectionEl, "@google/gemini-cli");
|
||||
|
||||
|
|
@ -853,9 +999,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
text.setPlaceholder("")
|
||||
.setValue(this.formatArgs(gemini.args))
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.gemini.args =
|
||||
this.parseArgs(value);
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
gemini: {
|
||||
...this.plugin.settings.gemini,
|
||||
args: this.parseArgs(value),
|
||||
},
|
||||
});
|
||||
});
|
||||
text.inputEl.rows = 3;
|
||||
});
|
||||
|
|
@ -863,14 +1012,18 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
new Setting(sectionEl)
|
||||
.setName("Environment variables")
|
||||
.setDesc(
|
||||
"Enter KEY=VALUE pairs, one per line. Required to authenticate with Vertex AI. GEMINI_API_KEY is derived from the field above.(Stored as plain text)",
|
||||
"Enter KEY=VALUE pairs, one per line. Required to authenticate with Vertex AI. GEMINI_API_KEY is derived from the field above.",
|
||||
)
|
||||
.addTextArea((text) => {
|
||||
text.setPlaceholder("GOOGLE_CLOUD_PROJECT=...")
|
||||
.setValue(this.formatEnv(gemini.env))
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.gemini.env = this.parseEnv(value);
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
gemini: {
|
||||
...this.plugin.settings.gemini,
|
||||
env: this.parseEnv(value),
|
||||
},
|
||||
});
|
||||
});
|
||||
text.inputEl.rows = 3;
|
||||
});
|
||||
|
|
@ -886,17 +1039,20 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
new Setting(sectionEl)
|
||||
.setName("API key")
|
||||
.setDesc(
|
||||
"Anthropic API key. Required if not logging in with an Anthropic account. (Stored as plain text)",
|
||||
"Anthropic API key. Required if not logging in with an Anthropic account. Select from Obsidian's Keychain or create a new secret.",
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("Enter your Anthropic API key")
|
||||
.setValue(claude.apiKey)
|
||||
.addComponent((el) =>
|
||||
new SecretComponent(this.app, el)
|
||||
.setValue(claude.apiKeySecretId)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.claude.apiKey = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
text.inputEl.type = "password";
|
||||
});
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
claude: {
|
||||
...this.plugin.settings.claude,
|
||||
apiKeySecretId: value,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const claudePathSetting = new Setting(sectionEl)
|
||||
.setName("Path")
|
||||
|
|
@ -907,16 +1063,24 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
text.setPlaceholder("claude-agent-acp")
|
||||
.setValue(claude.command)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.claude.command = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
claude: {
|
||||
...this.plugin.settings.claude,
|
||||
command: value.trim(),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
this.addAutoDetectButton(
|
||||
claudePathSetting,
|
||||
"claude-agent-acp",
|
||||
async (path) => {
|
||||
this.plugin.settings.claude.command = path;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
claude: {
|
||||
...this.plugin.settings.claude,
|
||||
command: path,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
this.addInstallHint(sectionEl, "@agentclientprotocol/claude-agent-acp");
|
||||
|
|
@ -930,9 +1094,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
text.setPlaceholder("")
|
||||
.setValue(this.formatArgs(claude.args))
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.claude.args =
|
||||
this.parseArgs(value);
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
claude: {
|
||||
...this.plugin.settings.claude,
|
||||
args: this.parseArgs(value),
|
||||
},
|
||||
});
|
||||
});
|
||||
text.inputEl.rows = 3;
|
||||
});
|
||||
|
|
@ -946,8 +1113,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
text.setPlaceholder("")
|
||||
.setValue(this.formatEnv(claude.env))
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.claude.env = this.parseEnv(value);
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
claude: {
|
||||
...this.plugin.settings.claude,
|
||||
env: this.parseEnv(value),
|
||||
},
|
||||
});
|
||||
});
|
||||
text.inputEl.rows = 3;
|
||||
});
|
||||
|
|
@ -963,17 +1134,20 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
new Setting(sectionEl)
|
||||
.setName("API key")
|
||||
.setDesc(
|
||||
"OpenAI API key. Required if not logging in with an OpenAI account. (Stored as plain text)",
|
||||
"OpenAI API key. Required if not logging in with an OpenAI account. Select from Obsidian's Keychain or create a new secret.",
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("Enter your OpenAI API key")
|
||||
.setValue(codex.apiKey)
|
||||
.addComponent((el) =>
|
||||
new SecretComponent(this.app, el)
|
||||
.setValue(codex.apiKeySecretId)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.codex.apiKey = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
text.inputEl.type = "password";
|
||||
});
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
codex: {
|
||||
...this.plugin.settings.codex,
|
||||
apiKeySecretId: value,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const codexPathSetting = new Setting(sectionEl)
|
||||
.setName("Path")
|
||||
|
|
@ -984,16 +1158,24 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
text.setPlaceholder("codex-acp")
|
||||
.setValue(codex.command)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.codex.command = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
codex: {
|
||||
...this.plugin.settings.codex,
|
||||
command: value.trim(),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
this.addAutoDetectButton(
|
||||
codexPathSetting,
|
||||
"codex-acp",
|
||||
async (path) => {
|
||||
this.plugin.settings.codex.command = path;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
codex: {
|
||||
...this.plugin.settings.codex,
|
||||
command: path,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
this.addInstallHint(sectionEl, "@zed-industries/codex-acp");
|
||||
|
|
@ -1007,8 +1189,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
text.setPlaceholder("")
|
||||
.setValue(this.formatArgs(codex.args))
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.codex.args = this.parseArgs(value);
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
codex: {
|
||||
...this.plugin.settings.codex,
|
||||
args: this.parseArgs(value),
|
||||
},
|
||||
});
|
||||
});
|
||||
text.inputEl.rows = 3;
|
||||
});
|
||||
|
|
@ -1022,8 +1208,12 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
text.setPlaceholder("")
|
||||
.setValue(this.formatEnv(codex.env))
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.codex.env = this.parseEnv(value);
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
codex: {
|
||||
...this.plugin.settings.codex,
|
||||
env: this.parseEnv(value),
|
||||
},
|
||||
});
|
||||
});
|
||||
text.inputEl.rows = 3;
|
||||
});
|
||||
|
|
@ -1056,7 +1246,7 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
env: [],
|
||||
});
|
||||
this.plugin.ensureDefaultAgentId();
|
||||
await this.plugin.saveSettings();
|
||||
await this.flushSettings();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
|
|
@ -1093,7 +1283,7 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.defaultAgentId = nextId;
|
||||
}
|
||||
this.plugin.ensureDefaultAgentId();
|
||||
await this.plugin.saveSettings();
|
||||
await this.flushSettings();
|
||||
this.refreshAgentDropdown();
|
||||
});
|
||||
});
|
||||
|
|
@ -1105,7 +1295,7 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
.onClick(async () => {
|
||||
this.plugin.settings.customAgents.splice(index, 1);
|
||||
this.plugin.ensureDefaultAgentId();
|
||||
await this.plugin.saveSettings();
|
||||
await this.flushSettings();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
|
|
@ -1122,7 +1312,7 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
trimmed.length > 0
|
||||
? trimmed
|
||||
: this.plugin.settings.customAgents[index].id;
|
||||
await this.plugin.saveSettings();
|
||||
await this.flushSettings();
|
||||
this.refreshAgentDropdown();
|
||||
});
|
||||
});
|
||||
|
|
@ -1138,7 +1328,7 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
.onChange(async (value) => {
|
||||
this.plugin.settings.customAgents[index].command =
|
||||
value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
await this.flushSettings();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1153,7 +1343,7 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
.onChange(async (value) => {
|
||||
this.plugin.settings.customAgents[index].args =
|
||||
this.parseArgs(value);
|
||||
await this.plugin.saveSettings();
|
||||
await this.flushSettings();
|
||||
});
|
||||
text.inputEl.rows = 3;
|
||||
});
|
||||
|
|
@ -1169,12 +1359,27 @@ export class AgentClientSettingTab extends PluginSettingTab {
|
|||
.onChange(async (value) => {
|
||||
this.plugin.settings.customAgents[index].env =
|
||||
this.parseEnv(value);
|
||||
await this.plugin.saveSettings();
|
||||
await this.flushSettings();
|
||||
});
|
||||
text.inputEl.rows = 3;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the current `plugin.settings` state through `settingsService.updateSettings()`
|
||||
* so that React components subscribed via `useSettings` re-render.
|
||||
*
|
||||
* Use this after calling legacy helpers (e.g. `ensureDefaultAgentId`) that mutate
|
||||
* `plugin.settings` directly. Passes the current values as the "update" to trigger
|
||||
* the notification pipeline without re-merging.
|
||||
*/
|
||||
private async flushSettings(): Promise<void> {
|
||||
await this.plugin.settingsService.updateSettings({
|
||||
customAgents: this.plugin.settings.customAgents,
|
||||
defaultAgentId: this.plugin.settings.defaultAgentId,
|
||||
});
|
||||
}
|
||||
|
||||
private generateCustomAgentDisplayName(): string {
|
||||
const base = "Custom agent";
|
||||
const existing = new Set<string>();
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ export const ToolCallBlock = React.memo(function ToolCallBlock({
|
|||
...permissionRequest,
|
||||
selectedOptionId: selectedOptionId,
|
||||
}}
|
||||
showEmojis={showEmojis}
|
||||
onApprovePermission={onApprovePermission}
|
||||
onOptionSelected={setSelectedOptionId}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as React from "react";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { setIcon } from "obsidian";
|
||||
import type { AttachedFile } from "../../types/chat";
|
||||
|
||||
|
|
@ -7,6 +8,43 @@ interface AttachmentStripProps {
|
|||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Remove button with a stable ref so setIcon runs once on mount. */
|
||||
function RemoveButton({
|
||||
fileId,
|
||||
onRemove,
|
||||
}: {
|
||||
fileId: string;
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
if (ref.current) setIcon(ref.current, "x");
|
||||
}, []);
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className="agent-client-attachment-preview-remove"
|
||||
onClick={() => onRemove(fileId)}
|
||||
title="Remove attachment"
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** File icon with a stable ref so setIcon runs once on mount. */
|
||||
function FileIcon() {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
useEffect(() => {
|
||||
if (ref.current) setIcon(ref.current, "file");
|
||||
}, []);
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className="agent-client-attachment-preview-file-icon"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal strip of attachment previews with remove buttons.
|
||||
* - Images: show thumbnail
|
||||
|
|
@ -30,26 +68,13 @@ export function AttachmentStrip({ files, onRemove }: AttachmentStripProps) {
|
|||
/>
|
||||
) : (
|
||||
<div className="agent-client-attachment-preview-file">
|
||||
<span
|
||||
className="agent-client-attachment-preview-file-icon"
|
||||
ref={(el) => {
|
||||
if (el) setIcon(el, "file");
|
||||
}}
|
||||
/>
|
||||
<FileIcon />
|
||||
<span className="agent-client-attachment-preview-file-name">
|
||||
{file.name ?? "file"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="agent-client-attachment-preview-remove"
|
||||
onClick={() => onRemove(file.id)}
|
||||
title="Remove attachment"
|
||||
type="button"
|
||||
ref={(el) => {
|
||||
if (el) setIcon(el, "x");
|
||||
}}
|
||||
/>
|
||||
<RemoveButton fileId={file.id} onRemove={onRemove} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,40 +5,60 @@ export interface LoggerConfig {
|
|||
let globalLogger: Logger | null = null;
|
||||
|
||||
export function initializeLogger(config: LoggerConfig): void {
|
||||
globalLogger = new Logger(config);
|
||||
if (globalLogger) {
|
||||
globalLogger.setDebugMode(config.debugMode);
|
||||
} else {
|
||||
globalLogger = new Logger(config);
|
||||
}
|
||||
}
|
||||
|
||||
export function getLogger(): Logger {
|
||||
if (!globalLogger) {
|
||||
return new Logger({ debugMode: false });
|
||||
globalLogger = new Logger({ debugMode: false });
|
||||
}
|
||||
return globalLogger;
|
||||
}
|
||||
|
||||
export function updateDebugMode(debugMode: boolean): void {
|
||||
if (globalLogger) {
|
||||
globalLogger.setDebugMode(debugMode);
|
||||
}
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
constructor(private config: LoggerConfig) {}
|
||||
private debugMode: boolean;
|
||||
|
||||
constructor(config: LoggerConfig) {
|
||||
this.debugMode = config.debugMode;
|
||||
}
|
||||
|
||||
setDebugMode(debugMode: boolean): void {
|
||||
this.debugMode = debugMode;
|
||||
}
|
||||
|
||||
log(...args: unknown[]): void {
|
||||
if (this.config.debugMode) {
|
||||
console.debug(...args);
|
||||
if (this.debugMode) {
|
||||
console.debug("[Debug]", ...args);
|
||||
}
|
||||
}
|
||||
|
||||
error(...args: unknown[]): void {
|
||||
if (this.config.debugMode) {
|
||||
console.error(...args);
|
||||
}
|
||||
}
|
||||
|
||||
warn(...args: unknown[]): void {
|
||||
if (this.config.debugMode) {
|
||||
console.warn(...args);
|
||||
debug(...args: unknown[]): void {
|
||||
if (this.debugMode) {
|
||||
console.debug("[Debug]", ...args);
|
||||
}
|
||||
}
|
||||
|
||||
info(...args: unknown[]): void {
|
||||
if (this.config.debugMode) {
|
||||
console.debug(...args);
|
||||
if (this.debugMode) {
|
||||
console.debug("[Debug]", ...args);
|
||||
}
|
||||
}
|
||||
|
||||
error(...args: unknown[]): void {
|
||||
console.error(...args);
|
||||
}
|
||||
|
||||
warn(...args: unknown[]): void {
|
||||
console.warn(...args);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { execFile } from "child_process";
|
||||
import { Platform } from "obsidian";
|
||||
import { access, stat } from "fs/promises";
|
||||
import { constants } from "fs";
|
||||
import { join } from "path";
|
||||
import { buildWslShellWrapper, getLoginShell } from "./platform";
|
||||
|
||||
/**
|
||||
|
|
@ -9,6 +12,47 @@ export function isAbsolutePath(path: string): boolean {
|
|||
return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort fallback for when `which` returns nothing — e.g. macOS GUI-launched
|
||||
* apps (Finder/Dock) inherit a reduced PATH that excludes /opt/homebrew/bin, so
|
||||
* `which` can fail even when the command is installed. Probes common install
|
||||
* directories directly (PATH-independent), returning only an executable regular
|
||||
* file so the result matches what `which` would have returned.
|
||||
*
|
||||
* Best-effort and intentionally narrow: version-manager installs
|
||||
* (nvm/fnm/asdf/volta) and ~/.local/bin live in per-version directories a static
|
||||
* list cannot enumerate and are out of scope — those users get an honest
|
||||
* "Not found". (Windows solves the same reduced-PATH problem authoritatively via
|
||||
* the registry; see getFullWindowsPath in platform.ts. macOS has no such
|
||||
* side-channel, so this static last-resort list is a deliberate trade-off.)
|
||||
*
|
||||
* @param command - Bare command name (e.g. "node", "codex-acp")
|
||||
* @returns Absolute path to an executable file, or null if not found
|
||||
*/
|
||||
async function findInKnownPaths(command: string): Promise<string | null> {
|
||||
// Only resolve bare names within the listed dirs; never let a separator
|
||||
// escape via join() (defensive — current callers pass hardcoded names).
|
||||
if (command.includes("/") || command.includes("\\")) return null;
|
||||
|
||||
const dirs = Platform.isMacOS
|
||||
? ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
|
||||
: ["/usr/local/bin", "/usr/bin", "/bin"];
|
||||
|
||||
for (const dir of dirs) {
|
||||
const candidate = join(dir, command);
|
||||
try {
|
||||
const st = await stat(candidate); // follows symlinks
|
||||
if (!st.isFile()) continue; // reject directories/sockets
|
||||
await access(candidate, constants.X_OK); // must be executable
|
||||
return candidate;
|
||||
} catch {
|
||||
// missing / not a runnable file / dangling symlink → keep scanning
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the absolute path of a command using `which` (macOS/Linux) or `where` (Windows).
|
||||
* If the command is already an absolute path, returns it as-is.
|
||||
|
|
@ -49,12 +93,21 @@ export function resolveCommandPath(command: string): Promise<string | null> {
|
|||
["-l", "-c", `which '${escaped}'`],
|
||||
{ timeout: 5000 },
|
||||
(err, stdout) => {
|
||||
const fallback = () => {
|
||||
findInKnownPaths(trimmed).then(resolve, () =>
|
||||
resolve(null),
|
||||
);
|
||||
};
|
||||
if (err) {
|
||||
resolve(null);
|
||||
fallback();
|
||||
return;
|
||||
}
|
||||
const resolved = stdout.split("\n")[0].trim();
|
||||
resolve(resolved.length > 0 ? resolved : null);
|
||||
if (resolved.length > 0) {
|
||||
resolve(resolved);
|
||||
} else {
|
||||
fallback();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -95,6 +148,11 @@ export function resolveCommandPathInWsl(
|
|||
{ timeout: 5000 },
|
||||
(err, stdout) => {
|
||||
if (err) {
|
||||
// No known-paths fallback here on purpose: a host-side
|
||||
// existsSync would check the Windows filesystem, not the
|
||||
// Linux FS inside WSL. The wrapper already runs a login
|
||||
// shell (-l, sources ~/.profile), so the reduced-PATH
|
||||
// problem is milder here than on a GUI-launched macOS app.
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
14
src/utils/text.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
const ELLIPSIS = "...";
|
||||
|
||||
/**
|
||||
* Truncate a string so its final length (including ellipsis) does not
|
||||
* exceed `maxLength`. If `text` already fits, it is returned as-is.
|
||||
*
|
||||
* For `maxLength <= ELLIPSIS.length`, the ellipsis is omitted to honor
|
||||
* the length contract — a small but well-defined edge case.
|
||||
*/
|
||||
export function truncateTitle(text: string, maxLength = 50): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
if (maxLength <= ELLIPSIS.length) return text.slice(0, maxLength);
|
||||
return text.slice(0, maxLength - ELLIPSIS.length) + ELLIPSIS;
|
||||
}
|
||||
443
styles.css
|
|
@ -7,6 +7,113 @@ If your plugin does not need CSS, delete this file.
|
|||
|
||||
*/
|
||||
|
||||
/* ===== Session Manager ===== */
|
||||
|
||||
.agent-client-session-manager {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.agent-client-session-manager-empty {
|
||||
padding: 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-small);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.agent-client-session-manager .tree-item-self {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-inline-start: 12px;
|
||||
}
|
||||
|
||||
.agent-client-session-item-more {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
/* Reset <button> browser defaults so the icon matches the prior <div> */
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.agent-client-session-manager .tree-item-self:hover .agent-client-session-item-more {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.agent-client-session-item-more:hover {
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.agent-client-session-item-more svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.agent-client-session-item-text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.agent-client-session-item-title {
|
||||
font-size: var(--font-ui-small);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-normal);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-client-session-item-agent {
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-client-session-status-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-client-session-status-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.agent-client-session-status-ready svg {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.agent-client-session-status-busy svg {
|
||||
color: var(--text-accent);
|
||||
animation: agent-client-spin 1.5s linear infinite;
|
||||
}
|
||||
|
||||
.agent-client-session-status-permission svg {
|
||||
color: var(--color-yellow);
|
||||
}
|
||||
|
||||
.agent-client-session-status-error svg {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.agent-client-session-status-disconnected svg {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
@keyframes agent-client-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ===== Settings Tab Documentation Link ===== */
|
||||
.agent-client-doc-link {
|
||||
padding: 12px 16px;
|
||||
|
|
@ -306,82 +413,85 @@ If your plugin does not need CSS, delete this file.
|
|||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Permission Request */
|
||||
/* ===== Permission Request (vertical option list) ===== */
|
||||
/* Mirrors .agent-client-tool-call-diff: full-bleed block inside the
|
||||
tool call with a 1px border container. Kind colors live on the icon,
|
||||
not the button background (theme compatibility). */
|
||||
.agent-client-message-permission-request {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding: 6px 0;
|
||||
margin-top: 4px;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ===== Permission Request Buttons ===== */
|
||||
.agent-client-permission-option {
|
||||
padding: 8px 16px !important;
|
||||
border: 1px solid var(--background-modifier-border) !important;
|
||||
border-radius: 6px !important;
|
||||
background-color: var(--background-primary) !important;
|
||||
color: var(--text-normal) !important;
|
||||
.agent-client-message-permission-request .agent-client-permission-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background-color: var(--background-primary);
|
||||
box-shadow: none;
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
opacity: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.agent-client-permission-option.agent-client-selected {
|
||||
background-color: var(--interactive-accent) !important;
|
||||
color: white !important;
|
||||
font-weight: 600;
|
||||
.agent-client-message-permission-request
|
||||
.agent-client-permission-option
|
||||
+ .agent-client-permission-option {
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.agent-client-permission-option.agent-client-disabled {
|
||||
background-color: var(--background-modifier-border) !important;
|
||||
color: var(--text-muted) !important;
|
||||
cursor: not-allowed !important;
|
||||
.agent-client-message-permission-request .agent-client-permission-option:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.agent-client-permission-option.agent-client-disabled:not(
|
||||
.agent-client-selected
|
||||
) {
|
||||
opacity: 0.5;
|
||||
.agent-client-message-permission-request
|
||||
.agent-client-permission-option:focus-visible {
|
||||
outline: 2px solid var(--interactive-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Special kinds */
|
||||
.agent-client-permission-option.agent-client-permission-kind-allow_always:not(
|
||||
.agent-client-selected
|
||||
):not(.agent-client-disabled) {
|
||||
background-color: var(--color-green) !important;
|
||||
color: white !important;
|
||||
border-color: var(--color-green) !important;
|
||||
.agent-client-permission-option-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-client-permission-option.agent-client-permission-kind-reject_once:not(
|
||||
.agent-client-selected
|
||||
):not(.agent-client-disabled) {
|
||||
background-color: var(--color-red) !important;
|
||||
color: white !important;
|
||||
border-color: var(--color-red) !important;
|
||||
.agent-client-permission-option-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.agent-client-permission-option.agent-client-permission-kind-allow_once:not(
|
||||
.agent-client-selected
|
||||
):not(.agent-client-disabled) {
|
||||
background-color: var(--color-orange) !important;
|
||||
color: white !important;
|
||||
border-color: var(--color-orange) !important;
|
||||
.agent-client-permission-option-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Normal option button hover (no kind specified) */
|
||||
.agent-client-permission-option:not(
|
||||
.agent-client-permission-kind-allow_always
|
||||
):not(.agent-client-permission-kind-reject_once):not(
|
||||
.agent-client-permission-kind-allow_once
|
||||
):not(.agent-client-disabled):not(.agent-client-selected):hover {
|
||||
background-color: var(--background-modifier-hover) !important;
|
||||
/* Kind colors (icon only) */
|
||||
.agent-client-permission-kind-allow_always
|
||||
.agent-client-permission-option-icon {
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.agent-client-permission-kind-allow_once .agent-client-permission-option-icon {
|
||||
color: var(--color-orange);
|
||||
}
|
||||
|
||||
.agent-client-permission-kind-reject_once .agent-client-permission-option-icon,
|
||||
.agent-client-permission-kind-reject_always
|
||||
.agent-client-permission-option-icon {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
/* ===== Chat View Components ===== */
|
||||
|
|
@ -394,6 +504,11 @@ If your plugin does not need CSS, delete this file.
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Hide stale inner view-header title (updateHeader() doesn't refresh it); visibility:hidden preserves flex layout. */
|
||||
.workspace-leaf-content[data-type="agent-client-chat-view"] > .view-header .view-header-title-container {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Main container */
|
||||
.agent-client-chat-view-container {
|
||||
--ac-chat-font-size: var(--font-text-size);
|
||||
|
|
@ -852,12 +967,18 @@ If your plugin does not need CSS, delete this file.
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Mode Selector */
|
||||
.agent-client-mode-selector {
|
||||
/* Toolbar dropdown — opens an Obsidian Menu on click. */
|
||||
|
||||
.agent-client-toolbar-dropdown {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
padding: 0 4px !important;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 4px !important;
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
height: 20px;
|
||||
|
|
@ -867,185 +988,52 @@ If your plugin does not need CSS, delete this file.
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-client-mode-selector:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
.agent-client-toolbar-dropdown:hover {
|
||||
background-color: var(--background-modifier-hover) !important;
|
||||
}
|
||||
|
||||
.agent-client-mode-selector-icon {
|
||||
.agent-client-toolbar-dropdown:focus {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.agent-client-toolbar-dropdown-label-area {
|
||||
display: grid;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-client-toolbar-dropdown-label-area > * {
|
||||
grid-area: 1 / 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.agent-client-toolbar-dropdown-sizer {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.agent-client-toolbar-dropdown-label {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.agent-client-toolbar-dropdown-chevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
color: var(--text-faint);
|
||||
margin-left: 2px;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.agent-client-mode-selector-icon svg {
|
||||
.agent-client-toolbar-dropdown-chevron svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.agent-client-mode-selector select.dropdown {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background: none !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--text-faint) !important;
|
||||
font-size: 11px !important;
|
||||
cursor: pointer !important;
|
||||
outline: none !important;
|
||||
appearance: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
-moz-appearance: none !important;
|
||||
order: 1;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-client-mode-selector select.dropdown:hover,
|
||||
.agent-client-mode-selector select.dropdown:focus {
|
||||
color: var(--text-faint) !important;
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Model Selector (experimental) */
|
||||
.agent-client-model-selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
height: 20px;
|
||||
max-width: 120px;
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-client-model-selector:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.agent-client-model-selector-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
color: var(--text-faint);
|
||||
margin-left: 2px;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.agent-client-model-selector-icon svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.agent-client-model-selector select.dropdown {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background: none !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--text-faint) !important;
|
||||
font-size: 11px !important;
|
||||
cursor: pointer !important;
|
||||
outline: none !important;
|
||||
appearance: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
-moz-appearance: none !important;
|
||||
order: 1;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-client-model-selector select.dropdown:hover,
|
||||
.agent-client-model-selector select.dropdown:focus {
|
||||
color: var(--text-faint) !important;
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Config option selector (configOptions API - supersedes mode/model selectors) */
|
||||
|
||||
.agent-client-config-options-container {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.agent-client-config-selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
height: 20px;
|
||||
max-width: 120px;
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-client-config-selector:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.agent-client-config-selector-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
color: var(--text-faint);
|
||||
margin-left: 2px;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.agent-client-config-selector-icon svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.agent-client-config-selector select.dropdown {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background: none !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--text-faint) !important;
|
||||
font-size: 11px !important;
|
||||
cursor: pointer !important;
|
||||
outline: none !important;
|
||||
appearance: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
-moz-appearance: none !important;
|
||||
order: 1;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-client-config-selector select.dropdown:hover,
|
||||
.agent-client-config-selector select.dropdown:focus {
|
||||
color: var(--text-faint) !important;
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Context Usage Indicator */
|
||||
.agent-client-usage-indicator {
|
||||
font-size: var(--font-smallest);
|
||||
|
|
@ -1332,7 +1320,10 @@ If your plugin does not need CSS, delete this file.
|
|||
.agent-client-markdown-text-renderer.markdown-rendered pre,
|
||||
.agent-client-markdown-text-renderer.markdown-rendered table,
|
||||
.agent-client-markdown-text-renderer.markdown-rendered .mermaid,
|
||||
.agent-client-markdown-text-renderer.markdown-rendered svg {
|
||||
.agent-client-markdown-text-renderer.markdown-rendered svg,
|
||||
.agent-client-markdown-text-renderer.markdown-rendered .math-block,
|
||||
.agent-client-markdown-text-renderer.markdown-rendered
|
||||
mjx-container[display="true"] {
|
||||
overflow-x: auto;
|
||||
display: block;
|
||||
}
|
||||
|
|
|
|||