3
.npmrc
|
|
@ -1 +1,2 @@
|
|||
tag-version-prefix=""
|
||||
tag-version-prefix=""
|
||||
legacy-peer-deps=true
|
||||
|
|
@ -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
|
||||
|
|
@ -143,6 +143,11 @@ If you sync your vault (e.g., via Nextcloud, Syncthing, or iCloud) across machin
|
|||
- Enable **Settings → Agent Client → Windows Subsystem for Linux → Enable WSL mode**
|
||||
- Optionally specify your distribution in **WSL distribution**
|
||||
|
||||
**Using a Node version manager (nvm/fnm) inside WSL:**
|
||||
- These tools usually add `node` to `PATH` only in **interactive** shells (e.g. from `~/.bashrc`), so the agent — launched non-interactively — may not see your nvm-managed `node` and can fall back to an old system `node` (or none).
|
||||
- Fix: set **Node.js path** to the absolute path of your nvm `node` directory, e.g. `/home/<user>/.nvm/versions/node/<version>/bin`. The plugin then injects it into `PATH` for the agent.
|
||||
- API keys: in WSL mode you can enter them directly in the agent's API key field — the plugin forwards them into WSL automatically (no need to put them in `~/.profile`).
|
||||
|
||||
### Agent works in Terminal but not in Obsidian
|
||||
|
||||
The PATH environment may differ between Terminal and Obsidian.
|
||||
|
|
|
|||
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
|
||||
|
|
@ -13,7 +13,7 @@ export default defineConfig([
|
|||
files: ["**/*.ts", "**/*.tsx"],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: { project: "./tsconfig.json" },
|
||||
parserOptions: { project: "./tsconfig.eslint.json" },
|
||||
},
|
||||
rules: {
|
||||
// Preserve existing rules
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"id": "agent-client",
|
||||
"name": "Agent Client",
|
||||
"version": "0.10.6",
|
||||
"minAppVersion": "1.7.2",
|
||||
"version": "0.11.0",
|
||||
"minAppVersion": "1.11.4",
|
||||
"description": "Chat with AI agents via the Agent Client Protocol directly from your vault.",
|
||||
"author": "RAIT-09",
|
||||
"authorUrl": "https://github.com/RAIT-09",
|
||||
"fundingUrl": "https://www.buymeacoffee.com/rait09",
|
||||
"isDesktopOnly": true
|
||||
}
|
||||
}
|
||||
1562
package-lock.json
generated
13
package.json
|
|
@ -1,11 +1,13 @@
|
|||
{
|
||||
"name": "obsidian-agent-client",
|
||||
"version": "0.10.6",
|
||||
"version": "0.11.0",
|
||||
"description": "Use AI coding agents via the Agent Client Protocol directly inside Obsidian",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx}\" \"*.css\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"*.css\"",
|
||||
|
|
@ -19,6 +21,7 @@
|
|||
"author": "",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@eslint/json": "^0.14.0",
|
||||
"@types/node": "^16.11.6",
|
||||
"@types/react": "18.3.29",
|
||||
"@types/react-dom": "18.3.7",
|
||||
|
|
@ -33,16 +36,18 @@
|
|||
"tslib": "2.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vitepress": "^1.6.4"
|
||||
"vitepress": "^1.6.4",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.14.1",
|
||||
"@agentclientprotocol/sdk": "^0.28.1",
|
||||
"@codemirror/state": "6.5.0",
|
||||
"@codemirror/view": "^6.35.0",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"diff": "^8.0.2",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"semver": "^7.7.3"
|
||||
"semver": "^7.7.3",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
convertWindowsPathToWsl,
|
||||
getEnhancedWindowsEnv,
|
||||
prepareShellCommand,
|
||||
buildWslEnv,
|
||||
} from "../utils/platform";
|
||||
import { resolveNodeDirectory } from "../utils/paths";
|
||||
import {
|
||||
|
|
@ -42,6 +43,19 @@ export interface AgentConfig {
|
|||
args: string[];
|
||||
env?: Record<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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -61,7 +75,7 @@ export interface TerminalOutputResult {
|
|||
*/
|
||||
export class AcpClient {
|
||||
// Connection & process
|
||||
private connection: acp.ClientSideConnection | null = null;
|
||||
private connection: acp.ClientConnection | null = null;
|
||||
private agentProcess: ChildProcess | null = null;
|
||||
private currentConfig: AgentConfig | null = null;
|
||||
private isInitializedFlag = false;
|
||||
|
|
@ -110,7 +124,7 @@ export class AcpClient {
|
|||
async initialize(config: AgentConfig): Promise<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}`,
|
||||
|
|
@ -180,6 +194,30 @@ export class AcpClient {
|
|||
);
|
||||
}
|
||||
|
||||
// Resolve API key secret just before spawn so the latest value is used.
|
||||
// Custom agents don't set config.apiKey and inject keys via env directly.
|
||||
if (config.apiKey) {
|
||||
const secretValue =
|
||||
this.plugin.app.secretStorage.getSecret(
|
||||
config.apiKey.secretId,
|
||||
) ?? "";
|
||||
baseEnv[config.apiKey.envVarName] = secretValue;
|
||||
}
|
||||
|
||||
// In WSL mode, forward the configured env var NAMES into WSL via WSLENV
|
||||
// (Windows env vars are otherwise invisible to the Linux agent process,
|
||||
// so the plugin's API key field would have no effect in WSL). Built-in
|
||||
// agents resolve the API key into baseEnv above — not into config.env —
|
||||
// so its var name must be added explicitly, or the key would never cross
|
||||
// into WSL. Must run AFTER the secret is injected into baseEnv. (#312)
|
||||
if (Platform.isWin && this.plugin.settings.windowsWslMode) {
|
||||
const wslEnvNames = Object.keys(config.env || {});
|
||||
if (config.apiKey?.envVarName) {
|
||||
wslEnvNames.push(config.apiKey.envVarName);
|
||||
}
|
||||
baseEnv = buildWslEnv(baseEnv, wslEnvNames);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
"[AcpClient] Starting agent process in directory:",
|
||||
config.workingDirectory,
|
||||
|
|
@ -341,15 +379,44 @@ export class AcpClient {
|
|||
);
|
||||
|
||||
const stream = acp.ndJsonStream(input, output);
|
||||
this.connection = new acp.ClientSideConnection(
|
||||
() => this.handler,
|
||||
stream,
|
||||
);
|
||||
// Build the client app by registering handlers by ACP method name, then
|
||||
// hold the persistent connection. This is the same builder the deprecated
|
||||
// ClientSideConnection constructed internally (legacyClientApp), inlined.
|
||||
const app = acp
|
||||
.client({ name: "obsidian-agent-client" })
|
||||
.onNotification("session/update", (ctx) =>
|
||||
this.handler.sessionUpdate(ctx.params),
|
||||
)
|
||||
.onRequest("session/request_permission", (ctx) =>
|
||||
this.handler.requestPermission(ctx.params),
|
||||
)
|
||||
.onRequest("fs/read_text_file", (ctx) =>
|
||||
this.handler.readTextFile(ctx.params),
|
||||
)
|
||||
.onRequest("fs/write_text_file", (ctx) =>
|
||||
this.handler.writeTextFile(ctx.params),
|
||||
)
|
||||
.onRequest("terminal/create", (ctx) =>
|
||||
this.handler.createTerminal(ctx.params),
|
||||
)
|
||||
.onRequest("terminal/output", (ctx) =>
|
||||
this.handler.terminalOutput(ctx.params),
|
||||
)
|
||||
.onRequest("terminal/wait_for_exit", (ctx) =>
|
||||
this.handler.waitForTerminalExit(ctx.params),
|
||||
)
|
||||
.onRequest("terminal/kill", (ctx) =>
|
||||
this.handler.killTerminal(ctx.params),
|
||||
)
|
||||
.onRequest("terminal/release", (ctx) =>
|
||||
this.handler.releaseTerminal(ctx.params),
|
||||
);
|
||||
this.connection = app.connect(stream);
|
||||
|
||||
try {
|
||||
this.logger.log("[AcpClient] Starting ACP initialization...");
|
||||
|
||||
const initResult = await this.connection.initialize({
|
||||
const initResult = await this.connection.agent.request("initialize", {
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
clientCapabilities: {
|
||||
fs: {
|
||||
|
|
@ -384,6 +451,35 @@ export class AcpClient {
|
|||
}
|
||||
}
|
||||
|
||||
private getSafeConfigForLog(config: AgentConfig): {
|
||||
id: string;
|
||||
displayName: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
envKeys: string[];
|
||||
workingDirectory: string;
|
||||
apiKeyEnvVar?: string;
|
||||
} {
|
||||
return {
|
||||
id: config.id,
|
||||
displayName: config.displayName,
|
||||
command: config.command,
|
||||
args: [...config.args],
|
||||
envKeys: Object.keys(config.env || {}),
|
||||
workingDirectory: config.workingDirectory,
|
||||
// Log env var name only — never the secret ID or value
|
||||
apiKeyEnvVar: config.apiKey?.envVarName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the auto-allow permission setting on a live client.
|
||||
* Called by the plugin when the setting changes at runtime.
|
||||
*/
|
||||
updateAutoAllow(autoAllow: boolean): void {
|
||||
this.permissionManager.setAutoAllow(autoAllow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chat session with the agent.
|
||||
*/
|
||||
|
|
@ -393,7 +489,7 @@ export class AcpClient {
|
|||
try {
|
||||
this.logger.log("[AcpClient] Creating new session...");
|
||||
|
||||
const response = await connection.newSession({
|
||||
const response = await connection.agent.request("session/new", {
|
||||
cwd: this.toSessionCwd(workingDirectory),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
|
@ -420,7 +516,7 @@ export class AcpClient {
|
|||
const connection = this.requireConnection();
|
||||
|
||||
try {
|
||||
await connection.authenticate({ methodId });
|
||||
await connection.agent.request("authenticate", { methodId });
|
||||
this.logger.log("[AcpClient] ✅ authenticate ok:", methodId);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
|
|
@ -451,7 +547,7 @@ export class AcpClient {
|
|||
`[AcpClient] Sending prompt with ${content.length} content blocks`,
|
||||
);
|
||||
|
||||
const promptResult = await connection.prompt({
|
||||
const promptResult = await connection.agent.request("session/prompt", {
|
||||
sessionId: sessionId,
|
||||
prompt: acpContent,
|
||||
});
|
||||
|
|
@ -506,7 +602,7 @@ export class AcpClient {
|
|||
this.logger.log(
|
||||
"[AcpClient] Sending session/cancel notification...",
|
||||
);
|
||||
await this.connection.cancel({ sessionId });
|
||||
await this.connection.agent.notify("session/cancel", { sessionId });
|
||||
this.logger.log(
|
||||
"[AcpClient] Cancellation request sent successfully",
|
||||
);
|
||||
|
|
@ -608,7 +704,7 @@ export class AcpClient {
|
|||
);
|
||||
|
||||
try {
|
||||
await connection.setSessionMode({
|
||||
await connection.agent.request("session/set_mode", {
|
||||
sessionId,
|
||||
modeId,
|
||||
});
|
||||
|
|
@ -619,31 +715,6 @@ export class AcpClient {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DEPRECATED: Use setSessionConfigOption instead.
|
||||
*/
|
||||
async setSessionModel(sessionId: string, modelId: string): Promise<void> {
|
||||
const connection = this.requireConnection();
|
||||
|
||||
this.logger.log(
|
||||
`[AcpClient] Setting session model to: ${modelId} for session: ${sessionId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await connection.unstable_setSessionModel({
|
||||
sessionId,
|
||||
modelId,
|
||||
});
|
||||
this.logger.log(`[AcpClient] Session model set to: ${modelId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
"[AcpClient] Failed to set session model:",
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a session configuration option.
|
||||
*
|
||||
|
|
@ -663,11 +734,14 @@ export class AcpClient {
|
|||
);
|
||||
|
||||
try {
|
||||
const response = await connection.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId,
|
||||
value,
|
||||
});
|
||||
const response = await connection.agent.request(
|
||||
"session/set_config_option",
|
||||
{
|
||||
sessionId,
|
||||
configId,
|
||||
value,
|
||||
},
|
||||
);
|
||||
this.logger.log(
|
||||
`[AcpClient] Config option set. Updated options:`,
|
||||
response.configOptions,
|
||||
|
|
@ -722,7 +796,7 @@ export class AcpClient {
|
|||
* Assert that the ACP connection is initialized and return it.
|
||||
* @throws Error if connection is not available
|
||||
*/
|
||||
private requireConnection(): acp.ClientSideConnection {
|
||||
private requireConnection(): acp.ClientConnection {
|
||||
if (!this.connection) {
|
||||
throw new Error(
|
||||
"Connection not initialized. Call initialize() first.",
|
||||
|
|
@ -781,7 +855,7 @@ export class AcpClient {
|
|||
|
||||
const filterCwd = cwd ? this.toSessionCwd(cwd) : undefined;
|
||||
|
||||
const response = await connection.unstable_listSessions({
|
||||
const response = await connection.agent.request("session/list", {
|
||||
cwd: filterCwd ?? null,
|
||||
cursor: cursor ?? null,
|
||||
});
|
||||
|
|
@ -824,7 +898,7 @@ export class AcpClient {
|
|||
try {
|
||||
this.logger.log(`[AcpClient] Loading session: ${sessionId}...`);
|
||||
|
||||
const response = await connection.loadSession({
|
||||
const response = await connection.agent.request("session/load", {
|
||||
sessionId,
|
||||
cwd: this.toSessionCwd(cwd),
|
||||
mcpServers: [],
|
||||
|
|
@ -864,7 +938,7 @@ export class AcpClient {
|
|||
try {
|
||||
this.logger.log(`[AcpClient] Resuming session: ${sessionId}...`);
|
||||
|
||||
const response = await connection.unstable_resumeSession({
|
||||
const response = await connection.agent.request("session/resume", {
|
||||
sessionId,
|
||||
cwd: this.toSessionCwd(cwd),
|
||||
mcpServers: [],
|
||||
|
|
@ -898,7 +972,7 @@ export class AcpClient {
|
|||
try {
|
||||
this.logger.log(`[AcpClient] Forking session: ${sessionId}...`);
|
||||
|
||||
const response = await connection.unstable_forkSession({
|
||||
const response = await connection.agent.request("session/fork", {
|
||||
sessionId,
|
||||
cwd: this.toSessionCwd(cwd),
|
||||
mcpServers: [],
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import type { Logger } from "../utils/logger";
|
|||
/**
|
||||
* Handles incoming ACP protocol events from the agent.
|
||||
*
|
||||
* Implements the acp.Client interface to receive session updates,
|
||||
* permission requests, and terminal operations from the SDK's
|
||||
* ClientSideConnection dispatch.
|
||||
* These methods are registered by ACP method name on the client app
|
||||
* (acp.client(...)) in AcpClient, and receive session updates, permission
|
||||
* requests, and terminal operations dispatched by the SDK connection.
|
||||
*
|
||||
* This class does not initiate communication — that is AcpClient's role.
|
||||
* It only reacts to events from the agent side.
|
||||
|
|
@ -61,14 +61,17 @@ export class AcpHandler {
|
|||
}
|
||||
|
||||
// ====================================================================
|
||||
// ACP Client Protocol Handlers (called by ClientSideConnection)
|
||||
// ACP Client Protocol Handlers (registered on the client app)
|
||||
// ====================================================================
|
||||
|
||||
sessionUpdate(params: acp.SessionNotification): Promise<void> {
|
||||
const update = params.update;
|
||||
const sessionId = params.sessionId;
|
||||
this.promptSessionUpdateCount++;
|
||||
this.logger.log("[AcpHandler] sessionUpdate:", { sessionId, update });
|
||||
this.logger.log("[AcpHandler] sessionUpdate:", {
|
||||
sessionId,
|
||||
type: update.sessionUpdate,
|
||||
});
|
||||
|
||||
switch (update.sessionUpdate) {
|
||||
case "agent_message_chunk":
|
||||
|
|
@ -164,20 +167,6 @@ export class AcpHandler {
|
|||
return this.permissionManager.request(params);
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// ACP Extension Handlers
|
||||
// ====================================================================
|
||||
|
||||
async extNotification(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
this.logger.log(
|
||||
`[AcpHandler] Extension notification received: ${method}`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// File System Stubs
|
||||
// ====================================================================
|
||||
|
|
@ -191,7 +180,7 @@ export class AcpHandler {
|
|||
}
|
||||
|
||||
// ====================================================================
|
||||
// Terminal Operations (called by ClientSideConnection)
|
||||
// Terminal Operations (registered on the client app)
|
||||
// ====================================================================
|
||||
|
||||
createTerminal(
|
||||
|
|
@ -229,8 +218,8 @@ export class AcpHandler {
|
|||
}
|
||||
|
||||
killTerminal(
|
||||
params: acp.KillTerminalCommandRequest,
|
||||
): Promise<acp.KillTerminalCommandResponse> {
|
||||
params: acp.KillTerminalRequest,
|
||||
): Promise<acp.KillTerminalResponse> {
|
||||
const success = this.terminalManager.killTerminal(params.terminalId);
|
||||
if (!success) {
|
||||
throw new Error(`Terminal ${params.terminalId} not found`);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ import type AgentClientPlugin from "../plugin";
|
|||
import { getLogger, Logger } from "../utils/logger";
|
||||
import { Platform } from "obsidian";
|
||||
import { resolveNodeDirectory } from "../utils/paths";
|
||||
import { getEnhancedWindowsEnv, prepareShellCommand } from "../utils/platform";
|
||||
import {
|
||||
getEnhancedWindowsEnv,
|
||||
prepareShellCommand,
|
||||
buildWslEnv,
|
||||
} from "../utils/platform";
|
||||
|
||||
/**
|
||||
* Parameters for creating a terminal process.
|
||||
|
|
@ -70,6 +74,15 @@ export class TerminalManager {
|
|||
}
|
||||
}
|
||||
|
||||
// In WSL mode, forward the tool-provided env vars into WSL via WSLENV
|
||||
// (Windows env is otherwise not visible to the Linux process).
|
||||
if (Platform.isWin && this.plugin.settings.windowsWslMode && params.env) {
|
||||
env = buildWslEnv(
|
||||
env,
|
||||
params.env.map((e) => e.name),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle command parsing
|
||||
let command = params.command;
|
||||
let args = params.args || [];
|
||||
|
|
@ -97,9 +110,13 @@ export class TerminalManager {
|
|||
cwd: params.cwd,
|
||||
});
|
||||
|
||||
// Spawn the process
|
||||
// Spawn the process.
|
||||
// In WSL mode the working directory is applied inside the launcher
|
||||
// (cd '<wslCwd>'); the wsl.exe process must NOT receive a Linux path as
|
||||
// its Windows cwd (CreateProcess would fail), so omit cwd there.
|
||||
const useWsl = Platform.isWin && this.plugin.settings.windowsWslMode;
|
||||
const spawnOptions: SpawnOptions = {
|
||||
cwd: params.cwd || undefined,
|
||||
cwd: useWsl ? undefined : params.cwd || undefined,
|
||||
env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: needsShell,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import type {
|
|||
interface AcpSessionResponse {
|
||||
sessionId?: string;
|
||||
modes?: acp.SessionModeState | null;
|
||||
models?: acp.SessionModelState | null;
|
||||
configOptions?: acp.SessionConfigOption[] | null;
|
||||
}
|
||||
|
||||
|
|
@ -100,15 +99,29 @@ export class AcpTypeConverter {
|
|||
static toSessionConfigOptions(
|
||||
acpOptions: acp.SessionConfigOption[],
|
||||
): SessionConfigOption[] {
|
||||
return acpOptions.map((opt) => ({
|
||||
id: opt.id,
|
||||
name: opt.name,
|
||||
description: opt.description ?? undefined,
|
||||
category: opt.category ?? undefined,
|
||||
type: opt.type,
|
||||
currentValue: opt.currentValue,
|
||||
options: this.toSessionConfigSelectOptions(opt.options),
|
||||
}));
|
||||
return acpOptions.map((opt): SessionConfigOption => {
|
||||
const base = {
|
||||
id: opt.id,
|
||||
name: opt.name,
|
||||
description: opt.description ?? undefined,
|
||||
category: opt.category ?? undefined,
|
||||
};
|
||||
// boolean options (ACP 0.28+) are carried as data; the UI renders
|
||||
// and sets only select options for now.
|
||||
if (opt.type === "boolean") {
|
||||
return {
|
||||
...base,
|
||||
type: "boolean",
|
||||
currentValue: opt.currentValue,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
type: "select",
|
||||
currentValue: opt.currentValue,
|
||||
options: this.toSessionConfigSelectOptions(opt.options),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private static toSessionConfigSelectOptions(
|
||||
|
|
@ -166,18 +179,6 @@ export class AcpTypeConverter {
|
|||
};
|
||||
}
|
||||
|
||||
let models: SessionResult["models"];
|
||||
if (response.models) {
|
||||
models = {
|
||||
availableModels: response.models.availableModels.map((m) => ({
|
||||
modelId: m.modelId,
|
||||
name: m.name,
|
||||
description: m.description ?? undefined,
|
||||
})),
|
||||
currentModelId: response.models.currentModelId,
|
||||
};
|
||||
}
|
||||
|
||||
const configOptions = response.configOptions
|
||||
? this.toSessionConfigOptions(response.configOptions)
|
||||
: undefined;
|
||||
|
|
@ -185,7 +186,6 @@ export class AcpTypeConverter {
|
|||
return {
|
||||
sessionId,
|
||||
modes,
|
||||
models,
|
||||
configOptions,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import type { ChatMessage, ActivePermission } from "../types/chat";
|
|||
import type {
|
||||
ChatSession,
|
||||
SessionModeState,
|
||||
SessionModelState,
|
||||
SessionConfigOption,
|
||||
} from "../types/session";
|
||||
import type { AgentDisplayInfo } from "../services/session-helpers";
|
||||
|
|
@ -66,13 +65,11 @@ export interface UseAgentReturn {
|
|||
updateSessionFromLoad: (
|
||||
sessionId: string,
|
||||
modes?: SessionModeState,
|
||||
models?: SessionModelState,
|
||||
configOptions?: SessionConfigOption[],
|
||||
) => Promise<void>;
|
||||
|
||||
// Config
|
||||
setMode: (modeId: string) => Promise<void>;
|
||||
setModel: (modelId: string) => Promise<void>;
|
||||
setConfigOption: (configId: string, value: string) => Promise<void>;
|
||||
|
||||
// Message operations
|
||||
|
|
@ -199,7 +196,6 @@ export function useAgent(
|
|||
|
||||
// Config
|
||||
setMode: agentSession.setMode,
|
||||
setModel: agentSession.setModel,
|
||||
setConfigOption: agentSession.setConfigOption,
|
||||
|
||||
// Message operations
|
||||
|
|
@ -232,7 +228,6 @@ export function useAgent(
|
|||
agentSession.getAvailableAgents,
|
||||
agentSession.updateSessionFromLoad,
|
||||
agentSession.setMode,
|
||||
agentSession.setModel,
|
||||
agentSession.setConfigOption,
|
||||
agentMessages.sendMessage,
|
||||
agentMessages.clearMessages,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ const { useState, useCallback, useRef } = React;
|
|||
import type {
|
||||
ChatSession,
|
||||
SessionModeState,
|
||||
SessionModelState,
|
||||
SessionUpdate,
|
||||
SessionConfigOption,
|
||||
} from "../types/session";
|
||||
|
|
@ -19,6 +18,7 @@ import type { AcpClient } from "../acp/acp-client";
|
|||
import type { ISettingsAccess } from "../services/settings-service";
|
||||
import type { ErrorInfo } from "../types/errors";
|
||||
import { extractErrorMessage } from "../utils/error-utils";
|
||||
import { getLogger } from "../utils/logger";
|
||||
import {
|
||||
type AgentDisplayInfo,
|
||||
getDefaultAgentId,
|
||||
|
|
@ -31,6 +31,7 @@ import {
|
|||
import {
|
||||
applyLegacyValue,
|
||||
tryRestoreConfigOption,
|
||||
restoreSavedConfigOptions,
|
||||
restoreLegacyConfig,
|
||||
} from "../services/session-state";
|
||||
|
||||
|
|
@ -58,13 +59,11 @@ export interface UseAgentSessionReturn {
|
|||
updateSessionFromLoad: (
|
||||
sessionId: string,
|
||||
modes?: SessionModeState,
|
||||
models?: SessionModelState,
|
||||
configOptions?: SessionConfigOption[],
|
||||
) => Promise<void>;
|
||||
|
||||
// Config
|
||||
setMode: (modeId: string) => Promise<void>;
|
||||
setModel: (modelId: string) => Promise<void>;
|
||||
setConfigOption: (configId: string, value: string) => Promise<void>;
|
||||
|
||||
/** Handle session-level updates (commands, mode, config, usage, error) */
|
||||
|
|
@ -182,7 +181,6 @@ export function useAgentSession(
|
|||
authMethods: [],
|
||||
availableCommands: undefined,
|
||||
modes: undefined,
|
||||
models: undefined,
|
||||
configOptions: undefined,
|
||||
usage: undefined,
|
||||
promptCapabilities: prev.promptCapabilities,
|
||||
|
|
@ -223,29 +221,23 @@ export function useAgentSession(
|
|||
const sessionResult =
|
||||
await agentClient.newSession(effectiveCwd);
|
||||
|
||||
setSession((prev) => ({
|
||||
...prev,
|
||||
sessionId: sessionResult.sessionId,
|
||||
state: "ready",
|
||||
authMethods: initResult?.authMethods ?? [],
|
||||
modes: sessionResult.modes,
|
||||
models: sessionResult.models,
|
||||
configOptions: sessionResult.configOptions,
|
||||
promptCapabilities: initResult
|
||||
? initResult.promptCapabilities
|
||||
: prev.promptCapabilities,
|
||||
agentCapabilities: initResult
|
||||
? initResult.agentCapabilities
|
||||
: prev.agentCapabilities,
|
||||
agentInfo: initResult
|
||||
? initResult.agentInfo
|
||||
: prev.agentInfo,
|
||||
lastActivityAt: new Date(),
|
||||
}));
|
||||
// Pre-compute restored modes/configOptions BEFORE
|
||||
// marking state as "ready" to avoid a UI race: without this,
|
||||
// the dropdowns briefly show the agent's default values and
|
||||
// a message sent during the window hits the agent in the
|
||||
// wrong mode. With this, the first render after session
|
||||
// creation already shows the user's saved selection.
|
||||
let finalModes = sessionResult.modes;
|
||||
let finalConfigOptions = sessionResult.configOptions;
|
||||
|
||||
// Restore last used config (model/mode)
|
||||
if (sessionResult.configOptions && sessionResult.sessionId) {
|
||||
let configOptions = sessionResult.configOptions;
|
||||
configOptions = await restoreSavedConfigOptions(
|
||||
agentClient,
|
||||
sessionResult.sessionId,
|
||||
configOptions,
|
||||
settings.lastUsedConfigOptions[agentId],
|
||||
);
|
||||
configOptions = await tryRestoreConfigOption(
|
||||
agentClient,
|
||||
sessionResult.sessionId,
|
||||
|
|
@ -260,21 +252,34 @@ export function useAgentSession(
|
|||
"mode",
|
||||
settings.lastUsedModes[agentId],
|
||||
);
|
||||
if (configOptions !== sessionResult.configOptions) {
|
||||
setSession((prev) => ({
|
||||
...prev,
|
||||
configOptions,
|
||||
}));
|
||||
}
|
||||
finalConfigOptions = configOptions;
|
||||
} else if (sessionResult.sessionId) {
|
||||
await restoreLegacyConfig(
|
||||
const restored = await restoreLegacyConfig(
|
||||
agentClient,
|
||||
sessionResult,
|
||||
settings.lastUsedModels[agentId],
|
||||
settings.lastUsedModes[agentId],
|
||||
setSession,
|
||||
);
|
||||
finalModes = restored.modes;
|
||||
}
|
||||
|
||||
setSession((prev) => ({
|
||||
...prev,
|
||||
sessionId: sessionResult.sessionId,
|
||||
state: "ready",
|
||||
authMethods: initResult?.authMethods ?? [],
|
||||
modes: finalModes,
|
||||
configOptions: finalConfigOptions,
|
||||
promptCapabilities: initResult
|
||||
? initResult.promptCapabilities
|
||||
: prev.promptCapabilities,
|
||||
agentCapabilities: initResult
|
||||
? initResult.agentCapabilities
|
||||
: prev.agentCapabilities,
|
||||
agentInfo: initResult
|
||||
? initResult.agentInfo
|
||||
: prev.agentInfo,
|
||||
lastActivityAt: new Date(),
|
||||
}));
|
||||
} catch (error) {
|
||||
setSession((prev) => ({ ...prev, state: "error" }));
|
||||
setErrorInfo({
|
||||
|
|
@ -301,13 +306,13 @@ export function useAgentSession(
|
|||
try {
|
||||
await agentClient.cancel(s.sessionId);
|
||||
} catch (error) {
|
||||
console.warn("Failed to cancel session:", error);
|
||||
getLogger().warn("Failed to cancel session:", error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await agentClient.disconnect();
|
||||
} catch (error) {
|
||||
console.warn("Failed to disconnect:", error);
|
||||
getLogger().warn("Failed to disconnect:", error);
|
||||
}
|
||||
setSession((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -329,7 +334,7 @@ export function useAgentSession(
|
|||
await agentClient.cancel(s.sessionId);
|
||||
setSession((prev) => ({ ...prev, state: "ready" }));
|
||||
} catch (error) {
|
||||
console.warn("Failed to cancel operation:", error);
|
||||
getLogger().warn("Failed to cancel operation:", error);
|
||||
setSession((prev) => ({ ...prev, state: "ready" }));
|
||||
}
|
||||
}, [agentClient]);
|
||||
|
|
@ -343,26 +348,27 @@ export function useAgentSession(
|
|||
async (
|
||||
sessionId: string,
|
||||
modes?: SessionModeState,
|
||||
models?: SessionModelState,
|
||||
configOptions?: SessionConfigOption[],
|
||||
) => {
|
||||
setSession((prev) => ({
|
||||
...prev,
|
||||
sessionId,
|
||||
state: "ready",
|
||||
modes: modes ?? prev.modes,
|
||||
models: models ?? prev.models,
|
||||
configOptions: configOptions ?? prev.configOptions,
|
||||
lastActivityAt: new Date(),
|
||||
}));
|
||||
|
||||
// Restore last used config (model/mode) — same logic as createSession
|
||||
// Pre-compute restored config BEFORE marking ready to avoid a UI
|
||||
// race where the dropdowns briefly show the agent's current values
|
||||
// before the user's saved selection is re-applied. See the matching
|
||||
// refactor in createSession for the rationale.
|
||||
const s = sessionRef.current;
|
||||
const settings = settingsAccess.getSnapshot();
|
||||
const agentId = s.agentId;
|
||||
|
||||
let finalModes = modes;
|
||||
let finalConfigOptions = configOptions;
|
||||
|
||||
if (configOptions && sessionId) {
|
||||
let restored = configOptions;
|
||||
restored = await restoreSavedConfigOptions(
|
||||
agentClient,
|
||||
sessionId,
|
||||
restored,
|
||||
settings.lastUsedConfigOptions[agentId],
|
||||
);
|
||||
restored = await tryRestoreConfigOption(
|
||||
agentClient,
|
||||
sessionId,
|
||||
|
|
@ -377,21 +383,24 @@ export function useAgentSession(
|
|||
"mode",
|
||||
settings.lastUsedModes[agentId],
|
||||
);
|
||||
if (restored !== configOptions) {
|
||||
setSession((prev) => ({
|
||||
...prev,
|
||||
configOptions: restored,
|
||||
}));
|
||||
}
|
||||
finalConfigOptions = restored;
|
||||
} else if (sessionId && modes) {
|
||||
await restoreLegacyConfig(
|
||||
const restored = await restoreLegacyConfig(
|
||||
agentClient,
|
||||
{ sessionId, modes, models, configOptions: undefined },
|
||||
settings.lastUsedModels[agentId],
|
||||
{ sessionId, modes, configOptions: undefined },
|
||||
settings.lastUsedModes[agentId],
|
||||
setSession,
|
||||
);
|
||||
finalModes = restored.modes;
|
||||
}
|
||||
|
||||
setSession((prev) => ({
|
||||
...prev,
|
||||
sessionId,
|
||||
state: "ready",
|
||||
modes: finalModes ?? prev.modes,
|
||||
configOptions: finalConfigOptions ?? prev.configOptions,
|
||||
lastActivityAt: new Date(),
|
||||
}));
|
||||
},
|
||||
[agentClient, settingsAccess],
|
||||
);
|
||||
|
|
@ -401,44 +410,33 @@ export function useAgentSession(
|
|||
// ============================================================
|
||||
|
||||
const setLegacyConfigValue = useCallback(
|
||||
async (kind: "mode" | "model", value: string) => {
|
||||
async (value: string) => {
|
||||
const s = sessionRef.current;
|
||||
if (!s.sessionId) {
|
||||
console.warn(`Cannot set ${kind}: no active session`);
|
||||
getLogger().debug("Cannot set mode: no active session");
|
||||
return;
|
||||
}
|
||||
|
||||
const previousValue =
|
||||
kind === "mode"
|
||||
? s.modes?.currentModeId
|
||||
: s.models?.currentModelId;
|
||||
const previousValue = s.modes?.currentModeId;
|
||||
|
||||
setSession((prev) => applyLegacyValue(prev, kind, value));
|
||||
setSession((prev) => applyLegacyValue(prev, value));
|
||||
|
||||
try {
|
||||
if (kind === "mode") {
|
||||
await agentClient.setSessionMode(s.sessionId, value);
|
||||
} else {
|
||||
await agentClient.setSessionModel(s.sessionId, value);
|
||||
}
|
||||
await agentClient.setSessionMode(s.sessionId, value);
|
||||
|
||||
if (s.agentId) {
|
||||
const persistKey =
|
||||
kind === "mode" ? "lastUsedModes" : "lastUsedModels";
|
||||
const currentSettings = settingsAccess.getSnapshot();
|
||||
void settingsAccess.updateSettings({
|
||||
[persistKey]: {
|
||||
...currentSettings[persistKey],
|
||||
lastUsedModes: {
|
||||
...currentSettings.lastUsedModes,
|
||||
[s.agentId]: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to set ${kind}:`, error);
|
||||
getLogger().error("Failed to set mode:", error);
|
||||
if (previousValue) {
|
||||
setSession((prev) =>
|
||||
applyLegacyValue(prev, kind, previousValue),
|
||||
);
|
||||
setSession((prev) => applyLegacyValue(prev, previousValue));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -446,12 +444,7 @@ export function useAgentSession(
|
|||
);
|
||||
|
||||
const setMode = useCallback(
|
||||
(modeId: string) => setLegacyConfigValue("mode", modeId),
|
||||
[setLegacyConfigValue],
|
||||
);
|
||||
|
||||
const setModel = useCallback(
|
||||
(modelId: string) => setLegacyConfigValue("model", modelId),
|
||||
(modeId: string) => setLegacyConfigValue(modeId),
|
||||
[setLegacyConfigValue],
|
||||
);
|
||||
|
||||
|
|
@ -459,7 +452,9 @@ export function useAgentSession(
|
|||
async (configId: string, value: string) => {
|
||||
const s = sessionRef.current;
|
||||
if (!s.sessionId) {
|
||||
console.warn("Cannot set config option: no active session");
|
||||
getLogger().debug(
|
||||
"Cannot set config option: no active session",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -470,7 +465,7 @@ export function useAgentSession(
|
|||
return {
|
||||
...prev,
|
||||
configOptions: prev.configOptions.map((opt) =>
|
||||
opt.id === configId
|
||||
opt.id === configId && opt.type === "select"
|
||||
? { ...opt, currentValue: value }
|
||||
: opt,
|
||||
),
|
||||
|
|
@ -491,26 +486,42 @@ export function useAgentSession(
|
|||
const changedOption = updatedOptions.find(
|
||||
(o) => o.id === configId,
|
||||
);
|
||||
if (changedOption?.category === "model" && s.agentId) {
|
||||
if (changedOption && s.agentId) {
|
||||
const currentSettings = settingsAccess.getSnapshot();
|
||||
void settingsAccess.updateSettings({
|
||||
lastUsedModels: {
|
||||
...currentSettings.lastUsedModels,
|
||||
[s.agentId]: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (changedOption?.category === "mode" && s.agentId) {
|
||||
const currentSettings = settingsAccess.getSnapshot();
|
||||
void settingsAccess.updateSettings({
|
||||
lastUsedModes: {
|
||||
...currentSettings.lastUsedModes,
|
||||
[s.agentId]: value,
|
||||
},
|
||||
});
|
||||
if (changedOption.category === "model") {
|
||||
void settingsAccess.updateSettings({
|
||||
lastUsedModels: {
|
||||
...currentSettings.lastUsedModels,
|
||||
[s.agentId]: value,
|
||||
},
|
||||
});
|
||||
} else if (changedOption.category === "mode") {
|
||||
void settingsAccess.updateSettings({
|
||||
lastUsedModes: {
|
||||
...currentSettings.lastUsedModes,
|
||||
[s.agentId]: value,
|
||||
},
|
||||
});
|
||||
} else if (
|
||||
configId !== "__proto__" &&
|
||||
configId !== "constructor"
|
||||
) {
|
||||
const allOptions =
|
||||
currentSettings.lastUsedConfigOptions;
|
||||
const agentOptions = allOptions[s.agentId] ?? {};
|
||||
void settingsAccess.updateSettings({
|
||||
lastUsedConfigOptions: {
|
||||
...allOptions,
|
||||
[s.agentId]: {
|
||||
...agentOptions,
|
||||
[configId]: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to set config option:", error);
|
||||
getLogger().error("Failed to set config option:", error);
|
||||
if (previousConfigOptions) {
|
||||
setSession((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -537,7 +548,6 @@ export function useAgentSession(
|
|||
getAvailableAgents,
|
||||
updateSessionFromLoad,
|
||||
setMode,
|
||||
setModel,
|
||||
setConfigOption,
|
||||
handleSessionUpdate,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ export interface UseChatActionsReturn {
|
|||
|
||||
// Config actions
|
||||
handleSetMode: (modeId: string) => Promise<void>;
|
||||
handleSetModel: (modelId: string) => Promise<void>;
|
||||
handleSetConfigOption: (configId: string, value: string) => Promise<void>;
|
||||
|
||||
// UI state actions
|
||||
|
|
@ -178,15 +177,14 @@ export function useChatActions(
|
|||
}
|
||||
|
||||
await agent.sendMessage(content, {
|
||||
activeNote: settings.autoMentionActiveNote
|
||||
? suggestions.mentions.activeNote
|
||||
: null,
|
||||
activeNote: suggestions.mentions.activeNote,
|
||||
vaultBasePath: vaultPath,
|
||||
isAutoMentionDisabled:
|
||||
suggestions.mentions.isAutoMentionDisabled,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
resourceLinks:
|
||||
resourceLinks.length > 0 ? resourceLinks : undefined,
|
||||
isFirstMessage,
|
||||
});
|
||||
|
||||
// Save session metadata locally on first message
|
||||
|
|
@ -207,7 +205,6 @@ export function useChatActions(
|
|||
session.sessionId,
|
||||
sessionHistory.saveSessionLocally,
|
||||
logger,
|
||||
settings.autoMentionActiveNote,
|
||||
suggestions.mentions.activeNote,
|
||||
suggestions.mentions.isAutoMentionDisabled,
|
||||
shouldConvertToWsl,
|
||||
|
|
@ -345,13 +342,6 @@ export function useChatActions(
|
|||
[agent.setMode],
|
||||
);
|
||||
|
||||
const handleSetModel = useCallback(
|
||||
async (modelId: string) => {
|
||||
await agent.setModel(modelId);
|
||||
},
|
||||
[agent.setModel],
|
||||
);
|
||||
|
||||
const handleSetConfigOption = useCallback(
|
||||
async (configId: string, value: string) => {
|
||||
await agent.setConfigOption(configId, value);
|
||||
|
|
@ -387,7 +377,6 @@ export function useChatActions(
|
|||
handleSwitchAgent,
|
||||
handleRestartAgent,
|
||||
handleSetMode,
|
||||
handleSetModel,
|
||||
handleSetConfigOption,
|
||||
handleClearError,
|
||||
handleClearAgentUpdate,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback, useRef, useMemo } from "react";
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
|
||||
import type { AcpClient } from "../acp/acp-client";
|
||||
import type { ISettingsAccess } from "../services/settings-service";
|
||||
import type {
|
||||
|
|
@ -7,12 +7,12 @@ import type {
|
|||
SavedSessionInfo,
|
||||
ChatSession,
|
||||
SessionModeState,
|
||||
SessionModelState,
|
||||
SessionConfigOption,
|
||||
AgentCapabilities,
|
||||
} from "../types/session";
|
||||
import type { ChatMessage } from "../types/chat";
|
||||
import { extractErrorMessage } from "../utils/error-utils";
|
||||
import { truncateTitle } from "../utils/text";
|
||||
|
||||
// ============================================================================
|
||||
// Session Capability Helpers (from session-capability-utils.ts)
|
||||
|
|
@ -56,13 +56,11 @@ export interface SessionLoadCallback {
|
|||
/**
|
||||
* @param sessionId - ID of the session (new session ID for fork)
|
||||
* @param modes - Available modes from the session
|
||||
* @param models - Available models from the session
|
||||
* @param configOptions - Config options from the session
|
||||
*/
|
||||
(
|
||||
sessionId: string,
|
||||
modes?: SessionModeState,
|
||||
models?: SessionModelState,
|
||||
configOptions?: SessionConfigOption[],
|
||||
): void;
|
||||
}
|
||||
|
|
@ -300,6 +298,36 @@ export function useSessionHistory(
|
|||
const cacheRef = useRef<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.
|
||||
*/
|
||||
|
|
@ -509,7 +537,7 @@ export function useSessionHistory(
|
|||
try {
|
||||
// IMPORTANT: Update session.sessionId BEFORE calling restore
|
||||
// so that session/update notifications are not ignored
|
||||
onSessionLoad(sessionId, undefined, undefined, undefined);
|
||||
onSessionLoad(sessionId, undefined, undefined);
|
||||
|
||||
if (capabilities.canLoad) {
|
||||
// Check local messages first to decide whether to use them or agent replay
|
||||
|
|
@ -528,7 +556,6 @@ export function useSessionHistory(
|
|||
onSessionLoad(
|
||||
result.sessionId,
|
||||
result.modes,
|
||||
result.models,
|
||||
result.configOptions,
|
||||
);
|
||||
onMessagesRestore(localMessages);
|
||||
|
|
@ -544,7 +571,6 @@ export function useSessionHistory(
|
|||
onSessionLoad(
|
||||
result.sessionId,
|
||||
result.modes,
|
||||
result.models,
|
||||
result.configOptions,
|
||||
);
|
||||
}
|
||||
|
|
@ -557,7 +583,6 @@ export function useSessionHistory(
|
|||
onSessionLoad(
|
||||
result.sessionId,
|
||||
result.modes,
|
||||
result.models,
|
||||
result.configOptions,
|
||||
);
|
||||
|
||||
|
|
@ -608,7 +633,6 @@ export function useSessionHistory(
|
|||
onSessionLoad(
|
||||
result.sessionId,
|
||||
result.modes,
|
||||
result.models,
|
||||
result.configOptions,
|
||||
);
|
||||
|
||||
|
|
@ -626,15 +650,9 @@ export function useSessionHistory(
|
|||
);
|
||||
const originalTitle = originalSession?.title ?? "Session";
|
||||
|
||||
// Truncate title to 50 characters
|
||||
const maxTitleLength = 50;
|
||||
// Keep "Fork: " prefix intact; truncate only the base.
|
||||
const prefix = "Fork: ";
|
||||
const maxBaseLength = maxTitleLength - prefix.length;
|
||||
const truncatedTitle =
|
||||
originalTitle.length > maxBaseLength
|
||||
? originalTitle.substring(0, maxBaseLength) + "..."
|
||||
: originalTitle;
|
||||
const newTitle = `${prefix}${truncatedTitle}`;
|
||||
const newTitle = `${prefix}${truncateTitle(originalTitle, 50 - prefix.length)}`;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
|
|
@ -710,7 +728,6 @@ export function useSessionHistory(
|
|||
*/
|
||||
const updateSessionTitle = useCallback(
|
||||
async (sessionId: string, newTitle: string, sessionCwd: string) => {
|
||||
// Read current title for potential rollback
|
||||
const savedSessions = settingsAccess.getSavedSessions();
|
||||
const existing = savedSessions.find(
|
||||
(s) => s.sessionId === sessionId,
|
||||
|
|
@ -725,28 +742,13 @@ export function useSessionHistory(
|
|||
);
|
||||
|
||||
try {
|
||||
if (existing) {
|
||||
await settingsAccess.saveSession({
|
||||
...existing,
|
||||
title: newTitle,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
// Session exists only on agent side — create local entry
|
||||
// Use sessionCwd (from SessionInfo) instead of hook's cwd
|
||||
await settingsAccess.saveSession({
|
||||
sessionId,
|
||||
agentId: session.agentId,
|
||||
cwd: sessionCwd,
|
||||
title: newTitle,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
await settingsAccess.updateSessionTitle(sessionId, newTitle, {
|
||||
agentId: session.agentId,
|
||||
cwd: sessionCwd,
|
||||
});
|
||||
invalidateCache();
|
||||
} catch (err) {
|
||||
// Rollback optimistic update
|
||||
// Rollback
|
||||
setSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.sessionId === sessionId
|
||||
|
|
@ -770,10 +772,7 @@ export function useSessionHistory(
|
|||
async (sessionId: string, messageContent: string) => {
|
||||
if (!session.agentId) return;
|
||||
|
||||
const title =
|
||||
messageContent.length > 50
|
||||
? messageContent.substring(0, 50) + "..."
|
||||
: messageContent;
|
||||
const title = truncateTitle(messageContent);
|
||||
|
||||
await settingsAccess.saveSession({
|
||||
sessionId,
|
||||
|
|
@ -791,6 +790,10 @@ export function useSessionHistory(
|
|||
* Save session messages locally.
|
||||
* Called when a turn ends (agent response complete).
|
||||
* Fire-and-forget (does not block UI).
|
||||
*
|
||||
* Also bumps the session's `updatedAt` metadata so that
|
||||
* `getSavedSessions()` (sorted by updatedAt desc) reflects actual
|
||||
* activity rather than just session-creation time (#257).
|
||||
*/
|
||||
const saveSessionMessages = useCallback(
|
||||
(
|
||||
|
|
@ -799,12 +802,20 @@ export function useSessionHistory(
|
|||
) => {
|
||||
if (!session.agentId || messages.length === 0) return;
|
||||
|
||||
// Fire-and-forget
|
||||
// Persist message content (fire-and-forget)
|
||||
void settingsAccess.saveSessionMessages(
|
||||
sessionId,
|
||||
session.agentId,
|
||||
messages,
|
||||
);
|
||||
|
||||
// Bump updatedAt on session metadata so "last used" ordering
|
||||
// reflects real activity. `updateSession` is a no-op if the entry
|
||||
// hasn't landed yet — saveSessionLocally will create it on the
|
||||
// first-message path.
|
||||
void settingsAccess.updateSession(sessionId, {
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[session.agentId, settingsAccess],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -177,13 +184,38 @@ export function createInitialSession(
|
|||
authMethods: [],
|
||||
availableCommands: undefined,
|
||||
modes: undefined,
|
||||
models: undefined,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
workingDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Title Derivation
|
||||
// ============================================================================
|
||||
|
||||
/** Derive the session display title (saved title > first user message > "New session"). */
|
||||
export function computeSessionTitle(
|
||||
sessionId: string | null,
|
||||
savedSessions: SavedSessionInfo[],
|
||||
messages: ChatMessage[],
|
||||
): string {
|
||||
if (sessionId) {
|
||||
const saved = savedSessions.find((s) => s.sessionId === sessionId);
|
||||
if (saved?.title) return saved.title;
|
||||
}
|
||||
const firstUserMessage = messages.find((m) => m.role === "user");
|
||||
if (firstUserMessage) {
|
||||
const textContent = firstUserMessage.content.find(
|
||||
(c) => c.type === "text" || c.type === "text_with_context",
|
||||
);
|
||||
if (textContent && "text" in textContent) {
|
||||
return truncateTitle(textContent.text);
|
||||
}
|
||||
}
|
||||
return "New session";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Gemini CLI Deprecation Notice
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -24,15 +24,10 @@ import type { AcpClient } from "../acp/acp-client";
|
|||
*/
|
||||
export function applyLegacyValue(
|
||||
prev: ChatSession,
|
||||
kind: "mode" | "model",
|
||||
value: string,
|
||||
): ChatSession {
|
||||
if (kind === "mode") {
|
||||
if (!prev.modes) return prev;
|
||||
return { ...prev, modes: { ...prev.modes, currentModeId: value } };
|
||||
}
|
||||
if (!prev.models) return prev;
|
||||
return { ...prev, models: { ...prev.models, currentModelId: value } };
|
||||
if (!prev.modes) return prev;
|
||||
return { ...prev, modes: { ...prev.modes, currentModeId: value } };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -53,7 +48,7 @@ export async function tryRestoreConfigOption(
|
|||
if (!savedValue) return configOptions;
|
||||
|
||||
const option = configOptions.find((o) => o.category === category);
|
||||
if (!option) return configOptions;
|
||||
if (!option || option.type !== "select") return configOptions;
|
||||
if (savedValue === option.currentValue) return configOptions;
|
||||
if (
|
||||
!flattenConfigSelectOptions(option.options).some(
|
||||
|
|
@ -73,58 +68,85 @@ export async function tryRestoreConfigOption(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore saved config option values by option id (non-legacy config options;
|
||||
* model/mode use the category-based path above + lastUsedModels/lastUsedModes).
|
||||
* Applies each saved value that still maps to a currently-available choice;
|
||||
* unknown ids and stale/unavailable values are skipped. Mirrors the validation
|
||||
* in tryRestoreConfigOption but keyed by id instead of category.
|
||||
*/
|
||||
export async function restoreSavedConfigOptions(
|
||||
agentClient: AcpClient,
|
||||
sessionId: string,
|
||||
configOptions: SessionConfigOption[],
|
||||
savedById: Record<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 || option.type !== "select") continue;
|
||||
if (savedValue === option.currentValue) continue;
|
||||
if (
|
||||
!flattenConfigSelectOptions(option.options).some(
|
||||
(o) => o.value === savedValue,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
try {
|
||||
result = await agentClient.setSessionConfigOption(
|
||||
sessionId,
|
||||
optionId,
|
||||
savedValue,
|
||||
);
|
||||
} catch {
|
||||
// Keep current value on failure.
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore last used mode/model via legacy APIs.
|
||||
* Only called when configOptions is not available.
|
||||
*
|
||||
* Returns the final modes/models state after restoration (or the originals
|
||||
* if no restoration was needed or if the agent-side calls failed).
|
||||
* The caller is responsible for applying these to session state.
|
||||
* This function has no side effects on React state so callers can sequence
|
||||
* the restore BEFORE marking the session as "ready", avoiding a UI race
|
||||
* where the dropdown briefly shows the agent's default mode/model before
|
||||
* the user's saved selection is re-applied.
|
||||
*/
|
||||
export async function restoreLegacyConfig(
|
||||
agentClient: AcpClient,
|
||||
sessionResult: SessionResult,
|
||||
savedModelId: string | undefined,
|
||||
savedModeId: string | undefined,
|
||||
setSession: (updater: (prev: ChatSession) => ChatSession) => void,
|
||||
): Promise<void> {
|
||||
if (!sessionResult.sessionId) return;
|
||||
): Promise<{
|
||||
modes: SessionResult["modes"];
|
||||
}> {
|
||||
let modes = sessionResult.modes;
|
||||
|
||||
// Legacy model restore
|
||||
if (sessionResult.models && savedModelId) {
|
||||
if (
|
||||
savedModelId !== sessionResult.models.currentModelId &&
|
||||
sessionResult.models.availableModels.some(
|
||||
(m) => m.modelId === savedModelId,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
await agentClient.setSessionModel(
|
||||
sessionResult.sessionId,
|
||||
savedModelId,
|
||||
);
|
||||
setSession((prev) =>
|
||||
applyLegacyValue(prev, "model", savedModelId),
|
||||
);
|
||||
} catch {
|
||||
// Agent default is fine as fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sessionResult.sessionId) return { modes };
|
||||
|
||||
// Legacy mode restore
|
||||
if (sessionResult.modes && savedModeId) {
|
||||
if (modes && savedModeId) {
|
||||
if (
|
||||
savedModeId !== sessionResult.modes.currentModeId &&
|
||||
sessionResult.modes.availableModes.some((m) => m.id === savedModeId)
|
||||
savedModeId !== modes.currentModeId &&
|
||||
modes.availableModes.some((m) => m.id === savedModeId)
|
||||
) {
|
||||
try {
|
||||
await agentClient.setSessionMode(
|
||||
sessionResult.sessionId,
|
||||
savedModeId,
|
||||
);
|
||||
setSession((prev) =>
|
||||
applyLegacyValue(prev, "mode", savedModeId),
|
||||
);
|
||||
modes = { ...modes, currentModeId: savedModeId };
|
||||
} catch {
|
||||
// Agent default is fine as fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { modes };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -122,44 +122,6 @@ export interface SessionModeState {
|
|||
currentModeId: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Model (Experimental)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Represents an AI model available in a session.
|
||||
*
|
||||
* Models determine which AI model is used for responses.
|
||||
* This is an experimental feature and may change.
|
||||
*/
|
||||
/** DEPRECATED: Use SessionConfigOption instead. Kept for backward compatibility. */
|
||||
export interface SessionModel {
|
||||
/** Unique identifier for this model (e.g., "claude-sonnet-4") */
|
||||
modelId: string;
|
||||
|
||||
/** Human-readable name for display */
|
||||
name: string;
|
||||
|
||||
/** Optional description of this model */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* State of available models in a session.
|
||||
*
|
||||
* Contains both the list of available models and the currently active model.
|
||||
* Updated via NewSessionResponse initially.
|
||||
* Note: Unlike modes, there is no dedicated notification for model changes.
|
||||
*/
|
||||
/** DEPRECATED: Use SessionConfigOption instead. Kept for backward compatibility. */
|
||||
export interface SessionModelState {
|
||||
/** List of models available in this session */
|
||||
availableModels: SessionModel[];
|
||||
|
||||
/** ID of the currently active model */
|
||||
currentModelId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context window usage and cost information for a session.
|
||||
* Reported by the agent via `usage_update` session notifications.
|
||||
|
|
@ -218,12 +180,6 @@ export interface ChatSession {
|
|||
*/
|
||||
modes?: SessionModeState;
|
||||
|
||||
/**
|
||||
* DEPRECATED: Use configOptions instead. Kept for backward compatibility
|
||||
* with agents that don't support configOptions.
|
||||
*/
|
||||
models?: SessionModelState;
|
||||
|
||||
/**
|
||||
* Session configuration options (mode, model, thought_level, etc.).
|
||||
* Supersedes legacy modes/models fields.
|
||||
|
|
@ -454,20 +410,31 @@ export interface ProcessErrorUpdate extends SessionUpdateBase {
|
|||
// Config Option Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* A session configuration option (e.g. mode, model, thought_level).
|
||||
* Part of the ACP configOptions API that supersedes legacy modes/models.
|
||||
*/
|
||||
export interface SessionConfigOption {
|
||||
interface SessionConfigOptionBase {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
category?: string | null;
|
||||
type: "select";
|
||||
currentValue: string;
|
||||
options: SessionConfigSelectOption[] | SessionConfigSelectGroup[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A session configuration option (e.g. mode, model, thought_level).
|
||||
* Part of the ACP configOptions API that supersedes legacy modes/models.
|
||||
*
|
||||
* `select` carries a string value + choices. `boolean` (ACP 0.28+) is held as
|
||||
* data for future support — it is not yet rendered or settable in the UI.
|
||||
*/
|
||||
export type SessionConfigOption =
|
||||
| (SessionConfigOptionBase & {
|
||||
type: "select";
|
||||
currentValue: string;
|
||||
options: SessionConfigSelectOption[] | SessionConfigSelectGroup[];
|
||||
})
|
||||
| (SessionConfigOptionBase & {
|
||||
type: "boolean";
|
||||
currentValue: boolean;
|
||||
});
|
||||
|
||||
export interface SessionConfigSelectOption {
|
||||
value: string;
|
||||
name: string;
|
||||
|
|
@ -571,9 +538,6 @@ export interface SessionResult {
|
|||
/** DEPRECATED: Use configOptions instead. Kept for backward compatibility. */
|
||||
modes?: SessionModeState;
|
||||
|
||||
/** DEPRECATED: Use configOptions instead. Kept for backward compatibility. */
|
||||
models?: SessionModelState;
|
||||
|
||||
/** Session config options (supersedes modes/models) */
|
||||
configOptions?: SessionConfigOption[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ import {
|
|||
type MenuItem,
|
||||
} from "obsidian";
|
||||
|
||||
import type { AttachedFile, ChatInputState } from "../types/chat";
|
||||
import type { AttachedFile, ChatInputState, ChatMessage } from "../types/chat";
|
||||
import { isSameDirectory } from "../utils/platform";
|
||||
import { computeSessionTitle } from "../services/session-helpers";
|
||||
import { useHistoryModal } from "../hooks/useHistoryModal";
|
||||
import { useChatActions } from "../hooks/useChatActions";
|
||||
import { ChangeDirectoryModal } from "./ChangeDirectoryModal";
|
||||
import { addRenameSessionMenuItem } from "./EditTitleModal";
|
||||
|
||||
// Service imports
|
||||
import { getLogger } from "../utils/logger";
|
||||
|
|
@ -35,10 +37,10 @@ import {
|
|||
flattenConfigSelectOptions,
|
||||
type SlashCommand,
|
||||
type SessionModeState,
|
||||
type SessionModelState,
|
||||
type SessionConfigOption,
|
||||
} from "../types/session";
|
||||
import { checkAgentUpdate } from "../services/update-checker";
|
||||
import type { SessionStatus } from "../services/view-registry";
|
||||
import { buildGeminiDeprecationNotice } from "../services/session-helpers";
|
||||
|
||||
/** Stable empty array for useSuggestions when no commands available */
|
||||
|
|
@ -61,6 +63,9 @@ import type { IChatViewHost } from "./view-host";
|
|||
*/
|
||||
export interface ChatPanelCallbacks {
|
||||
getDisplayName: () => string;
|
||||
getSessionStatus: () => SessionStatus;
|
||||
getSessionTitle: () => string;
|
||||
getSessionId: () => string | null;
|
||||
getInputState: () => ChatInputState | null;
|
||||
setInputState: (state: ChatInputState) => void;
|
||||
canSend: () => boolean;
|
||||
|
|
@ -81,6 +86,11 @@ export interface ChatPanelProps {
|
|||
onRegisterCallbacks?: (callbacks: ChatPanelCallbacks) => void;
|
||||
/** Called when agent ID changes (sidebar only — persists in Obsidian state) */
|
||||
onAgentIdChanged?: (agentId: string) => void;
|
||||
/**
|
||||
* Called when the derived session title may have changed (sidebar only —
|
||||
* triggers Obsidian to re-read getDisplayText() and update the tab header).
|
||||
*/
|
||||
onSessionTitleChanged?: () => void;
|
||||
// Floating-specific
|
||||
onMinimize?: () => void;
|
||||
onClose?: () => void;
|
||||
|
|
@ -109,6 +119,9 @@ interface AppWithSettings {
|
|||
// ChatPanel Component
|
||||
// ============================================================================
|
||||
|
||||
/** Debounce (ms) for re-saving when trailing chunks arrive after a turn ends (#320). */
|
||||
const TRAILING_SAVE_DEBOUNCE_MS = 800;
|
||||
|
||||
/**
|
||||
* Core chat panel component that encapsulates all chat logic.
|
||||
*
|
||||
|
|
@ -125,6 +138,7 @@ export function ChatPanel({
|
|||
config,
|
||||
onRegisterCallbacks,
|
||||
onAgentIdChanged,
|
||||
onSessionTitleChanged,
|
||||
onMinimize,
|
||||
onClose,
|
||||
onOpenNewWindow,
|
||||
|
|
@ -190,6 +204,7 @@ export function ChatPanel({
|
|||
vaultService,
|
||||
plugin,
|
||||
session.availableCommands || EMPTY_COMMANDS,
|
||||
settings.autoMentionActiveNote,
|
||||
);
|
||||
|
||||
// Session history hook with callback for session load
|
||||
|
|
@ -197,23 +212,16 @@ export function ChatPanel({
|
|||
(
|
||||
sessionId: string,
|
||||
modes?: SessionModeState,
|
||||
models?: SessionModelState,
|
||||
configOptions?: SessionConfigOption[],
|
||||
) => {
|
||||
logger.log(
|
||||
`[ChatPanel] Session loaded/resumed/forked: ${sessionId}`,
|
||||
{
|
||||
modes,
|
||||
models,
|
||||
configOptions,
|
||||
},
|
||||
);
|
||||
void agent.updateSessionFromLoad(
|
||||
sessionId,
|
||||
modes,
|
||||
models,
|
||||
configOptions,
|
||||
);
|
||||
void agent.updateSessionFromLoad(sessionId, modes, configOptions);
|
||||
},
|
||||
[logger, agent.updateSessionFromLoad],
|
||||
);
|
||||
|
|
@ -296,7 +304,6 @@ export function ChatPanel({
|
|||
handleSwitchAgent,
|
||||
handleRestartAgent,
|
||||
handleSetMode,
|
||||
handleSetModel,
|
||||
handleSetConfigOption,
|
||||
handleClearError,
|
||||
handleClearAgentUpdate,
|
||||
|
|
@ -423,6 +430,16 @@ export function ChatPanel({
|
|||
menu.addSeparator();
|
||||
|
||||
// -- Actions section --
|
||||
addRenameSessionMenuItem(
|
||||
menu,
|
||||
plugin,
|
||||
session.sessionId,
|
||||
plugin.settingsService
|
||||
.getSavedSessions()
|
||||
.find((s) => s.sessionId === session.sessionId)?.title ??
|
||||
"New session",
|
||||
);
|
||||
|
||||
menu.addItem((item: MenuItem) => {
|
||||
item.setTitle("Open new view")
|
||||
.setIcon("copy-plus")
|
||||
|
|
@ -456,6 +473,14 @@ export function ChatPanel({
|
|||
});
|
||||
});
|
||||
|
||||
menu.addItem((item: MenuItem) => {
|
||||
item.setTitle("Open session manager")
|
||||
.setIcon("layout-list")
|
||||
.onClick(() => {
|
||||
void plugin.activateSessionManager();
|
||||
});
|
||||
});
|
||||
|
||||
menu.addSeparator();
|
||||
|
||||
menu.addItem((item: MenuItem) => {
|
||||
|
|
@ -510,6 +535,16 @@ export function ChatPanel({
|
|||
|
||||
menu.addSeparator();
|
||||
|
||||
addRenameSessionMenuItem(
|
||||
menu,
|
||||
plugin,
|
||||
session.sessionId,
|
||||
plugin.settingsService
|
||||
.getSavedSessions()
|
||||
.find((s) => s.sessionId === session.sessionId)?.title ??
|
||||
"New session",
|
||||
);
|
||||
|
||||
if (onOpenNewWindow) {
|
||||
menu.addItem((item: MenuItem) => {
|
||||
item.setTitle("Open new floating chat")
|
||||
|
|
@ -543,6 +578,14 @@ export function ChatPanel({
|
|||
});
|
||||
});
|
||||
|
||||
menu.addItem((item: MenuItem) => {
|
||||
item.setTitle("Open session manager")
|
||||
.setIcon("layout-list")
|
||||
.onClick(() => {
|
||||
void plugin.activateSessionManager();
|
||||
});
|
||||
});
|
||||
|
||||
menu.addSeparator();
|
||||
|
||||
menu.addItem((item: MenuItem) => {
|
||||
|
|
@ -587,14 +630,14 @@ export function ChatPanel({
|
|||
// Floating: create a shim with listener tracking
|
||||
return {
|
||||
app: plugin.app,
|
||||
registerDomEvent: ((
|
||||
registerDomEvent: (
|
||||
target: Window | Document | HTMLElement,
|
||||
type: string,
|
||||
callback: EventListenerOrEventListenerObject,
|
||||
) => {
|
||||
target.addEventListener(type, callback);
|
||||
registeredListenersRef.current.push({ target, type, callback });
|
||||
}),
|
||||
},
|
||||
};
|
||||
}, [viewHostProp, plugin.app]);
|
||||
|
||||
|
|
@ -621,16 +664,20 @@ export function ChatPanel({
|
|||
void agent.createSession(config?.agent || initialAgentId);
|
||||
}, [agent.createSession, config?.agent, initialAgentId]);
|
||||
|
||||
// Apply configured model when session is ready
|
||||
// Apply configured model (a select config option with category "model")
|
||||
// when session is ready.
|
||||
useEffect(() => {
|
||||
if (!config?.model || !isSessionReady) return;
|
||||
|
||||
// Prefer configOptions if available
|
||||
if (session.configOptions) {
|
||||
const modelOption = session.configOptions.find(
|
||||
(o) => o.category === "model",
|
||||
);
|
||||
if (modelOption && modelOption.currentValue !== config.model) {
|
||||
if (
|
||||
modelOption &&
|
||||
modelOption.type === "select" &&
|
||||
modelOption.currentValue !== config.model
|
||||
) {
|
||||
const valueExists = flattenConfigSelectOptions(
|
||||
modelOption.options,
|
||||
).some((o) => o.value === config.model);
|
||||
|
|
@ -642,29 +689,12 @@ export function ChatPanel({
|
|||
void agent.setConfigOption(modelOption.id, config.model);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to legacy models
|
||||
if (session.models) {
|
||||
const modelExists = session.models.availableModels.some(
|
||||
(m) => m.modelId === config.model,
|
||||
);
|
||||
if (modelExists && session.models.currentModelId !== config.model) {
|
||||
logger.log(
|
||||
"[ChatPanel] Applying configured model:",
|
||||
config.model,
|
||||
);
|
||||
void agent.setModel(config.model);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
config?.model,
|
||||
isSessionReady,
|
||||
session.configOptions,
|
||||
session.models,
|
||||
agent.setConfigOption,
|
||||
agent.setModel,
|
||||
logger,
|
||||
]);
|
||||
|
||||
|
|
@ -673,19 +703,43 @@ export function ChatPanel({
|
|||
const sessionRef = useRef(session);
|
||||
const autoExportRef = useRef(autoExportIfEnabled);
|
||||
const closeSessionRef = useRef(agent.closeSession);
|
||||
const saveSessionMessagesRef = useRef(sessionHistory.saveSessionMessages);
|
||||
// True once the user has actually run a turn in THIS session. The
|
||||
// trailing-chunk re-save and close-time flush are armed only after a real
|
||||
// turn, so messages that were merely loaded/replayed are never re-saved
|
||||
// (which would bump updatedAt and corrupt "last used" ordering). (#320 review)
|
||||
const sentThisSessionRef = useRef(false);
|
||||
// Reference identity of the messages array last persisted to disk. Used to
|
||||
// de-duplicate the turn-end save, the trailing-chunk re-save, and the
|
||||
// close-time flush.
|
||||
const lastSavedMessagesRef = useRef<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 +781,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,13 +799,18 @@ export function ChatPanel({
|
|||
session.sessionId &&
|
||||
messages.length > 0
|
||||
) {
|
||||
sentThisSessionRef.current = true;
|
||||
lastSavedMessagesRef.current = messages;
|
||||
sessionHistory.saveSessionMessages(session.sessionId, messages);
|
||||
logger.log(
|
||||
`[ChatPanel] Session messages saved: ${session.sessionId}`,
|
||||
);
|
||||
|
||||
// System notification on response completion
|
||||
if (settings.enableSystemNotifications && !activeDocument.hasFocus()) {
|
||||
if (
|
||||
settings.enableSystemNotifications &&
|
||||
!activeDocument.hasFocus()
|
||||
) {
|
||||
new Notification("Agent Client", {
|
||||
body: `${activeAgentLabel} has completed the response.`,
|
||||
});
|
||||
|
|
@ -760,6 +826,64 @@ export function ChatPanel({
|
|||
logger,
|
||||
]);
|
||||
|
||||
// Some agents (e.g. OpenCode) emit trailing message chunks *after* end_turn,
|
||||
// so the turn-end save above runs before they arrive and the persisted copy
|
||||
// is truncated. Re-save when messages change while idle, debounced so rapid
|
||||
// trailing updates coalesce into a single write (avoids racing the file). (#320)
|
||||
useEffect(() => {
|
||||
const sessionId = session.sessionId;
|
||||
if (isSending || !sessionId || messages.length === 0) return;
|
||||
if (!sentThisSessionRef.current) return;
|
||||
if (lastSavedMessagesRef.current === messages) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
lastSavedMessagesRef.current = messages;
|
||||
sessionHistory.saveSessionMessages(sessionId, messages);
|
||||
}, TRAILING_SAVE_DEBOUNCE_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
isSending,
|
||||
session.sessionId,
|
||||
messages,
|
||||
sessionHistory.saveSessionMessages,
|
||||
]);
|
||||
|
||||
// ============================================================
|
||||
// Effects - Notify ViewRegistry of State Changes
|
||||
// ============================================================
|
||||
// `hasMessages` flips false → true on first message and then stays stable
|
||||
// for the rest of the conversation. The Session Manager's title and
|
||||
// status only depend on this boolean transition, not on per-chunk growth,
|
||||
// so we avoid notifying on every streamed token.
|
||||
const hasMessages = messages.length > 0;
|
||||
useEffect(() => {
|
||||
plugin.viewRegistry.notifyChange();
|
||||
}, [
|
||||
plugin.viewRegistry,
|
||||
session.state,
|
||||
session.sessionId,
|
||||
isSending,
|
||||
agent.hasActivePermission,
|
||||
sessionHistory.loading,
|
||||
hasMessages,
|
||||
]);
|
||||
|
||||
// ============================================================
|
||||
// Effects - Notify Sidebar Container of Session Title Changes
|
||||
// ============================================================
|
||||
const sessionTitle = useMemo(
|
||||
() =>
|
||||
computeSessionTitle(
|
||||
session.sessionId,
|
||||
settings.savedSessions ?? [],
|
||||
messages,
|
||||
),
|
||||
[session.sessionId, settings.savedSessions, messages],
|
||||
);
|
||||
// Fires on initial mount + every sessionTitle change so the tab reflects the current title.
|
||||
useEffect(() => {
|
||||
onSessionTitleChanged?.();
|
||||
}, [onSessionTitleChanged, sessionTitle]);
|
||||
|
||||
// ============================================================
|
||||
// Effects - System Notification on Permission Request
|
||||
// ============================================================
|
||||
|
|
@ -951,18 +1075,41 @@ export function ChatPanel({
|
|||
const attachedFilesRef = useRef(attachedFiles);
|
||||
const isSessionReadyRef = useRef(isSessionReady);
|
||||
const isSendingRef = useRef(isSending);
|
||||
const sessionStateRef = useRef(session.state);
|
||||
const sessionIdRef = useRef(session.sessionId);
|
||||
const hasActivePermissionRef = useRef(agent.hasActivePermission);
|
||||
const sessionHistoryLoadingRef = useRef(sessionHistory.loading);
|
||||
const handleSendMessageRef = useRef(handleSendMessage);
|
||||
inputValueRef.current = inputValue;
|
||||
attachedFilesRef.current = attachedFiles;
|
||||
isSessionReadyRef.current = isSessionReady;
|
||||
isSendingRef.current = isSending;
|
||||
sessionStateRef.current = session.state;
|
||||
sessionIdRef.current = session.sessionId;
|
||||
hasActivePermissionRef.current = agent.hasActivePermission;
|
||||
sessionHistoryLoadingRef.current = sessionHistory.loading;
|
||||
handleSendMessageRef.current = handleSendMessage;
|
||||
|
||||
useEffect(() => {
|
||||
onRegisterCallbacks?.({
|
||||
getDisplayName: () => activeAgentLabel,
|
||||
getSessionStatus: () => {
|
||||
const state = sessionStateRef.current;
|
||||
if (state === "error") return "error";
|
||||
if (state === "disconnected") return "disconnected";
|
||||
if (hasActivePermissionRef.current) return "permission";
|
||||
if (isSendingRef.current || sessionHistoryLoadingRef.current)
|
||||
return "busy";
|
||||
if (state === "ready") return "ready";
|
||||
return "busy";
|
||||
},
|
||||
getSessionTitle: () =>
|
||||
computeSessionTitle(
|
||||
sessionIdRef.current,
|
||||
plugin.settingsService.getSnapshot().savedSessions ?? [],
|
||||
messagesRef.current,
|
||||
),
|
||||
getSessionId: () => sessionIdRef.current,
|
||||
getInputState: () => ({
|
||||
text: inputValueRef.current,
|
||||
files: attachedFilesRef.current,
|
||||
|
|
@ -1097,8 +1244,6 @@ export function ChatPanel({
|
|||
onRestoredMessageConsumed={handleRestoredMessageConsumed}
|
||||
modes={session.modes}
|
||||
onModeChange={(modeId) => void handleSetMode(modeId)}
|
||||
models={session.models}
|
||||
onModelChange={(modelId) => void handleSetModel(modelId)}
|
||||
configOptions={session.configOptions}
|
||||
onConfigOptionChange={(configId, value) =>
|
||||
void handleSetConfigOption(configId, value)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import type { NoteMetadata } from "../services/vault-service";
|
|||
import type {
|
||||
SlashCommand,
|
||||
SessionModeState,
|
||||
SessionModelState,
|
||||
SessionUsage,
|
||||
SessionConfigOption,
|
||||
} from "../types/session";
|
||||
|
|
@ -212,10 +211,6 @@ export interface InputAreaProps {
|
|||
modes?: SessionModeState;
|
||||
/** Callback when mode is changed */
|
||||
onModeChange?: (modeId: string) => void;
|
||||
/** Session model state (available models and current model) - experimental */
|
||||
models?: SessionModelState;
|
||||
/** Callback when model is changed */
|
||||
onModelChange?: (modelId: string) => void;
|
||||
/** Session config options (supersedes modes/models when present) */
|
||||
configOptions?: SessionConfigOption[];
|
||||
/** Callback when a config option is changed */
|
||||
|
|
@ -279,8 +274,6 @@ export function InputArea({
|
|||
onRestoredMessageConsumed,
|
||||
modes,
|
||||
onModeChange,
|
||||
models,
|
||||
onModelChange,
|
||||
configOptions,
|
||||
onConfigOptionChange,
|
||||
usage,
|
||||
|
|
@ -310,7 +303,8 @@ export function InputArea({
|
|||
|
||||
// Unofficial Obsidian API (see src/types/obsidian-internals.d.ts)
|
||||
const obsidianSpellcheck =
|
||||
(plugin.app.vault.getConfig("spellcheck") as boolean | undefined) ?? true;
|
||||
(plugin.app.vault.getConfig("spellcheck") as boolean | undefined) ??
|
||||
true;
|
||||
|
||||
// Local state (hint and command are still local - not needed for broadcast)
|
||||
const [hintText, setHintText] = useState<string | null>(null);
|
||||
|
|
@ -362,6 +356,7 @@ export function InputArea({
|
|||
const removeFile = useCallback(
|
||||
(id: string) => {
|
||||
onAttachedFilesChange(attachedFiles.filter((f) => f.id !== id));
|
||||
textareaRef.current?.focus();
|
||||
},
|
||||
[attachedFiles, onAttachedFilesChange],
|
||||
);
|
||||
|
|
@ -406,7 +401,7 @@ export function InputArea({
|
|||
mimeType: file.type,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to convert image:", error);
|
||||
getLogger().error("Failed to convert image:", error);
|
||||
new Notice("[Agent Client] Failed to attach image");
|
||||
}
|
||||
}
|
||||
|
|
@ -1011,7 +1006,7 @@ export function InputArea({
|
|||
onDrop={(e) => void handleDrop(e)}
|
||||
>
|
||||
{/* Auto-mention Badge */}
|
||||
{autoMentionEnabled && mentions.activeNote && (
|
||||
{mentions.activeNote && (
|
||||
<button
|
||||
className="agent-client-auto-mention-inline"
|
||||
onClick={() =>
|
||||
|
|
@ -1062,7 +1057,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}
|
||||
/>
|
||||
|
|
@ -1094,8 +1089,6 @@ export function InputArea({
|
|||
onSendOrStop={() => void handleSendOrStop()}
|
||||
modes={modes}
|
||||
onModeChange={onModeChange}
|
||||
models={models}
|
||||
onModelChange={onModelChange}
|
||||
configOptions={configOptions}
|
||||
onConfigOptionChange={onConfigOptionChange}
|
||||
usage={usage}
|
||||
|
|
|
|||
|
|
@ -1,76 +1,124 @@
|
|||
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,
|
||||
type SessionModeState,
|
||||
type SessionModelState,
|
||||
type SessionUsage,
|
||||
type SessionConfigOption,
|
||||
type SessionConfigSelectGroup,
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -103,8 +151,6 @@ export interface InputToolbarProps {
|
|||
onSendOrStop: () => void;
|
||||
modes?: SessionModeState;
|
||||
onModeChange?: (modeId: string) => void;
|
||||
models?: SessionModelState;
|
||||
onModelChange?: (modelId: string) => void;
|
||||
configOptions?: SessionConfigOption[];
|
||||
onConfigOptionChange?: (configId: string, value: string) => void;
|
||||
usage?: SessionUsage;
|
||||
|
|
@ -118,35 +164,13 @@ export function InputToolbar({
|
|||
onSendOrStop,
|
||||
modes,
|
||||
onModeChange,
|
||||
models,
|
||||
onModelChange,
|
||||
configOptions,
|
||||
onConfigOptionChange,
|
||||
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 +192,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 +203,6 @@ export function InputToolbar({
|
|||
}
|
||||
}, [isSending, updateIconColor]);
|
||||
|
||||
// Update icon color when hasContent changes
|
||||
useEffect(() => {
|
||||
if (sendButtonRef.current) {
|
||||
const svg = sendButtonRef.current.querySelector("svg");
|
||||
|
|
@ -190,101 +212,22 @@ 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 currentModeLabel = useMemo(() => {
|
||||
const id = modes?.currentModeId;
|
||||
return modes?.availableModes?.find((m) => m.id === id)?.name ?? "Mode";
|
||||
}, [modes]);
|
||||
|
||||
// Clean up existing dropdowns
|
||||
containerEl.empty();
|
||||
configDropdownInstances.current.clear();
|
||||
|
||||
if (!configOptions || configOptions.length === 0) return;
|
||||
|
||||
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">
|
||||
|
|
@ -304,51 +247,79 @@ export function InputToolbar({
|
|||
|
||||
{/* Config Options (supersedes legacy mode/model selectors) */}
|
||||
{configOptions && configOptions.length > 0 ? (
|
||||
<div
|
||||
ref={configOptionsRef}
|
||||
className="agent-client-config-options-container"
|
||||
/>
|
||||
configOptions.map((option) => {
|
||||
// boolean options (ACP 0.28+) are carried as data but
|
||||
// not yet rendered; only select options get a dropdown.
|
||||
if (option.type !== "select") return null;
|
||||
const flatOptions = flattenConfigSelectOptions(
|
||||
option.options,
|
||||
);
|
||||
if (flatOptions.length <= 1) return null;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
} 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
{/* 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");
|
||||
}}
|
||||
{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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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"
|
||||
}
|
||||
>
|
||||
<div ref={modelDropdownRef} />
|
||||
<span
|
||||
className="agent-client-model-selector-icon"
|
||||
ref={(el) => {
|
||||
if (el) setIcon(el, "chevron-down");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,6 +174,60 @@ export function clearWindowsPathCache(): void {
|
|||
cachedFullPath = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward selected environment variables into WSL via WSLENV.
|
||||
*
|
||||
* WSL only imports Windows environment variables that are listed in WSLENV
|
||||
* (Build 17063+). Values cross through the Windows process environment, not the
|
||||
* command line, so secrets are not exposed in `ps`/argv. This makes env-based
|
||||
* config (API keys from the plugin's key field, custom agent env, tool env)
|
||||
* actually reach the agent in WSL mode, instead of relying on the user's
|
||||
* ~/.profile.
|
||||
*
|
||||
* Defensive by design (this runs on every WSL launch): merges with any existing
|
||||
* WSLENV without clobbering, skips empty values (so an empty key field never
|
||||
* overrides a profile-set value with ""), skips invalid key names, and never
|
||||
* throws.
|
||||
*
|
||||
* @param baseEnv - Base environment (not mutated)
|
||||
* @param keysToForward - Names of env vars to make visible inside WSL
|
||||
* @returns A new env object with WSLENV augmented, or baseEnv unchanged if nothing to add
|
||||
*/
|
||||
export function buildWslEnv(
|
||||
baseEnv: NodeJS.ProcessEnv,
|
||||
keysToForward: string[],
|
||||
): NodeJS.ProcessEnv {
|
||||
// A valid WSLENV entry name is a normal env var name. Reject names containing
|
||||
// ':' or '/' (the WSLENV separators / flag marker) to avoid corrupting the list.
|
||||
const validName = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const toAdd = keysToForward.filter(
|
||||
(k) =>
|
||||
validName.test(k) &&
|
||||
typeof baseEnv[k] === "string" &&
|
||||
baseEnv[k] !== "",
|
||||
);
|
||||
if (toAdd.length === 0) {
|
||||
return baseEnv;
|
||||
}
|
||||
|
||||
const existing =
|
||||
typeof baseEnv.WSLENV === "string"
|
||||
? baseEnv.WSLENV.split(":").filter((e) => e.length > 0)
|
||||
: [];
|
||||
const existingNames = new Set(existing.map((e) => e.split("/")[0]));
|
||||
|
||||
const merged = [...existing];
|
||||
for (const k of toAdd) {
|
||||
if (!existingNames.has(k)) {
|
||||
// "/u": share only when launching WSL from Win32 (our direction).
|
||||
merged.push(`${k}/u`);
|
||||
existingNames.add(k);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...baseEnv, WSLENV: merged.join(":") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Windows path to WSL path format.
|
||||
* Example: C:\Users\name\vault → /mnt/c/Users/name/vault
|
||||
|
|
@ -236,6 +290,10 @@ export function isSameDirectory(pathA: string, pathB: string): boolean {
|
|||
* $SHELL, and falls back to /bin/sh for non-POSIX shells (fish, elvish,
|
||||
* nushell, xonsh).
|
||||
*
|
||||
* NOTE: agent and terminal launches now use buildWslArgvScript /
|
||||
* buildWslTerminalScript (--exec + positional argv) instead. This helper is
|
||||
* currently used only by paths.ts (resolveCommandPathInWsl, the `which` lookup).
|
||||
*
|
||||
* IMPORTANT: wsl.exe pre-expands $VAR references using WSL environment
|
||||
* variables before passing them to the Linux shell. Intermediate variables
|
||||
* (e.g., s=$SHELL; exec $s) will NOT work because wsl.exe expands $s to
|
||||
|
|
@ -255,9 +313,87 @@ export function buildWslShellWrapper(innerCommand: string): string {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the constant launcher script for argv-based WSL agent launch.
|
||||
*
|
||||
* Used as: `wsl.exe [-d dist] --exec /bin/sh -c '<this>' sh <pathDir> <cwd> <command> <args...>`
|
||||
* where pathDir/cwd/command/args are passed as separate argv (positional params),
|
||||
* NEVER baked into this string. As a result there is no nested-quoting fragility
|
||||
* and no escaping of user data is required — the returned string is a pure constant.
|
||||
*
|
||||
* This combines the strengths of both prior approaches: it skips wsl's default
|
||||
* shell layer (via `--exec`, fixing environments where the nested `sh -c "<baked
|
||||
* string>"` construction fails), runs under the user's login shell (`$SHELL -l`,
|
||||
* so ~/.profile and login files are sourced — unlike a bare `--exec <command>`
|
||||
* which would drop the environment), and forwards command/args as clean argv.
|
||||
* Falls back to /bin/sh for non-POSIX shells (fish, elvish, nushell, xonsh).
|
||||
*
|
||||
* IMPORTANT (same caveat as buildWslShellWrapper): reference `${SHELL:-/bin/sh}`
|
||||
* directly; do not use intermediate variables.
|
||||
*
|
||||
* Positional params seen by the inner login shell:
|
||||
* $1 = extra PATH dir ("" = none), $2 = working dir, $3 = command, $4.. = args
|
||||
*/
|
||||
export function buildWslArgvScript(): string {
|
||||
// Constant inner script: optional PATH prepend, cd into the working dir
|
||||
// (fail fast like the terminal wrapper / pre-#304 launch, so the agent never
|
||||
// runs in an unintended directory), then exec the command with its args.
|
||||
const core =
|
||||
'[ -n "$1" ] && export PATH="$1:$PATH"; shift; ' +
|
||||
'cd "$1" || exit 1; shift; exec "$@"';
|
||||
const coreEsc = core.replace(/'/g, "'\\''");
|
||||
// Source ~/.profile first (like buildWslShellWrapper): bash -l skips ~/.profile
|
||||
// when ~/.bash_profile exists, yet linuxbrew/nvm/mise put their PATH there and
|
||||
// bare command names resolve via that PATH.
|
||||
return (
|
||||
`. ~/.profile 2>/dev/null; ` +
|
||||
`case \${SHELL:-/bin/sh} in ` +
|
||||
`*/fish|*/elvish|*/nushell|*/xonsh) exec /bin/sh -l -c '${coreEsc}' sh "$@";; ` +
|
||||
`*) exec \${SHELL:-/bin/sh} -l -c '${coreEsc}' sh "$@";; ` +
|
||||
`esac`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the constant launcher script for terminal commands in WSL.
|
||||
*
|
||||
* Used as: `wsl.exe [-d dist] --exec /bin/sh -c '<this>' sh '<commandLine>'`
|
||||
* where the full shell command line is passed as a single positional ($1),
|
||||
* NEVER baked into this string — so there is no nested-quoting fragility. The
|
||||
* command line is then run under the user's login shell with `-c`, so it is
|
||||
* parsed by the user's actual shell (e.g. bash): pipes, redirects, subshells and
|
||||
* bash-specific syntax are preserved, with ~/.profile sourced. Falls back to
|
||||
* /bin/sh for non-POSIX shells (fish, elvish, nushell, xonsh).
|
||||
*
|
||||
* This mirrors buildWslShellWrapper's effective behavior (`$SHELL -l -c
|
||||
* <commandLine>`) but delivers the command line via an argv positional + `--exec`
|
||||
* instead of a doubly-escaped nested string, which avoids quoting failures seen
|
||||
* in some WSL environments (e.g. RHEL8).
|
||||
*
|
||||
* IMPORTANT (same caveat as buildWslArgvScript): reference `${SHELL:-/bin/sh}`
|
||||
* directly; do not use intermediate variables.
|
||||
*/
|
||||
export function buildWslTerminalScript(): string {
|
||||
// Source ~/.profile first (like buildWslShellWrapper): bash -l skips ~/.profile
|
||||
// when ~/.bash_profile exists, yet linuxbrew/nvm/mise put their PATH there and
|
||||
// bare command names resolve via that PATH.
|
||||
return (
|
||||
`. ~/.profile 2>/dev/null; ` +
|
||||
`case \${SHELL:-/bin/sh} in ` +
|
||||
`*/fish|*/elvish|*/nushell|*/xonsh) exec /bin/sh -l -c "$1";; ` +
|
||||
`*) exec \${SHELL:-/bin/sh} -l -c "$1";; ` +
|
||||
`esac`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a command to run inside WSL using wsl.exe.
|
||||
* Generates wsl.exe command with proper arguments for executing commands in WSL environment.
|
||||
*
|
||||
* @param useArgvExec - When true (agent launch), use the hybrid argv launcher
|
||||
* (`--exec` + login shell + argv) so command/args need no escaping and the
|
||||
* environment is preserved. When false (terminal launch), use the shell-string
|
||||
* wrapper so the login shell parses pipes/redirects in the command.
|
||||
*/
|
||||
export function wrapCommandForWsl(
|
||||
command: string,
|
||||
|
|
@ -265,6 +401,7 @@ export function wrapCommandForWsl(
|
|||
cwd: string,
|
||||
distribution?: string,
|
||||
additionalPath?: string,
|
||||
useArgvExec = false,
|
||||
): { command: string; args: string[] } {
|
||||
// Validate working directory path
|
||||
// Check for UNC paths (\\server\share) which are not supported by WSL
|
||||
|
|
@ -294,8 +431,39 @@ export function wrapCommandForWsl(
|
|||
wslArgs.push("-d", distribution);
|
||||
}
|
||||
|
||||
// Build command to execute inside WSL
|
||||
// Use login shell (-l) to inherit PATH from user's shell profile
|
||||
if (useArgvExec) {
|
||||
// Agent launch: hybrid argv launcher.
|
||||
// command/args/cwd/pathDir are passed as separate argv (positional params),
|
||||
// so no shell quoting of user data is needed (fixes paths/args with spaces
|
||||
// or special chars), `--exec` skips wsl's default shell layer (fixes envs
|
||||
// where the nested `sh -c "<baked string>"` construction breaks), and the
|
||||
// login shell inside still sources ~/.profile (preserves the environment).
|
||||
const pathDir = additionalPath
|
||||
? convertWindowsPathToWsl(additionalPath)
|
||||
: "";
|
||||
wslArgs.push(
|
||||
"--exec",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
buildWslArgvScript(),
|
||||
"sh",
|
||||
pathDir,
|
||||
wslCwd,
|
||||
command,
|
||||
...args,
|
||||
);
|
||||
return {
|
||||
command: "C:\\Windows\\System32\\wsl.exe",
|
||||
args: wslArgs,
|
||||
};
|
||||
}
|
||||
|
||||
// Terminal launch: the command may be a shell line (pipes, redirects, &&,
|
||||
// subshells, bash-isms), so it must be parsed by the user's shell. Build the
|
||||
// same shell command line as before, but deliver it via --exec + a single
|
||||
// positional (no nested escaped string) so it survives WSL quoting, and run
|
||||
// it under the user's login shell ($SHELL -l -c "$1"). Behaviorally identical
|
||||
// to the previous `$SHELL -l -c <innerCommand>`, just delivered robustly.
|
||||
const escapedArgs = args.map(escapeShellArgBash).join(" ");
|
||||
const argsString = escapedArgs.length > 0 ? ` ${escapedArgs}` : "";
|
||||
|
||||
|
|
@ -308,7 +476,14 @@ export function wrapCommandForWsl(
|
|||
}
|
||||
|
||||
const innerCommand = `${pathPrefix}cd ${escapeShellArgBash(wslCwd)} && ${command}${argsString}`;
|
||||
wslArgs.push("sh", "-c", buildWslShellWrapper(innerCommand));
|
||||
wslArgs.push(
|
||||
"--exec",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
buildWslTerminalScript(),
|
||||
"sh",
|
||||
innerCommand,
|
||||
);
|
||||
|
||||
return {
|
||||
command: "C:\\Windows\\System32\\wsl.exe",
|
||||
|
|
@ -374,12 +549,16 @@ export function prepareShellCommand(
|
|||
|
||||
// WSL mode (Windows only)
|
||||
if (Platform.isWin && options.wslMode) {
|
||||
// Agent launch (alwaysEscape: true) uses the hybrid argv launcher;
|
||||
// terminal launch (alwaysEscape: false) uses the shell-string wrapper
|
||||
// so pipes/redirects in the command are parsed by the shell.
|
||||
const wrapped = wrapCommandForWsl(
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
options.wslDistribution,
|
||||
options.nodeDir,
|
||||
alwaysEscape,
|
||||
);
|
||||
return {
|
||||
command: wrapped.command,
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
375
test/platform.test.ts
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { Platform } from "obsidian";
|
||||
import {
|
||||
convertWindowsPathToWsl,
|
||||
convertWslPathToWindows,
|
||||
escapeShellArgBash,
|
||||
escapeShellArgWindows,
|
||||
buildWslArgvScript,
|
||||
buildWslTerminalScript,
|
||||
wrapCommandForWsl,
|
||||
prepareShellCommand,
|
||||
buildWslEnv,
|
||||
isSameDirectory,
|
||||
} from "../src/utils/platform";
|
||||
|
||||
const WSL_EXE = "C:\\Windows\\System32\\wsl.exe";
|
||||
|
||||
function resetPlatform(): void {
|
||||
Platform.isWin = false;
|
||||
Platform.isMacOS = false;
|
||||
Platform.isLinux = false;
|
||||
Platform.isDesktopApp = true;
|
||||
}
|
||||
|
||||
beforeEach(resetPlatform);
|
||||
|
||||
describe("convertWindowsPathToWsl", () => {
|
||||
it("converts a Windows drive path", () => {
|
||||
expect(convertWindowsPathToWsl("C:\\Users\\me")).toBe("/mnt/c/Users/me");
|
||||
});
|
||||
it("lowercases the drive letter and normalizes slashes", () => {
|
||||
expect(convertWindowsPathToWsl("D:/Foo/Bar")).toBe("/mnt/d/Foo/Bar");
|
||||
});
|
||||
it("is idempotent on already-WSL paths", () => {
|
||||
expect(convertWindowsPathToWsl("/mnt/c/x")).toBe("/mnt/c/x");
|
||||
});
|
||||
it("passes through non-drive paths unchanged", () => {
|
||||
expect(convertWindowsPathToWsl("relative/dir")).toBe("relative/dir");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertWslPathToWindows", () => {
|
||||
it("converts a /mnt path", () => {
|
||||
expect(convertWslPathToWindows("/mnt/c/Users/me")).toBe("C:\\Users\\me");
|
||||
});
|
||||
it("passes through non-/mnt paths unchanged", () => {
|
||||
expect(convertWslPathToWindows("/home/me")).toBe("/home/me");
|
||||
});
|
||||
it("round-trips with convertWindowsPathToWsl", () => {
|
||||
const win = "C:\\Users\\me\\vault";
|
||||
expect(convertWslPathToWindows(convertWindowsPathToWsl(win))).toBe(win);
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeShellArgBash", () => {
|
||||
it("wraps plain args in single quotes", () => {
|
||||
expect(escapeShellArgBash("foo")).toBe("'foo'");
|
||||
});
|
||||
it("preserves spaces inside quotes", () => {
|
||||
expect(escapeShellArgBash("foo bar")).toBe("'foo bar'");
|
||||
});
|
||||
it("escapes embedded single quotes", () => {
|
||||
expect(escapeShellArgBash("it's")).toBe("'it'\\''s'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeShellArgWindows", () => {
|
||||
it("leaves simple args unquoted", () => {
|
||||
expect(escapeShellArgWindows("foo")).toBe("foo");
|
||||
});
|
||||
it("quotes args with spaces", () => {
|
||||
expect(escapeShellArgWindows("foo bar")).toBe('"foo bar"');
|
||||
});
|
||||
it("doubles percent signs", () => {
|
||||
expect(escapeShellArgWindows("%PATH%")).toBe("%%PATH%%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWslArgvScript", () => {
|
||||
const script = buildWslArgvScript();
|
||||
it("runs a login shell so ~/.profile is sourced (env preserved)", () => {
|
||||
expect(script).toContain(" -l ");
|
||||
});
|
||||
it("execs the forwarded argv", () => {
|
||||
expect(script).toContain('exec "$@"');
|
||||
});
|
||||
it("references $SHELL directly with a /bin/sh fallback", () => {
|
||||
expect(script).toContain("${SHELL:-/bin/sh}");
|
||||
expect(script).toContain("*/fish");
|
||||
});
|
||||
it("sources ~/.profile (bash -l skips it when ~/.bash_profile exists)", () => {
|
||||
expect(script).toContain(". ~/.profile");
|
||||
});
|
||||
it("fails fast if cd to the working dir fails (no run in wrong dir)", () => {
|
||||
expect(script).toContain('cd "$1" || exit');
|
||||
});
|
||||
it("does not bake in any command/args (pure constant)", () => {
|
||||
// Sanity: the script must not contain a placeholder that implies
|
||||
// string interpolation of user data.
|
||||
expect(script).not.toContain("undefined");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapCommandForWsl — agent (useArgvExec)", () => {
|
||||
it("uses --exec + /bin/sh -c <script>, forwarding command/args as ordered argv", () => {
|
||||
const { command, args } = wrapCommandForWsl(
|
||||
"/home/u/agent",
|
||||
["acp"],
|
||||
"C:\\vault",
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(command).toBe(WSL_EXE);
|
||||
// header: --exec /bin/sh -c <launcher script>
|
||||
expect(args.slice(0, 3)).toEqual(["--exec", "/bin/sh", "-c"]);
|
||||
// launcher keeps a login shell so the environment is preserved
|
||||
expect(args[3]).toContain(" -l ");
|
||||
// and sources ~/.profile (regression guard: bash -l skips it when
|
||||
// ~/.bash_profile exists; linuxbrew/nvm/mise/bare-commands need it)
|
||||
expect(args[3]).toContain(". ~/.profile");
|
||||
// positionals: sh <pathDir> <cwd> <command> <args...> — ORDER matters
|
||||
// ("" pathDir must be present so $1..$N line up with the script)
|
||||
expect(args.slice(4)).toEqual([
|
||||
"sh",
|
||||
"",
|
||||
"/mnt/c/vault",
|
||||
"/home/u/agent",
|
||||
"acp",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a command path with spaces as a single, correctly-positioned argv element", () => {
|
||||
const { args } = wrapCommandForWsl(
|
||||
"/home/u/my agent",
|
||||
[],
|
||||
"C:\\vault",
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(args.slice(4)).toEqual([
|
||||
"sh",
|
||||
"",
|
||||
"/mnt/c/vault",
|
||||
"/home/u/my agent",
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards the additionalPath dir as the first positional (before cwd)", () => {
|
||||
const { args } = wrapCommandForWsl(
|
||||
"/home/u/agent",
|
||||
[],
|
||||
"C:\\vault",
|
||||
undefined,
|
||||
"C:\\node\\bin",
|
||||
true,
|
||||
);
|
||||
expect(args.slice(4)).toEqual([
|
||||
"sh",
|
||||
"/mnt/c/node/bin",
|
||||
"/mnt/c/vault",
|
||||
"/home/u/agent",
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes -d <distribution> before the launcher header", () => {
|
||||
const { args } = wrapCommandForWsl(
|
||||
"/home/u/agent",
|
||||
[],
|
||||
"C:\\vault",
|
||||
"Ubuntu",
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(args.slice(0, 5)).toEqual([
|
||||
"-d",
|
||||
"Ubuntu",
|
||||
"--exec",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
]);
|
||||
// positionals (index 5 is the launcher script)
|
||||
expect(args.slice(6)).toEqual([
|
||||
"sh",
|
||||
"",
|
||||
"/mnt/c/vault",
|
||||
"/home/u/agent",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapCommandForWsl — terminal (shell string via hybrid)", () => {
|
||||
it("uses --exec + login shell, command line as a single positional (pipes preserved)", () => {
|
||||
const { command, args } = wrapCommandForWsl(
|
||||
"ls -la | grep foo",
|
||||
[],
|
||||
"C:\\vault",
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
expect(command).toBe(WSL_EXE);
|
||||
// header: --exec /bin/sh -c <launcher script>
|
||||
expect(args.slice(0, 3)).toEqual(["--exec", "/bin/sh", "-c"]);
|
||||
// launcher runs the command line under the user's login shell
|
||||
const script = args[3];
|
||||
expect(script).toContain(" -l ");
|
||||
expect(script).toContain("${SHELL:-/bin/sh}");
|
||||
expect(script).toContain('-c "$1"');
|
||||
expect(script).toContain(". ~/.profile"); // regression guard
|
||||
|
||||
// positionals: sh <innerCommand> — the command line is ONE intact element
|
||||
expect(args).toHaveLength(6);
|
||||
expect(args[4]).toBe("sh");
|
||||
const innerCommand = args[5];
|
||||
expect(innerCommand).toContain("ls -la | grep foo"); // raw pipe preserved
|
||||
expect(innerCommand).toContain("cd '/mnt/c/vault'");
|
||||
});
|
||||
|
||||
it("includes the PATH export and cd in the command line when additionalPath is set", () => {
|
||||
const { args } = wrapCommandForWsl(
|
||||
"node x.js",
|
||||
[],
|
||||
"C:\\v",
|
||||
undefined,
|
||||
"C:\\node\\bin",
|
||||
false,
|
||||
);
|
||||
const innerCommand = args[args.length - 1];
|
||||
expect(innerCommand).toContain('export PATH="/mnt/c/node/bin:$PATH"');
|
||||
expect(innerCommand).toContain("node x.js");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWslTerminalScript", () => {
|
||||
const s = buildWslTerminalScript();
|
||||
it("runs the command line under the user's login shell via -c", () => {
|
||||
expect(s).toContain(" -l ");
|
||||
expect(s).toContain('-c "$1"');
|
||||
expect(s).toContain("${SHELL:-/bin/sh}");
|
||||
});
|
||||
it("falls back to /bin/sh for non-POSIX shells", () => {
|
||||
expect(s).toContain("*/fish");
|
||||
});
|
||||
it("sources ~/.profile (bash -l skips it when ~/.bash_profile exists)", () => {
|
||||
expect(s).toContain(". ~/.profile");
|
||||
});
|
||||
it("is a pure constant (no baked-in user data)", () => {
|
||||
expect(s).not.toContain("undefined");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapCommandForWsl — validation", () => {
|
||||
it("rejects UNC working directories", () => {
|
||||
expect(() =>
|
||||
wrapCommandForWsl("/x", [], "\\\\server\\share"),
|
||||
).toThrow(/UNC/);
|
||||
});
|
||||
it("rejects invalid distribution names", () => {
|
||||
expect(() =>
|
||||
wrapCommandForWsl("/x", [], "C:\\v", "bad;name"),
|
||||
).toThrow(/distribution/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareShellCommand", () => {
|
||||
it("WSL agent: wsl.exe via --exec, no shell", () => {
|
||||
Platform.isWin = true;
|
||||
const r = prepareShellCommand("/home/u/agent", ["acp"], "C:\\vault", {
|
||||
wslMode: true,
|
||||
alwaysEscape: true,
|
||||
});
|
||||
expect(r.command).toBe(WSL_EXE);
|
||||
expect(r.needsShell).toBe(false);
|
||||
expect(r.args).toContain("--exec");
|
||||
});
|
||||
|
||||
it("WSL terminal: wsl.exe via --exec + login shell, command line as positional", () => {
|
||||
Platform.isWin = true;
|
||||
const r = prepareShellCommand("ls | grep x", [], "C:\\vault", {
|
||||
wslMode: true,
|
||||
alwaysEscape: false,
|
||||
});
|
||||
expect(r.command).toBe(WSL_EXE);
|
||||
expect(r.needsShell).toBe(false);
|
||||
expect(r.args.slice(0, 3)).toEqual(["--exec", "/bin/sh", "-c"]);
|
||||
// the command line (with the pipe) is the last positional, intact
|
||||
expect(r.args[r.args.length - 1]).toContain("ls | grep x");
|
||||
});
|
||||
|
||||
it("macOS: wraps in a login shell (-l -c)", () => {
|
||||
Platform.isMacOS = true;
|
||||
const r = prepareShellCommand("agent", [], "/home/u", {
|
||||
wslMode: false,
|
||||
});
|
||||
expect(r.args[0]).toBe("-l");
|
||||
expect(r.args[1]).toBe("-c");
|
||||
expect(r.needsShell).toBe(false);
|
||||
});
|
||||
|
||||
it("Windows non-WSL: needs cmd.exe shell", () => {
|
||||
Platform.isWin = true;
|
||||
const r = prepareShellCommand("agent", ["x"], "C:\\vault", {
|
||||
wslMode: false,
|
||||
});
|
||||
expect(r.needsShell).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWslEnv", () => {
|
||||
it("adds a forwarded key with the /u flag", () => {
|
||||
const out = buildWslEnv({ FOO: "bar" }, ["FOO"]);
|
||||
expect(out.WSLENV).toBe("FOO/u");
|
||||
});
|
||||
|
||||
it("skips empty values (never clobbers a profile-set var)", () => {
|
||||
const base = { FOO: "" };
|
||||
const out = buildWslEnv(base, ["FOO"]);
|
||||
expect(out.WSLENV).toBeUndefined();
|
||||
expect(out).toBe(base); // unchanged reference when nothing to add
|
||||
});
|
||||
|
||||
it("skips undefined values", () => {
|
||||
const out = buildWslEnv({}, ["MISSING"]);
|
||||
expect(out.WSLENV).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips invalid key names", () => {
|
||||
const out = buildWslEnv({ "A:B": "x" }, ["A:B"]);
|
||||
expect(out.WSLENV).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges with an existing WSLENV without clobbering", () => {
|
||||
const out = buildWslEnv({ FOO: "bar", WSLENV: "EXISTING/p" }, ["FOO"]);
|
||||
expect(out.WSLENV).toBe("EXISTING/p:FOO/u");
|
||||
});
|
||||
|
||||
it("does not duplicate an already-listed key", () => {
|
||||
const out = buildWslEnv({ FOO: "bar", WSLENV: "FOO/p" }, ["FOO"]);
|
||||
expect(out.WSLENV).toBe("FOO/p");
|
||||
});
|
||||
|
||||
it("does not mutate the input env", () => {
|
||||
const base: NodeJS.ProcessEnv = { FOO: "bar" };
|
||||
buildWslEnv(base, ["FOO"]);
|
||||
expect(base.WSLENV).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not throw when WSLENV is present but not a string (defensive)", () => {
|
||||
const base = {
|
||||
FOO: "bar",
|
||||
WSLENV: 123 as unknown as string,
|
||||
};
|
||||
expect(() => buildWslEnv(base, ["FOO"])).not.toThrow();
|
||||
expect(buildWslEnv(base, ["FOO"]).WSLENV).toBe("FOO/u");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSameDirectory", () => {
|
||||
it("treats equivalent WSL and Windows paths as the same", () => {
|
||||
expect(isSameDirectory("/mnt/c/x", "C:\\x")).toBe(true);
|
||||
});
|
||||
it("ignores trailing slashes", () => {
|
||||
expect(isSameDirectory("/mnt/c/x/", "/mnt/c/x")).toBe(true);
|
||||
});
|
||||
it("is case-insensitive on Windows", () => {
|
||||
Platform.isWin = true;
|
||||
expect(isSameDirectory("C:\\Foo", "C:\\foo")).toBe(true);
|
||||
});
|
||||
it("is case-sensitive off Windows", () => {
|
||||
Platform.isWin = false;
|
||||
expect(isSameDirectory("/mnt/c/Foo", "/mnt/c/foo")).toBe(false);
|
||||
});
|
||||
});
|
||||
14
test/stubs/obsidian.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Lightweight `obsidian` stub for unit tests.
|
||||
*
|
||||
* The real `obsidian` module only exists inside the Obsidian runtime. The pure
|
||||
* utilities under test (`src/utils/platform.ts`, `src/utils/paths.ts`) only need
|
||||
* `Platform`, whose flags they read at call time. Tests mutate these flags to
|
||||
* exercise the platform-specific branches.
|
||||
*/
|
||||
export const Platform = {
|
||||
isWin: false,
|
||||
isMacOS: false,
|
||||
isLinux: false,
|
||||
isDesktopApp: true,
|
||||
};
|
||||
4
tsconfig.eslint.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"]
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
"1.0.0": "0.15.0",
|
||||
"0.11.0-preview.2": "1.11.4"
|
||||
}
|
||||
|
|
|
|||
19
vitest.config.mts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// `obsidian` has no real module outside Obsidian, so alias it to a lightweight
|
||||
// stub for unit tests. Only the pieces the tested pure functions need (Platform)
|
||||
// are provided.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["test/**/*.test.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
obsidian: fileURLToPath(
|
||||
new URL("./test/stubs/obsidian.ts", import.meta.url),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||