chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Path to your Obsidian vault for auto-copy during development
|
||||
# OBSIDIAN_VAULT=/path/to/your/vault
|
||||
@@ -0,0 +1,77 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Test
|
||||
run: npm run test
|
||||
|
||||
build:
|
||||
needs: [lint, typecheck, test]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
Please review this pull request and provide feedback on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
|
||||
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
|
||||
|
||||
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
|
||||
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Potential Duplicates
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
jobs:
|
||||
check-duplicates:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: wow-actions/potential-duplicates@v1
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
threshold: 0.6
|
||||
state: all
|
||||
label: possible-duplicate
|
||||
comment: >
|
||||
Possible duplicates found:
|
||||
{{#issues}}
|
||||
- #{{number}} ({{similarity}}% similar): {{title}}
|
||||
{{/issues}}
|
||||
|
||||
If one of these matches your issue, please add your details there instead.
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
artifact-metadata: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Full history for changelog generation
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Validate release version
|
||||
run: node scripts/check-release-version.mjs "${{ github.ref_name }}"
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Generate artifact attestations
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-path: |
|
||||
main.js
|
||||
manifest.json
|
||||
styles.css
|
||||
|
||||
- name: Get previous tag
|
||||
id: prev_tag
|
||||
run: |
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
echo "tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
if [ -n "${{ steps.prev_tag.outputs.tag }}" ]; then
|
||||
CHANGELOG=$(git log ${{ steps.prev_tag.outputs.tag }}..HEAD --pretty=format:"- %s" --no-merges | grep -v "^- ${{ github.ref_name }}$" || true)
|
||||
else
|
||||
CHANGELOG=$(git log --pretty=format:"- %s" --no-merges | head -20)
|
||||
fi
|
||||
# Handle multiline output
|
||||
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CHANGELOG" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
body: |
|
||||
## What's Changed
|
||||
${{ steps.changelog.outputs.notes }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.tag }}...${{ github.ref_name }}
|
||||
files: |
|
||||
main.js
|
||||
manifest.json
|
||||
styles.css
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Close stale issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Runs daily at midnight UTC
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
days-before-stale: 7
|
||||
days-before-close: 7
|
||||
stale-issue-message: 'This issue has been inactive for 7 days and will be closed in 7 days if no further activity occurs.'
|
||||
close-issue-message: 'Closed due to 14 days of inactivity.'
|
||||
stale-issue-label: 'stale'
|
||||
exempt-issue-labels: 'pinned,security,bug'
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
# Build output
|
||||
main.js
|
||||
styles.css
|
||||
.codex-vendor/
|
||||
*.js.map
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# npm/yarn
|
||||
npm-debug.log*
|
||||
yarn-error.log*
|
||||
.yarn-integrity
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Obsidian specific
|
||||
*.obsidian
|
||||
data.json
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
|
||||
# Development files
|
||||
sandbox/
|
||||
dev
|
||||
.context/
|
||||
CLAUDE.local.md
|
||||
|
||||
# Lock files (uncomment based on package manager)
|
||||
# yarn.lock
|
||||
# package-lock.json
|
||||
|
||||
.claude/
|
||||
.codex/
|
||||
@@ -0,0 +1 @@
|
||||
24.16.0
|
||||
@@ -0,0 +1,107 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project
|
||||
|
||||
Claudian is an Obsidian plugin that embeds provider-backed coding agents in a sidebar and inline-edit flow. Claude is the default provider. Codex, OpenCode, and Pi are optional providers that plug into the same conversation model through `Conversation.providerId` and opaque provider-owned `providerState`.
|
||||
|
||||
Do not assume provider parity. Check each provider's `capabilities.ts`, `registration.ts`, and UI config before wiring shared behavior.
|
||||
|
||||
## Instruction Map
|
||||
|
||||
- This file is the canonical cross-agent guide. Keep shared instructions here.
|
||||
- `CLAUDE.md` files should import the nearest `AGENTS.md`; do not duplicate shared guidance there.
|
||||
- Before editing a scoped area, read its nearest scoped guide:
|
||||
- `src/core/AGENTS.md`
|
||||
- `src/features/chat/AGENTS.md`
|
||||
- `src/providers/claude/AGENTS.md`
|
||||
- `src/providers/codex/AGENTS.md`
|
||||
- `src/providers/opencode/AGENTS.md`
|
||||
- `src/providers/pi/AGENTS.md`
|
||||
- `src/style/AGENTS.md`
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run lint:fix
|
||||
npm run test
|
||||
npm run test:watch
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
Use focused commands while iterating. Before handing off code changes, run the narrowest meaningful verification plus broader checks when the change touches shared behavior. The default full check is:
|
||||
|
||||
```bash
|
||||
npm run typecheck && npm run lint && npm run test && npm run build
|
||||
```
|
||||
|
||||
Tests mirror `src/` under `tests/unit/` and `tests/integration/`.
|
||||
|
||||
## Architecture
|
||||
|
||||
| Area | Ownership |
|
||||
| --- | --- |
|
||||
| `src/app/` | Shared settings defaults and plugin-level storage helpers |
|
||||
| `src/core/` | Provider-neutral runtime, registry, storage, tool, and type contracts |
|
||||
| `src/providers/*/` | Provider adaptors, provider-owned runtime protocol, history, storage, settings, and UI |
|
||||
| `src/features/chat/` | Sidebar chat orchestration against provider-neutral contracts |
|
||||
| `src/features/inline-edit/` | Inline edit modal and provider-backed edit services |
|
||||
| `src/features/settings/` | Shared settings shell and provider tab assembly |
|
||||
| `src/shared/` | Reusable UI components |
|
||||
| `src/style/` | Modular CSS built into `styles.css` |
|
||||
|
||||
The feature layer depends on `core/` contracts, not provider internals. Provider-specific session fields belong behind typed helpers in the owning provider directory.
|
||||
|
||||
## Provider Rules
|
||||
|
||||
- Prefer provider-native behavior over local reimplementation. Adapt provider output at the boundary instead of shadowing provider features.
|
||||
- Keep live streaming and history replay responsibilities separate. Live output should come from the provider runtime protocol when available; provider transcript files are the replay source.
|
||||
- New provider behavior must be expressed through registries and capabilities: `ProviderRegistry`, `ProviderWorkspaceRegistry`, `ProviderChatUIConfig`, provider capabilities, and provider-owned settings reconciliation.
|
||||
- Model, permission, plan-mode, command, MCP, skill, and subagent behavior is provider-specific unless the core contract explicitly makes it shared.
|
||||
- When provider behavior is uncertain, inspect real runtime output first. Put throwaway scripts, traces, and handoff notes in `.context/`.
|
||||
|
||||
## Storage
|
||||
|
||||
| Path | Contents |
|
||||
| --- | --- |
|
||||
| `.claudian/claudian-settings.json` | Shared Claudian settings and provider-specific configuration |
|
||||
| `.claudian/sessions/*.meta.json` | Provider-neutral session metadata |
|
||||
| `.claude/settings.json` | Claude Code-compatible project settings, permissions, and plugin overrides |
|
||||
| `.claude/mcp.json` | Claudian-managed MCP servers for Claude |
|
||||
| `.claude/commands/**/*.md` | Claude slash commands |
|
||||
| `.claude/skills/*/SKILL.md` | Claude skills |
|
||||
| `.claude/agents/*.md` | Claude vault agents |
|
||||
| `.codex/skills/*/SKILL.md` | Codex vault skills |
|
||||
| `.agents/skills/*/SKILL.md` | Alternate Codex vault skill root |
|
||||
| `.codex/agents/*.toml` | Codex vault subagent definitions |
|
||||
| `.opencode/agent`, `.opencode/agents` | OpenCode agent definitions |
|
||||
| `.pi/agent/sessions/` | Pi vault-local sessions |
|
||||
| `~/.claude/projects/{vault}/*.jsonl` | Claude-native transcripts |
|
||||
| `~/.codex/sessions/**/*.jsonl` | Codex-native transcripts |
|
||||
| `~/.pi/agent/sessions/` | Pi user-level sessions |
|
||||
|
||||
## Development Rules
|
||||
|
||||
- Use `rg` or `rg --files` for repo searches.
|
||||
- Write code, comments, identifiers, commit messages, and code blocks in English.
|
||||
- Keep comments sparse. Explain non-obvious intent, protocol constraints, or invariants; do not narrate obvious code.
|
||||
- Do not use `console.*` in production code.
|
||||
- Preserve user data and provider-native files. Settings writers should merge with existing provider-owned data instead of clobbering it.
|
||||
- Put non-committed notes, handoff files, traces, and throwaway scripts in `.context/`.
|
||||
- Do not add new production dependencies without a clear need and an explicit tradeoff.
|
||||
|
||||
## TDD Workflow
|
||||
|
||||
- For new behavior or bug fixes, write or update the failing test first in the mirrored `tests/` path.
|
||||
- Make the narrowest implementation change that passes the focused test.
|
||||
- Refactor after the test is green, preserving the provider and feature ownership boundaries above.
|
||||
- If a change cannot be tested directly, document why and cover the closest stable contract instead.
|
||||
|
||||
## Review Expectations
|
||||
|
||||
- Findings first: correctness, regression risk, API or contract ambiguity, and missing tests.
|
||||
- Treat maintainability issues as real findings when they increase future change cost or failure risk.
|
||||
- Call out duplicated logic, unclear ownership, and tight coupling with a concrete refactoring direction.
|
||||
@@ -0,0 +1,5 @@
|
||||
@AGENTS.md
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude-specific instructions belong here only when they do not apply to other agents.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Claudian
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
An Obsidian plugin that embeds AI coding agents (Claude Code, Codex, Opencode, Pi, and more to come) in your vault. Your vault becomes the agent's working directory — file read/write, search, bash, and multi-step workflows all work out of the box.
|
||||
|
||||
## Features & Usage
|
||||
|
||||
Open the chat sidebar from the ribbon icon or command palette. Select text and use the hotkey for inline edit. Everything works like your familiar coding agent, Claude Code, Codex, Opencode, and Pi — talk to the agent, and it reads, writes, edits, and searches files in your vault.
|
||||
|
||||
**Inline Edit** — Select text or start at the cursor position + hotkey to edit directly in notes with word-level diff preview.
|
||||
|
||||
**Slash Commands & Skills** — Type `/` or `$` for reusable prompt templates or Skills from user- and vault-level scopes.
|
||||
|
||||
**`@mention`** - Type `@` to mention anything you want the agent to work with, vault files, subagents, MCP servers, or files in external directories.
|
||||
|
||||
**Plan Mode** — Toggle via `Shift+Tab`. The agent explores and designs before implementing, then presents a plan for approval.
|
||||
|
||||
**Instruction Mode (`#`)** — Refined custom instructions added from the chat input.
|
||||
|
||||
**MCP Servers** — Connect external tools via Model Context Protocol (stdio, SSE, HTTP). Claude manages vault MCP in-app; Codex uses its own CLI-managed MCP configuration.
|
||||
|
||||
**Multi-Tab & Conversations** — Multiple chat tabs, conversation history, fork, resume, and compact.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Claude provider**: [Claude Code CLI](https://code.claude.com/docs/en/overview) installed (native install recommended). Claude subscription/API or compatible provider ([Openrouter](https://openrouter.ai/docs/guides/guides/claude-code-integration), [Kimi](https://platform.kimi.ai/docs/guide/claude-code-kimi), [GLM](https://docs.z.ai/devpack/tool/claude) etc.).
|
||||
- **Optional providers**: [Codex CLI](https://github.com/openai/codex), [Opencode](https://opencode.ai/), [Pi](https://github.com/earendil-works/pi).
|
||||
- Obsidian v1.7.2+
|
||||
- Desktop only (macOS, Linux, Windows)
|
||||
|
||||
## Installation
|
||||
|
||||
### From Obsidian Community Plugins (recommended)
|
||||
|
||||
1. Open Obsidian → Settings → Community plugins → Browse
|
||||
2. Search for "Claudian" and click Install
|
||||
3. Enable the plugin
|
||||
|
||||
Or install directly from the [community plugin page](https://community.obsidian.md/plugins/realclaudian).
|
||||
|
||||
### From GitHub Release
|
||||
|
||||
1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/YishenTu/claudian/releases/latest)
|
||||
2. Create a folder called `claudian` in your vault's plugins folder:
|
||||
```
|
||||
/path/to/vault/.obsidian/plugins/claudian/
|
||||
```
|
||||
3. Copy the downloaded files into the `claudian` folder
|
||||
4. Enable the plugin in Obsidian:
|
||||
- Settings → Community plugins → Enable "Claudian"
|
||||
|
||||
### From source (development)
|
||||
|
||||
1. Clone this repository into your vault's plugins folder:
|
||||
```bash
|
||||
cd /path/to/vault/.obsidian/plugins
|
||||
git clone https://github.com/YishenTu/claudian.git
|
||||
cd claudian
|
||||
```
|
||||
|
||||
2. Install dependencies and build:
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
3. Enable the plugin in Obsidian:
|
||||
- Settings → Community plugins → Enable "Claudian"
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Watch mode
|
||||
npm run dev
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Privacy & Data Use
|
||||
|
||||
- **Sent to API**: Your input, attached files, images, and tool call outputs. Default: Anthropic (Claude), OpenAI (Codex), or the provider configured in Opencode/Pi; configurable via provider settings and environment variables.
|
||||
- **Local storage**: Claudian settings and session metadata in `vault/.claudian/`; Claude provider files in `vault/.claude/`; transcripts in `~/.claude/projects/` (Claude), `~/.codex/sessions/` (Codex), and `.pi/agent/sessions/` or `~/.pi/agent/sessions/` (Pi).
|
||||
- **Environment variables**: Provider subprocesses inherit the Obsidian process environment plus any variables you configure in Claudian. This is needed for CLI authentication, proxies, certificates, and PATH resolution.
|
||||
- **Device-specific paths**: Per-device CLI paths use an opaque local key stored in browser local storage, not your system hostname.
|
||||
- **Background activity**: Claudian does not run telemetry beacons. UI polling timers read local Obsidian/editor selection state only. Network activity is limited to explicit provider runtime work, configured MCP endpoints, and provider SDK/CLI calls needed to answer your requests.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Claude CLI not found
|
||||
|
||||
If you encounter `spawn claude ENOENT` or `Claude CLI not found`, the plugin can't auto-detect your Claude installation. Common with Node version managers (nvm, fnm, volta).
|
||||
|
||||
**Solution**: Leave the setting empty first so Claudian can auto-detect Claude Code. If auto-detection fails, find your CLI path and set it in Settings → Advanced → Claude CLI path.
|
||||
|
||||
| Platform | Command | Example Path |
|
||||
|----------|---------|--------------|
|
||||
| macOS/Linux | `which claude` | `/Users/you/.volta/bin/claude` |
|
||||
| Windows (native) | `where.exe claude` | `C:\Users\you\AppData\Local\Claude\claude.exe` |
|
||||
| Windows (npm) | `npm root -g` | `{root}\@anthropic-ai\claude-code\cli-wrapper.cjs` |
|
||||
|
||||
> **Note**: On Windows, avoid `.cmd` and `.ps1` wrappers. Use `claude.exe` for native installs, or `cli-wrapper.cjs` for package-manager installs. `cli.js` is only a legacy fallback for older Claude Code npm packages.
|
||||
|
||||
**Alternative**: Add your Node.js bin directory to PATH in Settings → Environment → Custom variables.
|
||||
|
||||
### npm CLI and Node.js not in same directory
|
||||
|
||||
If using npm-installed CLI, check if `claude` and `node` are in the same directory:
|
||||
```bash
|
||||
dirname $(which claude)
|
||||
dirname $(which node)
|
||||
```
|
||||
|
||||
If different, GUI apps like Obsidian may not find Node.js.
|
||||
|
||||
**Solutions**:
|
||||
1. Install native binary (recommended)
|
||||
2. Add Node.js path to Settings → Environment: `PATH=/path/to/node/bin`
|
||||
|
||||
### Other providers
|
||||
|
||||
Codex, Opencode, and Pi support are live but features might be incomplete, and still need more testing across platforms and installation methods. If you have feature request or run into any bugs, please [submit a GitHub issue](https://github.com/YishenTu/claudian/issues).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.ts # Plugin entry point
|
||||
├── app/ # Shared defaults and plugin-level storage
|
||||
├── core/ # Provider-neutral runtime, registry, and type contracts
|
||||
│ ├── runtime/ # ChatRuntime interface and approval types
|
||||
│ ├── providers/ # Provider registry and workspace services
|
||||
│ ├── auxiliary/ # Shared provider auxiliary services
|
||||
│ ├── bootstrap/ # Plugin bootstrap wiring
|
||||
│ ├── security/ # Approval utilities
|
||||
│ └── ... # commands, mcp, prompt, storage, tools, types
|
||||
├── providers/
|
||||
│ ├── claude/ # Claude SDK adaptor, prompt encoding, storage, MCP, plugins
|
||||
│ ├── codex/ # Codex app-server adaptor, JSON-RPC transport, JSONL history
|
||||
│ ├── opencode/ # Opencode adaptor
|
||||
│ ├── pi/ # Pi RPC adaptor, model discovery, JSONL history
|
||||
│ └── acp/ # Agent Client Protocol shared transport
|
||||
├── features/
|
||||
│ ├── chat/ # Sidebar chat: tabs, controllers, renderers
|
||||
│ ├── inline-edit/ # Inline edit modal and provider-backed edit services
|
||||
│ └── settings/ # Settings shell with provider tabs
|
||||
├── shared/ # Reusable UI components and modals
|
||||
├── i18n/ # Internationalization (10 locales)
|
||||
├── types/ # Shared ambient types
|
||||
├── utils/ # Cross-cutting utilities
|
||||
└── style/ # Modular CSS
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [MIT License](LICENSE).
|
||||
|
||||
## Sponsorship
|
||||
|
||||
### Ke Holdings Inc. (BEIKE)
|
||||
|
||||
<img src="assets/sponsors/MOMA.png" alt="MOMA" width="90%">
|
||||
|
||||
Claudian is proudly sponsored by Ke Holdings Inc. (BEIKE) and the MOMA team. Their support helps Claudian continue to
|
||||
improve through ongoing development and maintenance.
|
||||
|
||||
> Want to support Claudian or appear here? Contact me: [tysk01213@gmail.com](mailto:tysk01213@gmail.com).
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=YishenTu%2Fclaudian&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=YishenTu/claudian&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=YishenTu/claudian&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=YishenTu/claudian&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [Obsidian](https://obsidian.md) for the plugin API
|
||||
- [Anthropic](https://anthropic.com) for Claude and the [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview)
|
||||
- [OpenAI](https://openai.com) for [Codex](https://github.com/openai/codex)
|
||||
- [Opencode](https://opencode.ai/)
|
||||
- [Pi](https://github.com/earendil-works/pi)
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`YishenTu/claudian`
|
||||
- 原始仓库:https://github.com/YishenTu/claudian
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 399 KiB |
@@ -0,0 +1,195 @@
|
||||
import esbuild from 'esbuild';
|
||||
import { builtinModules } from 'node:module';
|
||||
import path from 'path';
|
||||
import process from 'process';
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
promises as fsPromises,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
} from 'fs';
|
||||
import rendererSafeUnrefHelpers from './scripts/rendererSafeUnref.js';
|
||||
|
||||
const {
|
||||
findUnsafeTimerUnrefSites,
|
||||
patchRendererUnsafeUnrefSites,
|
||||
} = rendererSafeUnrefHelpers;
|
||||
|
||||
// Load .env.local if it exists
|
||||
if (existsSync('.env.local')) {
|
||||
const envContent = readFileSync('.env.local', 'utf-8');
|
||||
for (const line of envContent.split('\n')) {
|
||||
const match = line.match(/^([^=]+)=["']?(.+?)["']?$/);
|
||||
if (match && !process.env[match[1]]) {
|
||||
process.env[match[1]] = match[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prod = process.argv[2] === 'production';
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function getNamedImportAliases(contents, exportName, moduleNames) {
|
||||
const aliases = new Set([exportName]);
|
||||
const importPattern = /import\s*\{([^}]+)\}\s*from\s*["']([^"']+)["']/g;
|
||||
let match;
|
||||
|
||||
while ((match = importPattern.exec(contents)) !== null) {
|
||||
const [, specifiers, moduleName] = match;
|
||||
if (!moduleNames.includes(moduleName)) continue;
|
||||
|
||||
for (const specifier of specifiers.split(',')) {
|
||||
const parts = specifier.trim().split(/\s+as\s+/);
|
||||
if (parts[0] === exportName) {
|
||||
aliases.add(parts[1] ?? exportName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...aliases];
|
||||
}
|
||||
|
||||
function patchSdkImportMetaUrl(contents) {
|
||||
let patched = contents.replace(
|
||||
'createRequire(import.meta.url)',
|
||||
'createRequire(__filename)',
|
||||
);
|
||||
|
||||
for (const alias of getNamedImportAliases(patched, 'createRequire', ['module', 'node:module'])) {
|
||||
patched = patched.replace(
|
||||
new RegExp(`\\b${escapeRegExp(alias)}\\(import\\.meta\\.url\\)`, 'g'),
|
||||
`${alias}(__filename)`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const alias of getNamedImportAliases(patched, 'fileURLToPath', ['url', 'node:url'])) {
|
||||
patched = patched.replace(
|
||||
new RegExp(`\\b${escapeRegExp(alias)}\\(import\\.meta\\.url\\)`, 'g'),
|
||||
'__filename',
|
||||
);
|
||||
}
|
||||
|
||||
return patched;
|
||||
}
|
||||
|
||||
const patchSdkImportMeta = {
|
||||
name: 'patch-sdk-import-meta',
|
||||
setup(build) {
|
||||
build.onLoad(
|
||||
{
|
||||
filter: /[\\/]node_modules[\\/](?:@openai[\\/]codex-sdk[\\/]dist[\\/]index\.js|@anthropic-ai[\\/]claude-agent-sdk[\\/]sdk\.mjs)$/,
|
||||
},
|
||||
async (args) => {
|
||||
const contents = await fsPromises.readFile(args.path, 'utf8');
|
||||
return {
|
||||
contents: patchSdkImportMetaUrl(contents),
|
||||
loader: 'js',
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const patchRendererUnsafeUnref = {
|
||||
name: 'patch-renderer-unsafe-unref',
|
||||
setup(build) {
|
||||
build.onEnd(async (result) => {
|
||||
if (result.errors.length > 0 || !existsSync('main.js')) return;
|
||||
|
||||
const bundlePath = path.join(process.cwd(), 'main.js');
|
||||
const originalContents = await fsPromises.readFile(bundlePath, 'utf8');
|
||||
const patchedBundle = patchRendererUnsafeUnrefSites(originalContents);
|
||||
|
||||
if (patchedBundle.contents !== originalContents) {
|
||||
await fsPromises.writeFile(bundlePath, patchedBundle.contents, 'utf8');
|
||||
}
|
||||
|
||||
const unsafeMatches = findUnsafeTimerUnrefSites(patchedBundle.contents);
|
||||
if (unsafeMatches.length > 0) {
|
||||
const details = unsafeMatches
|
||||
.slice(0, 5)
|
||||
.map((match) => `line ${match.line}: ${match.snippet}`)
|
||||
.join('\n');
|
||||
|
||||
throw new Error(
|
||||
`Renderer-unsafe timer .unref() calls remain in main.js:\n${details}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Obsidian plugin folder path (set via OBSIDIAN_VAULT env var or .env.local)
|
||||
const OBSIDIAN_VAULT = process.env.OBSIDIAN_VAULT;
|
||||
const OBSIDIAN_PLUGIN_PATH = OBSIDIAN_VAULT && existsSync(OBSIDIAN_VAULT)
|
||||
? path.join(OBSIDIAN_VAULT, '.obsidian', 'plugins', 'claudian')
|
||||
: null;
|
||||
|
||||
// Plugin to copy built files to Obsidian plugin folder
|
||||
const copyToObsidian = {
|
||||
name: 'copy-to-obsidian',
|
||||
setup(build) {
|
||||
build.onEnd((result) => {
|
||||
if (result.errors.length > 0) return;
|
||||
rmSync(path.join(process.cwd(), '.codex-vendor'), { recursive: true, force: true });
|
||||
|
||||
if (!OBSIDIAN_PLUGIN_PATH) return;
|
||||
|
||||
if (!existsSync(OBSIDIAN_PLUGIN_PATH)) {
|
||||
mkdirSync(OBSIDIAN_PLUGIN_PATH, { recursive: true });
|
||||
}
|
||||
|
||||
const files = ['main.js', 'manifest.json', 'styles.css'];
|
||||
for (const file of files) {
|
||||
if (existsSync(file)) {
|
||||
copyFileSync(file, path.join(OBSIDIAN_PLUGIN_PATH, file));
|
||||
console.log(`Copied ${file} to Obsidian plugin folder`);
|
||||
}
|
||||
}
|
||||
|
||||
const pluginVendorRoot = path.join(OBSIDIAN_PLUGIN_PATH, '.codex-vendor');
|
||||
rmSync(pluginVendorRoot, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const context = await esbuild.context({
|
||||
entryPoints: ['src/main.ts'],
|
||||
bundle: true,
|
||||
plugins: [patchSdkImportMeta, patchRendererUnsafeUnref, copyToObsidian],
|
||||
external: [
|
||||
'obsidian',
|
||||
'electron',
|
||||
'@codemirror/autocomplete',
|
||||
'@codemirror/collab',
|
||||
'@codemirror/commands',
|
||||
'@codemirror/language',
|
||||
'@codemirror/lint',
|
||||
'@codemirror/search',
|
||||
'@codemirror/state',
|
||||
'@codemirror/view',
|
||||
'@lezer/common',
|
||||
'@lezer/highlight',
|
||||
'@lezer/lr',
|
||||
...builtinModules,
|
||||
...builtinModules.map(m => `node:${m}`),
|
||||
],
|
||||
format: 'cjs',
|
||||
target: 'es2018',
|
||||
logLevel: 'info',
|
||||
sourcemap: prod ? false : 'inline',
|
||||
treeShaking: true,
|
||||
outfile: 'main.js',
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from '@typescript-eslint/eslint-plugin';
|
||||
import jestPlugin from 'eslint-plugin-jest';
|
||||
import obsidianmd from 'eslint-plugin-obsidianmd';
|
||||
import { DEFAULT_ACRONYMS } from 'eslint-plugin-obsidianmd/dist/lib/rules/ui/acronyms.js';
|
||||
import { DEFAULT_BRANDS } from 'eslint-plugin-obsidianmd/dist/lib/rules/ui/brands.js';
|
||||
import simpleImportSort from 'eslint-plugin-simple-import-sort';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import { dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const jestRecommended = jestPlugin.configs['flat/recommended'];
|
||||
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
|
||||
const obsidianRuleSeverity = 'warn';
|
||||
|
||||
const stagedObsidianRules = {
|
||||
'obsidianmd/commands/no-command-in-command-id': obsidianRuleSeverity,
|
||||
'obsidianmd/commands/no-command-in-command-name': obsidianRuleSeverity,
|
||||
'obsidianmd/commands/no-default-hotkeys': obsidianRuleSeverity,
|
||||
'obsidianmd/commands/no-plugin-id-in-command-id': obsidianRuleSeverity,
|
||||
'obsidianmd/commands/no-plugin-name-in-command-name': obsidianRuleSeverity,
|
||||
'obsidianmd/detach-leaves': obsidianRuleSeverity,
|
||||
'obsidianmd/editor-drop-paste': obsidianRuleSeverity,
|
||||
'obsidianmd/hardcoded-config-path': obsidianRuleSeverity,
|
||||
'obsidianmd/no-forbidden-elements': obsidianRuleSeverity,
|
||||
'obsidianmd/no-global-this': obsidianRuleSeverity,
|
||||
'obsidianmd/no-plugin-as-component': obsidianRuleSeverity,
|
||||
'obsidianmd/no-sample-code': obsidianRuleSeverity,
|
||||
'obsidianmd/no-static-styles-assignment': obsidianRuleSeverity,
|
||||
'obsidianmd/no-tfile-tfolder-cast': obsidianRuleSeverity,
|
||||
'obsidianmd/no-unsupported-api': obsidianRuleSeverity,
|
||||
'obsidianmd/no-view-references-in-plugin': obsidianRuleSeverity,
|
||||
'obsidianmd/object-assign': obsidianRuleSeverity,
|
||||
'obsidianmd/platform': obsidianRuleSeverity,
|
||||
'obsidianmd/prefer-abstract-input-suggest': obsidianRuleSeverity,
|
||||
'obsidianmd/prefer-active-doc': obsidianRuleSeverity,
|
||||
'obsidianmd/prefer-file-manager-trash-file': obsidianRuleSeverity,
|
||||
'obsidianmd/prefer-get-language': obsidianRuleSeverity,
|
||||
'obsidianmd/prefer-instanceof': obsidianRuleSeverity,
|
||||
'obsidianmd/prefer-window-timers': obsidianRuleSeverity,
|
||||
'obsidianmd/regex-lookbehind': obsidianRuleSeverity,
|
||||
'obsidianmd/sample-names': obsidianRuleSeverity,
|
||||
'obsidianmd/settings-tab/no-manual-html-headings': obsidianRuleSeverity,
|
||||
'obsidianmd/settings-tab/no-problematic-settings-headings': obsidianRuleSeverity,
|
||||
'obsidianmd/ui/sentence-case': [
|
||||
obsidianRuleSeverity,
|
||||
{
|
||||
ignoreWords: ['Claudian', 'Codex', 'OpenCode', 'Pi', 'WSL'],
|
||||
brands: [...DEFAULT_BRANDS, 'Claudian', 'Codex', 'OpenCode', 'Pi'],
|
||||
acronyms: [...DEFAULT_ACRONYMS, 'TOML', 'WSL'],
|
||||
ignoreRegex: ['\\.(?:claude|codex|opencode)/'],
|
||||
enforceCamelCaseLower: true,
|
||||
},
|
||||
],
|
||||
'obsidianmd/vault/iterate': obsidianRuleSeverity,
|
||||
};
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'main.js'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['esbuild.config.mjs', 'scripts/**/*.js', 'scripts/**/*.mjs'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
console: 'readonly',
|
||||
module: 'readonly',
|
||||
process: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
...tseslint.configs['flat/recommended'],
|
||||
{
|
||||
files: ['src/**/*.ts', 'tests/**/*.ts'],
|
||||
plugins: {
|
||||
'simple-import-sort': simpleImportSort,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports', fixStyle: 'separate-type-imports' },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ args: 'none', ignoreRestSiblings: true },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'simple-import-sort/imports': 'error',
|
||||
'simple-import-sort/exports': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
tsconfigRootDir,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
obsidianmd,
|
||||
},
|
||||
rules: {
|
||||
...stagedObsidianRules,
|
||||
'@typescript-eslint/no-unsafe-argument': 'error',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'src/ClaudianService.ts',
|
||||
'src/InlineEditService.ts',
|
||||
'src/InstructionRefineService.ts',
|
||||
'src/images/**/*.ts',
|
||||
'src/prompt/**/*.ts',
|
||||
'src/sdk/**/*.ts',
|
||||
'src/security/**/*.ts',
|
||||
'src/tools/**/*.ts',
|
||||
],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['./ui', './ui/*', '../ui', '../ui/*'],
|
||||
message: 'Service and shared modules must not import UI modules.',
|
||||
},
|
||||
{
|
||||
group: ['./ClaudianView', '../ClaudianView'],
|
||||
message: 'Service and shared modules must not import the view.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['tests/**/*.ts'],
|
||||
...jestRecommended,
|
||||
rules: {
|
||||
...jestRecommended.rules,
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,41 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
const baseConfig = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.jest.json' }],
|
||||
},
|
||||
roots: ['<rootDir>/src', '<rootDir>/tests'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/setupWindow.ts'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^@test/(.*)$': '<rootDir>/tests/$1',
|
||||
'^@anthropic-ai/claude-agent-sdk$': '<rootDir>/tests/__mocks__/claude-agent-sdk.ts',
|
||||
'^obsidian$': '<rootDir>/tests/__mocks__/obsidian.ts',
|
||||
'^@modelcontextprotocol/sdk/(.*)$': '<rootDir>/node_modules/@modelcontextprotocol/sdk/dist/cjs/$1',
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(@anthropic-ai/claude-agent-sdk)/)',
|
||||
],
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
projects: [
|
||||
{
|
||||
...baseConfig,
|
||||
displayName: 'unit',
|
||||
testMatch: ['<rootDir>/tests/unit/**/*.test.ts'],
|
||||
},
|
||||
{
|
||||
...baseConfig,
|
||||
displayName: 'integration',
|
||||
testMatch: ['<rootDir>/tests/integration/**/*.test.ts'],
|
||||
},
|
||||
],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/**/*.d.ts',
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "realclaudian",
|
||||
"name": "Claudian",
|
||||
"version": "2.0.32",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Embeds Claude Code, Codex, and other coding agents as AI collaborators in your vault. Your vault becomes their working directory, giving them capabilities for file reads and writes, search, bash commands, and multi-step workflows.",
|
||||
"author": "Yishen Tu",
|
||||
"authorUrl": "https://github.com/YishenTu",
|
||||
"isDesktopOnly": true
|
||||
}
|
||||
Generated
+11472
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "claudian",
|
||||
"version": "2.0.32",
|
||||
"description": "Claudian - Claude Code embedded in Obsidian sidebar",
|
||||
"main": "main.js",
|
||||
"engines": {
|
||||
"node": ">=24 <25"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/postinstall.mjs",
|
||||
"build:css": "node scripts/build-css.mjs",
|
||||
"dev": "npm run build:css && node esbuild.config.mjs",
|
||||
"build": "node scripts/build.mjs production",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint \"{src,tests}/**/*.ts\"",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"test": "node scripts/run-tests.js",
|
||||
"test:unit": "node scripts/run-jest.js",
|
||||
"test:architecture": "node --test scripts/check-architecture-boundaries.test.mjs",
|
||||
"test:watch": "node scripts/run-jest.js --watch",
|
||||
"test:coverage": "node scripts/run-jest.js --coverage",
|
||||
"version": "node scripts/sync-version.js && git add manifest.json"
|
||||
},
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
"obsidian",
|
||||
"obsidian-plugin",
|
||||
"productivity",
|
||||
"ide"
|
||||
],
|
||||
"author": "Yishen Tu",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.5.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
||||
"@typescript-eslint/parser": "^8.58.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-plugin-jest": "^29.15.1",
|
||||
"eslint-plugin-obsidianmd": "^0.3.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"jest": "^30.3.0",
|
||||
"jest-environment-jsdom": "^30.3.0",
|
||||
"obsidian": "latest",
|
||||
"ts-jest": "^29.4.9",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.159",
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"@codemirror/view": "^6.38.6",
|
||||
"@modelcontextprotocol/sdk": "~1.29.0",
|
||||
"smol-toml": "^1.6.1",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"overrides": {
|
||||
"@babel/core": "7.29.7",
|
||||
"hono": "4.12.26",
|
||||
"js-yaml": "4.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CSS Build Script
|
||||
* Concatenates modular CSS files from src/style/ into root styles.css
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
|
||||
import { join, dirname, resolve, relative } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..');
|
||||
const STYLE_DIR = join(ROOT, 'src', 'style');
|
||||
const OUTPUT = join(ROOT, 'styles.css');
|
||||
const INDEX_FILE = join(STYLE_DIR, 'index.css');
|
||||
|
||||
const IMPORT_PATTERN = /^\s*@import\s+(?:url\()?['"]([^'"]+)['"]\)?\s*;/gm;
|
||||
|
||||
function getModuleOrder() {
|
||||
if (!existsSync(INDEX_FILE)) {
|
||||
console.error('Missing src/style/index.css');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const content = readFileSync(INDEX_FILE, 'utf-8');
|
||||
const matches = [...content.matchAll(IMPORT_PATTERN)];
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.error('No @import entries found in src/style/index.css');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return matches.map((match) => match[1]);
|
||||
}
|
||||
|
||||
function listCssFiles(dir, baseDir = dir) {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...listCssFiles(entryPath, baseDir));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name.endsWith('.css')) {
|
||||
const relativePath = relative(baseDir, entryPath).split('\\').join('/');
|
||||
files.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function build() {
|
||||
const moduleOrder = getModuleOrder();
|
||||
const parts = ['/* Claudian Plugin Styles */\n/* Built from src/style/ modules */\n'];
|
||||
const missingFiles = [];
|
||||
const invalidImports = [];
|
||||
const normalizedImports = [];
|
||||
|
||||
for (const modulePath of moduleOrder) {
|
||||
const resolvedPath = resolve(STYLE_DIR, modulePath);
|
||||
const relativePath = relative(STYLE_DIR, resolvedPath);
|
||||
|
||||
if (relativePath.startsWith('..') || !relativePath.endsWith('.css')) {
|
||||
invalidImports.push(modulePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedPath = relativePath.split('\\').join('/');
|
||||
normalizedImports.push(normalizedPath);
|
||||
|
||||
if (!existsSync(resolvedPath)) {
|
||||
missingFiles.push(normalizedPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = readFileSync(resolvedPath, 'utf-8');
|
||||
const header = `\n/* ============================================\n ${normalizedPath}\n ============================================ */\n`;
|
||||
parts.push(header + content);
|
||||
}
|
||||
|
||||
let hasErrors = false;
|
||||
|
||||
if (invalidImports.length > 0) {
|
||||
console.error('Invalid @import entries in src/style/index.css:');
|
||||
invalidImports.forEach((modulePath) => console.error(` - ${modulePath}`));
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (missingFiles.length > 0) {
|
||||
console.error('Missing CSS files:');
|
||||
missingFiles.forEach((f) => console.error(` - ${f}`));
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
const allCssFiles = listCssFiles(STYLE_DIR).filter((file) => file !== 'index.css');
|
||||
const importedSet = new Set(normalizedImports);
|
||||
const unlistedFiles = allCssFiles.filter((file) => !importedSet.has(file));
|
||||
|
||||
if (unlistedFiles.length > 0) {
|
||||
console.error('Unlisted CSS files (not imported in src/style/index.css):');
|
||||
unlistedFiles.forEach((file) => console.error(` - ${file}`));
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const output = parts.join('\n');
|
||||
writeFileSync(OUTPUT, output);
|
||||
console.log(`Built styles.css (${(output.length / 1024).toFixed(1)} KB)`);
|
||||
}
|
||||
|
||||
build();
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Combined build script - runs CSS build then esbuild
|
||||
* Avoids npm echoing commands
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..');
|
||||
|
||||
// Run CSS build silently
|
||||
execSync('node scripts/build-css.mjs', { cwd: ROOT, stdio: 'inherit' });
|
||||
|
||||
// Run esbuild with args passed through
|
||||
const args = process.argv.slice(2).join(' ');
|
||||
execSync(`node esbuild.config.mjs ${args}`, { cwd: ROOT, stdio: 'inherit' });
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
function listTypeScriptFiles(root) {
|
||||
const files = [];
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
const entryPath = path.join(root, entry.name);
|
||||
if (entry.isDirectory()) files.push(...listTypeScriptFiles(entryPath));
|
||||
else if (entry.isFile() && entry.name.endsWith('.ts')) files.push(entryPath);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function findMatches(roots, pattern) {
|
||||
const matches = [];
|
||||
for (const root of roots) {
|
||||
for (const file of listTypeScriptFiles(root)) {
|
||||
if (pattern.test(fs.readFileSync(file, 'utf8'))) {
|
||||
matches.push(path.relative(process.cwd(), file));
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
const sourceRoot = path.join(process.cwd(), 'src');
|
||||
|
||||
test('core is independent from main, features, and concrete providers', () => {
|
||||
const pattern = /from\s+['"][^'"]*(?:main['"]|features\/|providers\/(?:claude|codex|opencode|pi))/;
|
||||
assert.deepEqual(findMatches([path.join(sourceRoot, 'core')], pattern), []);
|
||||
});
|
||||
|
||||
test('providers are independent from main and features', () => {
|
||||
const pattern = /from\s+['"][^'"]*(?:main['"]|features\/)/;
|
||||
assert.deepEqual(findMatches([path.join(sourceRoot, 'providers')], pattern), []);
|
||||
});
|
||||
|
||||
test('features are independent from the composition root and app adapters', () => {
|
||||
const pattern = /from\s+['"][^'"]*(?:main['"]|app\/)/;
|
||||
assert.deepEqual(findMatches([path.join(sourceRoot, 'features')], pattern), []);
|
||||
});
|
||||
|
||||
test('features and shared UI are independent from concrete providers', () => {
|
||||
const pattern = /from\s+['"][^'"]*providers\/(?:claude|codex|opencode|pi)/;
|
||||
assert.deepEqual(findMatches([
|
||||
path.join(sourceRoot, 'features'),
|
||||
path.join(sourceRoot, 'shared'),
|
||||
], pattern), []);
|
||||
});
|
||||
|
||||
test('persisted settings changes use the coordinator boundary', () => {
|
||||
const matches = findMatches([sourceRoot], /\.saveSettings\(\)/).filter(file => ![
|
||||
'src/main.ts',
|
||||
'src/app/providers/ClaudianProviderHost.ts',
|
||||
].includes(file));
|
||||
assert.deepEqual(matches, []);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export function validateReleaseVersions({ tag, packageVersion, manifestVersion }) {
|
||||
if (typeof tag !== 'string' || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(tag)) {
|
||||
throw new Error(`Invalid or missing release tag: ${JSON.stringify(tag)}`);
|
||||
}
|
||||
if (tag !== packageVersion || tag !== manifestVersion) {
|
||||
throw new Error(
|
||||
`Release version mismatch: tag=${tag}, package.json=${packageVersion}, manifest.json=${manifestVersion}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function run() {
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(scriptDir, '..');
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8'));
|
||||
const manifestJson = JSON.parse(fs.readFileSync(path.join(rootDir, 'manifest.json'), 'utf8'));
|
||||
validateReleaseVersions({
|
||||
tag: process.argv[2],
|
||||
packageVersion: packageJson.version,
|
||||
manifestVersion: manifestJson.version,
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { validateReleaseVersions } from './check-release-version.mjs';
|
||||
|
||||
test('accepts matching release, package, and manifest versions', () => {
|
||||
assert.doesNotThrow(() => validateReleaseVersions({
|
||||
tag: '2.0.31',
|
||||
packageVersion: '2.0.31',
|
||||
manifestVersion: '2.0.31',
|
||||
}));
|
||||
});
|
||||
|
||||
test('rejects version mismatches', () => {
|
||||
assert.throws(() => validateReleaseVersions({
|
||||
tag: '2.0.32',
|
||||
packageVersion: '2.0.31',
|
||||
manifestVersion: '2.0.31',
|
||||
}), /Release version mismatch/);
|
||||
});
|
||||
|
||||
test('rejects malformed or missing release tags', () => {
|
||||
for (const tag of [undefined, '', 'v2.0.31', 'refs/tags/2.0.31']) {
|
||||
assert.throws(() => validateReleaseVersions({
|
||||
tag,
|
||||
packageVersion: '2.0.31',
|
||||
manifestVersion: '2.0.31',
|
||||
}), /Invalid or missing release tag/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post-install script - copies .env.local.example to .env.local if it doesn't exist
|
||||
*/
|
||||
|
||||
import { copyFileSync, existsSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Skip in CI environments
|
||||
if (process.env.CI) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..');
|
||||
|
||||
const example = join(ROOT, '.env.local.example');
|
||||
const target = join(ROOT, '.env.local');
|
||||
|
||||
if (existsSync(example) && !existsSync(target)) {
|
||||
copyFileSync(example, target);
|
||||
console.log('Created .env.local from .env.local.example');
|
||||
console.log('Edit it to set your OBSIDIAN_VAULT path for auto-copy during development.');
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
const JS_IDENTIFIER = '[A-Za-z_$][A-Za-z0-9_$]*';
|
||||
|
||||
const UNSAFE_TIMER_UNREF_PATTERNS = [
|
||||
{
|
||||
name: 'claude-sdk-process-transport-close-async',
|
||||
pattern: new RegExp(
|
||||
`if \\((${JS_IDENTIFIER}) && !\\1\\.killed && \\1\\.exitCode === null\\) setTimeout\\(\\((${JS_IDENTIFIER}), (${JS_IDENTIFIER})\\) => \\{\\s*` +
|
||||
`if \\(\\2\\.exitCode !== null\\) \\{\\s*` +
|
||||
`\\3\\(\\);\\s*` +
|
||||
`return;\\s*` +
|
||||
`\\}\\s*` +
|
||||
`if \\(process\\.platform === "win32"\\) \\{\\s*` +
|
||||
`setTimeout\\(\\((${JS_IDENTIFIER}), (${JS_IDENTIFIER})\\) => \\{\\s*` +
|
||||
`if \\(\\4\\.exitCode === null\\) \\4\\.kill\\("SIGKILL"\\);\\s*` +
|
||||
`\\5\\(\\);\\s*` +
|
||||
`\\}, 5e3, \\2, \\3\\)\\.unref\\(\\);\\s*` +
|
||||
`return;\\s*` +
|
||||
`\\}\\s*` +
|
||||
`\\2\\.kill\\("SIGTERM"\\), setTimeout\\(\\((${JS_IDENTIFIER})\\) => \\{\\s*` +
|
||||
`if \\(\\6\\.exitCode === null\\) \\6\\.kill\\("SIGKILL"\\);\\s*` +
|
||||
`\\}, 5e3, \\2\\)\\.unref\\(\\), \\3\\(\\);\\s*` +
|
||||
`\\}, (${JS_IDENTIFIER}), \\1, (${JS_IDENTIFIER})\\)\\.unref\\(\\), \\1\\.once\\("exit", (\\(\\) => (?:\\{[^{}]*\\}|[^;{}]+))\\);`,
|
||||
'g',
|
||||
),
|
||||
replacement:
|
||||
'if ($1 && !$1.killed && $1.exitCode === null) {' +
|
||||
'\n const processKillTimer = setTimeout(($2, $3) => {' +
|
||||
'\n if ($2.exitCode !== null) {' +
|
||||
'\n $3();' +
|
||||
'\n return;' +
|
||||
'\n }' +
|
||||
'\n if (process.platform === "win32") {' +
|
||||
'\n const windowsForceKillTimer = setTimeout(($4, $5) => {' +
|
||||
'\n if ($4.exitCode === null) $4.kill("SIGKILL");' +
|
||||
'\n $5();' +
|
||||
'\n }, 5e3, $2, $3);' +
|
||||
'\n windowsForceKillTimer.unref?.();' +
|
||||
'\n return;' +
|
||||
'\n }' +
|
||||
'\n $2.kill("SIGTERM");' +
|
||||
'\n const forceKillTimer = setTimeout(($6) => {' +
|
||||
'\n if ($6.exitCode === null) $6.kill("SIGKILL");' +
|
||||
'\n }, 5e3, $2);' +
|
||||
'\n forceKillTimer.unref?.();' +
|
||||
'\n $3();' +
|
||||
'\n }, $7, $1, $8);' +
|
||||
'\n processKillTimer.unref?.();' +
|
||||
'\n $1.once("exit", $9);' +
|
||||
'\n }',
|
||||
},
|
||||
{
|
||||
name: 'claude-sdk-process-transport-close',
|
||||
pattern: new RegExp(
|
||||
`if \\((${JS_IDENTIFIER}) && !\\1\\.killed && \\1\\.exitCode === null\\) setTimeout\\(\\((${JS_IDENTIFIER})\\) => \\{\\s*` +
|
||||
`if \\(\\2\\.killed \\|\\| \\2\\.exitCode !== null\\) return;\\s*` +
|
||||
`\\2\\.kill\\("SIGTERM"\\), setTimeout\\(\\((${JS_IDENTIFIER})\\) => \\{\\s*` +
|
||||
`if \\(\\3\\.exitCode === null\\) \\3\\.kill\\("SIGKILL"\\);\\s*` +
|
||||
`\\}, 5e3, \\2\\)\\.unref\\(\\);\\s*` +
|
||||
`\\}, (${JS_IDENTIFIER}), \\1\\)\\.unref\\(\\), \\1\\.once\\("exit", (\\(\\) => (?:\\{[^{}]*\\}|[^;{}]+))\\);`,
|
||||
'g',
|
||||
),
|
||||
replacement:
|
||||
'if ($1 && !$1.killed && $1.exitCode === null) {' +
|
||||
'\n const processKillTimer = setTimeout(($2) => {' +
|
||||
'\n if ($2.killed || $2.exitCode !== null) return;' +
|
||||
'\n $2.kill("SIGTERM");' +
|
||||
'\n const forceKillTimer = setTimeout(($3) => {' +
|
||||
'\n if ($3.exitCode === null) $3.kill("SIGKILL");' +
|
||||
'\n }, 5e3, $2);' +
|
||||
'\n forceKillTimer.unref?.();' +
|
||||
'\n }, $4, $1);' +
|
||||
'\n processKillTimer.unref?.();' +
|
||||
'\n $1.once("exit", $5);' +
|
||||
'\n }',
|
||||
},
|
||||
{
|
||||
name: 'mcp-sdk-stdio-close-wait',
|
||||
pattern: /new Promise\(\((resolve\d+)\) => setTimeout\(\1, 2e3\)\.unref\(\)\)/g,
|
||||
replacement:
|
||||
'new Promise(($1) => {' +
|
||||
'\n const closeTimeout = setTimeout($1, 2e3);' +
|
||||
'\n closeTimeout.unref?.();' +
|
||||
'\n })',
|
||||
},
|
||||
];
|
||||
|
||||
const TIMER_CALL_PREFIXES = ['setTimeout(', 'setInterval('];
|
||||
|
||||
function patchRendererUnsafeUnrefSites(contents) {
|
||||
let nextContents = contents;
|
||||
const appliedPatches = [];
|
||||
|
||||
for (const patch of UNSAFE_TIMER_UNREF_PATTERNS) {
|
||||
const matchCount = [...nextContents.matchAll(patch.pattern)].length;
|
||||
if (matchCount === 0) {
|
||||
continue;
|
||||
}
|
||||
nextContents = nextContents.replace(patch.pattern, patch.replacement);
|
||||
appliedPatches.push({ name: patch.name, count: matchCount });
|
||||
}
|
||||
|
||||
return {
|
||||
contents: nextContents,
|
||||
appliedPatches,
|
||||
};
|
||||
}
|
||||
|
||||
function findUnsafeTimerUnrefSites(contents) {
|
||||
const matches = [];
|
||||
|
||||
let searchIndex = 0;
|
||||
while (searchIndex < contents.length) {
|
||||
const timerStart = findNextTimerCall(contents, searchIndex);
|
||||
if (!timerStart) {
|
||||
break;
|
||||
}
|
||||
|
||||
const callEnd = findMatchingParen(contents, timerStart.openParenIndex);
|
||||
if (callEnd === -1) {
|
||||
searchIndex = timerStart.startIndex + timerStart.prefix.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const unrefMatch = contents.slice(callEnd + 1).match(/^\s*\.unref\(\)/);
|
||||
if (unrefMatch) {
|
||||
const startIndex = timerStart.startIndex;
|
||||
const endIndex = callEnd + 1 + unrefMatch[0].length;
|
||||
const line = contents.slice(0, startIndex).split('\n').length;
|
||||
matches.push({
|
||||
line,
|
||||
snippet: contents.slice(startIndex, endIndex),
|
||||
});
|
||||
searchIndex = endIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
searchIndex = callEnd + 1;
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function findNextTimerCall(contents, startIndex) {
|
||||
let nextMatch = null;
|
||||
|
||||
for (const prefix of TIMER_CALL_PREFIXES) {
|
||||
const index = contents.indexOf(prefix, startIndex);
|
||||
if (index === -1) {
|
||||
continue;
|
||||
}
|
||||
if (!nextMatch || index < nextMatch.startIndex) {
|
||||
nextMatch = {
|
||||
prefix,
|
||||
startIndex: index,
|
||||
openParenIndex: index + prefix.length - 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return nextMatch;
|
||||
}
|
||||
|
||||
function findMatchingParen(contents, openParenIndex) {
|
||||
let depth = 1;
|
||||
let quote = null;
|
||||
|
||||
for (let index = openParenIndex + 1; index < contents.length; index += 1) {
|
||||
const char = contents[index];
|
||||
|
||||
if (quote) {
|
||||
if (char === '\\') {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === '\'' || char === '`') {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '(') {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findUnsafeTimerUnrefSites,
|
||||
patchRendererUnsafeUnrefSites,
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
const { spawnSync } = require('child_process');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const jestPath = require.resolve('jest/bin/jest');
|
||||
const localStorageFile = path.join(os.tmpdir(), 'claudian-localstorage');
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[`--localstorage-file=${localStorageFile}`, jestPath, ...process.argv.slice(2)],
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
@@ -0,0 +1,29 @@
|
||||
const { spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.join(__dirname, '..');
|
||||
|
||||
function run(args) {
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
run([
|
||||
path.join(__dirname, 'run-jest.js'),
|
||||
...process.argv.slice(2),
|
||||
]);
|
||||
run([
|
||||
'--test',
|
||||
path.join(__dirname, 'check-architecture-boundaries.test.mjs'),
|
||||
path.join(__dirname, 'check-release-version.test.mjs'),
|
||||
]);
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const packagePath = path.join(__dirname, '..', 'package.json');
|
||||
const manifestPath = path.join(__dirname, '..', 'manifest.json');
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
const manifestJson = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
|
||||
manifestJson.version = packageJson.version;
|
||||
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifestJson, null, 2) + '\n');
|
||||
|
||||
console.log(`Synced version to ${packageJson.version}`);
|
||||
@@ -0,0 +1,317 @@
|
||||
import { normalizeProviderModelSelection, resolveConversationModel } from '../../core/providers/conversationModel';
|
||||
import { getRuntimeEnvironmentVariables } from '../../core/providers/providerEnvironment';
|
||||
import { ProviderRegistry } from '../../core/providers/ProviderRegistry';
|
||||
import { ProviderSettingsCoordinator } from '../../core/providers/ProviderSettingsCoordinator';
|
||||
import type { AppSessionStorage, ProviderHistoryPathContext } from '../../core/providers/types';
|
||||
import { DEFAULT_CHAT_PROVIDER_ID, type ProviderId } from '../../core/providers/types';
|
||||
import type { Conversation, ConversationMeta } from '../../core/types';
|
||||
import { extractUserDisplayContent } from '../../utils/context';
|
||||
|
||||
export interface ConversationRepositoryDeps {
|
||||
getSettings: () => Record<string, unknown>;
|
||||
getVaultPath: () => string | null;
|
||||
sessions: AppSessionStorage;
|
||||
onConversationDeleted: (conversationId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ConversationRepository {
|
||||
private conversations: Conversation[] = [];
|
||||
|
||||
constructor(private readonly deps: ConversationRepositoryDeps) {}
|
||||
|
||||
replaceAll(conversations: Conversation[]): void {
|
||||
this.conversations = conversations;
|
||||
}
|
||||
|
||||
getAll(): Conversation[] {
|
||||
return this.conversations;
|
||||
}
|
||||
|
||||
backfillResponseTimestamps(): Conversation[] {
|
||||
const updated: Conversation[] = [];
|
||||
for (const conversation of this.conversations) {
|
||||
if (conversation.lastResponseAt != null || conversation.messages.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let index = conversation.messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = conversation.messages[index];
|
||||
if (message.role === 'assistant') {
|
||||
conversation.lastResponseAt = message.timestamp;
|
||||
updated.push(conversation);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async create(options?: {
|
||||
providerId?: ProviderId;
|
||||
sessionId?: string;
|
||||
selectedModel?: string;
|
||||
}): Promise<Conversation> {
|
||||
const settings = this.deps.getSettings();
|
||||
const providerId = options?.providerId ?? DEFAULT_CHAT_PROVIDER_ID;
|
||||
const sessionId = options?.sessionId;
|
||||
const providerSettings = ProviderSettingsCoordinator.getProviderSettingsSnapshot(settings, providerId);
|
||||
const selectedModel = normalizeProviderModelSelection(
|
||||
providerId,
|
||||
settings,
|
||||
options?.selectedModel ?? providerSettings.model,
|
||||
) ?? undefined;
|
||||
const conversation: Conversation = {
|
||||
id: sessionId ?? this.generateId(),
|
||||
providerId,
|
||||
title: this.generateDefaultTitle(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
sessionId: sessionId ?? null,
|
||||
selectedModel,
|
||||
messages: [],
|
||||
};
|
||||
|
||||
this.conversations.unshift(conversation);
|
||||
await this.save(conversation);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async switchTo(id: string): Promise<Conversation | null> {
|
||||
const conversation = this.getSync(id);
|
||||
if (!conversation) return null;
|
||||
|
||||
await this.reconcileProviderSession(conversation);
|
||||
await this.ensureSelectedModel(conversation);
|
||||
await this.hydrate(conversation);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async delete(
|
||||
id: string,
|
||||
options: { deleteProviderSession?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const index = this.conversations.findIndex(conversation => conversation.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
const conversation = this.conversations[index];
|
||||
this.conversations.splice(index, 1);
|
||||
|
||||
if (options.deleteProviderSession !== false) {
|
||||
const vaultPath = this.deps.getVaultPath();
|
||||
await ProviderRegistry
|
||||
.getConversationHistoryService(conversation.providerId)
|
||||
.deleteConversationSession(
|
||||
conversation,
|
||||
vaultPath,
|
||||
this.getHistoryPathContext(conversation.providerId, vaultPath),
|
||||
);
|
||||
}
|
||||
|
||||
await this.deps.sessions.deleteMetadata(id);
|
||||
await this.deps.onConversationDeleted(id);
|
||||
}
|
||||
|
||||
async handleMissingProviderSession(
|
||||
id: string,
|
||||
missingProviderSessionId?: string,
|
||||
): Promise<'deleted' | 'reset' | 'preserved' | 'not_found'> {
|
||||
const conversation = this.getSync(id);
|
||||
if (!conversation) return 'not_found';
|
||||
|
||||
const historyService = ProviderRegistry.getConversationHistoryService(conversation.providerId);
|
||||
if (!historyService.resolveMissingConversationSession) return 'preserved';
|
||||
|
||||
const previousSessionId = conversation.sessionId;
|
||||
const previousProviderState = conversation.providerState;
|
||||
const previousResumeAtMessageId = conversation.resumeAtMessageId;
|
||||
const vaultPath = this.deps.getVaultPath();
|
||||
try {
|
||||
const resolution = await historyService.resolveMissingConversationSession(
|
||||
conversation,
|
||||
vaultPath,
|
||||
missingProviderSessionId,
|
||||
this.getHistoryPathContext(conversation.providerId, vaultPath),
|
||||
);
|
||||
if (resolution === 'delete') {
|
||||
await this.delete(id, { deleteProviderSession: false });
|
||||
return 'deleted';
|
||||
}
|
||||
if (resolution === 'reset') {
|
||||
await this.save(conversation);
|
||||
return 'reset';
|
||||
}
|
||||
return 'preserved';
|
||||
} catch {
|
||||
conversation.sessionId = previousSessionId;
|
||||
conversation.providerState = previousProviderState;
|
||||
conversation.resumeAtMessageId = previousResumeAtMessageId;
|
||||
return 'preserved';
|
||||
}
|
||||
}
|
||||
|
||||
async rename(id: string, title: string): Promise<void> {
|
||||
const conversation = this.getSync(id);
|
||||
if (!conversation) return;
|
||||
|
||||
conversation.title = title.trim() || this.generateDefaultTitle();
|
||||
conversation.updatedAt = Date.now();
|
||||
await this.save(conversation);
|
||||
}
|
||||
|
||||
async update(id: string, updates: Partial<Conversation>): Promise<void> {
|
||||
const conversation = this.getSync(id);
|
||||
if (!conversation) return;
|
||||
|
||||
const safeUpdates = { ...updates };
|
||||
delete safeUpdates.providerId;
|
||||
if ('selectedModel' in safeUpdates) {
|
||||
const selectedModel = normalizeProviderModelSelection(
|
||||
conversation.providerId,
|
||||
this.deps.getSettings(),
|
||||
safeUpdates.selectedModel,
|
||||
);
|
||||
if (selectedModel) {
|
||||
safeUpdates.selectedModel = selectedModel;
|
||||
} else {
|
||||
delete safeUpdates.selectedModel;
|
||||
}
|
||||
}
|
||||
Object.assign(conversation, safeUpdates, { updatedAt: Date.now() });
|
||||
await this.save(conversation);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Conversation | null> {
|
||||
const conversation = this.getSync(id);
|
||||
if (conversation) {
|
||||
await this.reconcileProviderSession(conversation);
|
||||
await this.ensureSelectedModel(conversation);
|
||||
await this.hydrate(conversation);
|
||||
}
|
||||
return conversation;
|
||||
}
|
||||
|
||||
getSync(id: string): Conversation | null {
|
||||
return this.conversations.find(conversation => conversation.id === id) ?? null;
|
||||
}
|
||||
|
||||
findEmpty(): Conversation | null {
|
||||
return this.conversations.find(conversation => conversation.messages.length === 0) ?? null;
|
||||
}
|
||||
|
||||
list(): ConversationMeta[] {
|
||||
return this.conversations.map(conversation => ({
|
||||
id: conversation.id,
|
||||
providerId: conversation.providerId,
|
||||
title: conversation.title,
|
||||
createdAt: conversation.createdAt,
|
||||
updatedAt: conversation.updatedAt,
|
||||
lastResponseAt: conversation.lastResponseAt,
|
||||
messageCount: conversation.messages.length,
|
||||
preview: this.getPreview(conversation),
|
||||
titleGenerationStatus: conversation.titleGenerationStatus,
|
||||
}));
|
||||
}
|
||||
|
||||
private async reconcileProviderSession(conversation: Conversation): Promise<void> {
|
||||
const historyService = ProviderRegistry.getConversationHistoryService(conversation.providerId);
|
||||
if (!historyService.getConversationSessionAvailability) return;
|
||||
|
||||
const vaultPath = this.deps.getVaultPath();
|
||||
const pathContext = this.getHistoryPathContext(conversation.providerId, vaultPath);
|
||||
let availability;
|
||||
try {
|
||||
availability = await historyService.getConversationSessionAvailability(
|
||||
conversation,
|
||||
vaultPath,
|
||||
pathContext,
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (availability !== 'relocated' || !historyService.prepareRelocatedConversationSession) return;
|
||||
|
||||
const previousSessionId = conversation.sessionId;
|
||||
const previousProviderState = conversation.providerState;
|
||||
const previousResumeAtMessageId = conversation.resumeAtMessageId;
|
||||
try {
|
||||
if (await historyService.prepareRelocatedConversationSession(
|
||||
conversation,
|
||||
vaultPath,
|
||||
pathContext,
|
||||
)) {
|
||||
await this.save(conversation);
|
||||
}
|
||||
} catch {
|
||||
conversation.sessionId = previousSessionId;
|
||||
conversation.providerState = previousProviderState;
|
||||
conversation.resumeAtMessageId = previousResumeAtMessageId;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureSelectedModel(conversation: Conversation): Promise<void> {
|
||||
const resolved = resolveConversationModel(
|
||||
this.deps.getSettings(),
|
||||
conversation.providerId,
|
||||
conversation,
|
||||
);
|
||||
if (!resolved.shouldPersist || !resolved.model || conversation.selectedModel === resolved.model) return;
|
||||
|
||||
conversation.selectedModel = resolved.model;
|
||||
await this.save(conversation);
|
||||
}
|
||||
|
||||
private async hydrate(conversation: Conversation): Promise<void> {
|
||||
const vaultPath = this.deps.getVaultPath();
|
||||
await ProviderRegistry
|
||||
.getConversationHistoryService(conversation.providerId)
|
||||
.hydrateConversationHistory(
|
||||
conversation,
|
||||
vaultPath,
|
||||
this.getHistoryPathContext(conversation.providerId, vaultPath),
|
||||
);
|
||||
}
|
||||
|
||||
private getHistoryPathContext(
|
||||
providerId: ProviderId,
|
||||
vaultPath: string | null = this.deps.getVaultPath(),
|
||||
): ProviderHistoryPathContext {
|
||||
const settings = this.deps.getSettings();
|
||||
return {
|
||||
environment: {
|
||||
...process.env,
|
||||
...getRuntimeEnvironmentVariables(settings, providerId),
|
||||
},
|
||||
hostPlatform: process.platform,
|
||||
settings,
|
||||
vaultPath,
|
||||
};
|
||||
}
|
||||
|
||||
private save(conversation: Conversation): Promise<void> {
|
||||
return this.deps.sessions.saveMetadata(this.deps.sessions.toSessionMetadata(conversation));
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return `conv-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
private generateDefaultTitle(): string {
|
||||
const now = new Date();
|
||||
return now.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
private getPreview(conversation: Conversation): string {
|
||||
const firstUserMessage = conversation.messages.find(message => message.role === 'user');
|
||||
if (!firstUserMessage) return 'New conversation';
|
||||
|
||||
const previewText = firstUserMessage.displayContent
|
||||
?? extractUserDisplayContent(firstUserMessage.content)
|
||||
?? firstUserMessage.content;
|
||||
return previewText.substring(0, 50) + (previewText.length > 50 ? '...' : '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { ProviderHost } from '../../core/providers/ProviderHost';
|
||||
import type { ProviderCliResolutionContext, ProviderId } from '../../core/providers/types';
|
||||
import type { ChatRuntime } from '../../core/runtime/ChatRuntime';
|
||||
import type { EnvironmentScope } from '../../core/types/settings';
|
||||
import type ClaudianPlugin from '../../main';
|
||||
|
||||
/** Delegates provider-facing capabilities to the application composition root. */
|
||||
export class ClaudianProviderHost implements ProviderHost {
|
||||
constructor(private readonly plugin: ClaudianPlugin) {}
|
||||
|
||||
get app() {
|
||||
return this.plugin.app;
|
||||
}
|
||||
|
||||
get settings() {
|
||||
return this.plugin.settings;
|
||||
}
|
||||
|
||||
get storage() {
|
||||
return this.plugin.storage;
|
||||
}
|
||||
|
||||
get manifest() {
|
||||
return this.plugin.manifest;
|
||||
}
|
||||
|
||||
saveSettings(): Promise<void> {
|
||||
return this.plugin.saveSettings();
|
||||
}
|
||||
|
||||
mutateSettings(
|
||||
mutation: (settings: typeof this.plugin.settings) => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
return this.plugin.mutateSettings(mutation);
|
||||
}
|
||||
|
||||
mutateSettingsConditionally(
|
||||
mutation: (settings: typeof this.plugin.settings) => boolean | Promise<boolean>,
|
||||
): Promise<void> {
|
||||
return this.plugin.mutateSettingsConditionally(mutation);
|
||||
}
|
||||
|
||||
loadData(): Promise<unknown> {
|
||||
return this.plugin.loadData();
|
||||
}
|
||||
|
||||
saveData(data: unknown): Promise<void> {
|
||||
return this.plugin.saveData(data);
|
||||
}
|
||||
|
||||
normalizeModelVariantSettings(): boolean {
|
||||
return this.plugin.normalizeModelVariantSettings();
|
||||
}
|
||||
|
||||
getActiveEnvironmentVariables(providerId: ProviderId): string {
|
||||
return this.plugin.getActiveEnvironmentVariables(providerId);
|
||||
}
|
||||
|
||||
getEnvironmentVariablesForScope(scope: EnvironmentScope): string {
|
||||
return this.plugin.getEnvironmentVariablesForScope(scope);
|
||||
}
|
||||
|
||||
applyEnvironmentVariables(scope: EnvironmentScope, envText: string): Promise<void> {
|
||||
return this.plugin.applyEnvironmentVariables(scope, envText);
|
||||
}
|
||||
|
||||
applyEnvironmentVariablesBatch(
|
||||
updates: Array<{ scope: EnvironmentScope; envText: string }>,
|
||||
): Promise<void> {
|
||||
return this.plugin.applyEnvironmentVariablesBatch(updates);
|
||||
}
|
||||
|
||||
getResolvedProviderCliPath(
|
||||
providerId: ProviderId,
|
||||
context?: ProviderCliResolutionContext,
|
||||
): string | null {
|
||||
return this.plugin.getResolvedProviderCliPath(providerId, context);
|
||||
}
|
||||
|
||||
refreshModelSelectors(): void {
|
||||
for (const view of this.plugin.getAllViews()) {
|
||||
view.refreshModelSelector();
|
||||
}
|
||||
}
|
||||
|
||||
async broadcastToActiveViewRuntimes(
|
||||
action: (runtime: ChatRuntime) => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
await this.plugin.getView()?.getTabManager()?.broadcastToAllTabs(
|
||||
(runtime) => Promise.resolve(action(runtime)),
|
||||
);
|
||||
}
|
||||
|
||||
async broadcastToAllViewRuntimes(
|
||||
action: (runtime: ChatRuntime) => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
for (const view of this.plugin.getAllViews()) {
|
||||
await view.getTabManager()?.broadcastToAllTabs(
|
||||
(runtime) => Promise.resolve(action(runtime)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async recycleProviderRuntimes(providerId: ProviderId): Promise<void> {
|
||||
for (const view of this.plugin.getAllViews()) {
|
||||
const tabManager = view.getTabManager();
|
||||
await tabManager?.recycleProviderRuntimes(providerId);
|
||||
view.invalidateProviderCommandCaches?.([providerId]);
|
||||
view.refreshModelSelector?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import {
|
||||
CLAUDIAN_SETTINGS_PATH,
|
||||
LEGACY_CLAUDIAN_SETTINGS_PATH,
|
||||
} from '../../core/bootstrap/StoragePaths';
|
||||
import {
|
||||
normalizeHiddenCommandList,
|
||||
normalizeHiddenProviderCommands,
|
||||
} from '../../core/providers/commands/hiddenCommands';
|
||||
import {
|
||||
getSharedEnvironmentVariables,
|
||||
inferEnvironmentSnippetScope,
|
||||
resolveEnvironmentSnippetScope,
|
||||
} from '../../core/providers/providerEnvironment';
|
||||
import { ProviderRegistry } from '../../core/providers/ProviderRegistry';
|
||||
import type { VaultFileAdapter } from '../../core/storage/VaultFileAdapter';
|
||||
import {
|
||||
CHAT_VIEW_PLACEMENTS,
|
||||
type ChatViewPlacement,
|
||||
type ClaudianSettings,
|
||||
type EnvironmentScope,
|
||||
type EnvSnippet,
|
||||
type HiddenProviderCommands,
|
||||
type ProviderConfigMap,
|
||||
} from '../../core/types/settings';
|
||||
import { DEFAULT_CLAUDIAN_SETTINGS } from './defaultSettings';
|
||||
|
||||
export {
|
||||
CLAUDIAN_SETTINGS_PATH,
|
||||
LEGACY_CLAUDIAN_SETTINGS_PATH,
|
||||
};
|
||||
|
||||
export type StoredClaudianSettings = ClaudianSettings;
|
||||
|
||||
const LEGACY_STRIPPED_SHARED_SETTING_FIELDS = [
|
||||
'activeConversationId',
|
||||
'show1MModel',
|
||||
'hiddenSlashCommands',
|
||||
'slashCommands',
|
||||
'allowExternalAccess',
|
||||
'allowedExportPaths',
|
||||
'enableBlocklist',
|
||||
'blockedCommands',
|
||||
'openInMainTab',
|
||||
] as const;
|
||||
|
||||
function getProviderSettingsAdapters() {
|
||||
return ProviderRegistry.getRegisteredProviderIds().map(providerId => ({
|
||||
adapter: ProviderRegistry.getSettingsStorageAdapter(providerId),
|
||||
providerId,
|
||||
}));
|
||||
}
|
||||
|
||||
function getLegacyTopLevelProviderFields(): string[] {
|
||||
return getProviderSettingsAdapters().flatMap(({ adapter }) => adapter.legacyTopLevelFields ?? []);
|
||||
}
|
||||
|
||||
function stripLegacyFields(settings: Record<string, unknown>): Record<string, unknown> {
|
||||
const cleaned = { ...settings };
|
||||
for (const key of [
|
||||
...LEGACY_STRIPPED_SHARED_SETTING_FIELDS,
|
||||
...getLegacyTopLevelProviderFields(),
|
||||
]) {
|
||||
delete cleaned[key];
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function isChatViewPlacement(value: unknown): value is ChatViewPlacement {
|
||||
return typeof value === 'string'
|
||||
&& (CHAT_VIEW_PLACEMENTS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function normalizeChatViewPlacement(
|
||||
value: unknown,
|
||||
legacyOpenInMainTab: unknown,
|
||||
): ChatViewPlacement {
|
||||
if (isChatViewPlacement(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof legacyOpenInMainTab === 'boolean') {
|
||||
return legacyOpenInMainTab ? 'main-tab' : 'right-sidebar';
|
||||
}
|
||||
|
||||
return DEFAULT_CLAUDIAN_SETTINGS.chatViewPlacement;
|
||||
}
|
||||
|
||||
function shouldPersistChatViewPlacementMigration(
|
||||
stored: Record<string, unknown>,
|
||||
normalized: ChatViewPlacement,
|
||||
): boolean {
|
||||
return 'openInMainTab' in stored
|
||||
|| (
|
||||
'chatViewPlacement' in stored
|
||||
&& stored.chatViewPlacement !== normalized
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeProviderConfigs(value: unknown): ProviderConfigMap {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: ProviderConfigMap = {};
|
||||
for (const [providerId, config] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (config && typeof config === 'object' && !Array.isArray(config)) {
|
||||
result[providerId] = { ...(config as Record<string, unknown>) };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function projectPersistableProviderConfigs(value: unknown): {
|
||||
changed: boolean;
|
||||
providerConfigs: ProviderConfigMap;
|
||||
} {
|
||||
const providerConfigs = normalizeProviderConfigs(value);
|
||||
let changed = false;
|
||||
|
||||
for (const { adapter, providerId } of getProviderSettingsAdapters()) {
|
||||
const fields = adapter.runtimeOnlyFields ?? [];
|
||||
const config = providerConfigs[providerId];
|
||||
if (!config) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const field of fields) {
|
||||
if (field in config) {
|
||||
delete config[field];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { changed, providerConfigs };
|
||||
}
|
||||
|
||||
function hasHostScopedProviderConfigNormalization(
|
||||
original: ProviderConfigMap,
|
||||
normalized: unknown,
|
||||
): boolean {
|
||||
if (!normalized || typeof normalized !== 'object' || Array.isArray(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedConfigs = normalized as ProviderConfigMap;
|
||||
for (const { adapter, providerId } of getProviderSettingsAdapters()) {
|
||||
const fields = adapter.hostScopedFields ?? [];
|
||||
const originalConfig = original[providerId];
|
||||
const normalizedConfig = normalizedConfigs[providerId];
|
||||
if (!originalConfig || !normalizedConfig) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const field of fields) {
|
||||
if (
|
||||
field in originalConfig
|
||||
&& JSON.stringify(originalConfig[field]) !== JSON.stringify(normalizedConfig[field])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isEnvironmentScope(value: unknown): value is EnvironmentScope {
|
||||
return value === 'shared' || (typeof value === 'string' && value.startsWith('provider:'));
|
||||
}
|
||||
|
||||
function normalizeContextLimits(value: unknown): Record<string, number> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: Record<string, number> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (typeof entry === 'number' && Number.isFinite(entry) && entry > 0) {
|
||||
result[key] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function normalizeModelAliases(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, alias] of Object.entries(value)) {
|
||||
if (typeof alias !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const modelId = key.trim();
|
||||
const normalizedAlias = alias.trim();
|
||||
if (modelId && normalizedAlias) {
|
||||
result[modelId] = normalizedAlias;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeEnvSnippets(value: unknown): EnvSnippet[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const snippets: EnvSnippet[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = item as Record<string, unknown>;
|
||||
if (
|
||||
typeof candidate.id !== 'string'
|
||||
|| typeof candidate.name !== 'string'
|
||||
|| typeof candidate.description !== 'string'
|
||||
|| typeof candidate.envVars !== 'string'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const modelAliases = 'modelAliases' in candidate
|
||||
? normalizeModelAliases(candidate.modelAliases)
|
||||
: undefined;
|
||||
|
||||
snippets.push({
|
||||
id: candidate.id,
|
||||
name: candidate.name,
|
||||
description: candidate.description,
|
||||
envVars: candidate.envVars,
|
||||
scope: resolveEnvironmentSnippetScope(
|
||||
candidate.envVars,
|
||||
isEnvironmentScope(candidate.scope)
|
||||
? candidate.scope
|
||||
: inferEnvironmentSnippetScope(candidate.envVars),
|
||||
),
|
||||
contextLimits: normalizeContextLimits(candidate.contextLimits),
|
||||
modelAliases,
|
||||
});
|
||||
}
|
||||
|
||||
return snippets;
|
||||
}
|
||||
|
||||
function hasLegacyTopLevelProviderFields(stored: Record<string, unknown>): boolean {
|
||||
return getLegacyTopLevelProviderFields().some((key) => key in stored);
|
||||
}
|
||||
|
||||
function mergeLegacyClaudeHiddenCommands(
|
||||
hiddenProviderCommands: HiddenProviderCommands,
|
||||
legacyHiddenSlashCommands: unknown,
|
||||
): HiddenProviderCommands {
|
||||
const legacyCommands = normalizeHiddenCommandList(legacyHiddenSlashCommands);
|
||||
if (legacyCommands.length === 0 || hiddenProviderCommands.claude) {
|
||||
return hiddenProviderCommands;
|
||||
}
|
||||
|
||||
return {
|
||||
...hiddenProviderCommands,
|
||||
claude: legacyCommands,
|
||||
};
|
||||
}
|
||||
|
||||
export class ClaudianSettingsStorage {
|
||||
constructor(private adapter: VaultFileAdapter) {}
|
||||
|
||||
async load(): Promise<StoredClaudianSettings> {
|
||||
const settingsPath = await this.getLoadPath();
|
||||
if (!settingsPath) {
|
||||
return this.getDefaults();
|
||||
}
|
||||
|
||||
const content = await this.adapter.read(settingsPath);
|
||||
const stored = JSON.parse(content) as Record<string, unknown>;
|
||||
const hiddenProviderCommands = mergeLegacyClaudeHiddenCommands(
|
||||
normalizeHiddenProviderCommands(stored.hiddenProviderCommands),
|
||||
stored.hiddenSlashCommands,
|
||||
);
|
||||
const envSnippets = normalizeEnvSnippets(stored.envSnippets);
|
||||
const customModelAliases = normalizeModelAliases(stored.customModelAliases);
|
||||
const {
|
||||
changed: didStripRuntimeProviderConfig,
|
||||
providerConfigs,
|
||||
} = projectPersistableProviderConfigs(stored.providerConfigs);
|
||||
const chatViewPlacement = normalizeChatViewPlacement(
|
||||
stored.chatViewPlacement,
|
||||
stored.openInMainTab,
|
||||
);
|
||||
const legacyProviderSettings = {
|
||||
...stored,
|
||||
hiddenProviderCommands,
|
||||
providerConfigs,
|
||||
};
|
||||
const storedWithoutLegacy = stripLegacyFields({
|
||||
...legacyProviderSettings,
|
||||
});
|
||||
|
||||
const legacyNormalized = {
|
||||
...storedWithoutLegacy,
|
||||
sharedEnvironmentVariables: getSharedEnvironmentVariables(legacyProviderSettings),
|
||||
envSnippets,
|
||||
customModelAliases,
|
||||
hiddenProviderCommands,
|
||||
providerConfigs,
|
||||
chatViewPlacement,
|
||||
};
|
||||
|
||||
const merged = {
|
||||
...this.getDefaults(),
|
||||
...legacyNormalized,
|
||||
};
|
||||
|
||||
let didNormalizeProviderSettings = false;
|
||||
for (const { adapter } of getProviderSettingsAdapters()) {
|
||||
didNormalizeProviderSettings = adapter.normalizeStored(
|
||||
merged,
|
||||
legacyProviderSettings,
|
||||
) || didNormalizeProviderSettings;
|
||||
}
|
||||
const didNormalizeHostScopedProviderConfigs = hasHostScopedProviderConfigNormalization(
|
||||
providerConfigs,
|
||||
merged.providerConfigs,
|
||||
);
|
||||
|
||||
if (
|
||||
settingsPath !== CLAUDIAN_SETTINGS_PATH
|
||||
|| (
|
||||
hasLegacyTopLevelProviderFields(stored)
|
||||
|| 'show1MModel' in stored
|
||||
|| 'slashCommands' in stored
|
||||
|| 'hiddenSlashCommands' in stored
|
||||
|| 'activeConversationId' in stored
|
||||
|| 'allowExternalAccess' in stored
|
||||
|| 'allowedExportPaths' in stored
|
||||
|| 'enableBlocklist' in stored
|
||||
|| 'blockedCommands' in stored
|
||||
|| shouldPersistChatViewPlacementMigration(stored, chatViewPlacement)
|
||||
|| JSON.stringify(envSnippets) !== JSON.stringify(stored.envSnippets ?? [])
|
||||
|| (
|
||||
'customModelAliases' in stored
|
||||
&& JSON.stringify(customModelAliases) !== JSON.stringify(stored.customModelAliases ?? {})
|
||||
)
|
||||
|| didNormalizeProviderSettings
|
||||
|| didStripRuntimeProviderConfig
|
||||
|| didNormalizeHostScopedProviderConfigs
|
||||
)
|
||||
) {
|
||||
await this.save(merged);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
async save(settings: StoredClaudianSettings): Promise<void> {
|
||||
const { providerConfigs } = projectPersistableProviderConfigs(settings.providerConfigs);
|
||||
const content = JSON.stringify(
|
||||
stripLegacyFields({
|
||||
...settings,
|
||||
providerConfigs,
|
||||
}),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
await this.adapter.write(CLAUDIAN_SETTINGS_PATH, content);
|
||||
await this.deleteLegacyFileIfPresent();
|
||||
}
|
||||
|
||||
async exists(): Promise<boolean> {
|
||||
if (await this.adapter.exists(CLAUDIAN_SETTINGS_PATH)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.adapter.exists(LEGACY_CLAUDIAN_SETTINGS_PATH);
|
||||
}
|
||||
|
||||
async update(updates: Partial<StoredClaudianSettings>): Promise<void> {
|
||||
const current = await this.load();
|
||||
await this.save({ ...current, ...updates });
|
||||
}
|
||||
|
||||
private getDefaults(): StoredClaudianSettings {
|
||||
return DEFAULT_CLAUDIAN_SETTINGS;
|
||||
}
|
||||
|
||||
private async getLoadPath(): Promise<string | null> {
|
||||
if (await this.adapter.exists(CLAUDIAN_SETTINGS_PATH)) {
|
||||
return CLAUDIAN_SETTINGS_PATH;
|
||||
}
|
||||
|
||||
if (await this.adapter.exists(LEGACY_CLAUDIAN_SETTINGS_PATH)) {
|
||||
return LEGACY_CLAUDIAN_SETTINGS_PATH;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async deleteLegacyFileIfPresent(): Promise<void> {
|
||||
if (await this.adapter.exists(LEGACY_CLAUDIAN_SETTINGS_PATH)) {
|
||||
await this.adapter.delete(LEGACY_CLAUDIAN_SETTINGS_PATH);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export type SettingsMutation<T extends object> = (
|
||||
settings: T,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export type ConditionalSettingsMutation<T extends object> = (
|
||||
settings: T,
|
||||
) => boolean | Promise<boolean>;
|
||||
|
||||
export class SettingsCoordinator<T extends object> {
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly settings: T,
|
||||
private readonly persist: (settings: T) => Promise<void>,
|
||||
) {}
|
||||
|
||||
mutate(mutation: SettingsMutation<T>): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
await mutation(this.settings);
|
||||
await this.persist(this.settings);
|
||||
});
|
||||
}
|
||||
|
||||
mutateConditionally(mutation: ConditionalSettingsMutation<T>): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
if (await mutation(this.settings)) {
|
||||
await this.persist(this.settings);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
persistCurrent(): Promise<void> {
|
||||
return this.enqueue(() => this.persist(this.settings));
|
||||
}
|
||||
|
||||
private enqueue(operation: () => Promise<void>): Promise<void> {
|
||||
const result = this.tail.then(operation);
|
||||
this.tail = result.catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { getDefaultHiddenProviderCommands } from '../../core/providers/commands/hiddenCommands';
|
||||
import { DEFAULT_REASONING_VALUE } from '../../core/providers/reasoning';
|
||||
import { type ClaudianSettings } from '../../core/types/settings';
|
||||
import { getBuiltInProviderDefaultConfigs } from '../../providers/defaultProviderConfigs';
|
||||
|
||||
export const DEFAULT_CLAUDIAN_SETTINGS: ClaudianSettings = {
|
||||
userName: '',
|
||||
|
||||
permissionMode: 'yolo',
|
||||
|
||||
model: 'haiku',
|
||||
thinkingBudget: 'off',
|
||||
effortLevel: DEFAULT_REASONING_VALUE,
|
||||
serviceTier: 'default',
|
||||
enableAutoTitleGeneration: true,
|
||||
titleGenerationModel: '',
|
||||
|
||||
excludedTags: [],
|
||||
mediaFolder: '',
|
||||
systemPrompt: '',
|
||||
persistentExternalContextPaths: [],
|
||||
|
||||
sharedEnvironmentVariables: '',
|
||||
envSnippets: [],
|
||||
customContextLimits: {},
|
||||
customModelAliases: {},
|
||||
|
||||
keyboardNavigation: {
|
||||
scrollUpKey: 'w',
|
||||
scrollDownKey: 's',
|
||||
focusInputKey: 'i',
|
||||
},
|
||||
requireCommandOrControlEnterToSend: false,
|
||||
|
||||
locale: 'en',
|
||||
|
||||
providerConfigs: getBuiltInProviderDefaultConfigs(),
|
||||
|
||||
settingsProvider: 'claude',
|
||||
savedProviderModel: {},
|
||||
savedProviderEffort: {},
|
||||
savedProviderServiceTier: {},
|
||||
savedProviderThinkingBudget: {},
|
||||
savedProviderPermissionMode: {},
|
||||
|
||||
lastCustomModel: '',
|
||||
|
||||
maxTabs: 3,
|
||||
enableAutoScroll: true,
|
||||
deferMathRenderingDuringStreaming: true,
|
||||
expandFileEditsByDefault: false,
|
||||
chatViewPlacement: 'right-sidebar',
|
||||
|
||||
hiddenProviderCommands: getDefaultHiddenProviderCommands(),
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Plugin } from 'obsidian';
|
||||
import { Notice } from 'obsidian';
|
||||
|
||||
import { SESSIONS_PATH, SessionStorage } from '../../core/bootstrap/SessionStorage';
|
||||
import type { SharedAppStorage } from '../../core/bootstrap/storage';
|
||||
import { CLAUDIAN_STORAGE_PATH } from '../../core/bootstrap/StoragePaths';
|
||||
import { normalizeTabManagerState } from '../../core/bootstrap/tabManagerState';
|
||||
import type { AppTabManagerState } from '../../core/providers/types';
|
||||
import { VaultFileAdapter } from '../../core/storage/VaultFileAdapter';
|
||||
import { ClaudianSettingsStorage, type StoredClaudianSettings } from '../settings/ClaudianSettingsStorage';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export class SharedStorageService implements SharedAppStorage {
|
||||
readonly claudianSettings: ClaudianSettingsStorage;
|
||||
readonly sessions: SessionStorage;
|
||||
|
||||
private adapter: VaultFileAdapter;
|
||||
private plugin: Plugin;
|
||||
|
||||
constructor(plugin: Plugin) {
|
||||
this.plugin = plugin;
|
||||
this.adapter = new VaultFileAdapter(plugin.app);
|
||||
this.claudianSettings = new ClaudianSettingsStorage(this.adapter);
|
||||
this.sessions = new SessionStorage(this.adapter);
|
||||
}
|
||||
|
||||
async initialize(): Promise<{ claudian: Record<string, unknown> }> {
|
||||
await this.ensureDirectories();
|
||||
const claudian = await this.claudianSettings.load();
|
||||
return { claudian };
|
||||
}
|
||||
|
||||
async saveClaudianSettings(settings: Record<string, unknown>): Promise<void> {
|
||||
await this.claudianSettings.save(settings as StoredClaudianSettings);
|
||||
}
|
||||
|
||||
async setTabManagerState(state: AppTabManagerState): Promise<void> {
|
||||
try {
|
||||
const loaded: unknown = await this.plugin.loadData();
|
||||
const data = isRecord(loaded) ? loaded : {};
|
||||
data.tabManagerState = state;
|
||||
await this.plugin.saveData(data);
|
||||
} catch {
|
||||
new Notice('Failed to save tab layout');
|
||||
}
|
||||
}
|
||||
|
||||
async getTabManagerState(): Promise<AppTabManagerState | null> {
|
||||
try {
|
||||
const data: unknown = await this.plugin.loadData();
|
||||
if (!isRecord(data) || !data.tabManagerState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeTabManagerState(data.tabManagerState);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getAdapter(): VaultFileAdapter {
|
||||
return this.adapter;
|
||||
}
|
||||
|
||||
private async ensureDirectories(): Promise<void> {
|
||||
await this.adapter.ensureFolder(CLAUDIAN_STORAGE_PATH);
|
||||
await this.adapter.ensureFolder(SESSIONS_PATH);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# Core Infrastructure
|
||||
|
||||
`src/core/` is provider-neutral infrastructure. Features depend on core contracts; providers implement those contracts behind the registry boundary.
|
||||
|
||||
## Ownership
|
||||
|
||||
| Module | Owns |
|
||||
| --- | --- |
|
||||
| `bootstrap/` | Provider-neutral session metadata storage and shared app-storage contracts |
|
||||
| `commands/` | Built-in cross-provider commands |
|
||||
| `mcp/` | Provider-neutral MCP coordination and config parsing |
|
||||
| `prompt/` | Shared prompt templates |
|
||||
| `providers/` | Registry, capability, environment, model-routing, and workspace-service contracts |
|
||||
| `providers/commands/` | Shared command catalog contracts |
|
||||
| `runtime/` | `ChatRuntime`, turn preparation, streaming, approval, and query contracts |
|
||||
| `security/` | Permission and approval helpers |
|
||||
| `storage/` | Generic vault/home filesystem adapters |
|
||||
| `tools/` | Shared tool constants and formatting helpers |
|
||||
| `types/` | Shared type definitions |
|
||||
|
||||
## Dependency Rules
|
||||
|
||||
```text
|
||||
types/ <- all modules
|
||||
storage/ <- bootstrap/, provider workspace services
|
||||
runtime/ + providers/ <- provider implementations
|
||||
features/ -> core contracts only
|
||||
```
|
||||
|
||||
Do not import provider implementation files from `core/`. If shared behavior needs provider data, add an explicit contract and have providers implement it.
|
||||
|
||||
## Key Contracts
|
||||
|
||||
```typescript
|
||||
const runtime = ProviderRegistry.createChatRuntime({ plugin, providerId });
|
||||
const preparedTurn = runtime.prepareTurn(request);
|
||||
|
||||
for await (const chunk of runtime.query(preparedTurn, history)) {
|
||||
// Feature layer consumes provider-neutral StreamChunk values.
|
||||
}
|
||||
```
|
||||
|
||||
Title generation is provider-routed by the global `titleGenerationModel` setting and is independent from the active chat tab provider.
|
||||
|
||||
Workspace services are resolved through `ProviderWorkspaceRegistry`:
|
||||
|
||||
```typescript
|
||||
const catalog = ProviderWorkspaceRegistry.getCommandCatalog(providerId);
|
||||
const agentMentions = ProviderWorkspaceRegistry.getAgentMentionProvider(providerId);
|
||||
const cliResolver = ProviderWorkspaceRegistry.getCliResolver(providerId);
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `ChatRuntime.cleanup()` must run when a tab is disposed.
|
||||
- `Conversation.providerState` is opaque to feature code. Provider-specific fields belong behind typed provider helpers.
|
||||
- Plan mode is capability-driven. Do not hardcode provider IDs in feature logic unless the provider contract cannot express the distinction.
|
||||
- Command discovery differs by provider:
|
||||
- Claude merges runtime-discovered commands with vault commands and skills.
|
||||
- Codex skills come from `CodexSkillCatalog` and do not depend on runtime command discovery.
|
||||
- OpenCode and Pi expose runtime commands through their provider protocols.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface AuxQueryConfig {
|
||||
systemPrompt: string;
|
||||
model?: string;
|
||||
abortController?: AbortController;
|
||||
onTextChunk?: (accumulatedText: string) => void;
|
||||
}
|
||||
|
||||
export interface AuxQueryRunner {
|
||||
query(config: AuxQueryConfig, prompt: string): Promise<string>;
|
||||
reset(): void;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { appendContextFiles } from '../../utils/context';
|
||||
import {
|
||||
buildInlineEditPrompt,
|
||||
getInlineEditSystemPrompt,
|
||||
parseInlineEditResponse,
|
||||
} from '../prompt/inlineEdit';
|
||||
import type {
|
||||
InlineEditRequest,
|
||||
InlineEditResult,
|
||||
InlineEditService,
|
||||
} from '../providers/types';
|
||||
import type { AuxQueryRunner } from './AuxQueryRunner';
|
||||
|
||||
export class QueryBackedInlineEditService implements InlineEditService {
|
||||
private abortController: AbortController | null = null;
|
||||
private hasConversation = false;
|
||||
private modelOverride: string | undefined;
|
||||
|
||||
constructor(private readonly runner: AuxQueryRunner) {}
|
||||
|
||||
setModelOverride(model?: string): void {
|
||||
const trimmed = model?.trim();
|
||||
this.modelOverride = trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
resetConversation(): void {
|
||||
this.runner.reset();
|
||||
this.hasConversation = false;
|
||||
}
|
||||
|
||||
async editText(request: InlineEditRequest): Promise<InlineEditResult> {
|
||||
this.resetConversation();
|
||||
return this.sendMessage(buildInlineEditPrompt(request));
|
||||
}
|
||||
|
||||
async continueConversation(message: string, contextFiles?: string[]): Promise<InlineEditResult> {
|
||||
if (!this.hasConversation) {
|
||||
return { success: false, error: 'No active conversation to continue' };
|
||||
}
|
||||
|
||||
let prompt = message;
|
||||
if (contextFiles && contextFiles.length > 0) {
|
||||
prompt = appendContextFiles(message, contextFiles);
|
||||
}
|
||||
return this.sendMessage(prompt);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.abortController?.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
|
||||
private async sendMessage(prompt: string): Promise<InlineEditResult> {
|
||||
this.abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const text = await this.runner.query({
|
||||
abortController: this.abortController,
|
||||
model: this.modelOverride,
|
||||
systemPrompt: getInlineEditSystemPrompt(),
|
||||
}, prompt);
|
||||
this.hasConversation = true;
|
||||
return parseInlineEditResponse(text);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
} finally {
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { buildRefineSystemPrompt, parseInstructionRefineResponse } from '../prompt/instructionRefine';
|
||||
import type {
|
||||
InstructionRefineService,
|
||||
RefineProgressCallback,
|
||||
} from '../providers/types';
|
||||
import type { InstructionRefineResult } from '../types';
|
||||
import type { AuxQueryRunner } from './AuxQueryRunner';
|
||||
|
||||
export class QueryBackedInstructionRefineService implements InstructionRefineService {
|
||||
private abortController: AbortController | null = null;
|
||||
private existingInstructions = '';
|
||||
private hasConversation = false;
|
||||
private modelOverride: string | undefined;
|
||||
|
||||
constructor(private readonly runner: AuxQueryRunner) {}
|
||||
|
||||
setModelOverride(model?: string): void {
|
||||
const trimmed = model?.trim();
|
||||
this.modelOverride = trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
resetConversation(): void {
|
||||
this.runner.reset();
|
||||
this.hasConversation = false;
|
||||
}
|
||||
|
||||
async refineInstruction(
|
||||
rawInstruction: string,
|
||||
existingInstructions: string,
|
||||
onProgress?: RefineProgressCallback,
|
||||
): Promise<InstructionRefineResult> {
|
||||
this.resetConversation();
|
||||
this.existingInstructions = existingInstructions;
|
||||
return this.sendMessage(`Please refine this instruction: "${rawInstruction}"`, onProgress);
|
||||
}
|
||||
|
||||
async continueConversation(
|
||||
message: string,
|
||||
onProgress?: RefineProgressCallback,
|
||||
): Promise<InstructionRefineResult> {
|
||||
if (!this.hasConversation) {
|
||||
return { success: false, error: 'No active conversation to continue' };
|
||||
}
|
||||
return this.sendMessage(message, onProgress);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.abortController?.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
|
||||
private async sendMessage(
|
||||
prompt: string,
|
||||
onProgress?: RefineProgressCallback,
|
||||
): Promise<InstructionRefineResult> {
|
||||
this.abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const text = await this.runner.query({
|
||||
abortController: this.abortController,
|
||||
model: this.modelOverride,
|
||||
onTextChunk: onProgress
|
||||
? (accumulatedText: string) => onProgress(parseInstructionRefineResponse(accumulatedText))
|
||||
: undefined,
|
||||
systemPrompt: buildRefineSystemPrompt(this.existingInstructions),
|
||||
}, prompt);
|
||||
this.hasConversation = true;
|
||||
return parseInstructionRefineResponse(text);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
} finally {
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
buildTitleGenerationPrompt,
|
||||
parseTitleGenerationResponse,
|
||||
TITLE_GENERATION_SYSTEM_PROMPT,
|
||||
} from '../prompt/titleGeneration';
|
||||
import type {
|
||||
TitleGenerationCallback,
|
||||
TitleGenerationResult,
|
||||
TitleGenerationService,
|
||||
} from '../providers/types';
|
||||
import type { AuxQueryRunner } from './AuxQueryRunner';
|
||||
|
||||
interface ActiveGeneration {
|
||||
abortController: AbortController;
|
||||
runner: AuxQueryRunner;
|
||||
}
|
||||
|
||||
export interface QueryBackedTitleGenerationServiceOptions {
|
||||
createRunner: () => AuxQueryRunner;
|
||||
resolveModel?: () => string | undefined;
|
||||
}
|
||||
|
||||
export class QueryBackedTitleGenerationService implements TitleGenerationService {
|
||||
private readonly activeGenerations = new Map<string, ActiveGeneration>();
|
||||
|
||||
constructor(private readonly options: QueryBackedTitleGenerationServiceOptions) {}
|
||||
|
||||
async generateTitle(
|
||||
conversationId: string,
|
||||
userMessage: string,
|
||||
callback: TitleGenerationCallback,
|
||||
): Promise<void> {
|
||||
const existing = this.activeGenerations.get(conversationId);
|
||||
if (existing) {
|
||||
existing.abortController.abort();
|
||||
existing.runner.reset();
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const runner = this.options.createRunner();
|
||||
const generation = { abortController, runner };
|
||||
this.activeGenerations.set(conversationId, generation);
|
||||
|
||||
try {
|
||||
const text = await runner.query({
|
||||
abortController,
|
||||
model: this.options.resolveModel?.(),
|
||||
systemPrompt: TITLE_GENERATION_SYSTEM_PROMPT,
|
||||
}, buildTitleGenerationPrompt(userMessage));
|
||||
const title = parseTitleGenerationResponse(text);
|
||||
await this.safeCallback(
|
||||
callback,
|
||||
conversationId,
|
||||
title
|
||||
? { success: true, title }
|
||||
: { success: false, error: 'Failed to parse title from response' },
|
||||
);
|
||||
} catch (error) {
|
||||
await this.safeCallback(callback, conversationId, {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
runner.reset();
|
||||
if (this.activeGenerations.get(conversationId) === generation) {
|
||||
this.activeGenerations.delete(conversationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
for (const active of this.activeGenerations.values()) {
|
||||
active.abortController.abort();
|
||||
active.runner.reset();
|
||||
}
|
||||
this.activeGenerations.clear();
|
||||
}
|
||||
|
||||
private async safeCallback(
|
||||
callback: TitleGenerationCallback,
|
||||
conversationId: string,
|
||||
result: TitleGenerationResult,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await callback(conversationId, result);
|
||||
} catch {
|
||||
// Ignore callback failures to match existing service behavior.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { ProviderRegistry } from '../providers/ProviderRegistry';
|
||||
import { DEFAULT_CHAT_PROVIDER_ID } from '../providers/types';
|
||||
import type { VaultFileAdapter } from '../storage/VaultFileAdapter';
|
||||
import type {
|
||||
Conversation,
|
||||
ConversationMeta,
|
||||
SessionMetadata,
|
||||
} from '../types';
|
||||
import { LEGACY_SESSIONS_PATH, SESSIONS_PATH } from './StoragePaths';
|
||||
|
||||
export {
|
||||
LEGACY_SESSIONS_PATH,
|
||||
SESSIONS_PATH,
|
||||
};
|
||||
|
||||
const SAFE_METADATA_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
|
||||
export function isValidSessionMetadataId(id: string): boolean {
|
||||
return SAFE_METADATA_ID_PATTERN.test(id)
|
||||
&& id !== '.'
|
||||
&& id !== '..'
|
||||
&& !/%(?:2f|5c)/i.test(id);
|
||||
}
|
||||
|
||||
function assertValidSessionMetadataId(id: string): void {
|
||||
if (!isValidSessionMetadataId(id)) {
|
||||
throw new Error(`Invalid session metadata id: ${JSON.stringify(id)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionStorage {
|
||||
constructor(private adapter: VaultFileAdapter) {}
|
||||
|
||||
getMetadataPath(id: string): string {
|
||||
assertValidSessionMetadataId(id);
|
||||
return `${SESSIONS_PATH}/${id}.meta.json`;
|
||||
}
|
||||
|
||||
getLegacyMetadataPath(id: string): string {
|
||||
assertValidSessionMetadataId(id);
|
||||
return `${LEGACY_SESSIONS_PATH}/${id}.meta.json`;
|
||||
}
|
||||
|
||||
async saveMetadata(metadata: SessionMetadata): Promise<void> {
|
||||
const filePath = this.getMetadataPath(metadata.id);
|
||||
const content = JSON.stringify(metadata, null, 2);
|
||||
await this.adapter.write(filePath, content);
|
||||
await this.deleteLegacyMetadataIfPresent(metadata.id);
|
||||
}
|
||||
|
||||
async loadMetadata(id: string): Promise<SessionMetadata | null> {
|
||||
if (!isValidSessionMetadataId(id)) {
|
||||
return null;
|
||||
}
|
||||
const filePath = await this.getLoadPath(id);
|
||||
|
||||
try {
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await this.adapter.read(filePath);
|
||||
const metadata = JSON.parse(content) as SessionMetadata;
|
||||
if (metadata.id !== id || !isValidSessionMetadataId(metadata.id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filePath !== this.getMetadataPath(id)) {
|
||||
await this.saveMetadata(metadata);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMetadata(id: string): Promise<void> {
|
||||
await this.adapter.delete(this.getMetadataPath(id));
|
||||
await this.deleteLegacyMetadataIfPresent(id);
|
||||
}
|
||||
|
||||
async listMetadata(): Promise<SessionMetadata[]> {
|
||||
const metas: SessionMetadata[] = [];
|
||||
|
||||
const files = await this.listUniqueMetadataFiles();
|
||||
|
||||
for (const filePath of files) {
|
||||
const fileId = this.getMetadataIdFromPath(filePath);
|
||||
if (!fileId || !isValidSessionMetadataId(fileId)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const content = await this.adapter.read(filePath);
|
||||
const raw = JSON.parse(content) as SessionMetadata;
|
||||
if (raw.id !== fileId || !isValidSessionMetadataId(raw.id)) {
|
||||
continue;
|
||||
}
|
||||
metas.push(raw);
|
||||
|
||||
if (filePath.startsWith(`${LEGACY_SESSIONS_PATH}/`)) {
|
||||
await this.saveMetadata(raw);
|
||||
}
|
||||
} catch {
|
||||
// Skip files that fail to load.
|
||||
}
|
||||
}
|
||||
|
||||
return metas;
|
||||
}
|
||||
|
||||
async listAllConversations(): Promise<ConversationMeta[]> {
|
||||
const nativeMetas = await this.listMetadata();
|
||||
|
||||
const metas: ConversationMeta[] = nativeMetas.map((meta) => ({
|
||||
id: meta.id,
|
||||
providerId: meta.providerId ?? DEFAULT_CHAT_PROVIDER_ID,
|
||||
title: meta.title,
|
||||
createdAt: meta.createdAt,
|
||||
updatedAt: meta.updatedAt,
|
||||
lastResponseAt: meta.lastResponseAt,
|
||||
messageCount: 0,
|
||||
preview: 'SDK session',
|
||||
titleGenerationStatus: meta.titleGenerationStatus,
|
||||
}));
|
||||
|
||||
return metas.sort((a, b) =>
|
||||
(b.lastResponseAt ?? b.createdAt) - (a.lastResponseAt ?? a.createdAt)
|
||||
);
|
||||
}
|
||||
|
||||
toSessionMetadata(conversation: Conversation): SessionMetadata {
|
||||
const historyService = ProviderRegistry.getConversationHistoryService(conversation.providerId);
|
||||
const providerState = historyService.buildPersistedProviderState
|
||||
? historyService.buildPersistedProviderState(conversation)
|
||||
: conversation.providerState;
|
||||
|
||||
return {
|
||||
id: conversation.id,
|
||||
providerId: conversation.providerId,
|
||||
title: conversation.title,
|
||||
titleGenerationStatus: conversation.titleGenerationStatus,
|
||||
createdAt: conversation.createdAt,
|
||||
updatedAt: conversation.updatedAt,
|
||||
lastResponseAt: conversation.lastResponseAt,
|
||||
sessionId: conversation.sessionId,
|
||||
selectedModel: conversation.selectedModel,
|
||||
providerState: providerState && Object.keys(providerState).length > 0 ? providerState : undefined,
|
||||
currentNote: conversation.currentNote,
|
||||
externalContextPaths: conversation.externalContextPaths,
|
||||
enabledMcpServers: conversation.enabledMcpServers,
|
||||
usage: conversation.usage,
|
||||
resumeAtMessageId: conversation.resumeAtMessageId,
|
||||
};
|
||||
}
|
||||
|
||||
private async getLoadPath(id: string): Promise<string | null> {
|
||||
const filePath = this.getMetadataPath(id);
|
||||
if (await this.adapter.exists(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const legacyFilePath = this.getLegacyMetadataPath(id);
|
||||
if (await this.adapter.exists(legacyFilePath)) {
|
||||
return legacyFilePath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async deleteLegacyMetadataIfPresent(id: string): Promise<void> {
|
||||
const legacyFilePath = this.getLegacyMetadataPath(id);
|
||||
if (await this.adapter.exists(legacyFilePath)) {
|
||||
await this.adapter.delete(legacyFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
private async listUniqueMetadataFiles(): Promise<string[]> {
|
||||
const preferredFiles = await this.listMetadataFiles(SESSIONS_PATH);
|
||||
const fallbackFiles = await this.listMetadataFiles(LEGACY_SESSIONS_PATH);
|
||||
const filesByName = new Map<string, string>();
|
||||
|
||||
for (const filePath of preferredFiles) {
|
||||
filesByName.set(this.getFileName(filePath), filePath);
|
||||
}
|
||||
|
||||
for (const filePath of fallbackFiles) {
|
||||
const fileName = this.getFileName(filePath);
|
||||
if (!filesByName.has(fileName)) {
|
||||
filesByName.set(fileName, filePath);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(filesByName.values());
|
||||
}
|
||||
|
||||
private async listMetadataFiles(folderPath: string): Promise<string[]> {
|
||||
try {
|
||||
const files = await this.adapter.listFiles(folderPath);
|
||||
return files.filter((filePath) => filePath.endsWith('.meta.json'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private getFileName(filePath: string): string {
|
||||
const parts = filePath.split('/');
|
||||
return parts[parts.length - 1] ?? filePath;
|
||||
}
|
||||
|
||||
private getMetadataIdFromPath(filePath: string): string | null {
|
||||
const fileName = this.getFileName(filePath);
|
||||
const suffix = '.meta.json';
|
||||
return fileName.endsWith(suffix)
|
||||
? fileName.slice(0, -suffix.length)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const CLAUDIAN_STORAGE_PATH = '.claudian';
|
||||
|
||||
export const LEGACY_CLAUDIAN_SETTINGS_PATH = '.claude/claudian-settings.json';
|
||||
export const CLAUDIAN_SETTINGS_PATH = `${CLAUDIAN_STORAGE_PATH}/claudian-settings.json`;
|
||||
|
||||
export const LEGACY_SESSIONS_PATH = '.claude/sessions';
|
||||
export const SESSIONS_PATH = `${CLAUDIAN_STORAGE_PATH}/sessions`;
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { AppSessionStorage, AppTabManagerState } from '../providers/types';
|
||||
import type { VaultFileAdapter } from '../storage/VaultFileAdapter';
|
||||
|
||||
/**
|
||||
* Minimal shared app storage contract.
|
||||
*
|
||||
* This interface covers only the storage concerns that are shared across
|
||||
* all providers: Claudian settings, tab manager state, and session metadata.
|
||||
*
|
||||
* Provider-specific storage surfaces (CC settings, slash commands, skills,
|
||||
* agents, MCP config) live behind provider-owned modules.
|
||||
*/
|
||||
export interface SharedAppStorage {
|
||||
initialize(): Promise<{ claudian: Record<string, unknown> }>;
|
||||
saveClaudianSettings(settings: Record<string, unknown>): Promise<void>;
|
||||
setTabManagerState(state: AppTabManagerState): Promise<void>;
|
||||
getTabManagerState(): Promise<AppTabManagerState | null>;
|
||||
sessions: AppSessionStorage;
|
||||
getAdapter(): VaultFileAdapter;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { AppTabManagerState } from '../providers/types';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function normalizeTabManagerState(data: unknown): AppTabManagerState | null {
|
||||
if (!isRecord(data) || !Array.isArray(data.openTabs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const openTabs: AppTabManagerState['openTabs'] = [];
|
||||
const openTabIds = new Set<string>();
|
||||
for (const tab of data.openTabs) {
|
||||
if (!isRecord(tab) || typeof tab.tabId !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
openTabs.push({
|
||||
tabId: tab.tabId,
|
||||
conversationId: typeof tab.conversationId === 'string' ? tab.conversationId : null,
|
||||
...(typeof tab.draftModel === 'string'
|
||||
? { draftModel: tab.draftModel }
|
||||
: {}),
|
||||
});
|
||||
openTabIds.add(tab.tabId);
|
||||
}
|
||||
|
||||
const expandedTitleTabIds: string[] = [];
|
||||
const seenExpandedTabIds = new Set<string>();
|
||||
if (Array.isArray(data.expandedTitleTabIds)) {
|
||||
for (const tabId of data.expandedTitleTabIds) {
|
||||
if (
|
||||
typeof tabId !== 'string'
|
||||
|| !openTabIds.has(tabId)
|
||||
|| seenExpandedTabIds.has(tabId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
expandedTitleTabIds.push(tabId);
|
||||
seenExpandedTabIds.add(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
openTabs,
|
||||
activeTabId: typeof data.activeTabId === 'string' ? data.activeTabId : null,
|
||||
...(expandedTitleTabIds.length > 0 ? { expandedTitleTabIds } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Claudian - Built-in slash commands
|
||||
*
|
||||
* System commands that perform actions (not prompt expansions).
|
||||
* These are handled separately from user-defined slash commands.
|
||||
*/
|
||||
|
||||
import { ProviderRegistry } from '../providers/ProviderRegistry';
|
||||
import type { ProviderCapabilities, ProviderId } from '../providers/types';
|
||||
|
||||
export type BuiltInCommandAction = 'clear' | 'add-dir' | 'resume' | 'fork';
|
||||
type BuiltInCommandCapability = 'supportsNativeHistory' | 'supportsFork';
|
||||
type BuiltInCommandSupportContext = ProviderId | Pick<ProviderCapabilities, BuiltInCommandCapability>;
|
||||
|
||||
export interface BuiltInCommand {
|
||||
name: string;
|
||||
aliases?: string[];
|
||||
description: string;
|
||||
action: BuiltInCommandAction;
|
||||
/** Whether this command accepts arguments. */
|
||||
hasArgs?: boolean;
|
||||
/** Hint for arguments shown in dropdown (e.g., "path"). */
|
||||
argumentHint?: string;
|
||||
/** When set, provider capabilities must expose this feature. */
|
||||
requiredCapability?: BuiltInCommandCapability;
|
||||
}
|
||||
|
||||
export interface BuiltInCommandResult {
|
||||
command: BuiltInCommand;
|
||||
/** Arguments passed to the command (trimmed, after command name). */
|
||||
args: string;
|
||||
}
|
||||
|
||||
export const BUILT_IN_COMMANDS: BuiltInCommand[] = [
|
||||
{
|
||||
name: 'clear',
|
||||
aliases: ['new'],
|
||||
description: 'Start a new conversation',
|
||||
action: 'clear',
|
||||
},
|
||||
{
|
||||
name: 'add-dir',
|
||||
description: 'Add external context directory',
|
||||
action: 'add-dir',
|
||||
hasArgs: true,
|
||||
argumentHint: '[path/to/directory]',
|
||||
},
|
||||
{
|
||||
name: 'resume',
|
||||
description: 'Resume a previous conversation',
|
||||
action: 'resume',
|
||||
requiredCapability: 'supportsNativeHistory',
|
||||
},
|
||||
{
|
||||
name: 'fork',
|
||||
description: 'Fork entire conversation to new session',
|
||||
action: 'fork',
|
||||
requiredCapability: 'supportsFork',
|
||||
},
|
||||
];
|
||||
|
||||
/** Map of command names/aliases to their definitions. */
|
||||
const commandMap = new Map<string, BuiltInCommand>();
|
||||
|
||||
for (const cmd of BUILT_IN_COMMANDS) {
|
||||
commandMap.set(cmd.name.toLowerCase(), cmd);
|
||||
if (cmd.aliases) {
|
||||
for (const alias of cmd.aliases) {
|
||||
commandMap.set(alias.toLowerCase(), cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCapabilities(
|
||||
context: BuiltInCommandSupportContext,
|
||||
): Pick<ProviderCapabilities, BuiltInCommandCapability> | null {
|
||||
if (typeof context !== 'string') {
|
||||
return context;
|
||||
}
|
||||
|
||||
try {
|
||||
return ProviderRegistry.getCapabilities(context);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isBuiltInCommandSupported(
|
||||
command: BuiltInCommand,
|
||||
context?: BuiltInCommandSupportContext,
|
||||
): boolean {
|
||||
if (!command.requiredCapability || !context) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const capabilities = resolveCapabilities(context);
|
||||
return capabilities ? capabilities[command.requiredCapability] : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if input is a built-in command.
|
||||
* Returns the command and arguments if found, null otherwise.
|
||||
*/
|
||||
export function detectBuiltInCommand(input: string): BuiltInCommandResult | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed.startsWith('/')) return null;
|
||||
|
||||
// Extract command name (first word after /)
|
||||
const match = trimmed.match(/^\/([a-zA-Z0-9_-]+)(?:\s(.*))?$/);
|
||||
if (!match) return null;
|
||||
|
||||
const cmdName = match[1].toLowerCase();
|
||||
const command = commandMap.get(cmdName);
|
||||
if (!command) return null;
|
||||
|
||||
const args = (match[2] || '').trim();
|
||||
|
||||
return { command, args };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets built-in commands for dropdown display.
|
||||
* When providerId is given, excludes commands restricted to other providers.
|
||||
*/
|
||||
export function getBuiltInCommandsForDropdown(context?: BuiltInCommandSupportContext): Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
argumentHint?: string;
|
||||
}> {
|
||||
return BUILT_IN_COMMANDS
|
||||
.filter((cmd) => isBuiltInCommandSupported(cmd, context))
|
||||
.map((cmd) => ({
|
||||
id: `builtin:${cmd.name}`,
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
content: '', // Built-in commands don't have prompt content
|
||||
argumentHint: cmd.argumentHint,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { McpServerConfig, ParsedMcpConfig } from '../types';
|
||||
import { isValidMcpServerConfig } from '../types';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse pasted JSON (supports multiple formats).
|
||||
*
|
||||
* Formats supported:
|
||||
* 1. Full Claude Code format: { "mcpServers": { "name": {...} } }
|
||||
* 2. Single server with name: { "name": { "command": "..." } }
|
||||
* 3. Single server without name: { "command": "..." }
|
||||
* 4. Multiple named servers: { "server1": {...}, "server2": {...} }
|
||||
*/
|
||||
export function parseClipboardConfig(json: string): ParsedMcpConfig {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(json);
|
||||
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error('Invalid JSON object');
|
||||
}
|
||||
|
||||
// Format 1: Full Claude Code format
|
||||
// { "mcpServers": { "server-name": { "command": "...", ... } } }
|
||||
if (isRecord(parsed.mcpServers)) {
|
||||
const servers: Array<{ name: string; config: McpServerConfig }> = [];
|
||||
|
||||
for (const [name, config] of Object.entries(parsed.mcpServers)) {
|
||||
if (isValidMcpServerConfig(config)) {
|
||||
servers.push({ name, config });
|
||||
}
|
||||
}
|
||||
|
||||
if (servers.length === 0) {
|
||||
throw new Error('No valid server configs found in mcpServers');
|
||||
}
|
||||
|
||||
return { servers, needsName: false };
|
||||
}
|
||||
|
||||
// Format 2: Single server config without name
|
||||
// { "command": "...", "args": [...] } or { "type": "sse", "url": "..." }
|
||||
if (isValidMcpServerConfig(parsed)) {
|
||||
return {
|
||||
servers: [{ name: '', config: parsed }],
|
||||
needsName: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Format 3: Single named server
|
||||
// { "server-name": { "command": "...", ... } }
|
||||
const entries = Object.entries(parsed);
|
||||
if (entries.length === 1) {
|
||||
const [name, config] = entries[0];
|
||||
if (isValidMcpServerConfig(config)) {
|
||||
return {
|
||||
servers: [{ name, config }],
|
||||
needsName: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Format 4: Multiple named servers (without mcpServers wrapper)
|
||||
// { "server1": {...}, "server2": {...} }
|
||||
const servers: Array<{ name: string; config: McpServerConfig }> = [];
|
||||
for (const [name, config] of entries) {
|
||||
if (isValidMcpServerConfig(config)) {
|
||||
servers.push({ name, config });
|
||||
}
|
||||
}
|
||||
|
||||
if (servers.length > 0) {
|
||||
return { servers, needsName: false };
|
||||
}
|
||||
|
||||
throw new Error('Invalid MCP configuration format');
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error('Invalid JSON', { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse clipboard content as MCP config.
|
||||
* Returns null if not valid MCP config.
|
||||
*/
|
||||
export function tryParseClipboardConfig(text: string): ParsedMcpConfig | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith('{')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return parseClipboardConfig(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { extractMcpMentions, transformMcpMentions } from '../../utils/mcp';
|
||||
import type { ManagedMcpServer, McpServerConfig } from '../types';
|
||||
|
||||
/** Storage interface for loading MCP servers. */
|
||||
export interface McpStorageAdapter {
|
||||
load(): Promise<ManagedMcpServer[]>;
|
||||
}
|
||||
|
||||
export class McpServerManager {
|
||||
private servers: ManagedMcpServer[] = [];
|
||||
private storage: McpStorageAdapter;
|
||||
|
||||
constructor(storage: McpStorageAdapter) {
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
async loadServers(): Promise<void> {
|
||||
this.servers = await this.storage.load();
|
||||
}
|
||||
|
||||
getServers(): ManagedMcpServer[] {
|
||||
return this.servers;
|
||||
}
|
||||
|
||||
getEnabledCount(): number {
|
||||
return this.servers.filter((s) => s.enabled).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get servers to include in SDK options.
|
||||
*
|
||||
* A server is included if:
|
||||
* - It is enabled AND
|
||||
* - Either context-saving is disabled OR the server is @-mentioned
|
||||
*
|
||||
* @param mentionedNames Set of server names that were @-mentioned in the prompt
|
||||
*/
|
||||
getActiveServers(mentionedNames: Set<string>): Record<string, McpServerConfig> {
|
||||
const result: Record<string, McpServerConfig> = {};
|
||||
|
||||
for (const server of this.servers) {
|
||||
if (!server.enabled) continue;
|
||||
|
||||
// If context-saving is enabled, only include if @-mentioned
|
||||
if (server.contextSaving && !mentionedNames.has(server.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result[server.name] = server.config;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disabled MCP tools formatted for SDK disallowedTools option.
|
||||
*
|
||||
* Only returns disabled tools from servers that would be active (same filter as getActiveServers).
|
||||
*
|
||||
* @param mentionedNames Set of server names that were @-mentioned in the prompt
|
||||
*/
|
||||
getDisallowedMcpTools(mentionedNames: Set<string>): string[] {
|
||||
return this.collectDisallowedTools(
|
||||
(s) => !s.contextSaving || mentionedNames.has(s.name)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all disabled MCP tools from ALL enabled servers (ignoring @-mentions).
|
||||
*
|
||||
* Used for persistent queries to pre-register all disabled tools upfront,
|
||||
* so @-mentioning servers doesn't require cold start.
|
||||
*/
|
||||
getAllDisallowedMcpTools(): string[] {
|
||||
return this.collectDisallowedTools().sort();
|
||||
}
|
||||
|
||||
private collectDisallowedTools(filter?: (server: ManagedMcpServer) => boolean): string[] {
|
||||
const disallowed = new Set<string>();
|
||||
|
||||
for (const server of this.servers) {
|
||||
if (!server.enabled) continue;
|
||||
if (filter && !filter(server)) continue;
|
||||
if (!server.disabledTools || server.disabledTools.length === 0) continue;
|
||||
|
||||
for (const tool of server.disabledTools) {
|
||||
const normalized = tool.trim();
|
||||
if (!normalized) continue;
|
||||
disallowed.add(`mcp__${server.name}__${normalized}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(disallowed);
|
||||
}
|
||||
|
||||
hasServers(): boolean {
|
||||
return this.servers.length > 0;
|
||||
}
|
||||
|
||||
getContextSavingServers(): ManagedMcpServer[] {
|
||||
return this.servers.filter((s) => s.enabled && s.contextSaving);
|
||||
}
|
||||
|
||||
private getContextSavingNames(): Set<string> {
|
||||
return new Set(this.getContextSavingServers().map((s) => s.name));
|
||||
}
|
||||
|
||||
/** Only matches against enabled servers with context-saving mode. */
|
||||
extractMentions(text: string): Set<string> {
|
||||
return extractMcpMentions(text, this.getContextSavingNames());
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends " MCP" after each valid @mention. Applied to API requests only, not shown in UI.
|
||||
*/
|
||||
transformMentions(text: string): string {
|
||||
return transformMcpMentions(text, this.getContextSavingNames());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
|
||||
import { getEnhancedPath } from '../../utils/env';
|
||||
import { parseCommand } from '../../utils/mcp';
|
||||
import type { ManagedMcpServer } from '../types';
|
||||
import { getMcpServerType } from '../types';
|
||||
|
||||
export interface McpTool {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface McpTestResult {
|
||||
success: boolean;
|
||||
serverName?: string;
|
||||
serverVersion?: string;
|
||||
tools: McpTool[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface UrlServerConfig {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
type StreamableHttpTransportOptions = ConstructorParameters<typeof StreamableHTTPClientTransport>[1];
|
||||
type LegacySseTransportConstructor = new (
|
||||
url: URL,
|
||||
options?: StreamableHttpTransportOptions,
|
||||
) => Transport;
|
||||
|
||||
function createLegacySseTransport(url: URL, options: StreamableHttpTransportOptions): Transport {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- Legacy SSE MCP servers still need the SDK's deprecated compatibility transport.
|
||||
const module = require('@modelcontextprotocol/sdk/client/sse') as {
|
||||
SSEClientTransport: LegacySseTransportConstructor;
|
||||
};
|
||||
return new module.SSEClientTransport(url, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use Node's HTTP stack for MCP server verification to avoid renderer CORS restrictions.
|
||||
* We still rely on official SDK transports for MCP protocol semantics.
|
||||
*/
|
||||
export function createNodeFetch(): (input: string | URL | Request, init?: RequestInit) => Promise<Response> {
|
||||
return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
const requestUrl = getRequestUrl(input);
|
||||
const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
|
||||
const headers = mergeHeaders(input, init);
|
||||
const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
|
||||
const body = await getRequestBody(init?.body ?? (input instanceof Request ? input.body : undefined));
|
||||
const transport = requestUrl.protocol === 'https:' ? https : http;
|
||||
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const fail = (error: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
req.destroy(new Error('Request aborted'));
|
||||
fail(signal?.reason ?? new Error('Request aborted'));
|
||||
};
|
||||
|
||||
const requestHeaders: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
requestHeaders[key] = value;
|
||||
});
|
||||
if (body) {
|
||||
requestHeaders['content-length'] = String(body.byteLength);
|
||||
}
|
||||
|
||||
const req = transport.request(
|
||||
requestUrl,
|
||||
{
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
},
|
||||
(res: http.IncomingMessage) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve(createFetchResponse(res) as Response);
|
||||
},
|
||||
);
|
||||
|
||||
req.on('error', (error: Error) => fail(error));
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
|
||||
if (body) {
|
||||
req.end(body);
|
||||
} else {
|
||||
req.end();
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
interface MinimalFetchResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Headers;
|
||||
body: ReadableStream<Uint8Array> | null;
|
||||
text: () => Promise<string>;
|
||||
json: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
function createFetchResponse(res: http.IncomingMessage): MinimalFetchResponse {
|
||||
const responseHeaders = new Headers();
|
||||
for (const [key, value] of Object.entries(res.headers)) {
|
||||
if (value === undefined) continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (const headerValue of value) {
|
||||
responseHeaders.append(key, headerValue);
|
||||
}
|
||||
} else {
|
||||
responseHeaders.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
res.on('data', (chunk: Buffer | string) => {
|
||||
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
||||
controller.enqueue(new Uint8Array(buffer));
|
||||
});
|
||||
res.on('end', () => controller.close());
|
||||
res.on('error', (error: Error) => controller.error(error));
|
||||
},
|
||||
cancel(reason?: unknown) {
|
||||
res.destroy(reason instanceof Error ? reason : new Error('Response body cancelled'));
|
||||
},
|
||||
});
|
||||
|
||||
let bodyUsed = false;
|
||||
const readAsText = async (): Promise<string> => {
|
||||
if (bodyUsed) {
|
||||
throw new TypeError('Body has already been consumed');
|
||||
}
|
||||
bodyUsed = true;
|
||||
const reader = body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
let done = false;
|
||||
try {
|
||||
while (!done) {
|
||||
const { value, done: streamDone } = await reader.read();
|
||||
done = streamDone;
|
||||
if (done) break;
|
||||
if (value) {
|
||||
chunks.push(value);
|
||||
total += value.byteLength;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return new TextDecoder().decode(merged);
|
||||
};
|
||||
|
||||
return {
|
||||
ok: (res.statusCode ?? 500) >= 200 && (res.statusCode ?? 500) < 300,
|
||||
status: res.statusCode ?? 500,
|
||||
statusText: res.statusMessage ?? '',
|
||||
headers: responseHeaders,
|
||||
body,
|
||||
text: readAsText,
|
||||
json: async () => {
|
||||
const parsed: unknown = JSON.parse(await readAsText());
|
||||
return parsed;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getRequestUrl(input: string | URL | Request): URL {
|
||||
if (input instanceof URL) {
|
||||
return input;
|
||||
}
|
||||
if (typeof input === 'string') {
|
||||
return new URL(input);
|
||||
}
|
||||
return new URL(input.url);
|
||||
}
|
||||
|
||||
function mergeHeaders(input: string | URL | Request, init?: RequestInit): Headers {
|
||||
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
||||
if (init?.headers) {
|
||||
const initHeaders = new Headers(init.headers);
|
||||
initHeaders.forEach((value, key) => {
|
||||
headers.set(key, value);
|
||||
});
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function getRequestBody(body: BodyInit | null | undefined): Promise<Buffer | undefined> {
|
||||
if (body === undefined || body === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const serialized = await new Response(body).arrayBuffer();
|
||||
return Buffer.from(serialized);
|
||||
}
|
||||
|
||||
const nodeFetch = createNodeFetch();
|
||||
|
||||
export async function testMcpServer(server: ManagedMcpServer): Promise<McpTestResult> {
|
||||
const type = getMcpServerType(server.config);
|
||||
|
||||
let transport: Transport;
|
||||
try {
|
||||
if (type === 'stdio') {
|
||||
const config = server.config as { command: string; args?: string[]; env?: Record<string, string> };
|
||||
const { cmd, args } = parseCommand(config.command, config.args);
|
||||
if (!cmd) {
|
||||
return { success: false, tools: [], error: 'Missing command' };
|
||||
}
|
||||
transport = new StdioClientTransport({
|
||||
command: cmd,
|
||||
args,
|
||||
env: { ...process.env, ...config.env, PATH: getEnhancedPath(config.env?.PATH) },
|
||||
stderr: 'ignore',
|
||||
});
|
||||
} else {
|
||||
const config = server.config as UrlServerConfig;
|
||||
const url = new URL(config.url);
|
||||
const options = {
|
||||
fetch: nodeFetch,
|
||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||
};
|
||||
transport = type === 'sse'
|
||||
? createLegacySseTransport(url, options)
|
||||
: new StreamableHTTPClientTransport(url, options);
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
tools: [],
|
||||
error: error instanceof Error ? error.message : 'Invalid server configuration',
|
||||
};
|
||||
}
|
||||
|
||||
const client = new Client({ name: 'claudian-tester', version: '1.0.0' });
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
await client.connect(transport, { signal: controller.signal });
|
||||
|
||||
const serverVersion = client.getServerVersion();
|
||||
let tools: McpTool[] = [];
|
||||
try {
|
||||
const result = await client.listTools(undefined, { signal: controller.signal });
|
||||
tools = result.tools.map((t: { name: string; description?: string; inputSchema?: Record<string, unknown> }) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema as Record<string, unknown>,
|
||||
}));
|
||||
} catch {
|
||||
// listTools failure after successful connect = partial success
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
serverName: serverVersion?.name,
|
||||
serverVersion: serverVersion?.version,
|
||||
tools,
|
||||
};
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
return { success: false, tools: [], error: 'Connection timeout (10s)' };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
tools: [],
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
try {
|
||||
await client.close();
|
||||
} catch {
|
||||
// Ignore close errors
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { appendContextFiles } from '../../utils/context';
|
||||
import { getTodayDate } from '../../utils/date';
|
||||
import type {
|
||||
InlineEditCursorRequest,
|
||||
InlineEditRequest,
|
||||
InlineEditResult,
|
||||
} from '../providers/types';
|
||||
|
||||
export function parseInlineEditResponse(responseText: string): InlineEditResult {
|
||||
const replacementMatch = responseText.match(/<replacement>([\s\S]*?)<\/replacement>/);
|
||||
if (replacementMatch) {
|
||||
return { success: true, editedText: replacementMatch[1] };
|
||||
}
|
||||
|
||||
const insertionMatch = responseText.match(/<insertion>([\s\S]*?)<\/insertion>/);
|
||||
if (insertionMatch) {
|
||||
return { success: true, insertedText: insertionMatch[1] };
|
||||
}
|
||||
|
||||
const trimmed = responseText.trim();
|
||||
if (trimmed) {
|
||||
return { success: true, clarification: trimmed };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Empty response' };
|
||||
}
|
||||
|
||||
function buildCursorPrompt(request: InlineEditCursorRequest): string {
|
||||
const ctx = request.cursorContext;
|
||||
const lineAttr = ` line="${ctx.line + 1}"`;
|
||||
|
||||
let cursorContent: string;
|
||||
if (ctx.isInbetween) {
|
||||
const parts = [];
|
||||
if (ctx.beforeCursor) parts.push(ctx.beforeCursor);
|
||||
parts.push('| #inbetween');
|
||||
if (ctx.afterCursor) parts.push(ctx.afterCursor);
|
||||
cursorContent = parts.join('\n');
|
||||
} else {
|
||||
cursorContent = `${ctx.beforeCursor}|${ctx.afterCursor} #inline`;
|
||||
}
|
||||
|
||||
return [
|
||||
request.instruction,
|
||||
'',
|
||||
`<editor_cursor path="${request.notePath}"${lineAttr}>`,
|
||||
cursorContent,
|
||||
'</editor_cursor>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildInlineEditPrompt(request: InlineEditRequest): string {
|
||||
let prompt: string;
|
||||
|
||||
if (request.mode === 'cursor') {
|
||||
prompt = buildCursorPrompt(request);
|
||||
} else {
|
||||
const lineAttr = request.startLine && request.lineCount
|
||||
? ` lines="${request.startLine}-${request.startLine + request.lineCount - 1}"`
|
||||
: '';
|
||||
prompt = [
|
||||
request.instruction,
|
||||
'',
|
||||
`<editor_selection path="${request.notePath}"${lineAttr}>`,
|
||||
request.selectedText,
|
||||
'</editor_selection>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (request.contextFiles && request.contextFiles.length > 0) {
|
||||
prompt = appendContextFiles(prompt, request.contextFiles);
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
export function getInlineEditSystemPrompt(): string {
|
||||
const pathRules = '- **Paths**: Must be RELATIVE to vault root (e.g., "notes/file.md").';
|
||||
|
||||
return `Today is ${getTodayDate()}.
|
||||
|
||||
You are **Claudian**, an expert editor and writing assistant embedded in Obsidian. You help users refine their text, answer questions, and generate content with high precision.
|
||||
|
||||
## Core Directives
|
||||
|
||||
1. **Style Matching**: Mimic the user's tone, voice, and formatting style (indentation, bullet points, capitalization).
|
||||
2. **Context Awareness**: Always Read the full file (or significant context) to understand the broader topic before editing. Do not rely solely on the selection.
|
||||
3. **Silent Execution**: Use tools (Read, WebSearch) silently. Your final output must be ONLY the result.
|
||||
4. **No Fluff**: No pleasantries, no "Here is the text", no "I have updated...". Just the content.
|
||||
|
||||
## Input Format
|
||||
|
||||
User messages have the instruction first, followed by XML context tags:
|
||||
|
||||
### Selection Mode
|
||||
\`\`\`
|
||||
user's instruction
|
||||
|
||||
<editor_selection path="path/to/file.md">
|
||||
selected text here
|
||||
</editor_selection>
|
||||
\`\`\`
|
||||
Use \`<replacement>\` tags for edits.
|
||||
|
||||
### Cursor Mode
|
||||
\`\`\`
|
||||
user's instruction
|
||||
|
||||
<editor_cursor path="path/to/file.md">
|
||||
text before|text after #inline
|
||||
</editor_cursor>
|
||||
\`\`\`
|
||||
Or between paragraphs:
|
||||
\`\`\`
|
||||
user's instruction
|
||||
|
||||
<editor_cursor path="path/to/file.md">
|
||||
Previous paragraph
|
||||
| #inbetween
|
||||
Next paragraph
|
||||
</editor_cursor>
|
||||
\`\`\`
|
||||
Use \`<insertion>\` tags to insert new content at the cursor position (\`|\`).
|
||||
|
||||
## Tools & Path Rules
|
||||
|
||||
- **Tools**: Read, Grep, Glob, LS, WebSearch, WebFetch. (All read-only).
|
||||
${pathRules}
|
||||
|
||||
## Thinking Process
|
||||
|
||||
Before generating the final output, mentally check:
|
||||
1. **Context**: Have I read enough of the file to understand the *topic* and *structure*?
|
||||
2. **Style**: What is the user's indentation (2 vs 4 spaces, tabs)? What is their tone?
|
||||
3. **Type**: Is this **Prose** (flow, grammar, clarity) or **Code** (syntax, logic, variable names)?
|
||||
- *Prose*: Ensure smooth transitions.
|
||||
- *Code*: Preserve syntax validity; do not break surrounding brackets/indentation.
|
||||
|
||||
## Output Rules - CRITICAL
|
||||
|
||||
**ABSOLUTE RULE**: Your text output must contain ONLY the final answer, replacement, or insertion. NEVER output:
|
||||
- "I'll read the file..." / "Let me check..." / "I will..."
|
||||
- "I'm asked about..." / "The user wants..."
|
||||
- "Based on my analysis..." / "After reading..."
|
||||
- "Here's..." / "The answer is..."
|
||||
- ANY announcement of what you're about to do or did
|
||||
|
||||
Use tools silently. Your text output = final result only.
|
||||
|
||||
### When Replacing Selected Text (Selection Mode)
|
||||
|
||||
If the user wants to MODIFY or REPLACE the selected text, wrap the replacement in <replacement> tags:
|
||||
|
||||
<replacement>your replacement text here</replacement>
|
||||
|
||||
The content inside the tags should be ONLY the replacement text - no explanation.
|
||||
|
||||
### When Inserting at Cursor (Cursor Mode)
|
||||
|
||||
If the user wants to INSERT new content at the cursor position, wrap the insertion in <insertion> tags:
|
||||
|
||||
<insertion>your inserted text here</insertion>
|
||||
|
||||
The content inside the tags should be ONLY the text to insert - no explanation.
|
||||
|
||||
### When Answering Questions or Providing Information
|
||||
|
||||
If the user is asking a QUESTION, respond WITHOUT tags. Output the answer directly.
|
||||
|
||||
WRONG: "I'll read the full context of this file to give you a better explanation. This is a guide about..."
|
||||
CORRECT: "This is a guide about..."
|
||||
|
||||
### When Clarification is Needed
|
||||
|
||||
If the request is ambiguous, ask a clarifying question. Keep questions concise and specific.
|
||||
|
||||
## Examples
|
||||
|
||||
### Selection Mode
|
||||
Input:
|
||||
\`\`\`
|
||||
translate to French
|
||||
|
||||
<editor_selection path="notes/readme.md">
|
||||
Hello world
|
||||
</editor_selection>
|
||||
\`\`\`
|
||||
|
||||
CORRECT (replacement):
|
||||
<replacement>Bonjour le monde</replacement>
|
||||
|
||||
Input:
|
||||
\`\`\`
|
||||
what does this do?
|
||||
|
||||
<editor_selection path="notes/code.md">
|
||||
const x = arr.reduce((a, b) => a + b, 0);
|
||||
</editor_selection>
|
||||
\`\`\`
|
||||
|
||||
CORRECT (question - no tags):
|
||||
This code sums all numbers in the array \`arr\`. It uses \`reduce\` to iterate through the array, accumulating the total starting from 0.
|
||||
|
||||
### Cursor Mode
|
||||
|
||||
Input:
|
||||
\`\`\`
|
||||
what animal?
|
||||
|
||||
<editor_cursor path="notes/draft.md">
|
||||
The quick brown | jumps over the lazy dog. #inline
|
||||
</editor_cursor>
|
||||
\`\`\`
|
||||
|
||||
CORRECT (insertion):
|
||||
<insertion>fox</insertion>
|
||||
|
||||
### Q&A
|
||||
Input:
|
||||
\`\`\`
|
||||
add a brief description section
|
||||
|
||||
<editor_cursor path="notes/readme.md">
|
||||
# Introduction
|
||||
This is my project.
|
||||
| #inbetween
|
||||
## Features
|
||||
</editor_cursor>
|
||||
\`\`\`
|
||||
|
||||
CORRECT (insertion):
|
||||
<insertion>
|
||||
## Description
|
||||
|
||||
This project provides tools for managing your notes efficiently.
|
||||
</insertion>
|
||||
|
||||
Input:
|
||||
\`\`\`
|
||||
translate to Spanish
|
||||
|
||||
<editor_selection path="notes/draft.md">
|
||||
The bank was steep.
|
||||
</editor_selection>
|
||||
\`\`\`
|
||||
|
||||
CORRECT (asking for clarification):
|
||||
"Bank" can mean a financial institution (banco) or a river bank (orilla). Which meaning should I use?
|
||||
|
||||
Then after user clarifies "river bank":
|
||||
<replacement>La orilla era empinada.</replacement>`;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export function buildRefineSystemPrompt(existingInstructions: string): string {
|
||||
const existingSection = existingInstructions.trim()
|
||||
? `\n\nEXISTING INSTRUCTIONS (already in the user's system prompt):
|
||||
\`\`\`
|
||||
${existingInstructions.trim()}
|
||||
\`\`\`
|
||||
|
||||
When refining the new instruction:
|
||||
- Consider how it fits with existing instructions
|
||||
- Avoid duplicating existing instructions
|
||||
- If the new instruction conflicts with an existing one, refine it to be complementary or note the conflict
|
||||
- Match the format of existing instructions (section, heading, bullet points, style, etc.)`
|
||||
: '';
|
||||
|
||||
return `You are an expert Prompt Engineer. You help users craft precise, effective system instructions for their AI assistant.
|
||||
|
||||
**Your Goal**: Transform vague or simple user requests into **high-quality, actionable, and non-conflicting** system prompt instructions.
|
||||
|
||||
**Process**:
|
||||
1. **Analyze Intent**: What behavior does the user want to enforce or change?
|
||||
2. **Check Context**: Does this conflict with existing instructions?
|
||||
- *No Conflict*: Add as new.
|
||||
- *Conflict*: Propose a **merged instruction** that resolves the contradiction (or ask if unsure).
|
||||
3. **Refine**: Draft a clear, positive instruction (e.g., "Do X" instead of "Don't do Y").
|
||||
4. **Format**: Return *only* the Markdown snippet wrapped in \`<instruction>\` tags.
|
||||
|
||||
**Guidelines**:
|
||||
- **Clarity**: Use precise language. Avoid ambiguity.
|
||||
- **Scope**: Keep it focused. Don't add unrelated rules.
|
||||
- **Format**: Valid Markdown (bullets \`-\` or sections \`##\`).
|
||||
- **No Header**: Do NOT include a top-level header like \`# Custom Instructions\`.
|
||||
- **Conflict Handling**: If the new rule directly contradicts an existing one, rewrite the *new* one to override specific cases or ask for clarification.
|
||||
|
||||
**Output Format**:
|
||||
- **Success**: \`<instruction>...markdown content...</instruction>\`
|
||||
- **Ambiguity**: Plain text question.
|
||||
|
||||
${existingSection}
|
||||
|
||||
**Examples**:
|
||||
|
||||
Input: "typescript for code"
|
||||
Output: <instruction>- **Code Language**: Always use TypeScript for code examples. Include proper type annotations and interfaces.</instruction>
|
||||
|
||||
Input: "be concise"
|
||||
Output: <instruction>- **Conciseness**: Provide brief, direct responses. Omit conversational filler and unnecessary explanations.</instruction>
|
||||
|
||||
Input: "organize coding style rules"
|
||||
Output: <instruction>## Coding Standards\n\n- **Language**: Use TypeScript.\n- **Style**: Prefer functional patterns.\n- **Review**: Keep diffs small.</instruction>
|
||||
|
||||
Input: "use that thing from before"
|
||||
Output: I'm not sure what you're referring to. Could you please clarify?`;
|
||||
}
|
||||
|
||||
export function parseInstructionRefineResponse(responseText: string): {
|
||||
success: boolean;
|
||||
clarification?: string;
|
||||
refinedInstruction?: string;
|
||||
error?: string;
|
||||
} {
|
||||
const instructionMatch = responseText.match(/<instruction>([\s\S]*?)<\/instruction>/);
|
||||
if (instructionMatch) {
|
||||
return { success: true, refinedInstruction: instructionMatch[1].trim() };
|
||||
}
|
||||
|
||||
const trimmed = responseText.trim();
|
||||
if (trimmed) {
|
||||
return { success: true, clarification: trimmed };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Empty response' };
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
export interface SystemPromptSettings {
|
||||
mediaFolder?: string;
|
||||
customPrompt?: string;
|
||||
vaultPath?: string;
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
export interface SystemPromptBuildOptions {
|
||||
appendices?: string[];
|
||||
}
|
||||
|
||||
function getPathRules(vaultPath?: string): string {
|
||||
return `## Path Conventions
|
||||
|
||||
| Location | Access | Path Format | Example |
|
||||
|----------|--------|-------------|---------|
|
||||
| **Vault** | Read/Write | Relative from vault root | \`notes/my-note.md\`, \`.\` |
|
||||
| **External contexts** | Full access | Absolute path | \`/Users/me/Workspace/file.ts\` |
|
||||
|
||||
**Vault files** (default working directory):
|
||||
- ✓ Correct: \`notes/my-note.md\`, \`my-note.md\`, \`folder/subfolder/file.md\`, \`.\`
|
||||
- ✗ WRONG: \`/notes/my-note.md\`, \`${vaultPath || '/absolute/path'}/file.md\`
|
||||
- A leading slash or absolute path will FAIL for vault operations.
|
||||
|
||||
**External context paths**: When external directories are selected, use absolute paths to access files there. These directories are explicitly granted for the current session.`;
|
||||
}
|
||||
|
||||
function getBaseSystemPrompt(
|
||||
vaultPath?: string,
|
||||
userName?: string,
|
||||
): string {
|
||||
const vaultInfo = vaultPath ? `\n\nVault absolute path: ${vaultPath}` : '';
|
||||
const trimmedUserName = userName?.trim();
|
||||
const userContext = trimmedUserName
|
||||
? `## User Context\n\nYou are collaborating with **${trimmedUserName}**.\n\n`
|
||||
: '';
|
||||
const pathRules = getPathRules(vaultPath);
|
||||
|
||||
return `${userContext}## Time Context
|
||||
|
||||
- **Current Date**: Use \`bash: date\` to get the current date and time. Never guess or assume.
|
||||
- **Knowledge Status**: You possess extensive internal knowledge up to your training cutoff. You do not know the exact date of your cutoff, but you must assume that your internal weights are static and "past," while the Current Date is "present."
|
||||
|
||||
## Identity & Role
|
||||
|
||||
You are **Claudian**, an expert AI assistant specialized in Obsidian vault management, knowledge organization, and code analysis. You operate directly inside the user's Obsidian vault.
|
||||
|
||||
**Core Principles:**
|
||||
1. **Obsidian Native**: You understand Markdown, YAML frontmatter, Wiki-links, and the "second brain" philosophy.
|
||||
2. **Safety First**: You never overwrite data without understanding context. You always use relative paths.
|
||||
3. **Proactive Thinking**: You do not just execute; you *plan* and *verify*. You anticipate potential issues (like broken links or missing files).
|
||||
4. **Clarity**: Your changes are precise, minimizing "noise" in the user's notes or code.
|
||||
|
||||
The current working directory is the user's vault root.${vaultInfo}
|
||||
|
||||
${pathRules}
|
||||
|
||||
## User Message Format
|
||||
|
||||
User messages have the query first, followed by optional XML context tags:
|
||||
|
||||
\`\`\`
|
||||
User's question or request here
|
||||
|
||||
<linked_note>
|
||||
path/to/note.md
|
||||
</linked_note>
|
||||
|
||||
<editor_selection path="path/to/note.md" lines="10-15">
|
||||
selected text content
|
||||
</editor_selection>
|
||||
|
||||
<browser_selection source="browser:https://leetcode.com/problems/two-sum" title="LeetCode" url="https://leetcode.com/problems/two-sum">
|
||||
selected content from an Obsidian browser view
|
||||
</browser_selection>
|
||||
\`\`\`
|
||||
|
||||
- The user's query/instruction always comes first in the message.
|
||||
- \`<linked_note>\`: The note this session is linked to. Read this to understand session context. Legacy messages may use \`<current_note>\` for the same context.
|
||||
- \`<editor_selection>\`: Text currently selected in the editor, with file path and line numbers.
|
||||
- \`<browser_selection>\`: Text selected in an Obsidian browser/web view (for example Surfing), including optional source/title/url metadata.
|
||||
- \`@filename.md\`: Files mentioned with @ in the query. Read these files when referenced.
|
||||
|
||||
## Obsidian Context
|
||||
|
||||
- **Structure**: Files are Markdown (.md). Folders organize content.
|
||||
- **Frontmatter**: YAML at the top of files (metadata). Respect existing fields.
|
||||
- **Links**: Internal Wiki-links \`[[note-name]]\` or \`[[folder/note-name]]\`. External links \`[text](url)\`.
|
||||
- When reading a note with wikilinks, consider reading linked notes; they often contain related context that helps understand the current note.
|
||||
- **Tags**: #tag-name for categorization.
|
||||
- **Dataview**: You may encounter Dataview queries (in \`\`\`dataview\`\`\` blocks). Do not break them unless asked.
|
||||
- **Vault Config**: \`.obsidian/\` contains internal config. Touch only if you know what you are doing.
|
||||
|
||||
**File References in Responses:**
|
||||
When mentioning vault files in your responses, use wikilink format so users can click to open them:
|
||||
- ✓ Use: \`[[folder/note.md]]\` or \`[[note]]\`
|
||||
- ✗ Avoid: plain paths like \`folder/note.md\` (not clickable)
|
||||
|
||||
**Image embeds:** Use \`![[image.png]]\` to display images directly in chat. Images render visually, making it easy to show diagrams, screenshots, or visual content you're discussing.
|
||||
|
||||
Examples:
|
||||
- "I found your notes in [[30.areas/finance/Investment lessons/2024.Current trading lessons.md]]"
|
||||
- "See [[daily notes/2024-01-15]] for more details"
|
||||
- "Here's the diagram: ![[attachments/architecture.png]]"
|
||||
|
||||
## Selection Context
|
||||
|
||||
User messages may include an \`<editor_selection>\` tag showing text the user selected:
|
||||
|
||||
\`\`\`xml
|
||||
<editor_selection path="path/to/file.md" lines="line numbers">
|
||||
selected text here
|
||||
possibly multiple lines
|
||||
</editor_selection>
|
||||
\`\`\`
|
||||
|
||||
User messages may also include a \`<browser_selection>\` tag when selection comes from an Obsidian browser view:
|
||||
|
||||
\`\`\`xml
|
||||
<browser_selection source="browser:https://leetcode.com/problems/two-sum" title="LeetCode" url="https://leetcode.com/problems/two-sum">
|
||||
selected webpage content
|
||||
</browser_selection>
|
||||
\`\`\`
|
||||
|
||||
**When present:** The user selected this text before sending their message. Use this context to understand what they're referring to.`;
|
||||
}
|
||||
|
||||
function getImageInstructions(mediaFolder: string): string {
|
||||
const folder = mediaFolder.trim();
|
||||
const mediaPath = folder ? `./${folder}` : '.';
|
||||
const examplePath = folder ? `${folder}/` : '';
|
||||
|
||||
return `
|
||||
|
||||
## Embedded Images in Notes
|
||||
|
||||
**Proactive image reading**: When reading a note with embedded images, read them alongside text for full context. Images often contain critical information (diagrams, screenshots, charts).
|
||||
|
||||
**Local images** (\`![[image.jpg]]\`):
|
||||
- Located in media folder: \`${mediaPath}\`
|
||||
- Read with: \`Read file_path="${examplePath}image.jpg"\`
|
||||
- Formats: PNG, JPG/JPEG, GIF, WebP
|
||||
|
||||
**External images** (\`\`):
|
||||
- WebFetch does NOT support images
|
||||
- Download to media folder -> Read -> Replace URL with wiki-link:
|
||||
|
||||
\`\`\`bash
|
||||
# Download to media folder with descriptive name
|
||||
mkdir -p ${mediaPath}
|
||||
img_name="downloaded_\\$(date +%s).png"
|
||||
curl -sfo "${examplePath}$img_name" 'URL'
|
||||
\`\`\`
|
||||
|
||||
Then read with \`Read file_path="${examplePath}$img_name"\`, and replace the markdown link \`\` with \`![[${examplePath}$img_name]]\` in the note.
|
||||
|
||||
**Benefits**: Image becomes a permanent vault asset, works offline, and uses Obsidian's native embed syntax.`;
|
||||
}
|
||||
|
||||
function getAppendixSections(appendices?: string[]): string {
|
||||
if (!appendices || appendices.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sections = appendices
|
||||
.map((appendix) => appendix.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (sections.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `\n\n${sections.join('\n\n')}`;
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(
|
||||
settings: SystemPromptSettings = {},
|
||||
options: SystemPromptBuildOptions = {},
|
||||
): string {
|
||||
let prompt = getBaseSystemPrompt(settings.vaultPath, settings.userName);
|
||||
|
||||
prompt += getImageInstructions(settings.mediaFolder || '');
|
||||
prompt += getAppendixSections(options.appendices);
|
||||
|
||||
if (settings.customPrompt?.trim()) {
|
||||
prompt += `\n\n## Custom Instructions\n\n${settings.customPrompt.trim()}`;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
export function computeSystemPromptKey(
|
||||
settings: SystemPromptSettings,
|
||||
options: SystemPromptBuildOptions = {},
|
||||
): string {
|
||||
const appendixKey = (options.appendices || [])
|
||||
.map((appendix) => appendix.trim())
|
||||
.filter(Boolean)
|
||||
.join('||');
|
||||
|
||||
const parts = [
|
||||
settings.mediaFolder || '',
|
||||
settings.customPrompt || '',
|
||||
settings.vaultPath || '',
|
||||
(settings.userName || '').trim(),
|
||||
];
|
||||
|
||||
if (appendixKey) {
|
||||
parts.push(appendixKey);
|
||||
}
|
||||
|
||||
return parts.join('::');
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
const MAX_TITLE_INPUT_LENGTH = 500;
|
||||
const MAX_TITLE_LENGTH = 50;
|
||||
|
||||
export const TITLE_GENERATION_SYSTEM_PROMPT = `You are a specialist in summarizing user intent.
|
||||
|
||||
**Task**: Generate a **concise, descriptive title** (max 50 chars) summarizing the user's task/request.
|
||||
|
||||
**Rules**:
|
||||
1. **Format**: Sentence case. No periods/quotes.
|
||||
2. **Structure**: Start with a **strong verb** (e.g., Create, Fix, Debug, Explain, Analyze).
|
||||
3. **Forbidden**: "Conversation with...", "Help me...", "Question about...", "I need...".
|
||||
4. **Tech Context**: Detect and include the primary language/framework if code is present (e.g., "Debug Python script", "Refactor React hook").
|
||||
|
||||
**Output**: Return ONLY the raw title text.`;
|
||||
|
||||
export function buildTitleGenerationPrompt(userMessage: string): string {
|
||||
const truncated = userMessage.length > MAX_TITLE_INPUT_LENGTH
|
||||
? `${userMessage.slice(0, MAX_TITLE_INPUT_LENGTH)}...`
|
||||
: userMessage;
|
||||
return `User's request:\n"""\n${truncated}\n"""\n\nGenerate a title for this conversation:`;
|
||||
}
|
||||
|
||||
export function parseTitleGenerationResponse(responseText: string): string | null {
|
||||
const trimmed = responseText.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let title = trimmed;
|
||||
if (
|
||||
(title.startsWith('"') && title.endsWith('"'))
|
||||
|| (title.startsWith("'") && title.endsWith("'"))
|
||||
) {
|
||||
title = title.slice(1, -1);
|
||||
}
|
||||
|
||||
title = title.replace(/[.!?:;,]+$/, '');
|
||||
|
||||
if (title.length > MAX_TITLE_LENGTH) {
|
||||
title = `${title.slice(0, MAX_TITLE_LENGTH - 3)}...`;
|
||||
}
|
||||
|
||||
return title || null;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { App } from 'obsidian';
|
||||
|
||||
import type { SharedAppStorage } from '../bootstrap/storage';
|
||||
import type { ChatRuntime } from '../runtime/ChatRuntime';
|
||||
import type { ClaudianSettings } from '../types';
|
||||
import type { EnvironmentScope } from '../types/settings';
|
||||
import type { ProviderCliResolutionContext, ProviderId } from './types';
|
||||
|
||||
/**
|
||||
* Application capabilities available to provider adapters.
|
||||
*
|
||||
* The host deliberately excludes plugin lifecycle, command registration, and
|
||||
* conversation ownership. Providers receive only the settings, environment,
|
||||
* path, CLI, storage, and interaction capabilities they currently consume.
|
||||
*/
|
||||
export interface ProviderHost {
|
||||
readonly app: App;
|
||||
readonly settings: ClaudianSettings;
|
||||
readonly storage: SharedAppStorage;
|
||||
readonly manifest?: { version?: string };
|
||||
|
||||
saveSettings(): Promise<void>;
|
||||
mutateSettings(
|
||||
mutation: (settings: ClaudianSettings) => void | Promise<void>,
|
||||
): Promise<void>;
|
||||
mutateSettingsConditionally(
|
||||
mutation: (settings: ClaudianSettings) => boolean | Promise<boolean>,
|
||||
): Promise<void>;
|
||||
loadData(): Promise<unknown>;
|
||||
saveData(data: unknown): Promise<void>;
|
||||
normalizeModelVariantSettings(): boolean;
|
||||
|
||||
getActiveEnvironmentVariables(providerId: ProviderId): string;
|
||||
getEnvironmentVariablesForScope(scope: EnvironmentScope): string;
|
||||
applyEnvironmentVariables(scope: EnvironmentScope, envText: string): Promise<void>;
|
||||
applyEnvironmentVariablesBatch(
|
||||
updates: Array<{ scope: EnvironmentScope; envText: string }>,
|
||||
): Promise<void>;
|
||||
getResolvedProviderCliPath(
|
||||
providerId: ProviderId,
|
||||
context?: ProviderCliResolutionContext,
|
||||
): string | null;
|
||||
|
||||
refreshModelSelectors?(): void;
|
||||
broadcastToActiveViewRuntimes?(
|
||||
action: (runtime: ChatRuntime) => Promise<void> | void,
|
||||
): Promise<void>;
|
||||
broadcastToAllViewRuntimes?(
|
||||
action: (runtime: ChatRuntime) => Promise<void> | void,
|
||||
): Promise<void>;
|
||||
recycleProviderRuntimes?(providerId: ProviderId): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import type { ChatRuntime } from '../runtime/ChatRuntime';
|
||||
import { decodeProviderModelSelectionId } from './modelSelection';
|
||||
import type { ProviderHost } from './ProviderHost';
|
||||
import {
|
||||
type CreateChatRuntimeOptions,
|
||||
DEFAULT_CHAT_PROVIDER_ID,
|
||||
type InlineEditService,
|
||||
type InstructionRefineService,
|
||||
type ProviderCapabilities,
|
||||
type ProviderChatUIConfig,
|
||||
type ProviderConversationHistoryService,
|
||||
type ProviderId,
|
||||
type ProviderRegistration,
|
||||
type ProviderSettingsReconciler,
|
||||
type ProviderSettingsStorageAdapter,
|
||||
type ProviderSubagentLifecycleAdapter,
|
||||
type ProviderTaskResultInterpreter,
|
||||
type TitleGenerationCallback,
|
||||
type TitleGenerationService,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Registry for chat-facing provider services.
|
||||
*
|
||||
* Bootstrap concerns (default settings, shared storage, CLI resolution,
|
||||
* workspace command/agent services) are composed explicitly in `main.ts`
|
||||
* through `src/core/bootstrap/` and `src/providers/<id>/app/`.
|
||||
*/
|
||||
export class ProviderRegistry {
|
||||
private static registrations: Partial<Record<ProviderId, ProviderRegistration>> = {};
|
||||
|
||||
static register(
|
||||
providerId: ProviderId,
|
||||
registration: ProviderRegistration,
|
||||
): void {
|
||||
this.registrations[providerId] = registration;
|
||||
}
|
||||
|
||||
private static getProviderRegistration(providerId: ProviderId): ProviderRegistration {
|
||||
const registration = this.registrations[providerId];
|
||||
if (!registration) {
|
||||
throw new Error(`Provider "${providerId}" is not registered.`);
|
||||
}
|
||||
return registration;
|
||||
}
|
||||
|
||||
static createChatRuntime(options: CreateChatRuntimeOptions): ChatRuntime {
|
||||
const providerId = options.providerId ?? DEFAULT_CHAT_PROVIDER_ID;
|
||||
return this.getProviderRegistration(providerId).createRuntime(options);
|
||||
}
|
||||
|
||||
static createTitleGenerationService(plugin: ProviderHost, providerId?: ProviderId): TitleGenerationService {
|
||||
if (!providerId) {
|
||||
return new RoutedTitleGenerationService(plugin);
|
||||
}
|
||||
return this.getProviderRegistration(providerId).createTitleGenerationService(plugin);
|
||||
}
|
||||
|
||||
static resolveTitleGenerationProviderId(settings: Record<string, unknown>): ProviderId {
|
||||
const titleModel = typeof settings.titleGenerationModel === 'string'
|
||||
? settings.titleGenerationModel.trim()
|
||||
: '';
|
||||
|
||||
if (!titleModel) {
|
||||
return DEFAULT_CHAT_PROVIDER_ID;
|
||||
}
|
||||
|
||||
return this.resolveProviderForModel(titleModel, settings, {
|
||||
fallbackProviderId: DEFAULT_CHAT_PROVIDER_ID,
|
||||
});
|
||||
}
|
||||
|
||||
static createInstructionRefineService(plugin: ProviderHost, providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID): InstructionRefineService {
|
||||
return this.getProviderRegistration(providerId).createInstructionRefineService(plugin);
|
||||
}
|
||||
|
||||
static createInlineEditService(plugin: ProviderHost, providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID): InlineEditService {
|
||||
return this.getProviderRegistration(providerId).createInlineEditService(plugin);
|
||||
}
|
||||
|
||||
static getConversationHistoryService(
|
||||
providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID,
|
||||
): ProviderConversationHistoryService {
|
||||
return this.getProviderRegistration(providerId).historyService;
|
||||
}
|
||||
|
||||
static getTaskResultInterpreter(
|
||||
providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID,
|
||||
): ProviderTaskResultInterpreter {
|
||||
return this.getProviderRegistration(providerId).taskResultInterpreter;
|
||||
}
|
||||
|
||||
static getSubagentLifecycleAdapter(
|
||||
providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID,
|
||||
): ProviderSubagentLifecycleAdapter | null {
|
||||
return this.getProviderRegistration(providerId).subagentLifecycleAdapter ?? null;
|
||||
}
|
||||
|
||||
static getCapabilities(providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID): ProviderCapabilities {
|
||||
return this.getProviderRegistration(providerId).capabilities;
|
||||
}
|
||||
|
||||
static getEnvironmentKeyPatterns(providerId: ProviderId): RegExp[] {
|
||||
return this.getProviderRegistration(providerId).environmentKeyPatterns ?? [];
|
||||
}
|
||||
|
||||
static getChatUIConfig(providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID): ProviderChatUIConfig {
|
||||
return this.getProviderRegistration(providerId).chatUIConfig;
|
||||
}
|
||||
|
||||
static getSettingsReconciler(providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID): ProviderSettingsReconciler {
|
||||
return this.getProviderRegistration(providerId).settingsReconciler;
|
||||
}
|
||||
|
||||
static getSettingsStorageAdapter(providerId: ProviderId): ProviderSettingsStorageAdapter {
|
||||
const registration = this.getProviderRegistration(providerId);
|
||||
if (!('settingsStorage' in registration)) {
|
||||
throw new Error(`Provider "${providerId}" does not own settings storage normalization.`);
|
||||
}
|
||||
return registration.settingsStorage as ProviderSettingsStorageAdapter;
|
||||
}
|
||||
|
||||
static getRegisteredProviderIds(): ProviderId[] {
|
||||
return Object.keys(this.registrations);
|
||||
}
|
||||
|
||||
static getEnabledProviderIds(settings: Record<string, unknown>): ProviderId[] {
|
||||
return this.getRegisteredProviderIds()
|
||||
.filter(providerId => this.getProviderRegistration(providerId).isEnabled(settings))
|
||||
.sort((a, b) => (
|
||||
this.getProviderRegistration(a).blankTabOrder - this.getProviderRegistration(b).blankTabOrder
|
||||
));
|
||||
}
|
||||
|
||||
static getProviderDisplayName(providerId: ProviderId): string {
|
||||
return this.getProviderRegistration(providerId).displayName;
|
||||
}
|
||||
|
||||
static isEnabled(providerId: ProviderId, settings: Record<string, unknown>): boolean {
|
||||
return this.getProviderRegistration(providerId).isEnabled(settings);
|
||||
}
|
||||
|
||||
static resolveSettingsProviderId(settings: Record<string, unknown>): ProviderId {
|
||||
const current = settings.settingsProvider;
|
||||
if (typeof current === 'string') {
|
||||
const currentProvider = current;
|
||||
if (
|
||||
this.getRegisteredProviderIds().includes(currentProvider)
|
||||
&& this.isEnabled(currentProvider, settings)
|
||||
) {
|
||||
return currentProvider;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isEnabled(DEFAULT_CHAT_PROVIDER_ID, settings)) {
|
||||
return DEFAULT_CHAT_PROVIDER_ID;
|
||||
}
|
||||
|
||||
return this.getEnabledProviderIds(settings)[0] ?? DEFAULT_CHAT_PROVIDER_ID;
|
||||
}
|
||||
|
||||
static resolveProviderForModel(
|
||||
model: string,
|
||||
settings: Record<string, unknown> = {},
|
||||
options: {
|
||||
onlyEnabledProviders?: boolean;
|
||||
fallbackProviderId?: ProviderId;
|
||||
} = {},
|
||||
): ProviderId {
|
||||
const providerIds = options.onlyEnabledProviders
|
||||
? this.getEnabledProviderIds(settings)
|
||||
: this.getRegisteredProviderIds();
|
||||
const fallbackProviderId = (
|
||||
options.fallbackProviderId
|
||||
&& (!options.onlyEnabledProviders || this.isEnabled(options.fallbackProviderId, settings))
|
||||
)
|
||||
? options.fallbackProviderId
|
||||
: (options.onlyEnabledProviders
|
||||
? this.resolveSettingsProviderId(settings)
|
||||
: DEFAULT_CHAT_PROVIDER_ID);
|
||||
const decodedSelection = decodeProviderModelSelectionId(model);
|
||||
|
||||
if (
|
||||
decodedSelection
|
||||
&& providerIds.includes(decodedSelection.providerId)
|
||||
&& (!options.onlyEnabledProviders || this.isEnabled(decodedSelection.providerId, settings))
|
||||
) {
|
||||
return decodedSelection.providerId;
|
||||
}
|
||||
|
||||
for (const providerId of providerIds) {
|
||||
if (providerId === fallbackProviderId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.getChatUIConfig(providerId).ownsModel(model, settings)) {
|
||||
return providerId;
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackProviderId;
|
||||
}
|
||||
|
||||
static getCustomModelIds(envVars: Record<string, string>): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const providerId of this.getRegisteredProviderIds()) {
|
||||
for (const modelId of this.getChatUIConfig(providerId).getCustomModelIds(envVars)) {
|
||||
ids.add(modelId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
interface ActiveTitleGeneration {
|
||||
service: TitleGenerationService;
|
||||
}
|
||||
|
||||
class RoutedTitleGenerationService implements TitleGenerationService {
|
||||
private readonly activeGenerations = new Map<string, ActiveTitleGeneration>();
|
||||
|
||||
constructor(private readonly plugin: ProviderHost) {}
|
||||
|
||||
async generateTitle(
|
||||
conversationId: string,
|
||||
userMessage: string,
|
||||
callback: TitleGenerationCallback,
|
||||
): Promise<void> {
|
||||
const providerId = ProviderRegistry.resolveTitleGenerationProviderId(
|
||||
this.plugin.settings,
|
||||
);
|
||||
const service = ProviderRegistry.createTitleGenerationService(this.plugin, providerId);
|
||||
const generation = { service };
|
||||
const previous = this.activeGenerations.get(conversationId);
|
||||
|
||||
this.activeGenerations.set(conversationId, generation);
|
||||
previous?.service.cancel();
|
||||
|
||||
try {
|
||||
await service.generateTitle(conversationId, userMessage, async (convId, result) => {
|
||||
if (this.activeGenerations.get(conversationId) !== generation) {
|
||||
return;
|
||||
}
|
||||
await callback(convId, result);
|
||||
});
|
||||
} finally {
|
||||
if (this.activeGenerations.get(conversationId) === generation) {
|
||||
this.activeGenerations.delete(conversationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
const services = new Set<TitleGenerationService>(
|
||||
[...this.activeGenerations.values()].map(generation => generation.service),
|
||||
);
|
||||
this.activeGenerations.clear();
|
||||
for (const service of services) {
|
||||
service.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
import type { Conversation } from '../types';
|
||||
import { toProviderRuntimeModelId } from './modelSelection';
|
||||
import { ProviderRegistry } from './ProviderRegistry';
|
||||
import type { ProviderChatUIConfig, ProviderId } from './types';
|
||||
|
||||
export interface SettingsReconciliationResult {
|
||||
changed: boolean;
|
||||
invalidatedConversations: Conversation[];
|
||||
}
|
||||
|
||||
const PROJECTION_KEYS = new Set([
|
||||
'model',
|
||||
'effortLevel',
|
||||
'serviceTier',
|
||||
'thinkingBudget',
|
||||
'permissionMode',
|
||||
]);
|
||||
|
||||
type ProviderProjectionMap = Partial<Record<string, string>>;
|
||||
|
||||
function getSettingsProviderId(settings: Record<string, unknown>): ProviderId {
|
||||
return ProviderRegistry.resolveSettingsProviderId(settings);
|
||||
}
|
||||
|
||||
function ensureProjectionMap(
|
||||
settings: Record<string, unknown>,
|
||||
key:
|
||||
| 'savedProviderModel'
|
||||
| 'savedProviderEffort'
|
||||
| 'savedProviderServiceTier'
|
||||
| 'savedProviderThinkingBudget'
|
||||
| 'savedProviderPermissionMode',
|
||||
): ProviderProjectionMap {
|
||||
const current = settings[key];
|
||||
if (current && typeof current === 'object') {
|
||||
return current;
|
||||
}
|
||||
|
||||
const next: ProviderProjectionMap = {};
|
||||
settings[key] = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
function cloneProviderSettings(settings: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...settings,
|
||||
savedProviderModel: { ...(settings.savedProviderModel as ProviderProjectionMap | undefined) },
|
||||
savedProviderEffort: { ...(settings.savedProviderEffort as ProviderProjectionMap | undefined) },
|
||||
savedProviderServiceTier: { ...(settings.savedProviderServiceTier as ProviderProjectionMap | undefined) },
|
||||
savedProviderThinkingBudget: { ...(settings.savedProviderThinkingBudget as ProviderProjectionMap | undefined) },
|
||||
savedProviderPermissionMode: { ...(settings.savedProviderPermissionMode as ProviderProjectionMap | undefined) },
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeToggleValue(
|
||||
value: unknown,
|
||||
allowedValues: Set<string>,
|
||||
): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return allowedValues.has(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function mergeProviderSettings(
|
||||
target: Record<string, unknown>,
|
||||
source: Record<string, unknown>,
|
||||
): void {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (PROJECTION_KEYS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeReasoningValue(
|
||||
uiConfig: ProviderChatUIConfig,
|
||||
settings: Record<string, unknown>,
|
||||
model: string,
|
||||
value: unknown,
|
||||
): string {
|
||||
const allowedValues = new Set(uiConfig.getReasoningOptions(model, settings).map(option => option.value));
|
||||
if (typeof value === 'string' && allowedValues.has(value)) {
|
||||
return value;
|
||||
}
|
||||
return uiConfig.getDefaultReasoningValue(model, settings);
|
||||
}
|
||||
|
||||
function normalizeProviderModel(
|
||||
uiConfig: ProviderChatUIConfig,
|
||||
settings: Record<string, unknown>,
|
||||
model: string | undefined,
|
||||
): string | undefined {
|
||||
if (!model) {
|
||||
return undefined;
|
||||
}
|
||||
return uiConfig.normalizeModelVariant(model, settings);
|
||||
}
|
||||
|
||||
function normalizeModelDependentSettings(
|
||||
uiConfig: ProviderChatUIConfig,
|
||||
settings: Record<string, unknown>,
|
||||
model: string,
|
||||
): void {
|
||||
if (uiConfig.isAdaptiveReasoningModel(model, settings)) {
|
||||
settings.effortLevel = normalizeReasoningValue(
|
||||
uiConfig,
|
||||
settings,
|
||||
model,
|
||||
settings.effortLevel,
|
||||
);
|
||||
} else {
|
||||
settings.thinkingBudget = normalizeReasoningValue(
|
||||
uiConfig,
|
||||
settings,
|
||||
model,
|
||||
settings.thinkingBudget,
|
||||
);
|
||||
}
|
||||
|
||||
const serviceTierToggle = uiConfig.getServiceTierToggle?.(settings) ?? null;
|
||||
if (!serviceTierToggle) {
|
||||
settings.serviceTier = 'default';
|
||||
return;
|
||||
}
|
||||
|
||||
const currentServiceTier = typeof settings.serviceTier === 'string'
|
||||
? settings.serviceTier
|
||||
: undefined;
|
||||
if (currentServiceTier === 'fast') {
|
||||
settings.serviceTier = serviceTierToggle.activeValue;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
currentServiceTier !== serviceTierToggle.inactiveValue
|
||||
&& currentServiceTier !== serviceTierToggle.activeValue
|
||||
) {
|
||||
settings.serviceTier = serviceTierToggle.inactiveValue;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProviderSettingsCoordinator {
|
||||
static applyModelSelection(
|
||||
settings: Record<string, unknown>,
|
||||
providerId: ProviderId,
|
||||
model: string,
|
||||
): void {
|
||||
const uiConfig = ProviderRegistry.getChatUIConfig(providerId);
|
||||
settings.model = model;
|
||||
uiConfig.applyModelDefaults(model, settings);
|
||||
normalizeModelDependentSettings(uiConfig, settings, model);
|
||||
}
|
||||
|
||||
static projectModelSelection(
|
||||
settings: Record<string, unknown>,
|
||||
providerId: ProviderId,
|
||||
model: string,
|
||||
): void {
|
||||
const uiConfig = ProviderRegistry.getChatUIConfig(providerId);
|
||||
settings.model = model;
|
||||
normalizeModelDependentSettings(uiConfig, settings, model);
|
||||
}
|
||||
|
||||
static handleEnvironmentChange(
|
||||
settings: Record<string, unknown>,
|
||||
providerIds: ProviderId[],
|
||||
): boolean {
|
||||
let anyChanged = false;
|
||||
for (const providerId of providerIds) {
|
||||
const reconciler = ProviderRegistry.getSettingsReconciler(providerId);
|
||||
if (reconciler.handleEnvironmentChange?.(settings)) {
|
||||
anyChanged = true;
|
||||
}
|
||||
}
|
||||
return anyChanged;
|
||||
}
|
||||
|
||||
static reconcileTitleGenerationModelSelection(settings: Record<string, unknown>): boolean {
|
||||
const currentModel = typeof settings.titleGenerationModel === 'string'
|
||||
? settings.titleGenerationModel
|
||||
: '';
|
||||
if (!currentModel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const providerId of ProviderRegistry.getRegisteredProviderIds()) {
|
||||
const uiConfig = ProviderRegistry.getChatUIConfig(providerId);
|
||||
if (!uiConfig.ownsModel(currentModel, settings)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedModel = normalizeProviderModel(uiConfig, settings, currentModel);
|
||||
const currentRuntimeModel = toProviderRuntimeModelId(providerId, currentModel);
|
||||
const isValid = normalizedModel !== undefined
|
||||
&& uiConfig.getModelOptions(settings).some((option) =>
|
||||
option.value === normalizedModel
|
||||
&& toProviderRuntimeModelId(providerId, option.value) === currentRuntimeModel
|
||||
);
|
||||
if (!isValid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizedModel !== currentModel) {
|
||||
settings.titleGenerationModel = normalizedModel;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
settings.titleGenerationModel = '';
|
||||
return true;
|
||||
}
|
||||
|
||||
static normalizeProviderSelection(settings: Record<string, unknown>): boolean {
|
||||
const next = getSettingsProviderId(settings);
|
||||
|
||||
if (settings.settingsProvider === next) {
|
||||
return false;
|
||||
}
|
||||
|
||||
settings.settingsProvider = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
static getProviderSettingsSnapshot<T extends Record<string, unknown>>(
|
||||
settings: T,
|
||||
providerId: ProviderId,
|
||||
): T {
|
||||
const snapshot = cloneProviderSettings(settings) as T;
|
||||
this.projectProviderState(snapshot, providerId);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
static commitProviderSettingsSnapshot(
|
||||
settings: Record<string, unknown>,
|
||||
providerId: ProviderId,
|
||||
snapshot: Record<string, unknown>,
|
||||
): void {
|
||||
this.persistProjectedProviderState(snapshot, providerId);
|
||||
|
||||
if (providerId === getSettingsProviderId(settings)) {
|
||||
Object.assign(settings, snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
mergeProviderSettings(settings, snapshot);
|
||||
}
|
||||
|
||||
static persistProjectedProviderState(
|
||||
settings: Record<string, unknown>,
|
||||
providerId: ProviderId = getSettingsProviderId(settings),
|
||||
): void {
|
||||
const savedModel = ensureProjectionMap(settings, 'savedProviderModel');
|
||||
const savedEffort = ensureProjectionMap(settings, 'savedProviderEffort');
|
||||
const savedServiceTier = ensureProjectionMap(settings, 'savedProviderServiceTier');
|
||||
const savedBudget = ensureProjectionMap(settings, 'savedProviderThinkingBudget');
|
||||
const savedPermissionMode = ensureProjectionMap(settings, 'savedProviderPermissionMode');
|
||||
const uiConfig = ProviderRegistry.getChatUIConfig(providerId);
|
||||
const normalizedModel = normalizeProviderModel(
|
||||
uiConfig,
|
||||
settings,
|
||||
typeof settings.model === 'string' ? settings.model : undefined,
|
||||
);
|
||||
const projectedSettings = normalizedModel && normalizedModel !== settings.model
|
||||
? { ...settings, model: normalizedModel }
|
||||
: settings;
|
||||
|
||||
if (normalizedModel) {
|
||||
savedModel[providerId] = normalizedModel;
|
||||
}
|
||||
if (typeof settings.effortLevel === 'string') {
|
||||
savedEffort[providerId] = settings.effortLevel;
|
||||
}
|
||||
const serviceTierToggle = uiConfig.getServiceTierToggle?.(projectedSettings) ?? null;
|
||||
if (serviceTierToggle && typeof settings.serviceTier === 'string') {
|
||||
savedServiceTier[providerId] = settings.serviceTier;
|
||||
}
|
||||
const usesBudget = normalizedModel !== undefined
|
||||
&& !uiConfig.isAdaptiveReasoningModel(normalizedModel, projectedSettings);
|
||||
if (usesBudget && typeof settings.thinkingBudget === 'string') {
|
||||
savedBudget[providerId] = settings.thinkingBudget;
|
||||
} else {
|
||||
delete savedBudget[providerId];
|
||||
}
|
||||
if (typeof settings.permissionMode === 'string' && uiConfig.getPermissionModeToggle?.()) {
|
||||
savedPermissionMode[providerId] = settings.permissionMode;
|
||||
}
|
||||
}
|
||||
|
||||
static projectProviderState(
|
||||
settings: Record<string, unknown>,
|
||||
providerId: ProviderId,
|
||||
): void {
|
||||
const uiConfig = ProviderRegistry.getChatUIConfig(providerId);
|
||||
const savedModel = settings.savedProviderModel as ProviderProjectionMap | undefined;
|
||||
const savedEffort = settings.savedProviderEffort as ProviderProjectionMap | undefined;
|
||||
const savedServiceTier = settings.savedProviderServiceTier as ProviderProjectionMap | undefined;
|
||||
const savedBudget = settings.savedProviderThinkingBudget as ProviderProjectionMap | undefined;
|
||||
const savedPermissionMode = settings.savedProviderPermissionMode as ProviderProjectionMap | undefined;
|
||||
|
||||
const shouldPreferCurrentProjection = providerId === getSettingsProviderId(settings);
|
||||
const currentModelRaw = typeof settings.model === 'string' ? settings.model : '';
|
||||
const currentModel = shouldPreferCurrentProjection
|
||||
? (normalizeProviderModel(uiConfig, settings, currentModelRaw) ?? '')
|
||||
: currentModelRaw;
|
||||
const currentEffort = typeof settings.effortLevel === 'string' ? settings.effortLevel : undefined;
|
||||
const currentServiceTier = typeof settings.serviceTier === 'string' ? settings.serviceTier : undefined;
|
||||
const currentBudget = typeof settings.thinkingBudget === 'string' ? settings.thinkingBudget : undefined;
|
||||
const modelOptions = uiConfig.getModelOptions(settings);
|
||||
const isDefaultModelOfAnotherProvider = currentModel.length > 0
|
||||
&& ProviderRegistry.getRegisteredProviderIds()
|
||||
.filter(id => id !== providerId)
|
||||
.some(id => ProviderRegistry.getChatUIConfig(id).isDefaultModel(currentModel));
|
||||
const canReuseCurrentModel = currentModel.length > 0
|
||||
&& !isDefaultModelOfAnotherProvider
|
||||
&& (
|
||||
shouldPreferCurrentProjection
|
||||
|| modelOptions.some(option => option.value === currentModel)
|
||||
);
|
||||
const providerDefaultModel = uiConfig.getDefaultModel?.(settings) ?? null;
|
||||
const validProviderDefaultModel = providerDefaultModel
|
||||
&& modelOptions.some(option => option.value === providerDefaultModel)
|
||||
? providerDefaultModel
|
||||
: null;
|
||||
const fallbackModel = canReuseCurrentModel
|
||||
? currentModel
|
||||
: (validProviderDefaultModel ?? modelOptions[0]?.value ?? currentModel);
|
||||
const savedModelValue = normalizeProviderModel(uiConfig, settings, savedModel?.[providerId]);
|
||||
const isSavedModelValid = savedModelValue !== undefined
|
||||
&& modelOptions.some(option => option.value === savedModelValue);
|
||||
const model = (isSavedModelValid ? savedModelValue : undefined) ?? fallbackModel;
|
||||
const canReuseCurrentProjection = canReuseCurrentModel && model === currentModel;
|
||||
|
||||
if (model) {
|
||||
settings.model = model;
|
||||
uiConfig.applyModelDefaults(model, settings);
|
||||
}
|
||||
|
||||
const serviceTierToggle = uiConfig.getServiceTierToggle?.({
|
||||
...settings,
|
||||
...(model ? { model } : {}),
|
||||
}) ?? null;
|
||||
|
||||
const isAdaptive = Boolean(model) && uiConfig.isAdaptiveReasoningModel(model, settings);
|
||||
|
||||
if (savedEffort?.[providerId] !== undefined) {
|
||||
settings.effortLevel = savedEffort[providerId];
|
||||
} else if (canReuseCurrentProjection && currentEffort !== undefined) {
|
||||
settings.effortLevel = currentEffort;
|
||||
} else if (isAdaptive) {
|
||||
settings.effortLevel = uiConfig.getDefaultReasoningValue(model, settings);
|
||||
}
|
||||
|
||||
if (isAdaptive) {
|
||||
settings.effortLevel = normalizeReasoningValue(uiConfig, settings, model, settings.effortLevel);
|
||||
}
|
||||
|
||||
if (savedServiceTier?.[providerId] !== undefined) {
|
||||
settings.serviceTier = savedServiceTier[providerId];
|
||||
} else if (canReuseCurrentProjection && currentServiceTier !== undefined) {
|
||||
settings.serviceTier = currentServiceTier;
|
||||
} else {
|
||||
settings.serviceTier = serviceTierToggle?.inactiveValue ?? 'default';
|
||||
}
|
||||
|
||||
const usesBudget = Boolean(model) && !isAdaptive;
|
||||
|
||||
if (usesBudget) {
|
||||
if (savedBudget?.[providerId] !== undefined) {
|
||||
settings.thinkingBudget = savedBudget[providerId];
|
||||
} else if (canReuseCurrentProjection && currentBudget !== undefined) {
|
||||
settings.thinkingBudget = currentBudget;
|
||||
} else {
|
||||
settings.thinkingBudget = uiConfig.getDefaultReasoningValue(model, settings);
|
||||
}
|
||||
settings.thinkingBudget = normalizeReasoningValue(uiConfig, settings, model, settings.thinkingBudget);
|
||||
}
|
||||
|
||||
const permissionToggle = uiConfig.getPermissionModeToggle?.() ?? null;
|
||||
if (!permissionToggle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedPermissionModes = new Set([
|
||||
permissionToggle.inactiveValue,
|
||||
permissionToggle.activeValue,
|
||||
...(permissionToggle.planValue ? [permissionToggle.planValue] : []),
|
||||
]);
|
||||
const currentPermissionMode = normalizeToggleValue(settings.permissionMode, allowedPermissionModes);
|
||||
const derivedPermissionMode = normalizeToggleValue(
|
||||
uiConfig.resolvePermissionMode?.(settings),
|
||||
allowedPermissionModes,
|
||||
);
|
||||
const savedPermissionModeValue = normalizeToggleValue(
|
||||
savedPermissionMode?.[providerId],
|
||||
allowedPermissionModes,
|
||||
);
|
||||
|
||||
const projectedPermissionMode = savedPermissionModeValue
|
||||
?? derivedPermissionMode
|
||||
?? (shouldPreferCurrentProjection ? currentPermissionMode : undefined)
|
||||
?? currentPermissionMode;
|
||||
|
||||
if (projectedPermissionMode !== undefined) {
|
||||
settings.permissionMode = projectedPermissionMode;
|
||||
}
|
||||
}
|
||||
|
||||
/** Each provider's reconciler only processes its own conversations. */
|
||||
static reconcileAllProviders(
|
||||
settings: Record<string, unknown>,
|
||||
conversations: Conversation[],
|
||||
): SettingsReconciliationResult {
|
||||
return this.reconcileProviders(
|
||||
settings,
|
||||
conversations,
|
||||
ProviderRegistry.getRegisteredProviderIds(),
|
||||
);
|
||||
}
|
||||
|
||||
static reconcileProviders(
|
||||
settings: Record<string, unknown>,
|
||||
conversations: Conversation[],
|
||||
providerIds: ProviderId[],
|
||||
): SettingsReconciliationResult {
|
||||
let anyChanged = false;
|
||||
const allInvalidated: Conversation[] = [];
|
||||
const settingsProvider = getSettingsProviderId(settings);
|
||||
|
||||
for (const providerId of providerIds) {
|
||||
const reconciler = ProviderRegistry.getSettingsReconciler(providerId);
|
||||
const providerConversations = conversations.filter(c => c.providerId === providerId);
|
||||
const targetSettings = providerId === settingsProvider
|
||||
? settings
|
||||
: cloneProviderSettings(settings);
|
||||
|
||||
if (providerId !== settingsProvider) {
|
||||
this.projectProviderState(targetSettings, providerId);
|
||||
}
|
||||
|
||||
const { changed, invalidatedConversations } = reconciler.reconcileModelWithEnvironment(
|
||||
targetSettings,
|
||||
providerConversations,
|
||||
);
|
||||
|
||||
if (changed) {
|
||||
anyChanged = true;
|
||||
this.persistProjectedProviderState(targetSettings, providerId);
|
||||
if (providerId !== settingsProvider) {
|
||||
mergeProviderSettings(settings, targetSettings);
|
||||
}
|
||||
}
|
||||
allInvalidated.push(...invalidatedConversations);
|
||||
}
|
||||
|
||||
if (this.reconcileTitleGenerationModelSelection(settings)) {
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
return { changed: anyChanged, invalidatedConversations: allInvalidated };
|
||||
}
|
||||
|
||||
static normalizeAllModelVariants(settings: Record<string, unknown>): boolean {
|
||||
let anyChanged = false;
|
||||
const settingsProvider = getSettingsProviderId(settings);
|
||||
|
||||
for (const providerId of ProviderRegistry.getRegisteredProviderIds()) {
|
||||
const reconciler = ProviderRegistry.getSettingsReconciler(providerId);
|
||||
const targetSettings = providerId === settingsProvider
|
||||
? settings
|
||||
: cloneProviderSettings(settings);
|
||||
|
||||
if (providerId !== settingsProvider) {
|
||||
this.projectProviderState(targetSettings, providerId);
|
||||
}
|
||||
|
||||
const changed = reconciler.normalizeModelVariantSettings(targetSettings);
|
||||
if (changed) {
|
||||
anyChanged = true;
|
||||
this.persistProjectedProviderState(targetSettings, providerId);
|
||||
if (providerId !== settingsProvider) {
|
||||
mergeProviderSettings(settings, targetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.reconcileTitleGenerationModelSelection(settings)) {
|
||||
anyChanged = true;
|
||||
}
|
||||
return anyChanged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the settings provider's saved values into the top-level
|
||||
* model/effortLevel/thinkingBudget fields.
|
||||
*/
|
||||
static projectActiveProviderState(settings: Record<string, unknown>): void {
|
||||
this.projectProviderState(settings, getSettingsProviderId(settings));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { HomeFileAdapter } from '../storage/HomeFileAdapter';
|
||||
import type { ProviderCommandCatalog } from './commands/ProviderCommandCatalog';
|
||||
import type { ProviderHost } from './ProviderHost';
|
||||
import type {
|
||||
AgentMentionProvider,
|
||||
ProviderCliResolver,
|
||||
ProviderId,
|
||||
ProviderModelCatalogRefreshResult,
|
||||
ProviderRuntimeCommandLoader,
|
||||
ProviderSettingsTabRenderer,
|
||||
ProviderTabWarmupPolicy,
|
||||
ProviderWorkspaceRegistration,
|
||||
ProviderWorkspaceServices,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Registry for provider-owned workspace/bootstrap services.
|
||||
*
|
||||
* Unlike `ProviderRegistry`, this boundary owns app-level provider services such
|
||||
* as command catalogs, mention providers, MCP/plugin/agent managers, and
|
||||
* provider-specific storage adaptors.
|
||||
*/
|
||||
export class ProviderWorkspaceRegistry {
|
||||
private static registrations: Partial<Record<ProviderId, ProviderWorkspaceRegistration>> = {};
|
||||
private static services: Partial<Record<ProviderId, ProviderWorkspaceServices>> = {};
|
||||
|
||||
static register(
|
||||
providerId: ProviderId,
|
||||
registration: ProviderWorkspaceRegistration,
|
||||
): void {
|
||||
this.registrations[providerId] = registration;
|
||||
}
|
||||
|
||||
private static getWorkspaceRegistration(providerId: ProviderId): ProviderWorkspaceRegistration {
|
||||
const registration = this.registrations[providerId];
|
||||
if (!registration) {
|
||||
throw new Error(`Provider workspace "${providerId}" is not registered.`);
|
||||
}
|
||||
return registration;
|
||||
}
|
||||
|
||||
static async initializeAll(plugin: ProviderHost): Promise<void> {
|
||||
const providerIds = Object.keys(this.registrations);
|
||||
const storage = plugin.storage;
|
||||
const vaultAdapter = storage.getAdapter();
|
||||
const homeAdapter = new HomeFileAdapter();
|
||||
|
||||
for (const providerId of providerIds) {
|
||||
this.services[providerId] = await this.getWorkspaceRegistration(providerId).initialize({
|
||||
plugin,
|
||||
storage,
|
||||
vaultAdapter,
|
||||
homeAdapter,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static setServices(
|
||||
providerId: ProviderId,
|
||||
services: ProviderWorkspaceServices | undefined,
|
||||
): void {
|
||||
if (services) {
|
||||
this.services[providerId] = services;
|
||||
} else {
|
||||
delete this.services[providerId];
|
||||
}
|
||||
}
|
||||
|
||||
static clear(): void {
|
||||
this.services = {};
|
||||
}
|
||||
|
||||
static getServices(
|
||||
providerId: ProviderId,
|
||||
): ProviderWorkspaceServices | null {
|
||||
return this.services[providerId] ?? null;
|
||||
}
|
||||
|
||||
static requireServices(
|
||||
providerId: ProviderId,
|
||||
): ProviderWorkspaceServices {
|
||||
const services = this.getServices(providerId);
|
||||
if (!services) {
|
||||
throw new Error(`Provider workspace "${providerId}" is not initialized.`);
|
||||
}
|
||||
return services;
|
||||
}
|
||||
|
||||
static getCommandCatalog(providerId: ProviderId): ProviderCommandCatalog | null {
|
||||
return this.getServices(providerId)?.commandCatalog ?? null;
|
||||
}
|
||||
|
||||
static getAgentMentionProvider(providerId: ProviderId): AgentMentionProvider | null {
|
||||
return this.getServices(providerId)?.agentMentionProvider ?? null;
|
||||
}
|
||||
|
||||
static async refreshAgentMentions(providerId: ProviderId): Promise<void> {
|
||||
await this.getServices(providerId)?.refreshAgentMentions?.();
|
||||
}
|
||||
|
||||
static async refreshModelCatalog(
|
||||
providerId: ProviderId,
|
||||
): Promise<ProviderModelCatalogRefreshResult> {
|
||||
return await this.getServices(providerId)?.refreshModelCatalog?.() ?? { changed: false };
|
||||
}
|
||||
|
||||
static getCliResolver(providerId: ProviderId): ProviderCliResolver | null {
|
||||
return this.getServices(providerId)?.cliResolver ?? null;
|
||||
}
|
||||
|
||||
static getRuntimeCommandLoader(providerId: ProviderId): ProviderRuntimeCommandLoader | null {
|
||||
return this.getServices(providerId)?.runtimeCommandLoader ?? null;
|
||||
}
|
||||
|
||||
static getTabWarmupPolicy(providerId: ProviderId): ProviderTabWarmupPolicy | null {
|
||||
return this.getServices(providerId)?.tabWarmupPolicy ?? null;
|
||||
}
|
||||
|
||||
static getMcpServerManager(providerId: ProviderId) {
|
||||
return this.getServices(providerId)?.mcpServerManager ?? null;
|
||||
}
|
||||
|
||||
static getSettingsTabRenderer(providerId: ProviderId): ProviderSettingsTabRenderer | null {
|
||||
return this.getServices(providerId)?.settingsTabRenderer ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SlashCommand } from '../../types';
|
||||
import type { ProviderId } from '../types';
|
||||
import type { ProviderCommandEntry } from './ProviderCommandEntry';
|
||||
|
||||
export interface ProviderCommandDropdownConfig {
|
||||
providerId: ProviderId;
|
||||
triggerChars: string[];
|
||||
builtInPrefix: string;
|
||||
skillPrefix: string;
|
||||
commandPrefix: string;
|
||||
}
|
||||
|
||||
export interface ProviderCommandCatalog {
|
||||
listDropdownEntries(context: { includeBuiltIns: boolean }): Promise<ProviderCommandEntry[]>;
|
||||
listVaultEntries(): Promise<ProviderCommandEntry[]>;
|
||||
saveVaultEntry(entry: ProviderCommandEntry): Promise<void>;
|
||||
deleteVaultEntry(entry: ProviderCommandEntry): Promise<void>;
|
||||
setRuntimeCommands(commands: SlashCommand[]): void;
|
||||
getDropdownConfig(): ProviderCommandDropdownConfig;
|
||||
refresh(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { SlashCommandSource } from '../../types/settings';
|
||||
import type { ProviderId } from '../types';
|
||||
|
||||
export type ProviderCommandKind = 'command' | 'skill';
|
||||
export type ProviderCommandScope = 'builtin' | 'vault' | 'user' | 'system' | 'runtime';
|
||||
|
||||
export interface ProviderCommandEntry {
|
||||
id: string;
|
||||
providerId: ProviderId;
|
||||
kind: ProviderCommandKind;
|
||||
name: string;
|
||||
description?: string;
|
||||
content: string;
|
||||
argumentHint?: string;
|
||||
allowedTools?: string[];
|
||||
model?: string;
|
||||
disableModelInvocation?: boolean;
|
||||
userInvocable?: boolean;
|
||||
context?: 'fork';
|
||||
agent?: string;
|
||||
hooks?: Record<string, unknown>;
|
||||
scope: ProviderCommandScope;
|
||||
source: SlashCommandSource;
|
||||
isEditable: boolean;
|
||||
isDeletable: boolean;
|
||||
displayPrefix: string;
|
||||
insertPrefix: string;
|
||||
/**
|
||||
* Opaque provider-owned persistence token used to preserve storage location
|
||||
* across edits, renames, and deletes in shared settings UIs.
|
||||
*/
|
||||
persistenceKey?: string;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ClaudianSettings, HiddenProviderCommands } from '../../types/settings';
|
||||
import type { ProviderId } from '../types';
|
||||
|
||||
function normalizeHiddenCommandName(value: string): string {
|
||||
return value.trim().replace(/^[/$]+/, '');
|
||||
}
|
||||
|
||||
export function normalizeHiddenCommandList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const commandName = normalizeHiddenCommandName(item);
|
||||
if (!commandName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = commandName.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
normalized.push(commandName);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getDefaultHiddenProviderCommands(): HiddenProviderCommands {
|
||||
return {};
|
||||
}
|
||||
|
||||
export function normalizeHiddenProviderCommands(
|
||||
value: unknown,
|
||||
): HiddenProviderCommands {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return getDefaultHiddenProviderCommands();
|
||||
}
|
||||
|
||||
const candidate = value as Partial<Record<string, unknown>>;
|
||||
const normalized: HiddenProviderCommands = {};
|
||||
|
||||
for (const [providerId, commands] of Object.entries(candidate)) {
|
||||
const next = normalizeHiddenCommandList(commands);
|
||||
if (next.length > 0) {
|
||||
normalized[providerId] = next;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getHiddenProviderCommands(
|
||||
settings: Pick<ClaudianSettings, 'hiddenProviderCommands'>,
|
||||
providerId: ProviderId,
|
||||
): string[] {
|
||||
return settings.hiddenProviderCommands?.[providerId] ?? [];
|
||||
}
|
||||
|
||||
export function getHiddenProviderCommandSet(
|
||||
settings: Pick<ClaudianSettings, 'hiddenProviderCommands'>,
|
||||
providerId: ProviderId,
|
||||
): Set<string> {
|
||||
return new Set(getHiddenProviderCommands(settings, providerId).map((command) => command.toLowerCase()));
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { Conversation } from '../types';
|
||||
import { toProviderRuntimeModelId } from './modelSelection';
|
||||
import { ProviderRegistry } from './ProviderRegistry';
|
||||
import { ProviderSettingsCoordinator } from './ProviderSettingsCoordinator';
|
||||
import type { ProviderId } from './types';
|
||||
|
||||
export type ConversationModelSource = 'selected' | 'usage' | 'default';
|
||||
|
||||
export interface ConversationModelResolution {
|
||||
model: string;
|
||||
source: ConversationModelSource;
|
||||
shouldPersist: boolean;
|
||||
}
|
||||
|
||||
function trimModel(model: unknown): string {
|
||||
return typeof model === 'string' ? model.trim() : '';
|
||||
}
|
||||
|
||||
function findModelOption(
|
||||
providerId: ProviderId,
|
||||
model: string,
|
||||
settings: Record<string, unknown>,
|
||||
): string | null {
|
||||
const runtimeModel = toProviderRuntimeModelId(providerId, model);
|
||||
const option = ProviderRegistry
|
||||
.getChatUIConfig(providerId)
|
||||
.getModelOptions(settings)
|
||||
.find(candidate =>
|
||||
candidate.value === model
|
||||
|| toProviderRuntimeModelId(providerId, candidate.value) === runtimeModel
|
||||
);
|
||||
return option?.value ?? null;
|
||||
}
|
||||
|
||||
export function normalizeProviderModelSelection(
|
||||
providerId: ProviderId,
|
||||
settings: Record<string, unknown>,
|
||||
model: unknown,
|
||||
): string | null {
|
||||
const rawModel = trimModel(model);
|
||||
if (!rawModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uiConfig = ProviderRegistry.getChatUIConfig(providerId);
|
||||
const baseSettings = ProviderSettingsCoordinator.getProviderSettingsSnapshot(
|
||||
settings,
|
||||
providerId,
|
||||
);
|
||||
const rawSettings = {
|
||||
...baseSettings,
|
||||
model: rawModel,
|
||||
};
|
||||
|
||||
const rawOption = findModelOption(providerId, rawModel, rawSettings);
|
||||
if (rawOption) {
|
||||
return rawOption;
|
||||
}
|
||||
if (uiConfig.ownsModel(rawModel, rawSettings)) {
|
||||
return rawModel;
|
||||
}
|
||||
|
||||
const normalizedModel = trimModel(uiConfig.normalizeModelVariant(rawModel, rawSettings));
|
||||
if (!normalizedModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSettings = {
|
||||
...baseSettings,
|
||||
model: normalizedModel,
|
||||
};
|
||||
const normalizedOption = findModelOption(providerId, normalizedModel, normalizedSettings);
|
||||
if (normalizedOption) {
|
||||
return normalizedOption;
|
||||
}
|
||||
|
||||
return normalizedModel === rawModel && uiConfig.ownsModel(normalizedModel, normalizedSettings)
|
||||
? normalizedModel
|
||||
: null;
|
||||
}
|
||||
|
||||
export function resolveConversationModel(
|
||||
settings: Record<string, unknown>,
|
||||
providerId: ProviderId,
|
||||
conversation?: Conversation | null,
|
||||
): ConversationModelResolution {
|
||||
const selectedModel = normalizeProviderModelSelection(
|
||||
providerId,
|
||||
settings,
|
||||
conversation?.selectedModel,
|
||||
);
|
||||
if (selectedModel) {
|
||||
return {
|
||||
model: selectedModel,
|
||||
source: 'selected',
|
||||
shouldPersist: selectedModel !== conversation?.selectedModel,
|
||||
};
|
||||
}
|
||||
|
||||
const usageModel = normalizeProviderModelSelection(
|
||||
providerId,
|
||||
settings,
|
||||
conversation?.usage?.model,
|
||||
);
|
||||
if (usageModel) {
|
||||
return {
|
||||
model: usageModel,
|
||||
source: 'usage',
|
||||
shouldPersist: true,
|
||||
};
|
||||
}
|
||||
|
||||
const providerSettings = ProviderSettingsCoordinator.getProviderSettingsSnapshot(
|
||||
settings,
|
||||
providerId,
|
||||
);
|
||||
const defaultModel = normalizeProviderModelSelection(
|
||||
providerId,
|
||||
settings,
|
||||
providerSettings.model,
|
||||
) ?? trimModel(providerSettings.model);
|
||||
|
||||
return {
|
||||
model: defaultModel,
|
||||
source: 'default',
|
||||
shouldPersist: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function getProviderSettingsSnapshotWithModel<T extends Record<string, unknown>>(
|
||||
settings: T,
|
||||
providerId: ProviderId,
|
||||
model?: string | null,
|
||||
): T {
|
||||
const snapshot = ProviderSettingsCoordinator.getProviderSettingsSnapshot(
|
||||
settings,
|
||||
providerId,
|
||||
);
|
||||
const normalizedModel = normalizeProviderModelSelection(providerId, snapshot, model);
|
||||
if (normalizedModel) {
|
||||
ProviderSettingsCoordinator.projectModelSelection(snapshot, providerId, normalizedModel);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ProviderRegistry } from './ProviderRegistry';
|
||||
import type { ProviderId } from './types';
|
||||
|
||||
export function getProviderForModel(model: string, settings?: Record<string, unknown>): ProviderId {
|
||||
return ProviderRegistry.resolveProviderForModel(model, settings);
|
||||
}
|
||||
|
||||
export function getEnabledProviderForModel(
|
||||
model: string,
|
||||
settings: Record<string, unknown>,
|
||||
fallbackProviderId?: ProviderId,
|
||||
): ProviderId {
|
||||
return ProviderRegistry.resolveProviderForModel(model, settings, {
|
||||
onlyEnabledProviders: true,
|
||||
fallbackProviderId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ProviderId } from './types';
|
||||
|
||||
const PROVIDER_MODEL_SELECTION_PREFIXES: Partial<Record<ProviderId, string>> = {
|
||||
claude: 'claude-code/',
|
||||
codex: 'openai-codex/',
|
||||
opencode: 'opencode/',
|
||||
pi: 'pi/',
|
||||
};
|
||||
|
||||
export interface ProviderModelSelection {
|
||||
modelId: string;
|
||||
providerId: ProviderId;
|
||||
}
|
||||
|
||||
export function getProviderModelSelectionPrefix(providerId: ProviderId): string | null {
|
||||
return PROVIDER_MODEL_SELECTION_PREFIXES[providerId] ?? null;
|
||||
}
|
||||
|
||||
export function encodeProviderModelSelectionId(
|
||||
providerId: ProviderId,
|
||||
modelId: string,
|
||||
): string {
|
||||
const normalized = modelId.trim();
|
||||
const prefix = getProviderModelSelectionPrefix(providerId);
|
||||
if (!prefix || !normalized || normalized.startsWith(prefix)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return `${prefix}${normalized}`;
|
||||
}
|
||||
|
||||
export function decodeProviderModelSelectionId(
|
||||
value: string,
|
||||
): ProviderModelSelection | null {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const [providerId, prefix] of Object.entries(PROVIDER_MODEL_SELECTION_PREFIXES)) {
|
||||
if (!prefix || !normalized.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const modelId = normalized.slice(prefix.length).trim();
|
||||
if (!modelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
providerId,
|
||||
modelId,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isProviderModelSelectionId(
|
||||
providerId: ProviderId,
|
||||
value: string,
|
||||
): boolean {
|
||||
return decodeProviderModelSelectionId(value)?.providerId === providerId;
|
||||
}
|
||||
|
||||
export function toProviderRuntimeModelId(
|
||||
providerId: ProviderId,
|
||||
value: string,
|
||||
): string {
|
||||
const decoded = decodeProviderModelSelectionId(value);
|
||||
return decoded && decoded.providerId === providerId ? decoded.modelId : value;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ProviderId } from './types';
|
||||
|
||||
type ProviderConfigMap = Partial<Record<string, Record<string, unknown>>>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function getProviderConfig(
|
||||
settings: Record<string, unknown>,
|
||||
providerId: ProviderId,
|
||||
): Record<string, unknown> {
|
||||
const candidate = settings.providerConfigs;
|
||||
if (!isRecord(candidate)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const config = candidate[providerId];
|
||||
return isRecord(config) ? { ...config } : {};
|
||||
}
|
||||
|
||||
export function setProviderConfig(
|
||||
settings: Record<string, unknown>,
|
||||
providerId: ProviderId,
|
||||
config: Record<string, unknown>,
|
||||
): void {
|
||||
const current = settings.providerConfigs;
|
||||
const nextConfigs: ProviderConfigMap = isRecord(current)
|
||||
? { ...(current as ProviderConfigMap) }
|
||||
: {};
|
||||
|
||||
nextConfigs[providerId] = { ...config };
|
||||
settings.providerConfigs = nextConfigs;
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { parseEnvironmentVariables } from '../../utils/env';
|
||||
import { getProviderConfig, setProviderConfig } from './providerConfig';
|
||||
import { ProviderRegistry } from './ProviderRegistry';
|
||||
import type { ProviderId } from './types';
|
||||
|
||||
export type EnvironmentScope = 'shared' | `provider:${string}`;
|
||||
export interface EnvironmentScopeUpdate {
|
||||
scope: EnvironmentScope;
|
||||
envText: string;
|
||||
}
|
||||
|
||||
type EnvironmentKeyOwnership =
|
||||
| { type: 'shared-known' }
|
||||
| { type: 'shared-unknown' }
|
||||
| { type: 'provider'; providerId: ProviderId };
|
||||
|
||||
interface ClassifiedEnvironmentLines {
|
||||
shared: string[];
|
||||
providers: Partial<Record<ProviderId, string[]>>;
|
||||
reviewKeys: Set<string>;
|
||||
}
|
||||
|
||||
const SHARED_ENVIRONMENT_KEYS = new Set([
|
||||
'PATH',
|
||||
'HTTP_PROXY',
|
||||
'HTTPS_PROXY',
|
||||
'NO_PROXY',
|
||||
'ALL_PROXY',
|
||||
'SSL_CERT_FILE',
|
||||
'SSL_CERT_DIR',
|
||||
'REQUESTS_CA_BUNDLE',
|
||||
'CURL_CA_BUNDLE',
|
||||
'NODE_EXTRA_CA_CERTS',
|
||||
'TMPDIR',
|
||||
'TMP',
|
||||
'TEMP',
|
||||
]);
|
||||
|
||||
function resolveScopeProviderId(scope: EnvironmentScope): ProviderId | null {
|
||||
return scope.startsWith('provider:') ? scope.slice('provider:'.length) : null;
|
||||
}
|
||||
|
||||
function classifyEnvironmentKey(key: string): EnvironmentKeyOwnership {
|
||||
const normalized = key.trim().toUpperCase();
|
||||
if (!normalized) {
|
||||
return { type: 'shared-unknown' };
|
||||
}
|
||||
|
||||
if (SHARED_ENVIRONMENT_KEYS.has(normalized)) {
|
||||
return { type: 'shared-known' };
|
||||
}
|
||||
|
||||
for (const providerId of ProviderRegistry.getRegisteredProviderIds()) {
|
||||
const patterns = ProviderRegistry.getEnvironmentKeyPatterns(providerId);
|
||||
if (patterns.some((pattern) => pattern.test(normalized))) {
|
||||
return { type: 'provider', providerId };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'shared-unknown' };
|
||||
}
|
||||
|
||||
function extractEnvironmentKey(line: string): string | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = trimmed.startsWith('export ') ? trimmed.slice(7) : trimmed;
|
||||
const eqIndex = normalized.indexOf('=');
|
||||
if (eqIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = normalized.slice(0, eqIndex).trim();
|
||||
return key || null;
|
||||
}
|
||||
|
||||
function appendLines(target: string[], pendingDecorators: string[], line: string): void {
|
||||
target.push(...pendingDecorators, line);
|
||||
}
|
||||
|
||||
function createClassifiedEnvironmentLines(): ClassifiedEnvironmentLines {
|
||||
return {
|
||||
shared: [],
|
||||
providers: {},
|
||||
reviewKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
function joinEnvironmentLines(lines: string[]): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function hasMeaningfulEnvironmentContent(envText: string): boolean {
|
||||
return envText
|
||||
.split(/\r?\n/)
|
||||
.some((line) => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed.length > 0 && !trimmed.startsWith('#');
|
||||
});
|
||||
}
|
||||
|
||||
function getLegacyEnvironmentClassification(
|
||||
settings: Record<string, unknown>,
|
||||
): ReturnType<typeof classifyEnvironmentVariablesByOwnership> {
|
||||
const legacyEnvironmentVariables = settings.environmentVariables;
|
||||
if (typeof legacyEnvironmentVariables !== 'string' || legacyEnvironmentVariables.length === 0) {
|
||||
return {
|
||||
shared: '',
|
||||
providers: {},
|
||||
reviewKeys: [],
|
||||
};
|
||||
}
|
||||
|
||||
return classifyEnvironmentVariablesByOwnership(legacyEnvironmentVariables);
|
||||
}
|
||||
|
||||
export function classifyEnvironmentVariablesByOwnership(input: string): {
|
||||
shared: string;
|
||||
providers: Partial<Record<ProviderId, string>>;
|
||||
reviewKeys: string[];
|
||||
} {
|
||||
const result = createClassifiedEnvironmentLines();
|
||||
let pendingDecorators: string[] = [];
|
||||
|
||||
for (const line of input.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
pendingDecorators.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = extractEnvironmentKey(line);
|
||||
if (!key) {
|
||||
appendLines(result.shared, pendingDecorators, line);
|
||||
pendingDecorators = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const ownership = classifyEnvironmentKey(key);
|
||||
if (ownership.type === 'provider') {
|
||||
const target = result.providers[ownership.providerId] ?? [];
|
||||
appendLines(target, pendingDecorators, line);
|
||||
result.providers[ownership.providerId] = target;
|
||||
} else {
|
||||
appendLines(result.shared, pendingDecorators, line);
|
||||
if (ownership.type === 'shared-unknown') {
|
||||
result.reviewKeys.add(key);
|
||||
}
|
||||
}
|
||||
pendingDecorators = [];
|
||||
}
|
||||
|
||||
if (pendingDecorators.length > 0) {
|
||||
result.shared.push(...pendingDecorators);
|
||||
}
|
||||
|
||||
return {
|
||||
shared: joinEnvironmentLines(result.shared),
|
||||
providers: Object.fromEntries(
|
||||
Object.entries(result.providers).map(([providerId, lines]) => [
|
||||
providerId,
|
||||
joinEnvironmentLines(lines ?? []),
|
||||
]),
|
||||
),
|
||||
reviewKeys: Array.from(result.reviewKeys),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSharedEnvironmentVariables(settings: Record<string, unknown>): string {
|
||||
const sharedEnvironmentVariables = settings.sharedEnvironmentVariables;
|
||||
if (typeof sharedEnvironmentVariables === 'string') {
|
||||
return sharedEnvironmentVariables;
|
||||
}
|
||||
|
||||
return getLegacyEnvironmentClassification(settings).shared;
|
||||
}
|
||||
|
||||
export function setSharedEnvironmentVariables(
|
||||
settings: Record<string, unknown>,
|
||||
envText: string,
|
||||
): void {
|
||||
settings.sharedEnvironmentVariables = envText;
|
||||
delete settings.environmentVariables;
|
||||
}
|
||||
|
||||
export function getProviderEnvironmentVariables(
|
||||
settings: Record<string, unknown>,
|
||||
providerId: ProviderId,
|
||||
): string {
|
||||
const providerConfig = getProviderConfig(settings, providerId);
|
||||
if (typeof providerConfig.environmentVariables === 'string') {
|
||||
return providerConfig.environmentVariables;
|
||||
}
|
||||
|
||||
return getLegacyEnvironmentClassification(settings).providers[providerId] ?? '';
|
||||
}
|
||||
|
||||
export function setProviderEnvironmentVariables(
|
||||
settings: Record<string, unknown>,
|
||||
providerId: ProviderId,
|
||||
envText: string,
|
||||
): void {
|
||||
setProviderConfig(settings, providerId, {
|
||||
...getProviderConfig(settings, providerId),
|
||||
environmentVariables: envText,
|
||||
});
|
||||
delete settings.environmentVariables;
|
||||
}
|
||||
|
||||
export function joinEnvironmentTexts(...parts: Array<string | undefined>): string {
|
||||
const filtered = parts.filter((part): part is string => typeof part === 'string' && part.length > 0);
|
||||
if (filtered.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return filtered.reduce((combined, part) => {
|
||||
if (!combined) {
|
||||
return part;
|
||||
}
|
||||
|
||||
return combined.endsWith('\n') ? `${combined}${part}` : `${combined}\n${part}`;
|
||||
}, '');
|
||||
}
|
||||
|
||||
export function getRuntimeEnvironmentText(
|
||||
settings: Record<string, unknown>,
|
||||
providerId: ProviderId,
|
||||
): string {
|
||||
return joinEnvironmentTexts(
|
||||
getSharedEnvironmentVariables(settings),
|
||||
getProviderEnvironmentVariables(settings, providerId),
|
||||
);
|
||||
}
|
||||
|
||||
export function getRuntimeEnvironmentVariables(
|
||||
settings: Record<string, unknown>,
|
||||
providerId: ProviderId,
|
||||
): Record<string, string> {
|
||||
return parseEnvironmentVariables(getRuntimeEnvironmentText(settings, providerId));
|
||||
}
|
||||
|
||||
export function getEnvironmentVariablesForScope(
|
||||
settings: Record<string, unknown>,
|
||||
scope: EnvironmentScope,
|
||||
): string {
|
||||
if (scope === 'shared') {
|
||||
return getSharedEnvironmentVariables(settings);
|
||||
}
|
||||
|
||||
return getProviderEnvironmentVariables(settings, resolveScopeProviderId(scope) ?? '');
|
||||
}
|
||||
|
||||
export function setEnvironmentVariablesForScope(
|
||||
settings: Record<string, unknown>,
|
||||
scope: EnvironmentScope,
|
||||
envText: string,
|
||||
): void {
|
||||
if (scope === 'shared') {
|
||||
setSharedEnvironmentVariables(settings, envText);
|
||||
return;
|
||||
}
|
||||
|
||||
const providerId = resolveScopeProviderId(scope);
|
||||
if (!providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProviderEnvironmentVariables(settings, providerId, envText);
|
||||
}
|
||||
|
||||
export function getEnvironmentReviewKeysForScope(
|
||||
envText: string,
|
||||
scope: EnvironmentScope,
|
||||
): string[] {
|
||||
const reviewKeys = new Set<string>();
|
||||
const expectedProviderId = resolveScopeProviderId(scope);
|
||||
|
||||
for (const line of envText.split(/\r?\n/)) {
|
||||
const key = extractEnvironmentKey(line);
|
||||
if (!key || reviewKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ownership = classifyEnvironmentKey(key);
|
||||
if (scope === 'shared') {
|
||||
if (ownership.type !== 'shared-known') {
|
||||
reviewKeys.add(key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ownership.type !== 'provider' || ownership.providerId !== expectedProviderId) {
|
||||
reviewKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(reviewKeys);
|
||||
}
|
||||
|
||||
export function inferEnvironmentSnippetScope(
|
||||
envText: string,
|
||||
): EnvironmentScope | undefined {
|
||||
const classified = classifyEnvironmentVariablesByOwnership(envText);
|
||||
const nonEmptyScopes: EnvironmentScope[] = [];
|
||||
|
||||
if (hasMeaningfulEnvironmentContent(classified.shared)) {
|
||||
nonEmptyScopes.push('shared');
|
||||
}
|
||||
|
||||
for (const [providerId, providerEnv] of Object.entries(classified.providers)) {
|
||||
if (providerEnv && hasMeaningfulEnvironmentContent(providerEnv)) {
|
||||
nonEmptyScopes.push(`provider:${providerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return nonEmptyScopes.length === 1 ? nonEmptyScopes[0] : undefined;
|
||||
}
|
||||
|
||||
export function resolveEnvironmentSnippetScope(
|
||||
envText: string,
|
||||
fallbackScope?: EnvironmentScope,
|
||||
): EnvironmentScope | undefined {
|
||||
const inferredScope = inferEnvironmentSnippetScope(envText);
|
||||
if (inferredScope) {
|
||||
return inferredScope;
|
||||
}
|
||||
|
||||
return hasMeaningfulEnvironmentContent(envText) ? undefined : fallbackScope;
|
||||
}
|
||||
|
||||
export function getEnvironmentScopeUpdates(
|
||||
envText: string,
|
||||
fallbackScope?: EnvironmentScope,
|
||||
): EnvironmentScopeUpdate[] {
|
||||
const classified = classifyEnvironmentVariablesByOwnership(envText);
|
||||
const updates: EnvironmentScopeUpdate[] = [];
|
||||
|
||||
if (classified.shared.trim()) {
|
||||
updates.push({ scope: 'shared', envText: classified.shared });
|
||||
}
|
||||
|
||||
for (const [providerId, providerEnv] of Object.entries(classified.providers)) {
|
||||
if (!providerEnv || !providerEnv.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
updates.push({
|
||||
scope: `provider:${providerId}`,
|
||||
envText: providerEnv,
|
||||
});
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
return updates;
|
||||
}
|
||||
|
||||
if (fallbackScope) {
|
||||
return [{ scope: fallbackScope, envText }];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const DEFAULT_REASONING_VALUE = 'high';
|
||||
|
||||
export function resolvePreferredReasoningDefault(
|
||||
availableValues: readonly string[],
|
||||
fallbackValue: string,
|
||||
): string {
|
||||
if (availableValues.includes(DEFAULT_REASONING_VALUE)) {
|
||||
return DEFAULT_REASONING_VALUE;
|
||||
}
|
||||
if (availableValues.includes(fallbackValue)) {
|
||||
return fallbackValue;
|
||||
}
|
||||
return availableValues[0] ?? fallbackValue;
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
import type { CursorContext } from '../../utils/editor';
|
||||
import type { SharedAppStorage } from '../bootstrap/storage';
|
||||
import type { McpServerManager } from '../mcp/McpServerManager';
|
||||
import type { ChatRuntime } from '../runtime/ChatRuntime';
|
||||
import type { HomeFileAdapter } from '../storage/HomeFileAdapter';
|
||||
import type { VaultFileAdapter } from '../storage/VaultFileAdapter';
|
||||
import type {
|
||||
AgentDefinition,
|
||||
Conversation,
|
||||
InstructionRefineResult,
|
||||
ManagedMcpServer,
|
||||
PluginInfo,
|
||||
SessionMetadata,
|
||||
SlashCommand,
|
||||
SubagentInfo,
|
||||
ToolCallInfo,
|
||||
} from '../types';
|
||||
import type { ProviderId } from '../types/provider';
|
||||
import type { ProviderCommandCatalog } from './commands/ProviderCommandCatalog';
|
||||
import type { ProviderHost } from './ProviderHost';
|
||||
|
||||
export type { ProviderId } from '../types/provider';
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
providerId: ProviderId;
|
||||
supportsPersistentRuntime: boolean;
|
||||
supportsNativeHistory: boolean;
|
||||
supportsPlanMode: boolean;
|
||||
supportsRewind: boolean;
|
||||
supportsFork: boolean;
|
||||
supportsProviderCommands: boolean;
|
||||
supportsImageAttachments: boolean;
|
||||
supportsInstructionMode: boolean;
|
||||
supportsMcpTools: boolean;
|
||||
supportsTurnSteer?: boolean;
|
||||
reasoningControl: 'effort' | 'token-budget' | 'none';
|
||||
planPathPrefix?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_CHAT_PROVIDER_ID = 'claude' as const satisfies ProviderId;
|
||||
|
||||
export interface CreateChatRuntimeOptions {
|
||||
plugin: ProviderHost;
|
||||
providerId?: ProviderId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat-facing provider registration.
|
||||
*
|
||||
* This is intentionally limited to chat-facing services.
|
||||
* Shared bootstrap (defaults, storage) is in `src/core/bootstrap/`.
|
||||
* Provider-owned workspace services (CLI resolution, commands, agents,
|
||||
* MCP, settings tabs) live behind `src/providers/<id>/app/`.
|
||||
*/
|
||||
export interface ProviderRegistration {
|
||||
displayName: string;
|
||||
blankTabOrder: number;
|
||||
isEnabled: (settings: Record<string, unknown>) => boolean;
|
||||
capabilities: ProviderCapabilities;
|
||||
environmentKeyPatterns?: RegExp[];
|
||||
chatUIConfig: ProviderChatUIConfig;
|
||||
settingsReconciler: ProviderSettingsReconciler;
|
||||
createRuntime: (options: Omit<CreateChatRuntimeOptions, 'providerId'>) => ChatRuntime;
|
||||
createTitleGenerationService: (plugin: ProviderHost) => TitleGenerationService;
|
||||
createInstructionRefineService: (plugin: ProviderHost) => InstructionRefineService;
|
||||
createInlineEditService: (plugin: ProviderHost) => InlineEditService;
|
||||
historyService: ProviderConversationHistoryService;
|
||||
taskResultInterpreter: ProviderTaskResultInterpreter;
|
||||
subagentLifecycleAdapter?: ProviderSubagentLifecycleAdapter;
|
||||
}
|
||||
|
||||
export interface ProviderModule extends ProviderRegistration {
|
||||
id: ProviderId;
|
||||
settingsStorage: ProviderSettingsStorageAdapter;
|
||||
workspace: ProviderWorkspaceRegistration;
|
||||
}
|
||||
|
||||
export interface ProviderSettingsStorageAdapter {
|
||||
hostScopedFields?: string[];
|
||||
legacyTopLevelFields?: string[];
|
||||
runtimeOnlyFields?: string[];
|
||||
normalizeStored(
|
||||
target: Record<string, unknown>,
|
||||
stored: Record<string, unknown>,
|
||||
): boolean;
|
||||
}
|
||||
|
||||
export interface ProviderSettingsReconciler {
|
||||
handleEnvironmentChange?(settings: Record<string, unknown>): boolean;
|
||||
|
||||
reconcileModelWithEnvironment(
|
||||
settings: Record<string, unknown>,
|
||||
conversations: Conversation[],
|
||||
): { changed: boolean; invalidatedConversations: Conversation[] };
|
||||
|
||||
normalizeModelVariantSettings(settings: Record<string, unknown>): boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App-level service interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Tab manager state persisted across restarts. */
|
||||
export interface AppTabManagerState {
|
||||
openTabs: Array<{ tabId: string; conversationId: string | null; draftModel?: string | null }>;
|
||||
activeTabId: string | null;
|
||||
expandedTitleTabIds?: string[];
|
||||
}
|
||||
|
||||
/** Provider-neutral session metadata storage. */
|
||||
export interface AppSessionStorage {
|
||||
listMetadata(): Promise<SessionMetadata[]>;
|
||||
saveMetadata(meta: SessionMetadata): Promise<void>;
|
||||
deleteMetadata(id: string): Promise<void>;
|
||||
toSessionMetadata(conv: Conversation): SessionMetadata;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider-owned workspace sub-interfaces
|
||||
//
|
||||
// These remain here as standalone types so app-level settings/chat code can
|
||||
// depend on stable provider workspace contracts without importing concrete
|
||||
// provider implementations. They are NOT part of the shared bootstrap storage
|
||||
// contract (`SharedAppStorage`).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AppMcpStorage {
|
||||
load(): Promise<ManagedMcpServer[]>;
|
||||
save(servers: ManagedMcpServer[]): Promise<void>;
|
||||
tryParseClipboardConfig?(text: string): unknown;
|
||||
}
|
||||
|
||||
export interface AppCommandStorage {
|
||||
save(command: SlashCommand): Promise<void>;
|
||||
delete(name: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AppSkillStorage {
|
||||
save(skill: SlashCommand): Promise<void>;
|
||||
delete(name: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AppAgentStorage {
|
||||
load(agent: AgentDefinition): Promise<AgentDefinition | null>;
|
||||
save(agent: AgentDefinition): Promise<void>;
|
||||
delete(agent: AgentDefinition): Promise<void>;
|
||||
}
|
||||
|
||||
export type AgentMentionSource = AgentDefinition['source'];
|
||||
|
||||
export interface AgentMentionProvider {
|
||||
searchAgents(query: string): Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
source: AgentMentionSource;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Provider plugin manager interface consumed by the app layer. */
|
||||
export interface AppPluginManager {
|
||||
loadPlugins(): Promise<void>;
|
||||
getPlugins(): PluginInfo[];
|
||||
hasPlugins(): boolean;
|
||||
hasEnabledPlugins(): boolean;
|
||||
getEnabledCount(): number;
|
||||
getPluginsKey(): string;
|
||||
togglePlugin(pluginId: string): Promise<void>;
|
||||
enablePlugin(pluginId: string): Promise<void>;
|
||||
disablePlugin(pluginId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Provider agent manager interface consumed by the app layer. */
|
||||
export interface AppAgentManager extends AgentMentionProvider {
|
||||
loadAgents(): Promise<void>;
|
||||
getAvailableAgents(): AgentDefinition[];
|
||||
getAgentById(id: string): AgentDefinition | undefined;
|
||||
searchAgents(query: string): AgentDefinition[];
|
||||
setBuiltinAgentNames(names: string[]): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider-owned chat UI configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Option for model, reasoning, or other UI selectors. */
|
||||
export interface ProviderUIOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
/** Optional group label for visual separators in dropdowns. */
|
||||
group?: string;
|
||||
/** Per-option icon override (e.g. when mixing providers in a single dropdown). */
|
||||
providerIcon?: ProviderIconSvg;
|
||||
}
|
||||
|
||||
export interface ProviderPathIconSvg {
|
||||
kind?: 'path';
|
||||
viewBox: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ProviderSvgPathChild {
|
||||
tag: 'path';
|
||||
attributes: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ProviderSvgGroupChild {
|
||||
tag: 'g';
|
||||
attributes: Record<string, string>;
|
||||
children: ProviderSvgPathChild[];
|
||||
}
|
||||
|
||||
export type ProviderSvgChild = ProviderSvgGroupChild | ProviderSvgPathChild;
|
||||
|
||||
export interface ProviderCompositeIconSvg {
|
||||
kind: 'composite';
|
||||
viewBox: string;
|
||||
children: ProviderSvgChild[];
|
||||
}
|
||||
|
||||
/** SVG icon descriptor for provider branding in selectors and headers. */
|
||||
export type ProviderIconSvg = ProviderPathIconSvg | ProviderCompositeIconSvg;
|
||||
|
||||
/** Extended option with token count for budget-based reasoning controls. */
|
||||
export interface ProviderReasoningOption extends ProviderUIOption {
|
||||
tokens?: number;
|
||||
}
|
||||
|
||||
/** Compact permission-mode toggle descriptor for providers that expose the current toolbar control. */
|
||||
export interface ProviderPermissionModeToggleConfig {
|
||||
inactiveValue: string;
|
||||
inactiveLabel: string;
|
||||
activeValue: string;
|
||||
activeLabel: string;
|
||||
planValue?: string;
|
||||
planLabel?: string;
|
||||
}
|
||||
|
||||
/** Compact service-tier toggle descriptor for providers that expose a fast/standard toolbar control. */
|
||||
export interface ProviderServiceTierToggleConfig {
|
||||
inactiveValue: string;
|
||||
inactiveLabel: string;
|
||||
activeValue: string;
|
||||
activeLabel: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ProviderModeSelectorConfig {
|
||||
activeValue?: string;
|
||||
label: string;
|
||||
options: ProviderUIOption[];
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Synchronous UI projection owned by the provider and backed by provider-owned metadata. */
|
||||
export interface ProviderChatUIConfig {
|
||||
/** Model options for the selector dropdown. Provider extracts what it needs from the settings bag. */
|
||||
getModelOptions(settings: Record<string, unknown>): ProviderUIOption[];
|
||||
|
||||
/** Semantic default model, independent from selector display order. */
|
||||
getDefaultModel?(settings: Record<string, unknown>): string | null;
|
||||
|
||||
/** Whether this provider owns the given model id. */
|
||||
ownsModel(model: string, settings: Record<string, unknown>): boolean;
|
||||
|
||||
/** Whether the model uses adaptive reasoning (effort levels vs token budgets). */
|
||||
isAdaptiveReasoningModel(model: string, settings: Record<string, unknown>): boolean;
|
||||
|
||||
/** Reasoning options for the current model (effort levels if adaptive, budgets otherwise). */
|
||||
getReasoningOptions(model: string, settings: Record<string, unknown>): ProviderReasoningOption[];
|
||||
|
||||
/** Default reasoning value for the model. */
|
||||
getDefaultReasoningValue(model: string, settings: Record<string, unknown>): string;
|
||||
|
||||
/** Context window size in tokens. */
|
||||
getContextWindowSize(
|
||||
model: string,
|
||||
customLimits?: Record<string, number>,
|
||||
settings?: Record<string, unknown>,
|
||||
): number;
|
||||
|
||||
/** Whether this is a built-in (default) model vs custom/env model. */
|
||||
isDefaultModel(model: string): boolean;
|
||||
|
||||
/** Apply model change side effects to settings (defaults, tracking). */
|
||||
applyModelDefaults(model: string, settings: unknown): void;
|
||||
|
||||
/** Optional provider hook to discover model-scoped metadata after a model is selected. */
|
||||
prepareModelMetadata?(
|
||||
model: string,
|
||||
settings: Record<string, unknown>,
|
||||
context: { plugin: ProviderHost },
|
||||
): Promise<void>;
|
||||
|
||||
/** Optional hook when the toolbar changes a reasoning selection. */
|
||||
applyReasoningSelection?(model: string, value: string, settings: unknown): void;
|
||||
|
||||
/** Normalize model variant based on visibility flags. Provider extracts what it needs from the settings bag. */
|
||||
normalizeModelVariant(model: string, settings: Record<string, unknown>): string;
|
||||
|
||||
/** Extract custom model IDs from parsed environment variables. Used for per-model context limit UI. */
|
||||
getCustomModelIds(envVars: Record<string, string>): Set<string>;
|
||||
|
||||
/** Optional permission-mode toggle descriptor. Return null when the provider exposes no permission toggle UI. */
|
||||
getPermissionModeToggle?(): ProviderPermissionModeToggleConfig | null;
|
||||
|
||||
/** Optional provider-owned mapping back into the shared permission-mode contract. */
|
||||
resolvePermissionMode?(settings: Record<string, unknown>): string | null;
|
||||
|
||||
/** Optional hook when the toolbar changes permission mode. */
|
||||
applyPermissionMode?(value: string, settings: unknown): void;
|
||||
|
||||
/** Optional service-tier toggle descriptor. Return null when the provider exposes no fast/standard UI. */
|
||||
getServiceTierToggle?(settings: Record<string, unknown>): ProviderServiceTierToggleConfig | null;
|
||||
|
||||
/** Optional provider-owned mode selector descriptor. */
|
||||
getModeSelector?(settings: Record<string, unknown>): ProviderModeSelectorConfig | null;
|
||||
|
||||
/** Optional hook when the toolbar changes a provider-owned mode selection. */
|
||||
applyModeSelection?(value: string, settings: unknown): void;
|
||||
|
||||
/** Whether the provider enables the shared bang-bash input mode. */
|
||||
isBangBashEnabled?(settings: Record<string, unknown>): boolean;
|
||||
|
||||
/** SVG icon for the provider (shown next to model names in selectors). */
|
||||
getProviderIcon?(): ProviderIconSvg | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider-owned boundary services
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProviderCliResolutionContext {
|
||||
executionTarget?: unknown;
|
||||
}
|
||||
|
||||
export interface ProviderCliResolver {
|
||||
resolveFromSettings(
|
||||
settings: Record<string, unknown>,
|
||||
context?: ProviderCliResolutionContext,
|
||||
): string | null;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export interface ProviderRuntimeCommandLoaderContext {
|
||||
// Shared command discovery may need a short-lived provider session; the tab
|
||||
// manager decides when that is allowed for the active tab.
|
||||
allowSessionCreation?: boolean;
|
||||
conversation: Conversation | null;
|
||||
externalContextPaths: string[];
|
||||
plugin: ProviderHost;
|
||||
runtime: ChatRuntime | null;
|
||||
}
|
||||
|
||||
export interface ProviderRuntimeCommandLoader {
|
||||
isAvailable(settings: Record<string, unknown>): boolean;
|
||||
loadCommands(context: ProviderRuntimeCommandLoaderContext): Promise<SlashCommand[]>;
|
||||
}
|
||||
|
||||
// `commands` warms provider-owned command discovery without fully priming the
|
||||
// bound tab runtime. `runtime` primes the real tab runtime itself.
|
||||
export type ProviderTabWarmupMode = 'none' | 'commands' | 'runtime';
|
||||
|
||||
export type ProviderTabWarmupLifecycleState = 'blank' | 'bound_cold' | 'bound_active' | 'closing';
|
||||
|
||||
export interface ProviderTabWarmupContext {
|
||||
conversation: Conversation | null;
|
||||
externalContextPaths: string[];
|
||||
plugin: ProviderHost;
|
||||
runtime: ChatRuntime | null;
|
||||
tab: {
|
||||
conversationId: string | null;
|
||||
draftModel: string | null;
|
||||
lifecycleState: ProviderTabWarmupLifecycleState;
|
||||
providerId: ProviderId;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProviderTabWarmupPolicy {
|
||||
resolveMode(context: ProviderTabWarmupContext): ProviderTabWarmupMode;
|
||||
}
|
||||
|
||||
export interface ProviderWorkspaceServices {
|
||||
commandCatalog?: ProviderCommandCatalog | null;
|
||||
agentMentionProvider?: AgentMentionProvider | null;
|
||||
cliResolver?: ProviderCliResolver | null;
|
||||
runtimeCommandLoader?: ProviderRuntimeCommandLoader | null;
|
||||
tabWarmupPolicy?: ProviderTabWarmupPolicy | null;
|
||||
mcpServerManager?: McpServerManager | null;
|
||||
settingsTabRenderer?: ProviderSettingsTabRenderer | null;
|
||||
refreshAgentMentions?(): Promise<void>;
|
||||
refreshModelCatalog?(): Promise<ProviderModelCatalogRefreshResult>;
|
||||
}
|
||||
|
||||
export interface ProviderModelCatalogRefreshResult {
|
||||
/** Whether runtime catalog or persisted selection state changed. */
|
||||
changed: boolean;
|
||||
diagnostics?: string;
|
||||
/** Whether the provider-owned refresh persisted selection settings. */
|
||||
persistedSettingsChanged?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderSettingsTabRendererContext {
|
||||
plugin: ProviderHost;
|
||||
renderHiddenProviderCommandSetting(
|
||||
container: HTMLElement,
|
||||
providerId: ProviderId,
|
||||
copy: { name: string; desc: string; placeholder: string },
|
||||
): void;
|
||||
refreshModelSelectors(): void;
|
||||
renderCustomContextLimits(container: HTMLElement, providerId?: ProviderId): void;
|
||||
}
|
||||
|
||||
export interface ProviderSettingsTabRenderer {
|
||||
render(container: HTMLElement, context: ProviderSettingsTabRendererContext): void;
|
||||
}
|
||||
|
||||
export interface ProviderWorkspaceInitContext {
|
||||
plugin: ProviderHost;
|
||||
storage: SharedAppStorage;
|
||||
vaultAdapter: VaultFileAdapter;
|
||||
homeAdapter: HomeFileAdapter;
|
||||
}
|
||||
|
||||
export interface ProviderWorkspaceRegistration<
|
||||
TServices extends ProviderWorkspaceServices = ProviderWorkspaceServices,
|
||||
> {
|
||||
initialize(context: ProviderWorkspaceInitContext): Promise<TServices>;
|
||||
}
|
||||
|
||||
export interface ProviderConversationHistoryService {
|
||||
/**
|
||||
* Reports whether the provider-native session needed to resume a persisted
|
||||
* conversation is still available. Providers that cannot distinguish a
|
||||
* missing session from an inaccessible history store should return unknown.
|
||||
*/
|
||||
getConversationSessionAvailability?(
|
||||
conversation: Conversation,
|
||||
vaultPath: string | null,
|
||||
pathContext?: ProviderHistoryPathContext,
|
||||
): Promise<ProviderConversationSessionAvailability>;
|
||||
/** Clears stale resume state so relocated provider history can rebuild natively. */
|
||||
prepareRelocatedConversationSession?(
|
||||
conversation: Conversation,
|
||||
vaultPath: string | null,
|
||||
pathContext?: ProviderHistoryPathContext,
|
||||
): Promise<boolean>;
|
||||
/** Decides whether a confirmed missing resume session makes the whole record disposable. */
|
||||
resolveMissingConversationSession?(
|
||||
conversation: Conversation,
|
||||
vaultPath: string | null,
|
||||
missingProviderSessionId?: string,
|
||||
pathContext?: ProviderHistoryPathContext,
|
||||
): Promise<'delete' | 'reset' | 'preserve'>;
|
||||
hydrateConversationHistory(
|
||||
conversation: Conversation,
|
||||
vaultPath: string | null,
|
||||
pathContext?: ProviderHistoryPathContext,
|
||||
): Promise<void>;
|
||||
deleteConversationSession(
|
||||
conversation: Conversation,
|
||||
vaultPath: string | null,
|
||||
pathContext?: ProviderHistoryPathContext,
|
||||
): Promise<void>;
|
||||
resolveSessionIdForConversation(conversation: Conversation | null): string | null;
|
||||
isPendingForkConversation(conversation: Conversation): boolean;
|
||||
/** Builds opaque provider state for a forked conversation. */
|
||||
buildForkProviderState(
|
||||
sourceSessionId: string,
|
||||
resumeAt: string,
|
||||
sourceProviderState?: Record<string, unknown>,
|
||||
): Record<string, unknown>;
|
||||
/** Adds provider-owned persisted metadata to Conversation.providerState before session save. */
|
||||
buildPersistedProviderState?(conversation: Conversation): Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
export interface ProviderHistoryPathContext {
|
||||
environment: NodeJS.ProcessEnv;
|
||||
hostPlatform?: NodeJS.Platform;
|
||||
settings?: Record<string, unknown>;
|
||||
vaultPath?: string | null;
|
||||
}
|
||||
|
||||
export type ProviderConversationSessionAvailability =
|
||||
| 'available'
|
||||
| 'relocated'
|
||||
| 'missing'
|
||||
| 'unknown';
|
||||
|
||||
export type ProviderTaskTerminalStatus = Extract<ToolCallInfo['status'], 'completed' | 'error'>;
|
||||
|
||||
export interface ProviderTaskResultInterpreter {
|
||||
hasAsyncLaunchMarker(toolUseResult: unknown): boolean;
|
||||
extractAgentId(toolUseResult: unknown): string | null;
|
||||
extractStructuredResult(toolUseResult: unknown): string | null;
|
||||
resolveTerminalStatus(
|
||||
toolUseResult: unknown,
|
||||
fallbackStatus: ProviderTaskTerminalStatus,
|
||||
): ProviderTaskTerminalStatus;
|
||||
extractTagValue(payload: string, tagName: string): string | null;
|
||||
}
|
||||
|
||||
export interface ProviderSubagentLaunchResult {
|
||||
agentId?: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
export interface ProviderSubagentWaitStatus {
|
||||
completed?: string;
|
||||
error?: string;
|
||||
failed?: string;
|
||||
}
|
||||
|
||||
export interface ProviderSubagentWaitResult {
|
||||
statuses: Record<string, ProviderSubagentWaitStatus>;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderSubagentLifecycleAdapter {
|
||||
isHiddenTool(name: string): boolean;
|
||||
isSpawnTool(name: string): boolean;
|
||||
isWaitTool(name: string): boolean;
|
||||
isCloseTool(name: string): boolean;
|
||||
resolveSpawnToolIds(
|
||||
waitToolCall: ToolCallInfo,
|
||||
agentIdToSpawnId: ReadonlyMap<string, string>,
|
||||
): string[];
|
||||
buildSubagentInfo(
|
||||
spawnToolCall: ToolCallInfo,
|
||||
siblingToolCalls?: ToolCallInfo[],
|
||||
): SubagentInfo;
|
||||
extractSpawnResult(raw: string | undefined): ProviderSubagentLaunchResult;
|
||||
extractWaitResult(raw: string | undefined): ProviderSubagentWaitResult;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auxiliary service contracts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// -- Title generation --
|
||||
|
||||
export type TitleGenerationResult =
|
||||
| { success: true; title: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export type TitleGenerationCallback = (
|
||||
conversationId: string,
|
||||
result: TitleGenerationResult
|
||||
) => Promise<void>;
|
||||
|
||||
export interface TitleGenerationService {
|
||||
generateTitle(
|
||||
conversationId: string,
|
||||
userMessage: string,
|
||||
callback: TitleGenerationCallback
|
||||
): Promise<void>;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
// -- Instruction refinement --
|
||||
|
||||
export type RefineProgressCallback = (update: InstructionRefineResult) => void;
|
||||
|
||||
export interface InstructionRefineService {
|
||||
setModelOverride?(model?: string): void;
|
||||
resetConversation(): void;
|
||||
refineInstruction(
|
||||
rawInstruction: string,
|
||||
existingInstructions: string,
|
||||
onProgress?: RefineProgressCallback
|
||||
): Promise<InstructionRefineResult>;
|
||||
continueConversation(
|
||||
message: string,
|
||||
onProgress?: RefineProgressCallback
|
||||
): Promise<InstructionRefineResult>;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
// -- Inline edit --
|
||||
|
||||
export type InlineEditMode = 'selection' | 'cursor';
|
||||
|
||||
export interface InlineEditSelectionRequest {
|
||||
mode: 'selection';
|
||||
instruction: string;
|
||||
notePath: string;
|
||||
selectedText: string;
|
||||
startLine?: number;
|
||||
lineCount?: number;
|
||||
contextFiles?: string[];
|
||||
}
|
||||
|
||||
export interface InlineEditCursorRequest {
|
||||
mode: 'cursor';
|
||||
instruction: string;
|
||||
notePath: string;
|
||||
cursorContext: CursorContext;
|
||||
contextFiles?: string[];
|
||||
}
|
||||
|
||||
export type InlineEditRequest = InlineEditSelectionRequest | InlineEditCursorRequest;
|
||||
|
||||
export interface InlineEditResult {
|
||||
success: boolean;
|
||||
editedText?: string;
|
||||
insertedText?: string;
|
||||
clarification?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface InlineEditService {
|
||||
setModelOverride?(model?: string): void;
|
||||
resetConversation(): void;
|
||||
editText(request: InlineEditRequest): Promise<InlineEditResult>;
|
||||
continueConversation(message: string, contextFiles?: string[]): Promise<InlineEditResult>;
|
||||
cancel(): void;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ProviderCapabilities, ProviderId } from '../providers/types';
|
||||
import type { ChatMessage, Conversation, SlashCommand, StreamChunk, ToolCallInfo } from '../types';
|
||||
import type {
|
||||
ApprovalCallback,
|
||||
AskUserQuestionCallback,
|
||||
AutoTurnCallback,
|
||||
ChatRewindMode,
|
||||
ChatRewindResult,
|
||||
ChatRuntimeConversationState,
|
||||
ChatRuntimeEnsureReadyOptions,
|
||||
ChatRuntimeQueryOptions,
|
||||
ChatTurnMetadata,
|
||||
ChatTurnRequest,
|
||||
ExitPlanModeCallback,
|
||||
PreparedChatTurn,
|
||||
SessionUpdateResult,
|
||||
SubagentRuntimeState,
|
||||
} from './types';
|
||||
|
||||
export interface ChatRuntime {
|
||||
readonly providerId: ProviderId;
|
||||
|
||||
getCapabilities(): Readonly<ProviderCapabilities>;
|
||||
prepareTurn(request: ChatTurnRequest): PreparedChatTurn;
|
||||
onReadyStateChange(listener: (ready: boolean) => void): () => void;
|
||||
setResumeCheckpoint(checkpointId: string | undefined): void;
|
||||
syncConversationState(
|
||||
conversation: ChatRuntimeConversationState | null,
|
||||
externalContextPaths?: string[],
|
||||
): void;
|
||||
reloadMcpServers(): Promise<void>;
|
||||
ensureReady(options?: ChatRuntimeEnsureReadyOptions): Promise<boolean>;
|
||||
query(
|
||||
turn: PreparedChatTurn,
|
||||
conversationHistory?: ChatMessage[],
|
||||
queryOptions?: ChatRuntimeQueryOptions,
|
||||
): AsyncGenerator<StreamChunk>;
|
||||
steer?(turn: PreparedChatTurn): Promise<boolean>;
|
||||
cancel(): void;
|
||||
resetSession(): void;
|
||||
getSessionId(): string | null;
|
||||
consumeSessionInvalidation(): boolean;
|
||||
isReady(): boolean;
|
||||
getSupportedCommands(): Promise<SlashCommand[]>;
|
||||
getAuxiliaryModel?(): string | null;
|
||||
cleanup(): void;
|
||||
rewind(userMessageId: string, assistantMessageId: string | undefined, mode?: ChatRewindMode): Promise<ChatRewindResult>;
|
||||
setApprovalCallback(callback: ApprovalCallback | null): void;
|
||||
setApprovalDismisser(dismisser: (() => void) | null): void;
|
||||
setAskUserQuestionCallback(callback: AskUserQuestionCallback | null): void;
|
||||
setExitPlanModeCallback(callback: ExitPlanModeCallback | null): void;
|
||||
setPermissionModeSyncCallback(callback: ((sdkMode: string) => void) | null): void;
|
||||
setSubagentHookProvider(getState: () => SubagentRuntimeState): void;
|
||||
setAutoTurnCallback(callback: AutoTurnCallback | null): void;
|
||||
consumeTurnMetadata(): ChatTurnMetadata;
|
||||
|
||||
buildSessionUpdates(params: {
|
||||
conversation: Conversation | null;
|
||||
sessionInvalidated: boolean;
|
||||
}): SessionUpdateResult;
|
||||
|
||||
resolveSessionIdForFork(conversation: Conversation | null): string | null;
|
||||
|
||||
loadSubagentToolCalls?(agentId: string): Promise<ToolCallInfo[]>;
|
||||
loadSubagentFinalResult?(agentId: string): Promise<string | null>;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ImageAttachment } from '../types';
|
||||
import type { ChatTurnRequest } from './types';
|
||||
|
||||
export interface QueuedChatTurn {
|
||||
displayContent: string;
|
||||
request: ChatTurnRequest;
|
||||
}
|
||||
|
||||
export function cloneChatTurnRequest(request: ChatTurnRequest): ChatTurnRequest {
|
||||
return {
|
||||
...request,
|
||||
images: cloneImages(request.images),
|
||||
externalContextPaths: request.externalContextPaths
|
||||
? [...request.externalContextPaths]
|
||||
: undefined,
|
||||
enabledMcpServers: request.enabledMcpServers
|
||||
? new Set(request.enabledMcpServers)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneQueuedChatTurn(turn: QueuedChatTurn): QueuedChatTurn {
|
||||
return {
|
||||
displayContent: turn.displayContent,
|
||||
request: cloneChatTurnRequest(turn.request),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeQueuedChatTurns(
|
||||
existing: QueuedChatTurn,
|
||||
incoming: QueuedChatTurn,
|
||||
): QueuedChatTurn {
|
||||
const existingRequest = existing.request;
|
||||
const incomingRequest = incoming.request;
|
||||
|
||||
return {
|
||||
displayContent: mergeText(existing.displayContent, incoming.displayContent),
|
||||
request: {
|
||||
...cloneChatTurnRequest(incomingRequest),
|
||||
text: mergeText(existingRequest.text, incomingRequest.text),
|
||||
images: mergeImages(existingRequest.images, incomingRequest.images),
|
||||
currentNotePath: incomingRequest.currentNotePath ?? existingRequest.currentNotePath,
|
||||
externalContextPaths: mergeStringLists(
|
||||
existingRequest.externalContextPaths,
|
||||
incomingRequest.externalContextPaths,
|
||||
),
|
||||
enabledMcpServers: mergeSets(
|
||||
existingRequest.enabledMcpServers,
|
||||
incomingRequest.enabledMcpServers,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeText(first: string, second: string): string {
|
||||
return [first, second]
|
||||
.map(part => part.trim())
|
||||
.filter(part => part.length > 0)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function cloneImages(images: ImageAttachment[] | undefined): ImageAttachment[] | undefined {
|
||||
return images && images.length > 0 ? [...images] : undefined;
|
||||
}
|
||||
|
||||
function mergeImages(
|
||||
first: ImageAttachment[] | undefined,
|
||||
second: ImageAttachment[] | undefined,
|
||||
): ImageAttachment[] | undefined {
|
||||
const merged = [...(first ?? []), ...(second ?? [])];
|
||||
return merged.length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
function mergeStringLists(
|
||||
first: string[] | undefined,
|
||||
second: string[] | undefined,
|
||||
): string[] | undefined {
|
||||
const merged = [...(first ?? []), ...(second ?? [])];
|
||||
if (merged.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.from(new Set(merged));
|
||||
}
|
||||
|
||||
function mergeSets<T>(
|
||||
first: Set<T> | undefined,
|
||||
second: Set<T> | undefined,
|
||||
): Set<T> | undefined {
|
||||
const merged = new Set<T>();
|
||||
for (const value of first ?? []) {
|
||||
merged.add(value);
|
||||
}
|
||||
for (const value of second ?? []) {
|
||||
merged.add(value);
|
||||
}
|
||||
return merged.size > 0 ? merged : undefined;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { BrowserSelectionContext } from '../../utils/browser';
|
||||
import type { CanvasSelectionContext } from '../../utils/canvas';
|
||||
import type { EditorSelectionContext } from '../../utils/editor';
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
Conversation,
|
||||
ExitPlanModeCallback,
|
||||
ImageAttachment,
|
||||
StreamChunk,
|
||||
} from '../types';
|
||||
|
||||
export interface ApprovalDecisionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: string;
|
||||
decision?: ApprovalDecision;
|
||||
}
|
||||
|
||||
export interface ApprovalNetworkContext {
|
||||
host: string;
|
||||
protocol: string;
|
||||
}
|
||||
|
||||
export interface ApprovalCallbackOptions {
|
||||
decisionReason?: string;
|
||||
blockedPath?: string;
|
||||
agentID?: string;
|
||||
decisionOptions?: ApprovalDecisionOption[];
|
||||
networkApprovalContext?: ApprovalNetworkContext;
|
||||
additionalPermissions?: unknown;
|
||||
}
|
||||
|
||||
export type ApprovalCallback = (
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
description: string,
|
||||
options?: ApprovalCallbackOptions,
|
||||
) => Promise<ApprovalDecision>;
|
||||
|
||||
export type AskUserQuestionCallback = (
|
||||
input: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<Record<string, string | string[]> | null>;
|
||||
|
||||
export interface ChatTurnRequest {
|
||||
text: string;
|
||||
images?: ImageAttachment[];
|
||||
currentNotePath?: string;
|
||||
editorSelection?: EditorSelectionContext | null;
|
||||
browserSelection?: BrowserSelectionContext | null;
|
||||
canvasSelection?: CanvasSelectionContext | null;
|
||||
externalContextPaths?: string[];
|
||||
enabledMcpServers?: Set<string>;
|
||||
}
|
||||
|
||||
export interface PreparedChatTurn {
|
||||
request: ChatTurnRequest;
|
||||
persistedContent: string;
|
||||
prompt: string;
|
||||
isCompact: boolean;
|
||||
mcpMentions: Set<string>;
|
||||
}
|
||||
|
||||
export interface ChatRuntimeQueryOptions {
|
||||
allowedTools?: string[];
|
||||
model?: string;
|
||||
mcpMentions?: Set<string>;
|
||||
enabledMcpServers?: Set<string>;
|
||||
forceColdStart?: boolean;
|
||||
externalContextPaths?: string[];
|
||||
}
|
||||
|
||||
export interface ChatRuntimeEnsureReadyOptions {
|
||||
allowSessionCreation?: boolean;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export type ChatRuntimeConversationState = Pick<
|
||||
Conversation,
|
||||
'sessionId' | 'providerState' | 'selectedModel'
|
||||
> & Partial<Pick<Conversation, 'id'>>;
|
||||
|
||||
export interface SessionUpdateResult {
|
||||
updates: Partial<Conversation>;
|
||||
}
|
||||
|
||||
export interface ChatRewindResult {
|
||||
canRewind: boolean;
|
||||
error?: string;
|
||||
filesChanged?: string[];
|
||||
insertions?: number;
|
||||
deletions?: number;
|
||||
}
|
||||
|
||||
export type ChatRewindMode = 'conversation' | 'code-and-conversation';
|
||||
|
||||
export interface SubagentRuntimeState {
|
||||
hasRunning: boolean;
|
||||
}
|
||||
|
||||
export interface ChatTurnMetadata {
|
||||
userMessageId?: string;
|
||||
assistantMessageId?: string;
|
||||
wasSent?: boolean;
|
||||
planCompleted?: boolean;
|
||||
}
|
||||
|
||||
export interface AutoTurnResult {
|
||||
chunks: StreamChunk[];
|
||||
metadata: ChatTurnMetadata;
|
||||
}
|
||||
|
||||
export type AutoTurnCallback = (result: AutoTurnResult) => void | Promise<void>;
|
||||
|
||||
export type {
|
||||
ApprovalDecision,
|
||||
ExitPlanModeCallback,
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
/** Permission utilities for tool action approval. */
|
||||
|
||||
import {
|
||||
TOOL_BASH,
|
||||
TOOL_EDIT,
|
||||
TOOL_GLOB,
|
||||
TOOL_GREP,
|
||||
TOOL_NOTEBOOK_EDIT,
|
||||
TOOL_READ,
|
||||
TOOL_WRITE,
|
||||
} from '../tools/toolNames';
|
||||
|
||||
export function getActionPattern(toolName: string, input: Record<string, unknown>): string | null {
|
||||
switch (toolName) {
|
||||
case TOOL_BASH:
|
||||
return typeof input.command === 'string' ? input.command.trim() : '';
|
||||
case TOOL_READ:
|
||||
case TOOL_WRITE:
|
||||
case TOOL_EDIT:
|
||||
return typeof input.file_path === 'string' && input.file_path ? input.file_path : null;
|
||||
case TOOL_NOTEBOOK_EDIT:
|
||||
if (typeof input.notebook_path === 'string' && input.notebook_path) {
|
||||
return input.notebook_path;
|
||||
}
|
||||
return typeof input.file_path === 'string' && input.file_path ? input.file_path : null;
|
||||
case TOOL_GLOB:
|
||||
return typeof input.pattern === 'string' && input.pattern ? input.pattern : null;
|
||||
case TOOL_GREP:
|
||||
return typeof input.pattern === 'string' && input.pattern ? input.pattern : null;
|
||||
default:
|
||||
return JSON.stringify(input);
|
||||
}
|
||||
}
|
||||
|
||||
export function getActionDescription(toolName: string, input: Record<string, unknown>): string {
|
||||
const pattern = getActionPattern(toolName, input) ?? '(unknown)';
|
||||
switch (toolName) {
|
||||
case TOOL_BASH:
|
||||
return `Run command: ${pattern}`;
|
||||
case TOOL_READ:
|
||||
return `Read file: ${pattern}`;
|
||||
case TOOL_WRITE:
|
||||
return `Write to file: ${pattern}`;
|
||||
case TOOL_EDIT:
|
||||
return `Edit file: ${pattern}`;
|
||||
case TOOL_GLOB:
|
||||
return `Search files matching: ${pattern}`;
|
||||
case TOOL_GREP:
|
||||
return `Search content matching: ${pattern}`;
|
||||
default:
|
||||
return `${toolName}: ${pattern}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bash: exact or explicit wildcard ("git *", "npm:*").
|
||||
* File tools: path-prefix matching with segment boundaries.
|
||||
* Other tools: simple prefix matching.
|
||||
*/
|
||||
export function matchesRulePattern(
|
||||
toolName: string,
|
||||
actionPattern: string | null,
|
||||
rulePattern: string | undefined
|
||||
): boolean {
|
||||
// No rule pattern means match all
|
||||
if (!rulePattern) return true;
|
||||
|
||||
// Null action pattern means we can't determine the action - don't match
|
||||
if (actionPattern === null) return false;
|
||||
|
||||
const normalizedAction = actionPattern.replace(/\\/g, '/');
|
||||
const normalizedRule = rulePattern.replace(/\\/g, '/');
|
||||
|
||||
// Wildcard matches everything
|
||||
if (normalizedRule === '*') return true;
|
||||
|
||||
// Exact match
|
||||
if (normalizedAction === normalizedRule) return true;
|
||||
|
||||
// Bash: Only exact match (handled above) or explicit wildcard patterns are allowed.
|
||||
// This is intentional - Bash commands require explicit wildcards for security.
|
||||
// Supported formats:
|
||||
// - "git *" matches "git status", "git commit", etc.
|
||||
// - "npm:*" matches "npm install", "npm run", etc. (CC format)
|
||||
if (toolName === TOOL_BASH) {
|
||||
// CC format "npm:*" — colon is a separator, not part of the prefix
|
||||
if (normalizedRule.endsWith(':*')) {
|
||||
const prefix = normalizedRule.slice(0, -2);
|
||||
return matchesBashPrefix(normalizedAction, prefix);
|
||||
}
|
||||
// Space wildcard "git *"
|
||||
if (normalizedRule.endsWith('*')) {
|
||||
const prefix = normalizedRule.slice(0, -1);
|
||||
return matchesBashPrefix(normalizedAction, prefix);
|
||||
}
|
||||
// No wildcard present and exact match failed above - reject
|
||||
return false;
|
||||
}
|
||||
|
||||
// File tools: prefix match with path-segment boundary awareness
|
||||
if (
|
||||
toolName === TOOL_READ ||
|
||||
toolName === TOOL_WRITE ||
|
||||
toolName === TOOL_EDIT ||
|
||||
toolName === TOOL_NOTEBOOK_EDIT
|
||||
) {
|
||||
return isPathPrefixMatch(normalizedAction, normalizedRule);
|
||||
}
|
||||
|
||||
// Other tools: allow simple prefix matching
|
||||
if (normalizedAction.startsWith(normalizedRule)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPathPrefixMatch(actionPath: string, approvedPath: string): boolean {
|
||||
if (!actionPath.startsWith(approvedPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (approvedPath.endsWith('/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (actionPath.length === approvedPath.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return actionPath.charAt(approvedPath.length) === '/';
|
||||
}
|
||||
|
||||
function matchesBashPrefix(action: string, prefix: string): boolean {
|
||||
if (action === prefix) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (prefix.endsWith(' ')) {
|
||||
return action.startsWith(prefix);
|
||||
}
|
||||
|
||||
return action.startsWith(`${prefix} `);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { VaultFileAdapter } from './VaultFileAdapter';
|
||||
|
||||
/**
|
||||
* Filesystem adapter rooted at the user's home directory.
|
||||
* Implements the same interface as VaultFileAdapter so storage
|
||||
* classes (like CodexSkillStorage) can scan home-level paths.
|
||||
*/
|
||||
export class HomeFileAdapter implements Pick<VaultFileAdapter,
|
||||
'exists' | 'read' | 'write' | 'delete' | 'deleteFolder' | 'listFolders' | 'ensureFolder'
|
||||
> {
|
||||
private readonly root: string;
|
||||
|
||||
constructor(root: string = os.homedir()) {
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
private resolve(relativePath: string): string {
|
||||
return path.join(this.root, relativePath);
|
||||
}
|
||||
|
||||
async exists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.access(this.resolve(p));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async read(p: string): Promise<string> {
|
||||
return fs.promises.readFile(this.resolve(p), 'utf-8');
|
||||
}
|
||||
|
||||
async write(p: string, content: string): Promise<void> {
|
||||
const full = this.resolve(p);
|
||||
await fs.promises.mkdir(path.dirname(full), { recursive: true });
|
||||
await fs.promises.writeFile(full, content, 'utf-8');
|
||||
}
|
||||
|
||||
async delete(p: string): Promise<void> {
|
||||
try {
|
||||
await fs.promises.unlink(this.resolve(p));
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFolder(p: string): Promise<void> {
|
||||
try {
|
||||
await fs.promises.rmdir(this.resolve(p));
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
async listFolders(folder: string): Promise<string[]> {
|
||||
const full = this.resolve(folder);
|
||||
try {
|
||||
const entries = await fs.promises.readdir(full, { withFileTypes: true });
|
||||
return entries
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => `${folder}/${e.name}`);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async ensureFolder(p: string): Promise<void> {
|
||||
await fs.promises.mkdir(this.resolve(p), { recursive: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** A mutation failure that has already been presented to the user. */
|
||||
export class NotifiedMutationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NotifiedMutationError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isNotifiedMutationError(error: unknown): error is NotifiedMutationError {
|
||||
return error instanceof NotifiedMutationError;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* VaultFileAdapter - Wrapper around Obsidian Vault API for file operations.
|
||||
*
|
||||
* Provides a consistent interface for file operations using Obsidian's
|
||||
* vault adapter instead of Node's fs module.
|
||||
*/
|
||||
|
||||
import type { App } from 'obsidian';
|
||||
|
||||
export class VaultFileAdapter {
|
||||
private writeQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(private app: App) {}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
return this.app.vault.adapter.exists(path);
|
||||
}
|
||||
|
||||
async read(path: string): Promise<string> {
|
||||
return this.app.vault.adapter.read(path);
|
||||
}
|
||||
|
||||
async write(path: string, content: string): Promise<void> {
|
||||
await this.ensureParentFolder(path);
|
||||
await this.app.vault.adapter.write(path, content);
|
||||
}
|
||||
|
||||
async append(path: string, content: string): Promise<void> {
|
||||
await this.ensureParentFolder(path);
|
||||
this.writeQueue = this.writeQueue.then(async () => {
|
||||
if (await this.exists(path)) {
|
||||
const existing = await this.read(path);
|
||||
await this.app.vault.adapter.write(path, existing + content);
|
||||
} else {
|
||||
await this.app.vault.adapter.write(path, content);
|
||||
}
|
||||
}).catch(() => {
|
||||
// prevent queue from getting stuck
|
||||
});
|
||||
await this.writeQueue;
|
||||
}
|
||||
|
||||
async delete(path: string): Promise<void> {
|
||||
if (await this.exists(path)) {
|
||||
await this.app.vault.adapter.remove(path);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fails silently if non-empty or missing. */
|
||||
async deleteFolder(path: string): Promise<void> {
|
||||
try {
|
||||
if (await this.exists(path)) {
|
||||
await this.app.vault.adapter.rmdir(path, false);
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: directory may not be empty
|
||||
}
|
||||
}
|
||||
|
||||
async listFiles(folder: string): Promise<string[]> {
|
||||
if (!(await this.exists(folder))) {
|
||||
return [];
|
||||
}
|
||||
const listing = await this.app.vault.adapter.list(folder);
|
||||
return listing.files;
|
||||
}
|
||||
|
||||
/** List subfolders in a folder. Returns relative paths from the folder. */
|
||||
async listFolders(folder: string): Promise<string[]> {
|
||||
if (!(await this.exists(folder))) {
|
||||
return [];
|
||||
}
|
||||
const listing = await this.app.vault.adapter.list(folder);
|
||||
return listing.folders;
|
||||
}
|
||||
|
||||
/** Recursively list all files in a folder and subfolders. */
|
||||
async listFilesRecursive(folder: string): Promise<string[]> {
|
||||
const allFiles: string[] = [];
|
||||
|
||||
const processFolder = async (currentFolder: string) => {
|
||||
if (!(await this.exists(currentFolder))) return;
|
||||
|
||||
const listing = await this.app.vault.adapter.list(currentFolder);
|
||||
allFiles.push(...listing.files);
|
||||
|
||||
for (const subfolder of listing.folders) {
|
||||
await processFolder(subfolder);
|
||||
}
|
||||
};
|
||||
|
||||
await processFolder(folder);
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
private async ensureParentFolder(filePath: string): Promise<void> {
|
||||
const folder = filePath.substring(0, filePath.lastIndexOf('/'));
|
||||
if (folder && !(await this.exists(folder))) {
|
||||
await this.ensureFolder(folder);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure a folder exists, creating it and parent folders if needed. */
|
||||
async ensureFolder(path: string): Promise<void> {
|
||||
if (await this.exists(path)) return;
|
||||
|
||||
// Create parent folders recursively
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
let current = '';
|
||||
for (const part of parts) {
|
||||
current = current ? `${current}/${part}` : part;
|
||||
if (!(await this.exists(current))) {
|
||||
await this.app.vault.adapter.mkdir(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Rename/move a file. */
|
||||
async rename(oldPath: string, newPath: string): Promise<void> {
|
||||
await this.app.vault.adapter.rename(oldPath, newPath);
|
||||
}
|
||||
|
||||
async stat(path: string): Promise<{ mtime: number; size: number } | null> {
|
||||
try {
|
||||
const stat = await this.app.vault.adapter.stat(path);
|
||||
if (!stat) return null;
|
||||
return { mtime: stat.mtime, size: stat.size };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
function getPathModule(value: string): typeof path.posix {
|
||||
return value.includes('\\') || /^[A-Za-z]:/.test(value)
|
||||
? path.win32
|
||||
: path.posix;
|
||||
}
|
||||
|
||||
function isHostPath(pathModule: typeof path.posix): boolean {
|
||||
return process.platform === 'win32'
|
||||
? pathModule === path.win32
|
||||
: pathModule === path.posix;
|
||||
}
|
||||
|
||||
function resolveExistingPath(value: string, pathModule: typeof path.posix): string {
|
||||
if (!isHostPath(pathModule)) {
|
||||
return pathModule.resolve(value);
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.realpathSync.native(value);
|
||||
} catch {
|
||||
return pathModule.resolve(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function isPathWithinRoot(candidate: string, root: string): boolean {
|
||||
if (!candidate.trim() || !root.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pathModule = getPathModule(root);
|
||||
if (getPathModule(candidate) !== pathModule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedRoot = resolveExistingPath(root, pathModule);
|
||||
const normalizedCandidate = resolveExistingPath(candidate, pathModule);
|
||||
const relative = pathModule.relative(normalizedRoot, normalizedCandidate);
|
||||
|
||||
return relative === '' || (
|
||||
relative !== '..'
|
||||
&& !relative.startsWith(`..${pathModule.sep}`)
|
||||
&& !pathModule.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
export function isSamePath(left: string, right: string): boolean {
|
||||
return isPathWithinRoot(left, right) && isPathWithinRoot(right, left);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Todo tool helpers.
|
||||
*
|
||||
* Parses TodoWrite tool input into typed todo items.
|
||||
*/
|
||||
|
||||
import { TOOL_TODO_WRITE } from './toolNames';
|
||||
|
||||
export interface TodoItem {
|
||||
/** Imperative description (e.g., "Run tests") */
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
/** Present continuous form (e.g., "Running tests") */
|
||||
activeForm: string;
|
||||
}
|
||||
|
||||
function isValidTodoItem(item: unknown): item is TodoItem {
|
||||
if (typeof item !== 'object' || item === null) return false;
|
||||
const record = item as Record<string, unknown>;
|
||||
return (
|
||||
typeof record.content === 'string' &&
|
||||
record.content.length > 0 &&
|
||||
typeof record.activeForm === 'string' &&
|
||||
record.activeForm.length > 0 &&
|
||||
typeof record.status === 'string' &&
|
||||
['pending', 'in_progress', 'completed'].includes(record.status)
|
||||
);
|
||||
}
|
||||
|
||||
export function parseTodoInput(input: Record<string, unknown>): TodoItem[] | null {
|
||||
if (!input.todos || !Array.isArray(input.todos)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validTodos: TodoItem[] = [];
|
||||
for (const item of input.todos) {
|
||||
if (isValidTodoItem(item)) {
|
||||
validTodos.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return validTodos.length > 0 ? validTodos : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the last TodoWrite todos from a list of messages.
|
||||
* Used to restore the todo panel when loading a saved conversation.
|
||||
*/
|
||||
export function extractLastTodosFromMessages(
|
||||
messages: Array<{ role: string; toolCalls?: Array<{ name: string; input: Record<string, unknown> }> }>
|
||||
): TodoItem[] | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role === 'assistant' && msg.toolCalls) {
|
||||
for (let j = msg.toolCalls.length - 1; j >= 0; j--) {
|
||||
const toolCall = msg.toolCalls[j];
|
||||
if (toolCall.name === TOOL_TODO_WRITE) {
|
||||
const todos = parseTodoInput(toolCall.input);
|
||||
return todos;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
TOOL_AGENT_OUTPUT,
|
||||
TOOL_APPLY_PATCH,
|
||||
TOOL_ASK_USER_QUESTION,
|
||||
TOOL_BASH,
|
||||
TOOL_BASH_OUTPUT,
|
||||
TOOL_CLOSE_AGENT,
|
||||
TOOL_EDIT,
|
||||
TOOL_ENTER_PLAN_MODE,
|
||||
TOOL_EXIT_PLAN_MODE,
|
||||
TOOL_GLOB,
|
||||
TOOL_GREP,
|
||||
TOOL_KILL_SHELL,
|
||||
TOOL_LIST_MCP_RESOURCES,
|
||||
TOOL_LS,
|
||||
TOOL_MCP,
|
||||
TOOL_NOTEBOOK_EDIT,
|
||||
TOOL_READ,
|
||||
TOOL_READ_MCP_RESOURCE,
|
||||
TOOL_RESUME_AGENT,
|
||||
TOOL_SEND_INPUT,
|
||||
TOOL_SKILL,
|
||||
TOOL_SPAWN_AGENT,
|
||||
TOOL_SUBAGENT_LEGACY,
|
||||
TOOL_TASK,
|
||||
TOOL_TODO_WRITE,
|
||||
TOOL_TOOL_SEARCH,
|
||||
TOOL_WAIT,
|
||||
TOOL_WAIT_AGENT,
|
||||
TOOL_WEB_FETCH,
|
||||
TOOL_WEB_SEARCH,
|
||||
TOOL_WRITE,
|
||||
TOOL_WRITE_STDIN,
|
||||
} from './toolNames';
|
||||
|
||||
const TOOL_ICONS: Record<string, string> = {
|
||||
[TOOL_READ]: 'file-text',
|
||||
[TOOL_WRITE]: 'file-plus',
|
||||
[TOOL_EDIT]: 'file-pen',
|
||||
[TOOL_NOTEBOOK_EDIT]: 'file-pen',
|
||||
[TOOL_BASH]: 'terminal',
|
||||
[TOOL_BASH_OUTPUT]: 'terminal',
|
||||
[TOOL_KILL_SHELL]: 'terminal',
|
||||
[TOOL_GLOB]: 'folder-search',
|
||||
[TOOL_GREP]: 'search',
|
||||
[TOOL_LS]: 'list',
|
||||
[TOOL_TODO_WRITE]: 'list-checks',
|
||||
[TOOL_TASK]: 'bot',
|
||||
[TOOL_SUBAGENT_LEGACY]: 'bot',
|
||||
[TOOL_LIST_MCP_RESOURCES]: 'list',
|
||||
[TOOL_READ_MCP_RESOURCE]: 'file-text',
|
||||
[TOOL_MCP]: 'wrench',
|
||||
[TOOL_WEB_SEARCH]: 'globe',
|
||||
[TOOL_WEB_FETCH]: 'download',
|
||||
[TOOL_AGENT_OUTPUT]: 'bot',
|
||||
[TOOL_ASK_USER_QUESTION]: 'help-circle',
|
||||
[TOOL_SKILL]: 'zap',
|
||||
[TOOL_TOOL_SEARCH]: 'search-check',
|
||||
[TOOL_ENTER_PLAN_MODE]: 'map',
|
||||
[TOOL_EXIT_PLAN_MODE]: 'check-circle',
|
||||
// Runtime-managed tools
|
||||
[TOOL_APPLY_PATCH]: 'file-pen',
|
||||
[TOOL_WRITE_STDIN]: 'terminal',
|
||||
[TOOL_SPAWN_AGENT]: 'bot',
|
||||
[TOOL_SEND_INPUT]: 'bot',
|
||||
[TOOL_WAIT]: 'clock',
|
||||
[TOOL_WAIT_AGENT]: 'clock',
|
||||
[TOOL_RESUME_AGENT]: 'bot',
|
||||
[TOOL_CLOSE_AGENT]: 'bot',
|
||||
};
|
||||
|
||||
/** Special marker for MCP tools - signals to use custom SVG. */
|
||||
export const MCP_ICON_MARKER = '__mcp_icon__';
|
||||
|
||||
export function getToolIcon(toolName: string): string {
|
||||
if (toolName.startsWith('mcp__')) {
|
||||
return MCP_ICON_MARKER;
|
||||
}
|
||||
return TOOL_ICONS[toolName] || 'wrench';
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Tool input helpers.
|
||||
*
|
||||
* Keeps parsing of common tool inputs consistent across services.
|
||||
*/
|
||||
|
||||
import type { AskUserAnswers } from '../types/tools';
|
||||
import {
|
||||
TOOL_EDIT,
|
||||
TOOL_GLOB,
|
||||
TOOL_GREP,
|
||||
TOOL_LS,
|
||||
TOOL_NOTEBOOK_EDIT,
|
||||
TOOL_READ,
|
||||
TOOL_WRITE,
|
||||
} from './toolNames';
|
||||
|
||||
export function extractResolvedAnswers(toolUseResult: unknown): AskUserAnswers | undefined {
|
||||
if (typeof toolUseResult !== 'object' || toolUseResult === null) return undefined;
|
||||
const r = toolUseResult as Record<string, unknown>;
|
||||
return normalizeAnswersObject(r.answers);
|
||||
}
|
||||
|
||||
function normalizeAnswerValue(value: unknown): string | string[] | undefined {
|
||||
if (typeof value === 'string') return value;
|
||||
if (Array.isArray(value)) {
|
||||
const normalized = value
|
||||
.map((item) => (typeof item === 'string' ? item : String(item)))
|
||||
.filter(Boolean)
|
||||
.filter((item) => item.length > 0);
|
||||
if (normalized.length === 0) return undefined;
|
||||
return normalized.length === 1 ? normalized[0] : normalized;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const record = value as Record<string, unknown>;
|
||||
if ('answers' in record) return normalizeAnswerValue(record.answers);
|
||||
if ('answer' in record) return normalizeAnswerValue(record.answer);
|
||||
if ('value' in record) return normalizeAnswerValue(record.value);
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeAnswersObject(value: unknown): AskUserAnswers | undefined {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
||||
|
||||
const answers: AskUserAnswers = {};
|
||||
for (const [question, rawValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
const normalized = normalizeAnswerValue(rawValue);
|
||||
if (normalized) {
|
||||
answers[question] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(answers).length > 0 ? answers : undefined;
|
||||
}
|
||||
|
||||
function parseAnswersFromJsonObject(resultText: string): AskUserAnswers | undefined {
|
||||
const start = resultText.indexOf('{');
|
||||
const end = resultText.lastIndexOf('}');
|
||||
if (start < 0 || end <= start) return undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(resultText.slice(start, end + 1)) as unknown;
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
const record = parsed as Record<string, unknown>;
|
||||
return normalizeAnswersObject(record.answers) ?? normalizeAnswersObject(parsed);
|
||||
}
|
||||
return normalizeAnswersObject(parsed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseAnswersFromQuotedPairs(resultText: string): AskUserAnswers | undefined {
|
||||
const answers: AskUserAnswers = {};
|
||||
const pattern = /"([^"]+)"="([^"]*)"/g;
|
||||
|
||||
for (const match of resultText.matchAll(pattern)) {
|
||||
const question = match[1]?.trim();
|
||||
if (!question) continue;
|
||||
answers[question] = match[2] ?? '';
|
||||
}
|
||||
|
||||
return Object.keys(answers).length > 0 ? answers : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback extractor for AskUserQuestion results when structured `toolUseResult.answers`
|
||||
* is unavailable (for example after reload from JSONL history).
|
||||
*/
|
||||
export function extractResolvedAnswersFromResultText(result: unknown): AskUserAnswers | undefined {
|
||||
if (typeof result !== 'string') return undefined;
|
||||
const trimmed = result.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
return parseAnswersFromJsonObject(trimmed) ?? parseAnswersFromQuotedPairs(trimmed);
|
||||
}
|
||||
|
||||
export function getPathFromToolInput(
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>
|
||||
): string | null {
|
||||
switch (toolName) {
|
||||
case TOOL_READ:
|
||||
case TOOL_WRITE:
|
||||
case TOOL_EDIT:
|
||||
case TOOL_NOTEBOOK_EDIT:
|
||||
return (toolInput.file_path as string) || (toolInput.notebook_path as string) || null;
|
||||
case TOOL_GLOB:
|
||||
return (toolInput.path as string) || (toolInput.pattern as string) || null;
|
||||
case TOOL_GREP:
|
||||
return (toolInput.path as string) || null;
|
||||
case TOOL_LS:
|
||||
return (toolInput.path as string) || null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
export const TOOL_AGENT_OUTPUT = 'TaskOutput' as const;
|
||||
export const TOOL_ASK_USER_QUESTION = 'AskUserQuestion' as const;
|
||||
export const TOOL_BASH = 'Bash' as const;
|
||||
export const TOOL_BASH_OUTPUT = 'BashOutput' as const;
|
||||
export const TOOL_EDIT = 'Edit' as const;
|
||||
export const TOOL_GLOB = 'Glob' as const;
|
||||
export const TOOL_GREP = 'Grep' as const;
|
||||
export const TOOL_KILL_SHELL = 'KillShell' as const;
|
||||
export const TOOL_LS = 'LS' as const;
|
||||
export const TOOL_LIST_MCP_RESOURCES = 'ListMcpResources' as const;
|
||||
export const TOOL_MCP = 'Mcp' as const;
|
||||
export const TOOL_NOTEBOOK_EDIT = 'NotebookEdit' as const;
|
||||
export const TOOL_READ = 'Read' as const;
|
||||
export const TOOL_READ_MCP_RESOURCE = 'ReadMcpResource' as const;
|
||||
export const TOOL_SKILL = 'Skill' as const;
|
||||
export const TOOL_SUBAGENT = 'Agent' as const;
|
||||
export const TOOL_SUBAGENT_LEGACY = 'Task' as const;
|
||||
// Kept as an alias while the internal codebase is still named around "Task".
|
||||
export const TOOL_TASK = TOOL_SUBAGENT;
|
||||
export const TOOL_TODO_WRITE = 'TodoWrite' as const;
|
||||
export const TOOL_TOOL_SEARCH = 'ToolSearch' as const;
|
||||
export const TOOL_WEB_FETCH = 'WebFetch' as const;
|
||||
export const TOOL_WEB_SEARCH = 'WebSearch' as const;
|
||||
export const TOOL_WRITE = 'Write' as const;
|
||||
|
||||
export const TOOL_ENTER_PLAN_MODE = 'EnterPlanMode' as const;
|
||||
export const TOOL_EXIT_PLAN_MODE = 'ExitPlanMode' as const;
|
||||
|
||||
// Runtime-managed tools exposed through provider adapters.
|
||||
export const TOOL_APPLY_PATCH = 'apply_patch' as const;
|
||||
export const TOOL_WRITE_STDIN = 'write_stdin' as const;
|
||||
export const TOOL_SPAWN_AGENT = 'spawn_agent' as const;
|
||||
export const TOOL_SEND_INPUT = 'send_input' as const;
|
||||
export const TOOL_WAIT = 'wait' as const;
|
||||
export const TOOL_WAIT_AGENT = 'wait_agent' as const;
|
||||
export const TOOL_RESUME_AGENT = 'resume_agent' as const;
|
||||
export const TOOL_CLOSE_AGENT = 'close_agent' as const;
|
||||
|
||||
export const AGENT_LIFECYCLE_TOOLS = [
|
||||
TOOL_SPAWN_AGENT,
|
||||
TOOL_SEND_INPUT,
|
||||
TOOL_WAIT,
|
||||
TOOL_WAIT_AGENT,
|
||||
TOOL_RESUME_AGENT,
|
||||
TOOL_CLOSE_AGENT,
|
||||
] as const;
|
||||
|
||||
export function isAgentLifecycleTool(name: string): boolean {
|
||||
return (AGENT_LIFECYCLE_TOOLS as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
/** Tools that should be hidden from rendering when a provider subagent block is shown. */
|
||||
export const SUBAGENT_HIDDEN_TOOLS = [
|
||||
TOOL_WAIT,
|
||||
TOOL_WAIT_AGENT,
|
||||
TOOL_CLOSE_AGENT,
|
||||
] as const;
|
||||
|
||||
export function isSubagentSpawnTool(name: string): boolean {
|
||||
return name === TOOL_SPAWN_AGENT;
|
||||
}
|
||||
|
||||
export function isSubagentHiddenTool(name: string): boolean {
|
||||
return (SUBAGENT_HIDDEN_TOOLS as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
// These tools resolve via dedicated callbacks (not content-based), so their
|
||||
// tool_result should never be marked "blocked" based on result text.
|
||||
export const TOOLS_SKIP_BLOCKED_DETECTION = [
|
||||
TOOL_ENTER_PLAN_MODE,
|
||||
TOOL_EXIT_PLAN_MODE,
|
||||
TOOL_ASK_USER_QUESTION,
|
||||
] as const;
|
||||
|
||||
export const SUBAGENT_TOOL_NAMES = [
|
||||
TOOL_SUBAGENT,
|
||||
TOOL_SUBAGENT_LEGACY,
|
||||
] as const;
|
||||
export type SubagentToolName = (typeof SUBAGENT_TOOL_NAMES)[number];
|
||||
|
||||
export function skipsBlockedDetection(name: string): boolean {
|
||||
return (TOOLS_SKIP_BLOCKED_DETECTION as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
export function isSubagentToolName(name: string): name is SubagentToolName {
|
||||
return (SUBAGENT_TOOL_NAMES as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
export const EDIT_TOOLS = [TOOL_WRITE, TOOL_EDIT, TOOL_NOTEBOOK_EDIT] as const;
|
||||
export type EditToolName = (typeof EDIT_TOOLS)[number];
|
||||
|
||||
export const WRITE_EDIT_TOOLS = [TOOL_WRITE, TOOL_EDIT] as const;
|
||||
export type WriteEditToolName = (typeof WRITE_EDIT_TOOLS)[number];
|
||||
|
||||
export const BASH_TOOLS = [TOOL_BASH, TOOL_BASH_OUTPUT, TOOL_KILL_SHELL] as const;
|
||||
export type BashToolName = (typeof BASH_TOOLS)[number];
|
||||
|
||||
export const FILE_TOOLS = [
|
||||
TOOL_READ,
|
||||
TOOL_WRITE,
|
||||
TOOL_EDIT,
|
||||
TOOL_GLOB,
|
||||
TOOL_GREP,
|
||||
TOOL_LS,
|
||||
TOOL_NOTEBOOK_EDIT,
|
||||
TOOL_BASH,
|
||||
] as const;
|
||||
export type FileToolName = (typeof FILE_TOOLS)[number];
|
||||
|
||||
export const MCP_TOOLS = [
|
||||
TOOL_LIST_MCP_RESOURCES,
|
||||
TOOL_READ_MCP_RESOURCE,
|
||||
TOOL_MCP,
|
||||
] as const;
|
||||
export type McpToolName = (typeof MCP_TOOLS)[number];
|
||||
|
||||
export const READ_ONLY_TOOLS = [
|
||||
TOOL_READ,
|
||||
TOOL_GREP,
|
||||
TOOL_GLOB,
|
||||
TOOL_LS,
|
||||
TOOL_WEB_SEARCH,
|
||||
TOOL_WEB_FETCH,
|
||||
] as const;
|
||||
export type ReadOnlyToolName = (typeof READ_ONLY_TOOLS)[number];
|
||||
|
||||
export function isEditTool(toolName: string): toolName is EditToolName {
|
||||
return (EDIT_TOOLS as readonly string[]).includes(toolName);
|
||||
}
|
||||
|
||||
export function isWriteEditTool(toolName: string): toolName is WriteEditToolName {
|
||||
return (WRITE_EDIT_TOOLS as readonly string[]).includes(toolName);
|
||||
}
|
||||
|
||||
export function isFileTool(toolName: string): toolName is FileToolName {
|
||||
return (FILE_TOOLS as readonly string[]).includes(toolName);
|
||||
}
|
||||
|
||||
export function isBashTool(toolName: string): toolName is BashToolName {
|
||||
return (BASH_TOOLS as readonly string[]).includes(toolName);
|
||||
}
|
||||
|
||||
export function isMcpTool(toolName: string): toolName is McpToolName {
|
||||
return (MCP_TOOLS as readonly string[]).includes(toolName);
|
||||
}
|
||||
|
||||
export function isReadOnlyTool(toolName: string): toolName is ReadOnlyToolName {
|
||||
return (READ_ONLY_TOOLS as readonly string[]).includes(toolName);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface ToolResultContentOptions {
|
||||
fallbackIndent?: number;
|
||||
}
|
||||
|
||||
export function extractToolResultContent(
|
||||
content: unknown,
|
||||
options?: ToolResultContentOptions,
|
||||
): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (content == null) return '';
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const textParts = content.filter(isTextBlock).map((block) => block.text);
|
||||
if (textParts.length > 0) return textParts.join('\n');
|
||||
if (content.length > 0) return JSON.stringify(content, null, options?.fallbackIndent);
|
||||
return '';
|
||||
}
|
||||
|
||||
return JSON.stringify(content, null, options?.fallbackIndent);
|
||||
}
|
||||
|
||||
function isTextBlock(block: unknown): block is { type: 'text'; text: string } {
|
||||
if (!block || typeof block !== 'object') return false;
|
||||
const record = block as Record<string, unknown>;
|
||||
return record.type === 'text' && typeof record.text === 'string';
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface AgentDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
tools?: string[];
|
||||
disallowedTools?: string[];
|
||||
model?: string;
|
||||
source: 'plugin' | 'vault' | 'global' | 'builtin';
|
||||
pluginName?: string;
|
||||
filePath?: string;
|
||||
skills?: string[];
|
||||
permissionMode?: string;
|
||||
hooks?: Record<string, unknown>;
|
||||
extraFrontmatter?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentFrontmatter {
|
||||
name: string;
|
||||
description: string;
|
||||
tools?: string | string[];
|
||||
disallowedTools?: string | string[];
|
||||
model?: string;
|
||||
skills?: string[];
|
||||
permissionMode?: string;
|
||||
hooks?: Record<string, unknown>;
|
||||
extraFrontmatter?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { SDKToolUseResult } from './diff';
|
||||
import type { ProviderId } from './provider';
|
||||
import type { SubagentMode, ToolCallInfo } from './tools';
|
||||
|
||||
/** Fork origin reference: identifies the source session and checkpoint. */
|
||||
export interface ForkSource {
|
||||
sessionId: string;
|
||||
resumeAt: string;
|
||||
}
|
||||
|
||||
/** View type identifier for Obsidian. */
|
||||
export const VIEW_TYPE_CLAUDIAN = 'claudian-view';
|
||||
|
||||
/** Supported image media types for attachments. */
|
||||
export type ImageMediaType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
|
||||
|
||||
/** Image attachment metadata. */
|
||||
export interface ImageAttachment {
|
||||
id: string;
|
||||
name: string;
|
||||
mediaType: ImageMediaType;
|
||||
/** Base64 encoded image data - single source of truth. */
|
||||
data: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
size: number;
|
||||
source: 'file' | 'paste' | 'drop';
|
||||
}
|
||||
|
||||
/** Content block for preserving streaming order in messages. */
|
||||
export type ContentBlock =
|
||||
| { type: 'text'; content: string }
|
||||
| { type: 'tool_use'; toolId: string }
|
||||
| { type: 'thinking'; content: string; durationSeconds?: number }
|
||||
| { type: 'subagent'; subagentId: string; mode?: SubagentMode }
|
||||
| { type: 'context_compacted' };
|
||||
|
||||
/** Chat message with content, tool calls, and attachments. */
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
/** Display-only content (e.g., "/tests" when content is the expanded prompt). */
|
||||
displayContent?: string;
|
||||
timestamp: number;
|
||||
toolCalls?: ToolCallInfo[];
|
||||
contentBlocks?: ContentBlock[];
|
||||
currentNote?: string;
|
||||
images?: ImageAttachment[];
|
||||
/** True if this message represents a user interrupt (from SDK storage). */
|
||||
isInterrupt?: boolean;
|
||||
/** True if this message is rebuilt context sent to SDK on session reset (should be hidden). */
|
||||
isRebuiltContext?: boolean;
|
||||
/** Duration in seconds from user send to response completion. */
|
||||
durationSeconds?: number;
|
||||
/** Flavor word used for duration display (e.g., "Baked", "Cooked"). */
|
||||
durationFlavorWord?: string;
|
||||
/** Provider-native user message identifier used for rewind. */
|
||||
userMessageId?: string;
|
||||
/** Provider-native assistant message identifier used for rewind/fork checkpoints. */
|
||||
assistantMessageId?: string;
|
||||
}
|
||||
|
||||
/** Persisted conversation with messages and session state. */
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
providerId: ProviderId;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
/** Timestamp when the last agent response completed. */
|
||||
lastResponseAt?: number;
|
||||
sessionId: string | null;
|
||||
/** Conversation-owned model selection. Missing values are migrated lazily. */
|
||||
selectedModel?: string;
|
||||
/** Opaque provider-owned state bag (session tracking, fork metadata, etc.). */
|
||||
providerState?: Record<string, unknown>;
|
||||
messages: ChatMessage[];
|
||||
currentNote?: string;
|
||||
/** Session-specific external context paths (directories with full access). Resets on new session. */
|
||||
externalContextPaths?: string[];
|
||||
/** Context window usage information. */
|
||||
usage?: UsageInfo;
|
||||
/** Status of AI title generation. */
|
||||
titleGenerationStatus?: 'pending' | 'success' | 'failed';
|
||||
/** UI-enabled MCP servers for this session (context-saving servers activated via selector). */
|
||||
enabledMcpServers?: string[];
|
||||
/** Assistant checkpoint identifier for resumeAtMessageId after rewind. */
|
||||
resumeAtMessageId?: string;
|
||||
}
|
||||
|
||||
/** Lightweight conversation metadata for the history dropdown. */
|
||||
export interface ConversationMeta {
|
||||
id: string;
|
||||
providerId: ProviderId;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
/** Timestamp when the last agent response completed. */
|
||||
lastResponseAt?: number;
|
||||
messageCount: number;
|
||||
preview: string;
|
||||
/** Status of AI title generation. */
|
||||
titleGenerationStatus?: 'pending' | 'success' | 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Session metadata overlay for provider-native storage.
|
||||
* The provider handles message storage; this stores UI-only state.
|
||||
*/
|
||||
export interface SessionMetadata {
|
||||
id: string;
|
||||
providerId?: ProviderId;
|
||||
title: string;
|
||||
titleGenerationStatus?: 'pending' | 'success' | 'failed';
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastResponseAt?: number;
|
||||
/** Session ID used for provider resume (may be cleared when invalidated). */
|
||||
sessionId?: string | null;
|
||||
/** Conversation-owned model selection. */
|
||||
selectedModel?: string;
|
||||
/** Opaque provider-owned state bag. */
|
||||
providerState?: Record<string, unknown>;
|
||||
currentNote?: string;
|
||||
externalContextPaths?: string[];
|
||||
enabledMcpServers?: string[];
|
||||
usage?: UsageInfo;
|
||||
/** Assistant checkpoint identifier for resumeAtMessageId after rewind. */
|
||||
resumeAtMessageId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized stream chunk emitted by the active provider runtime.
|
||||
*
|
||||
* All providers must emit: text, tool_use, tool_result, error, done, usage.
|
||||
* Provider-specific behavior must be normalized before reaching this contract.
|
||||
* Providers may keep provider-native turn metadata internally and expose it via
|
||||
* runtime methods instead of encoding it as stream-control chunks.
|
||||
*/
|
||||
export type StreamChunk =
|
||||
| { type: 'user_message_start'; content: string; itemId?: string }
|
||||
| { type: 'assistant_message_start'; itemId?: string }
|
||||
| { type: 'text'; content: string }
|
||||
| { type: 'thinking'; content: string }
|
||||
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
|
||||
| { type: 'tool_result'; id: string; content: string; isError?: boolean; toolUseResult?: SDKToolUseResult }
|
||||
| { type: 'tool_output'; id: string; content: string }
|
||||
| {
|
||||
type: 'error';
|
||||
content: string;
|
||||
code?: 'provider_session_missing';
|
||||
providerSessionId?: string;
|
||||
}
|
||||
| { type: 'notice'; content: string; level?: 'info' | 'warning' }
|
||||
| { type: 'done' }
|
||||
| { type: 'usage'; usage: UsageInfo; sessionId?: string | null }
|
||||
| { type: 'context_compacted' }
|
||||
| { type: 'async_subagent_result'; agentId: string; status: 'completed' | 'error'; result?: string }
|
||||
| { type: 'subagent_tool_use'; subagentId: string; id: string; name: string; input: Record<string, unknown> }
|
||||
| { type: 'subagent_tool_result'; subagentId: string; id: string; content: string; isError?: boolean; toolUseResult?: SDKToolUseResult };
|
||||
|
||||
/**
|
||||
* Context window usage information.
|
||||
*
|
||||
* `contextTokens` is the provider-computed total token count in the context window.
|
||||
* Claude sets it to `inputTokens + cacheCreationInputTokens + cacheReadInputTokens`;
|
||||
* other providers should set it to their equivalent total.
|
||||
*
|
||||
* Cache token fields are optional — only providers with prompt caching (Claude)
|
||||
* populate them. Feature code should use `contextTokens` for display, not recompute
|
||||
* from the cache breakdown.
|
||||
*/
|
||||
export interface UsageInfo {
|
||||
model?: string;
|
||||
inputTokens: number;
|
||||
/** Prompt caching: tokens used to create cache entries. Claude-specific; 0 if omitted. */
|
||||
cacheCreationInputTokens?: number;
|
||||
/** Prompt caching: tokens read from cache. Claude-specific; 0 if omitted. */
|
||||
cacheReadInputTokens?: number;
|
||||
contextWindow: number;
|
||||
/** True when `contextWindow` came from provider runtime data instead of a local heuristic. */
|
||||
contextWindowIsAuthoritative?: boolean;
|
||||
contextTokens: number;
|
||||
percentage: number;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Diff-related type definitions.
|
||||
*/
|
||||
|
||||
export interface DiffLine {
|
||||
type: 'equal' | 'insert' | 'delete';
|
||||
text: string;
|
||||
oldLineNum?: number;
|
||||
newLineNum?: number;
|
||||
}
|
||||
|
||||
export interface DiffStats {
|
||||
added: number;
|
||||
removed: number;
|
||||
}
|
||||
|
||||
/** A single hunk from the SDK's structuredPatch format. */
|
||||
export interface StructuredPatchHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
/** Shape of the SDK's toolUseResult object for Write/Edit tools. */
|
||||
export interface SDKToolUseResult {
|
||||
structuredPatch?: StructuredPatchHunk[];
|
||||
filePath?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Chat types
|
||||
export {
|
||||
type ChatMessage,
|
||||
type ContentBlock,
|
||||
type Conversation,
|
||||
type ConversationMeta,
|
||||
type ForkSource,
|
||||
type ImageAttachment,
|
||||
type ImageMediaType,
|
||||
type SessionMetadata,
|
||||
type StreamChunk,
|
||||
type UsageInfo,
|
||||
VIEW_TYPE_CLAUDIAN,
|
||||
} from './chat';
|
||||
export { type ProviderId } from './provider';
|
||||
|
||||
// Settings and command types
|
||||
export {
|
||||
type ApprovalDecision,
|
||||
type ClaudianSettings,
|
||||
type EnvironmentScope,
|
||||
type EnvSnippet,
|
||||
type HostnameCliPaths,
|
||||
type InstructionRefineResult,
|
||||
type KeyboardNavigationSettings,
|
||||
type PermissionMode,
|
||||
type SlashCommand,
|
||||
} from './settings';
|
||||
|
||||
// Diff types
|
||||
export {
|
||||
type DiffLine,
|
||||
type DiffStats,
|
||||
type SDKToolUseResult,
|
||||
type StructuredPatchHunk,
|
||||
} from './diff';
|
||||
|
||||
// Tool types
|
||||
export {
|
||||
type AskUserAnswers,
|
||||
type AskUserQuestionItem,
|
||||
type AskUserQuestionOption,
|
||||
type AsyncSubagentStatus,
|
||||
type ExitPlanModeCallback,
|
||||
type ExitPlanModeDecision,
|
||||
type SubagentInfo,
|
||||
type SubagentMode,
|
||||
type ToolCallInfo,
|
||||
type ToolDiffData,
|
||||
} from './tools';
|
||||
|
||||
// Agent types
|
||||
export {
|
||||
type AgentDefinition,
|
||||
type AgentFrontmatter,
|
||||
} from './agent';
|
||||
|
||||
// Plugin types
|
||||
export {
|
||||
type PluginInfo,
|
||||
type PluginScope,
|
||||
} from './plugins';
|
||||
|
||||
// MCP types
|
||||
export {
|
||||
DEFAULT_MCP_SERVER,
|
||||
getMcpServerType,
|
||||
isValidMcpServerConfig,
|
||||
type ManagedMcpConfigFile,
|
||||
type ManagedMcpServer,
|
||||
type McpConfigFile,
|
||||
type McpHttpServerConfig,
|
||||
type McpServerConfig,
|
||||
type McpServerType,
|
||||
type McpSSEServerConfig,
|
||||
type McpStdioServerConfig,
|
||||
type ParsedMcpConfig,
|
||||
} from './mcp';
|
||||
@@ -0,0 +1,97 @@
|
||||
/** MCP (Model Context Protocol) type definitions used by the shared manager/UI. */
|
||||
|
||||
/** Stdio server configuration (local command-line programs). */
|
||||
export interface McpStdioServerConfig {
|
||||
type?: 'stdio';
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Server-Sent Events remote server configuration. */
|
||||
export interface McpSSEServerConfig {
|
||||
type: 'sse';
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** HTTP remote server configuration. */
|
||||
export interface McpHttpServerConfig {
|
||||
type: 'http';
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Union type for all MCP server configurations. */
|
||||
export type McpServerConfig =
|
||||
| McpStdioServerConfig
|
||||
| McpSSEServerConfig
|
||||
| McpHttpServerConfig;
|
||||
|
||||
/** Server type identifier. */
|
||||
export type McpServerType = 'stdio' | 'sse' | 'http';
|
||||
|
||||
/** Managed MCP server configuration with UI/runtime metadata. */
|
||||
export interface ManagedMcpServer {
|
||||
/** Unique server name (key in mcpServers record). */
|
||||
name: string;
|
||||
config: McpServerConfig;
|
||||
enabled: boolean;
|
||||
/** Context-saving mode: hide tools unless @-mentioned. */
|
||||
contextSaving: boolean;
|
||||
/** Tool names disabled for this server. */
|
||||
disabledTools?: string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** MCP configuration file format used by the current CLI integrations. */
|
||||
export interface McpConfigFile {
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
}
|
||||
|
||||
/** Extended config file with app-owned server metadata. */
|
||||
export interface ManagedMcpConfigFile extends McpConfigFile {
|
||||
_claudian?: {
|
||||
/** Per-server UI/runtime settings. */
|
||||
servers: Record<
|
||||
string,
|
||||
{
|
||||
enabled?: boolean;
|
||||
contextSaving?: boolean;
|
||||
disabledTools?: string[];
|
||||
description?: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
}
|
||||
|
||||
/** Result of parsing clipboard config. */
|
||||
export interface ParsedMcpConfig {
|
||||
servers: Array<{ name: string; config: McpServerConfig }>;
|
||||
needsName: boolean;
|
||||
}
|
||||
|
||||
export function getMcpServerType(config: McpServerConfig): McpServerType {
|
||||
if (config.type === 'sse') return 'sse';
|
||||
if (config.type === 'http') return 'http';
|
||||
if ('url' in config) return 'http'; // URL without explicit type defaults to http
|
||||
return 'stdio';
|
||||
}
|
||||
|
||||
export function isValidMcpServerConfig(obj: unknown): obj is McpServerConfig {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
const config = obj as Record<string, unknown>;
|
||||
|
||||
// Check for stdio (command required)
|
||||
if (config.command && typeof config.command === 'string') return true;
|
||||
|
||||
// Check for sse/http (url required, type is optional - defaults to http)
|
||||
if (config.url && typeof config.url === 'string') return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export const DEFAULT_MCP_SERVER: Omit<ManagedMcpServer, 'name' | 'config'> = {
|
||||
enabled: true,
|
||||
contextSaving: true,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export type PluginScope = 'user' | 'project';
|
||||
|
||||
export interface PluginInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
scope: PluginScope;
|
||||
installPath: string;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type ProviderId = string;
|
||||
@@ -0,0 +1,152 @@
|
||||
export type HiddenProviderCommands = Record<string, string[]>;
|
||||
|
||||
export interface ApprovalSelectionDecision {
|
||||
type: 'select-option';
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** User decision from the approval modal. */
|
||||
export type ApprovalDecision =
|
||||
| 'allow'
|
||||
| 'allow-always'
|
||||
| 'deny'
|
||||
| 'cancel'
|
||||
| ApprovalSelectionDecision;
|
||||
|
||||
/** Saved environment variable configuration. */
|
||||
export interface EnvSnippet {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
envVars: string;
|
||||
scope?: EnvironmentScope;
|
||||
contextLimits?: Record<string, number>; // Optional: context limits for custom models
|
||||
modelAliases?: Record<string, string>; // Optional: display aliases for custom models
|
||||
}
|
||||
|
||||
/** Source of a slash command. */
|
||||
export type SlashCommandSource = 'builtin' | 'user' | 'plugin' | 'sdk';
|
||||
|
||||
/** Slash command configuration shared by the UI, storage, and runtime boundary. */
|
||||
export interface SlashCommand {
|
||||
id: string;
|
||||
name: string; // Command name used after / (e.g., "review-code")
|
||||
description?: string; // Optional description shown in dropdown
|
||||
argumentHint?: string; // Placeholder text for arguments (e.g., "[file] [focus]")
|
||||
allowedTools?: string[]; // Restrict tools when command is used
|
||||
model?: string; // Optional provider-specific model override
|
||||
content: string; // Prompt template with placeholders
|
||||
source?: SlashCommandSource; // Origin of the command (builtin, user, plugin, sdk)
|
||||
kind?: 'command' | 'skill'; // Explicit type — replaces id-prefix heuristic
|
||||
// Provider-owned command metadata that the UI preserves and round-trips.
|
||||
disableModelInvocation?: boolean; // Disable model invocation for this skill
|
||||
userInvocable?: boolean; // Whether user can invoke this skill directly
|
||||
context?: 'fork'; // Subagent execution mode
|
||||
agent?: string; // Subagent type when context='fork'
|
||||
hooks?: Record<string, unknown>; // Pass-through to SDK
|
||||
}
|
||||
|
||||
/** Keyboard navigation settings for vim-style scrolling. */
|
||||
export interface KeyboardNavigationSettings {
|
||||
scrollUpKey: string; // Key to scroll up when focused on messages (default: 'w')
|
||||
scrollDownKey: string; // Key to scroll down when focused on messages (default: 's')
|
||||
focusInputKey: string; // Key to focus input (default: 'i', like vim insert mode)
|
||||
}
|
||||
|
||||
export const CHAT_VIEW_PLACEMENTS = [
|
||||
'right-sidebar',
|
||||
'left-sidebar',
|
||||
'main-tab',
|
||||
] as const;
|
||||
|
||||
/** Workspace location used when opening the Claudian chat view. */
|
||||
export type ChatViewPlacement = typeof CHAT_VIEW_PLACEMENTS[number];
|
||||
|
||||
/** Result from instruction refinement agent query. */
|
||||
export interface InstructionRefineResult {
|
||||
success: boolean;
|
||||
refinedInstruction?: string; // The refined instruction text
|
||||
clarification?: string; // Agent's clarifying question (if any)
|
||||
error?: string; // Error message (if failed)
|
||||
}
|
||||
|
||||
/** Permission mode for tool execution. */
|
||||
export type PermissionMode = 'yolo' | 'plan' | 'normal';
|
||||
|
||||
/** Scope for environment variable storage and snippets. */
|
||||
export type EnvironmentScope = 'shared' | `provider:${string}`;
|
||||
|
||||
/** Opaque device-keyed CLI paths for per-device configuration. */
|
||||
export type HostnameCliPaths = Record<string, string>;
|
||||
|
||||
/** Opaque provider-owned settings bags keyed by provider id. */
|
||||
export type ProviderConfigMap = Partial<Record<string, Record<string, unknown>>>;
|
||||
|
||||
/**
|
||||
* Application settings stored in .claudian/claudian-settings.json.
|
||||
*
|
||||
* Provider-specific fields (model, thinkingBudget, effortLevel, serviceTier, etc.) use
|
||||
* `string` here. The active provider casts internally when it needs
|
||||
* narrower types.
|
||||
*/
|
||||
export interface ClaudianSettings {
|
||||
// User preferences
|
||||
userName: string;
|
||||
|
||||
// Security
|
||||
permissionMode: PermissionMode;
|
||||
|
||||
// Model & thinking (provider interprets values)
|
||||
model: string;
|
||||
thinkingBudget: string;
|
||||
effortLevel: string;
|
||||
serviceTier: string;
|
||||
enableAutoTitleGeneration: boolean;
|
||||
titleGenerationModel: string;
|
||||
|
||||
// Content settings
|
||||
excludedTags: string[];
|
||||
mediaFolder: string;
|
||||
systemPrompt: string;
|
||||
persistentExternalContextPaths: string[];
|
||||
|
||||
// Environment
|
||||
sharedEnvironmentVariables: string;
|
||||
envSnippets: EnvSnippet[];
|
||||
customContextLimits: Record<string, number>;
|
||||
customModelAliases: Record<string, string>;
|
||||
|
||||
// UI settings
|
||||
keyboardNavigation: KeyboardNavigationSettings;
|
||||
requireCommandOrControlEnterToSend: boolean;
|
||||
|
||||
// Internationalization
|
||||
locale: string;
|
||||
|
||||
// Provider-owned settings
|
||||
providerConfigs: ProviderConfigMap;
|
||||
|
||||
// Provider selection
|
||||
settingsProvider: string; // ProviderId — which provider's model/effort/budget is projected to top-level fields
|
||||
savedProviderModel: Partial<Record<string, string>>;
|
||||
savedProviderEffort: Partial<Record<string, string>>;
|
||||
savedProviderServiceTier: Partial<Record<string, string>>;
|
||||
savedProviderThinkingBudget: Partial<Record<string, string>>;
|
||||
savedProviderPermissionMode: Partial<Record<string, string>>;
|
||||
|
||||
// State (provider-specific, round-tripped opaquely)
|
||||
lastCustomModel?: string;
|
||||
|
||||
// UI preferences
|
||||
maxTabs: number;
|
||||
enableAutoScroll: boolean;
|
||||
deferMathRenderingDuringStreaming: boolean;
|
||||
expandFileEditsByDefault: boolean;
|
||||
chatViewPlacement: ChatViewPlacement;
|
||||
|
||||
// Provider command visibility
|
||||
hiddenProviderCommands: HiddenProviderCommands;
|
||||
|
||||
// Allow provider-specific extension fields
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { DiffLine, DiffStats } from './diff';
|
||||
|
||||
/** Diff data for Write/Edit tool operations (pre-computed from SDK structuredPatch). */
|
||||
export interface ToolDiffData {
|
||||
filePath: string;
|
||||
diffLines: DiffLine[];
|
||||
stats: DiffStats;
|
||||
}
|
||||
|
||||
/** Parsed option for AskUserQuestion tool. */
|
||||
export interface AskUserQuestionOption {
|
||||
label: string;
|
||||
description: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
/** Parsed question for AskUserQuestion tool. */
|
||||
export interface AskUserQuestionItem {
|
||||
question: string;
|
||||
id?: string;
|
||||
header: string;
|
||||
options: AskUserQuestionOption[];
|
||||
multiSelect: boolean;
|
||||
isOther?: boolean;
|
||||
isSecret?: boolean;
|
||||
}
|
||||
|
||||
/** User-provided answers keyed by question text or stable question id. */
|
||||
export type AskUserAnswers = Record<string, string | string[]>;
|
||||
|
||||
/** Tool call tracking with status and result. */
|
||||
export interface ToolCallInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
status: 'running' | 'completed' | 'error' | 'blocked';
|
||||
result?: string;
|
||||
isExpanded?: boolean;
|
||||
diffData?: ToolDiffData;
|
||||
resolvedAnswers?: AskUserAnswers;
|
||||
subagent?: SubagentInfo;
|
||||
}
|
||||
|
||||
export type ExitPlanModeDecision =
|
||||
| { type: 'approve' }
|
||||
| { type: 'approve-new-session'; planContent: string }
|
||||
| { type: 'feedback'; text: string };
|
||||
|
||||
export type ExitPlanModeCallback = (
|
||||
input: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<ExitPlanModeDecision | null>;
|
||||
|
||||
/** Subagent execution mode: sync (nested tools) or async (background). */
|
||||
export type SubagentMode = 'sync' | 'async';
|
||||
|
||||
/** Async subagent lifecycle states. */
|
||||
export type AsyncSubagentStatus =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'error'
|
||||
| 'orphaned';
|
||||
|
||||
/** Subagent (Agent tool, legacy Task) tracking for sync and async modes. */
|
||||
export interface SubagentInfo {
|
||||
id: string;
|
||||
description: string;
|
||||
prompt?: string;
|
||||
mode?: SubagentMode;
|
||||
isExpanded: boolean;
|
||||
result?: string;
|
||||
status: 'running' | 'completed' | 'error';
|
||||
toolCalls: ToolCallInfo[];
|
||||
asyncStatus?: AsyncSubagentStatus;
|
||||
agentId?: string;
|
||||
outputToolId?: string;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { App } from 'obsidian';
|
||||
|
||||
import type { SharedAppStorage } from '../core/bootstrap/storage';
|
||||
import type { ProviderHost } from '../core/providers/ProviderHost';
|
||||
import type { AppTabManagerState, ProviderId } from '../core/providers/types';
|
||||
import type { ChatRuntime } from '../core/runtime/ChatRuntime';
|
||||
import type { ClaudianSettings, Conversation, ConversationMeta } from '../core/types';
|
||||
import type { TabData, TabId, TabManagerViewHost } from './chat/tabs/types';
|
||||
|
||||
export interface FeatureTabManagerHost {
|
||||
getAllTabs(): TabData[];
|
||||
getTab(tabId: TabId): TabData | null;
|
||||
switchToTab(tabId: TabId): Promise<void>;
|
||||
broadcastToAllTabs(action: (runtime: ChatRuntime) => Promise<void>): Promise<void>;
|
||||
recycleProviderRuntimes(providerIds: ProviderId | ProviderId[]): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FeatureViewHost extends TabManagerViewHost {
|
||||
getActiveTab(): TabData | null;
|
||||
getTabManager(): FeatureTabManagerHost | null;
|
||||
refreshModelSelector(): void;
|
||||
refreshTabControls(): void;
|
||||
updateHiddenProviderCommands(): void;
|
||||
}
|
||||
|
||||
/** Application capabilities consumed by user-facing features. */
|
||||
export interface FeatureHost {
|
||||
readonly app: App;
|
||||
readonly providerHost: ProviderHost;
|
||||
readonly settings: ClaudianSettings;
|
||||
readonly storage: SharedAppStorage;
|
||||
|
||||
mutateSettings(
|
||||
mutation: (settings: ClaudianSettings) => void | Promise<void>,
|
||||
): Promise<void>;
|
||||
getActiveEnvironmentVariables(providerId?: ProviderId): string;
|
||||
|
||||
createConversation(options?: {
|
||||
providerId?: ProviderId;
|
||||
sessionId?: string;
|
||||
selectedModel?: string;
|
||||
}): Promise<Conversation>;
|
||||
switchConversation(id: string): Promise<Conversation | null>;
|
||||
deleteConversation(
|
||||
id: string,
|
||||
options?: { deleteProviderSession?: boolean },
|
||||
): Promise<void>;
|
||||
handleMissingProviderSession(
|
||||
id: string,
|
||||
missingProviderSessionId?: string,
|
||||
): Promise<'deleted' | 'reset' | 'preserved' | 'not_found'>;
|
||||
renameConversation(id: string, title: string): Promise<void>;
|
||||
updateConversation(id: string, updates: Partial<Conversation>): Promise<void>;
|
||||
getConversationById(id: string): Promise<Conversation | null>;
|
||||
getConversationSync(id: string): Conversation | null;
|
||||
getConversationList(): ConversationMeta[];
|
||||
|
||||
persistTabManagerState(state: AppTabManagerState): Promise<void>;
|
||||
getView(): FeatureViewHost | null;
|
||||
getAllViews(): FeatureViewHost[];
|
||||
findConversationAcrossViews(
|
||||
conversationId: string,
|
||||
): { view: FeatureViewHost; tabId: TabId } | null;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# Chat Feature
|
||||
|
||||
`src/features/chat/` owns the main sidebar chat interface. It assembles tabs, controllers, renderers, and provider-backed services around the shared `ChatRuntime` contract.
|
||||
|
||||
## Provider Boundary
|
||||
|
||||
- Feature code depends on `ChatRuntime`, `ProviderCapabilities`, provider-neutral `Conversation`, and provider-neutral `StreamChunk` values.
|
||||
- `InputController` builds `ChatTurnRequest`; providers own prompt encoding through `prepareTurn()`.
|
||||
- Do not read provider-specific fields from `Conversation.providerState` in feature code. Use runtime methods, provider history services, or typed provider helpers.
|
||||
- Resolve provider-owned services through registries:
|
||||
- `ProviderRegistry`: runtime, title generation, instruction refinement, inline edit, task-result interpretation.
|
||||
- `ProviderWorkspaceRegistry`: command catalogs, agent mentions, MCP managers, CLI resolution, settings tabs.
|
||||
|
||||
## State Flow
|
||||
|
||||
```text
|
||||
User input
|
||||
-> InputController
|
||||
-> ensure runtime for active provider
|
||||
-> ChatRuntime.prepareTurn()
|
||||
-> ChatRuntime.query()
|
||||
-> StreamController
|
||||
-> renderers + ChatState persistence
|
||||
```
|
||||
|
||||
Tabs stay cold until the first send. Keep runtime warmup explicit and provider-owned so command discovery does not accidentally create real sessions for history-backed conversations.
|
||||
|
||||
## Main Parts
|
||||
|
||||
| Area | Owns |
|
||||
| --- | --- |
|
||||
| `ClaudianView` | Lifecycle, assembly, active-tab orchestration |
|
||||
| `ChatState` | Per-tab state and persistence inputs |
|
||||
| Controllers | Conversation, stream, input, selection, browser/canvas selection, navigation |
|
||||
| Renderers | Messages, tools, thinking, diffs, todos, subagents, plan approval, ask-user UI |
|
||||
| Tabs | Tab manager, tab bar, tab state |
|
||||
| UI components | Input toolbar, context managers, status panel, navigation sidebar, mode managers |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `ClaudianView.onClose()` must abort active tabs and dispose runtimes.
|
||||
- `ChatState` is per-tab. `TabManager` coordinates tab-level operations such as forks and provider-aware command catalogs.
|
||||
- Title generation runs concurrently per conversation and routes by the global title-generation model, not the active chat tab provider.
|
||||
- `/compact` is provider-specific:
|
||||
- Claude skips context injection so the provider handles the built-in command.
|
||||
- Codex routes compact turns to `thread/compact/start` and persists `context_compacted`.
|
||||
- Pi sends a `compact` RPC request.
|
||||
- Plan mode is provider-specific:
|
||||
- Claude uses provider/runtime events for enter and exit.
|
||||
- Codex uses `collaborationMode` plus post-stream metadata.
|
||||
- OpenCode maps managed modes to shared permission modes.
|
||||
- Bang-bash mode bypasses provider runtimes and executes a local shell command directly. It is available only when the enabled provider exposes it in `ProviderChatUIConfig`.
|
||||
- Forking is provider-owned under the hood. Use runtime and provider history contracts instead of reconstructing provider session IDs in feature code.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,813 @@
|
||||
import type { EventRef, WorkspaceLeaf } from 'obsidian';
|
||||
import { ItemView, Notice, Scope, setIcon } from 'obsidian';
|
||||
|
||||
import { getHiddenProviderCommandSet } from '../../core/providers/commands/hiddenCommands';
|
||||
import {
|
||||
getProviderSettingsSnapshotWithModel,
|
||||
resolveConversationModel,
|
||||
} from '../../core/providers/conversationModel';
|
||||
import { ProviderRegistry } from '../../core/providers/ProviderRegistry';
|
||||
import { ProviderSettingsCoordinator } from '../../core/providers/ProviderSettingsCoordinator';
|
||||
import { type AppTabManagerState, DEFAULT_CHAT_PROVIDER_ID, type ProviderId } from '../../core/providers/types';
|
||||
import { VIEW_TYPE_CLAUDIAN } from '../../core/types';
|
||||
import { createProviderIconSvg } from '../../shared/icons';
|
||||
import {
|
||||
cancelScheduledAnimationFrame,
|
||||
scheduleAnimationFrame,
|
||||
type ScheduledAnimationFrame,
|
||||
} from '../../utils/animationFrame';
|
||||
import type { FeatureHost } from '../FeatureHost';
|
||||
import type { HistoryConversationStatus } from './controllers/ConversationController';
|
||||
import { MentionCacheCoordinator } from './services/MentionCacheCoordinator';
|
||||
import {
|
||||
getTabProviderId,
|
||||
onProviderAvailabilityChanged,
|
||||
sendTabInputMessageFromExplicitEnterShortcut,
|
||||
updatePlanModeUI,
|
||||
} from './tabs/Tab';
|
||||
import { TabBar } from './tabs/TabBar';
|
||||
import { TabManager } from './tabs/TabManager';
|
||||
import type { TabData, TabId } from './tabs/types';
|
||||
import { recalculateUsageForModel } from './utils/usageInfo';
|
||||
|
||||
type LoadableView = {
|
||||
containerEl?: HTMLElement;
|
||||
load: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export class ClaudianView extends ItemView {
|
||||
private plugin: FeatureHost;
|
||||
|
||||
// Tab management
|
||||
private tabManager: TabManager | null = null;
|
||||
private mentionCacheCoordinator: MentionCacheCoordinator | null = null;
|
||||
private tabBar: TabBar | null = null;
|
||||
private tabBarContainerEl: HTMLElement | null = null;
|
||||
private tabContentEl: HTMLElement | null = null;
|
||||
private navRowContent: HTMLElement | null = null;
|
||||
private inputFooterEl: HTMLElement | null = null;
|
||||
private inputNavRowHostEl: HTMLElement | null = null;
|
||||
private activeInputSlotEl: HTMLElement | null = null;
|
||||
private activeInputTabId: TabId | null = null;
|
||||
|
||||
// DOM Elements
|
||||
private viewContainerEl: HTMLElement | null = null;
|
||||
private logoEl: HTMLElement | null = null;
|
||||
private newTabButtonEl: HTMLElement | null = null;
|
||||
|
||||
// Header elements
|
||||
private historyDropdown: HTMLElement | null = null;
|
||||
|
||||
// Event refs for cleanup
|
||||
private eventRefs: EventRef[] = [];
|
||||
|
||||
// Debouncing for tab bar updates
|
||||
private pendingTabBarUpdate: ScheduledAnimationFrame | null = null;
|
||||
|
||||
// Debouncing for tab state persistence
|
||||
private pendingPersist: number | null = null;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: FeatureHost) {
|
||||
super(leaf);
|
||||
this.plugin = plugin;
|
||||
|
||||
// Hover Editor compatibility: Define load as an instance method that can't be
|
||||
// overwritten by prototype patching. Hover Editor patches ClaudianView.prototype.load
|
||||
// after our class is defined, but instance methods take precedence over prototype methods.
|
||||
const prototype = Object.getPrototypeOf(this) as LoadableView;
|
||||
const originalLoad = prototype.load.bind(this);
|
||||
Object.defineProperty(this, 'load', {
|
||||
value: async () => {
|
||||
// Ensure containerEl exists before any patched load code tries to use it
|
||||
if (!this.containerEl) {
|
||||
(this as LoadableView).containerEl = createDiv({ cls: 'view-content' });
|
||||
}
|
||||
// Wrap in try-catch to prevent Hover Editor errors from breaking our view
|
||||
try {
|
||||
return await originalLoad();
|
||||
} catch {
|
||||
// Hover Editor may throw if its DOM setup fails - continue anyway
|
||||
}
|
||||
},
|
||||
writable: false,
|
||||
configurable: false,
|
||||
});
|
||||
}
|
||||
|
||||
getViewType(): string {
|
||||
return VIEW_TYPE_CLAUDIAN;
|
||||
}
|
||||
|
||||
getDisplayText(): string {
|
||||
return 'Claudian';
|
||||
}
|
||||
|
||||
getIcon(): string {
|
||||
return 'bot';
|
||||
}
|
||||
|
||||
/** Refreshes model-dependent UI across all tabs (used after settings/env changes). */
|
||||
refreshModelSelector(): void {
|
||||
for (const tab of this.tabManager?.getAllTabs() ?? []) {
|
||||
onProviderAvailabilityChanged(tab, this.plugin);
|
||||
const providerId = getTabProviderId(tab, this.plugin);
|
||||
const conversation = tab.conversationId
|
||||
? this.plugin.getConversationSync(tab.conversationId)
|
||||
: null;
|
||||
const modelOverride = conversation
|
||||
? resolveConversationModel(this.plugin.settings, providerId, conversation).model
|
||||
: tab.lifecycleState === 'blank'
|
||||
? tab.draftModel
|
||||
: tab.service?.getAuxiliaryModel?.() ?? null;
|
||||
const providerSettings = getProviderSettingsSnapshotWithModel(
|
||||
this.plugin.settings,
|
||||
providerId,
|
||||
modelOverride,
|
||||
);
|
||||
const model = providerSettings.model;
|
||||
const uiConfig = ProviderRegistry.getChatUIConfig(providerId);
|
||||
const capabilities = ProviderRegistry.getCapabilities(providerId);
|
||||
const contextWindow = uiConfig.getContextWindowSize(
|
||||
model,
|
||||
providerSettings.customContextLimits,
|
||||
providerSettings,
|
||||
);
|
||||
|
||||
if (tab.state.usage) {
|
||||
tab.state.usage = recalculateUsageForModel(tab.state.usage, model, contextWindow);
|
||||
}
|
||||
|
||||
tab.ui.modelSelector?.updateDisplay();
|
||||
tab.ui.modelSelector?.renderOptions();
|
||||
tab.ui.modeSelector?.updateDisplay();
|
||||
tab.ui.modeSelector?.renderOptions();
|
||||
tab.ui.thinkingBudgetSelector?.updateDisplay();
|
||||
tab.ui.permissionToggle?.updateDisplay();
|
||||
tab.ui.serviceTierToggle?.updateDisplay();
|
||||
tab.dom.inputWrapper.toggleClass(
|
||||
'claudian-input-plan-mode',
|
||||
providerSettings.permissionMode === 'plan' && capabilities.supportsPlanMode,
|
||||
);
|
||||
}
|
||||
|
||||
this.tabManager?.primeProviderRuntime();
|
||||
}
|
||||
|
||||
invalidateProviderCommandCaches(providerIds?: ProviderId[]): void {
|
||||
this.tabManager?.invalidateProviderCommandCaches(providerIds);
|
||||
}
|
||||
|
||||
/** Updates provider-scoped hidden commands on all tabs after settings changes. */
|
||||
updateHiddenProviderCommands(): void {
|
||||
for (const tab of this.tabManager?.getAllTabs() ?? []) {
|
||||
tab.ui.slashCommandDropdown?.setHiddenCommands(
|
||||
getHiddenProviderCommandSet(this.plugin.settings, getTabProviderId(tab, this.plugin)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
// Guard: Hover Editor and similar plugins may call onOpen before DOM is ready.
|
||||
// containerEl must exist before we can access contentEl or create elements.
|
||||
if (!this.containerEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use contentEl (standard Obsidian API) as primary target.
|
||||
// Hover Editor and other plugins may modify the DOM structure,
|
||||
// so we need fallbacks to handle non-standard scenarios.
|
||||
let container: HTMLElement | null =
|
||||
this.contentEl ?? (this.containerEl.children[1] as HTMLElement | null);
|
||||
|
||||
if (!container) {
|
||||
// Last resort: create our own container inside containerEl
|
||||
container = this.containerEl.createDiv();
|
||||
}
|
||||
|
||||
this.viewContainerEl = container;
|
||||
this.viewContainerEl.empty();
|
||||
this.viewContainerEl.addClass('claudian-container');
|
||||
|
||||
const header = this.viewContainerEl.createDiv({ cls: 'claudian-header' });
|
||||
this.buildHeader(header);
|
||||
|
||||
this.navRowContent = this.buildNavRowContent();
|
||||
this.tabContentEl = this.viewContainerEl.createDiv({ cls: 'claudian-tab-content-container' });
|
||||
this.buildInputFooter();
|
||||
|
||||
this.tabManager = new TabManager(
|
||||
this.plugin,
|
||||
this.tabContentEl,
|
||||
this,
|
||||
{
|
||||
onTabCreated: () => {
|
||||
this.updateTabBar();
|
||||
this.updateHistoryDropdown();
|
||||
this.updateInputLocation();
|
||||
this.persistTabState();
|
||||
this.syncProviderBrandColor();
|
||||
},
|
||||
onActiveTabChanged: () => {
|
||||
this.updateTabBar();
|
||||
this.updateHistoryDropdown();
|
||||
this.updateInputLocation();
|
||||
this.syncProviderBrandColor();
|
||||
},
|
||||
onTabSwitched: () => {
|
||||
this.updateTabBar();
|
||||
this.updateHistoryDropdown();
|
||||
this.updateInputLocation();
|
||||
this.persistTabState();
|
||||
this.syncProviderBrandColor();
|
||||
},
|
||||
onTabClosed: () => {
|
||||
this.updateTabBar();
|
||||
this.updateHistoryDropdown();
|
||||
this.updateInputLocation();
|
||||
this.persistTabState();
|
||||
},
|
||||
onTabStreamingChanged: () => {
|
||||
this.updateTabBar();
|
||||
this.updateHistoryDropdown();
|
||||
},
|
||||
onTabTitleChanged: () => this.updateTabBar(),
|
||||
onTabAttentionChanged: () => this.updateTabBar(),
|
||||
onTabConversationChanged: () => {
|
||||
this.updateTabBar();
|
||||
this.updateHistoryDropdown();
|
||||
this.persistTabState();
|
||||
this.syncProviderBrandColor();
|
||||
},
|
||||
onTabProviderChanged: () => {
|
||||
this.updateTabBar();
|
||||
this.syncProviderBrandColor();
|
||||
},
|
||||
}
|
||||
);
|
||||
this.mentionCacheCoordinator = new MentionCacheCoordinator(
|
||||
() => (this.tabManager?.getAllTabs() ?? []).map(tab => ({
|
||||
fileContextManager: tab.ui.fileContextManager,
|
||||
})),
|
||||
);
|
||||
|
||||
this.wireEventHandlers();
|
||||
await this.restoreOrCreateTabs();
|
||||
this.syncProviderBrandColor();
|
||||
this.attachNavRowContentToInputFooter();
|
||||
this.updateInputLocation();
|
||||
this.updateTabBarVisibility();
|
||||
this.tabManager?.primeProviderRuntime();
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
if (this.pendingTabBarUpdate !== null) {
|
||||
cancelScheduledAnimationFrame(this.pendingTabBarUpdate);
|
||||
this.pendingTabBarUpdate = null;
|
||||
}
|
||||
|
||||
for (const ref of this.eventRefs) {
|
||||
this.plugin.app.vault.offref(ref);
|
||||
}
|
||||
this.eventRefs = [];
|
||||
|
||||
await this.persistTabStateImmediate();
|
||||
|
||||
this.restoreActiveInputToTabContent();
|
||||
await this.tabManager?.destroy();
|
||||
this.tabManager = null;
|
||||
this.mentionCacheCoordinator = null;
|
||||
|
||||
this.tabBar?.destroy();
|
||||
this.tabBar = null;
|
||||
this.scope = null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UI Building
|
||||
// ============================================
|
||||
|
||||
private buildHeader(header: HTMLElement): void {
|
||||
const titleEl = header.createDiv({ cls: 'claudian-title' });
|
||||
|
||||
this.logoEl = titleEl.createSpan({ cls: 'claudian-logo' });
|
||||
this.syncHeaderLogo(DEFAULT_CHAT_PROVIDER_ID);
|
||||
|
||||
titleEl.createEl('h4', { text: 'Claudian', cls: 'claudian-title-text' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the active tab nav row content.
|
||||
* The wrapper is moved to the active tab's nav row on tab switches.
|
||||
*/
|
||||
private buildNavRowContent(): HTMLElement {
|
||||
const activeDocument = this.containerEl.ownerDocument;
|
||||
|
||||
const fragment = activeDocument.createDocumentFragment();
|
||||
|
||||
this.tabBarContainerEl = activeDocument.createElement('div');
|
||||
this.tabBarContainerEl.className = 'claudian-tab-bar-container';
|
||||
this.tabBar = new TabBar(this.tabBarContainerEl, {
|
||||
onTabClick: (tabId) => this.handleTabClick(tabId),
|
||||
onTabClose: (tabId) => {
|
||||
void this.handleTabClose(tabId);
|
||||
},
|
||||
onNewTab: () => {
|
||||
void this.createNewTab().catch(() => new Notice('Failed to create tab'));
|
||||
},
|
||||
onTitleExpansionChanged: () => this.persistTabState(),
|
||||
});
|
||||
fragment.appendChild(this.tabBarContainerEl);
|
||||
|
||||
const navActionsEl = activeDocument.createElement('div');
|
||||
navActionsEl.className = 'claudian-input-nav-actions';
|
||||
|
||||
this.newTabButtonEl = navActionsEl.createDiv({ cls: 'claudian-input-nav-btn claudian-new-tab-btn' });
|
||||
setIcon(this.newTabButtonEl, 'square-plus');
|
||||
this.newTabButtonEl.setAttribute('aria-label', 'New tab');
|
||||
this.newTabButtonEl.addEventListener('click', () => {
|
||||
void this.createNewTab().catch(() => new Notice('Failed to create tab'));
|
||||
});
|
||||
|
||||
const newBtn = navActionsEl.createDiv({ cls: 'claudian-input-nav-btn' });
|
||||
setIcon(newBtn, 'square-pen');
|
||||
newBtn.setAttribute('aria-label', 'New conversation');
|
||||
newBtn.addEventListener('click', () => {
|
||||
void (async () => {
|
||||
await this.tabManager?.createNewConversation();
|
||||
this.updateHistoryDropdown();
|
||||
})().catch(() => new Notice('Failed to create conversation'));
|
||||
});
|
||||
|
||||
// History dropdown
|
||||
const historyContainer = navActionsEl.createDiv({ cls: 'claudian-history-container' });
|
||||
const historyBtn = historyContainer.createDiv({ cls: 'claudian-input-nav-btn' });
|
||||
setIcon(historyBtn, 'history');
|
||||
historyBtn.setAttribute('aria-label', 'Chat history');
|
||||
|
||||
this.historyDropdown = historyContainer.createDiv({ cls: 'claudian-history-menu' });
|
||||
|
||||
historyBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleHistoryDropdown();
|
||||
});
|
||||
|
||||
fragment.appendChild(navActionsEl);
|
||||
|
||||
const wrapper = activeDocument.createElement('div');
|
||||
wrapper.className = 'claudian-input-nav-content';
|
||||
wrapper.appendChild(fragment);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
private buildInputFooter(): void {
|
||||
if (!this.viewContainerEl) return;
|
||||
|
||||
this.inputFooterEl = this.viewContainerEl.createDiv({ cls: 'claudian-input-footer' });
|
||||
this.inputNavRowHostEl = this.inputFooterEl.createDiv({
|
||||
cls: 'claudian-input-nav-row claudian-view-input-nav-row',
|
||||
});
|
||||
this.activeInputSlotEl = this.inputFooterEl.createDiv({ cls: 'claudian-active-input-slot' });
|
||||
}
|
||||
|
||||
private attachNavRowContentToInputFooter(): void {
|
||||
if (!this.inputNavRowHostEl || !this.navRowContent) return;
|
||||
|
||||
this.tabBar?.captureScrollPosition();
|
||||
this.inputNavRowHostEl.appendChild(this.navRowContent);
|
||||
this.tabBar?.restoreScrollPosition();
|
||||
}
|
||||
|
||||
private updateInputLocation(): void {
|
||||
const activeTab = this.tabManager?.getActiveTab();
|
||||
if (!this.activeInputSlotEl) return;
|
||||
|
||||
if (!activeTab) {
|
||||
this.activeInputSlotEl.empty();
|
||||
this.activeInputTabId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeInputTabId && this.activeInputTabId !== activeTab.id) {
|
||||
const previousTab = this.tabManager?.getTab(this.activeInputTabId);
|
||||
if (previousTab) {
|
||||
previousTab.dom.contentEl.appendChild(previousTab.dom.inputComposerEl);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.activeInputTabId === activeTab.id) {
|
||||
if (activeTab.dom.inputComposerEl.parentElement !== this.activeInputSlotEl) {
|
||||
this.activeInputSlotEl.appendChild(activeTab.dom.inputComposerEl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeInputSlotEl.empty();
|
||||
this.activeInputSlotEl.appendChild(activeTab.dom.inputComposerEl);
|
||||
this.activeInputTabId = activeTab.id;
|
||||
}
|
||||
|
||||
private restoreActiveInputToTabContent(): void {
|
||||
if (!this.activeInputTabId) return;
|
||||
|
||||
const activeInputTab = this.tabManager?.getTab(this.activeInputTabId);
|
||||
if (activeInputTab) {
|
||||
activeInputTab.dom.contentEl.appendChild(activeInputTab.dom.inputComposerEl);
|
||||
}
|
||||
this.activeInputSlotEl?.empty();
|
||||
this.activeInputTabId = null;
|
||||
}
|
||||
|
||||
/** Refreshes tab controls after settings that affect tab availability change. */
|
||||
refreshTabControls(): void {
|
||||
this.updateTabBarVisibility();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Tab Management
|
||||
// ============================================
|
||||
|
||||
private handleTabClick(tabId: TabId): void {
|
||||
const switched = this.tabManager?.switchToTab(tabId);
|
||||
if (switched) {
|
||||
void switched.catch(() => new Notice('Failed to switch tab'));
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTabClose(tabId: TabId): Promise<void> {
|
||||
try {
|
||||
const tab = this.tabManager?.getTab(tabId);
|
||||
// If streaming, treat close like user interrupt (force close cancels the stream)
|
||||
const force = tab?.state.isStreaming ?? false;
|
||||
await this.tabManager?.closeTab(tabId, force);
|
||||
this.updateTabBarVisibility();
|
||||
} catch {
|
||||
new Notice('Failed to close tab');
|
||||
}
|
||||
}
|
||||
|
||||
async createNewTab(): Promise<void> {
|
||||
const tab = await this.tabManager?.createTab();
|
||||
if (!tab) {
|
||||
const maxTabs = this.plugin.settings.maxTabs ?? 3;
|
||||
new Notice(`Maximum ${maxTabs} tabs allowed`);
|
||||
this.updateTabBarVisibility();
|
||||
return;
|
||||
}
|
||||
this.updateTabBarVisibility();
|
||||
}
|
||||
|
||||
private updateTabBar(): void {
|
||||
if (!this.tabManager || !this.tabBar) return;
|
||||
|
||||
// Debounce tab bar updates using requestAnimationFrame
|
||||
if (this.pendingTabBarUpdate !== null) {
|
||||
cancelScheduledAnimationFrame(this.pendingTabBarUpdate);
|
||||
}
|
||||
|
||||
this.pendingTabBarUpdate = scheduleAnimationFrame(() => {
|
||||
this.pendingTabBarUpdate = null;
|
||||
if (!this.tabManager || !this.tabBar) return;
|
||||
|
||||
const items = this.tabManager.getTabBarItems();
|
||||
this.tabBar.update(items);
|
||||
this.updateTabBarVisibility();
|
||||
}, this.containerEl.ownerDocument.defaultView ?? null);
|
||||
}
|
||||
|
||||
private updateTabBarVisibility(): void {
|
||||
if (!this.tabBarContainerEl || !this.tabManager) return;
|
||||
|
||||
const tabCount = this.tabManager.getTabCount();
|
||||
const showTabBar = tabCount >= 2;
|
||||
|
||||
this.tabBarContainerEl.toggleClass('claudian-hidden', !showTabBar);
|
||||
|
||||
this.updateNewTabButtonVisibility();
|
||||
}
|
||||
|
||||
private updateNewTabButtonVisibility(): void {
|
||||
if (!this.newTabButtonEl || !this.tabManager) return;
|
||||
|
||||
const canCreateTab = this.tabManager.canCreateTab();
|
||||
this.newTabButtonEl.toggleClass('claudian-hidden', !canCreateTab);
|
||||
if (canCreateTab) {
|
||||
this.newTabButtonEl.removeAttribute('aria-disabled');
|
||||
this.newTabButtonEl.removeAttribute('aria-hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
this.newTabButtonEl.setAttribute('aria-disabled', 'true');
|
||||
this.newTabButtonEl.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
/** Sets `data-provider` on the root container so CSS brand color follows the active provider. */
|
||||
private syncProviderBrandColor(): void {
|
||||
if (!this.viewContainerEl) return;
|
||||
const activeTab = this.tabManager?.getActiveTab();
|
||||
const providerId = activeTab ? getTabProviderId(activeTab, this.plugin) : DEFAULT_CHAT_PROVIDER_ID;
|
||||
this.viewContainerEl.dataset.provider = providerId;
|
||||
this.syncHeaderLogo(providerId);
|
||||
}
|
||||
|
||||
/** Rebuilds the header logo SVG to match the given provider. */
|
||||
private syncHeaderLogo(providerId: ProviderId): void {
|
||||
if (!this.logoEl) return;
|
||||
const icon = ProviderRegistry.getChatUIConfig(providerId).getProviderIcon?.();
|
||||
if (!icon) return;
|
||||
const existing = this.logoEl.querySelector('svg');
|
||||
if (existing?.getAttribute('data-provider') === providerId) return;
|
||||
this.logoEl.empty();
|
||||
const svg = createProviderIconSvg(icon, {
|
||||
dataProvider: providerId,
|
||||
height: 18,
|
||||
ownerDocument: this.logoEl.ownerDocument,
|
||||
width: 18,
|
||||
});
|
||||
this.logoEl.appendChild(svg);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// History Dropdown
|
||||
// ============================================
|
||||
|
||||
private toggleHistoryDropdown(): void {
|
||||
if (!this.historyDropdown) return;
|
||||
|
||||
const isVisible = this.historyDropdown.hasClass('visible');
|
||||
if (isVisible) {
|
||||
this.historyDropdown.removeClass('visible');
|
||||
} else {
|
||||
this.updateHistoryDropdown();
|
||||
this.historyDropdown.addClass('visible');
|
||||
}
|
||||
}
|
||||
|
||||
private updateHistoryDropdown(): void {
|
||||
if (!this.historyDropdown) return;
|
||||
this.historyDropdown.empty();
|
||||
|
||||
const activeTab = this.tabManager?.getActiveTab();
|
||||
const conversationController = activeTab?.controllers.conversationController;
|
||||
|
||||
if (conversationController) {
|
||||
conversationController.renderHistoryDropdown(this.historyDropdown, {
|
||||
onSelectConversation: (id) => this.openHistoryConversation(id),
|
||||
onOpenConversationInNewTab: (id, activate) =>
|
||||
this.openHistoryConversationInNewTab(id, activate),
|
||||
getConversationStatus: (id) => this.getHistoryConversationStatus(id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async openHistoryConversation(conversationId: string): Promise<void> {
|
||||
await this.tabManager?.openConversation(conversationId);
|
||||
this.historyDropdown?.removeClass('visible');
|
||||
}
|
||||
|
||||
private async openHistoryConversationInNewTab(
|
||||
conversationId: string,
|
||||
activate = true,
|
||||
): Promise<void> {
|
||||
await this.tabManager?.openConversation(conversationId, {
|
||||
preferNewTab: true,
|
||||
activate,
|
||||
});
|
||||
this.historyDropdown?.removeClass('visible');
|
||||
}
|
||||
|
||||
private getHistoryConversationStatus(conversationId: string): HistoryConversationStatus {
|
||||
const activeTab = this.tabManager?.getActiveTab();
|
||||
if (activeTab?.conversationId === conversationId) {
|
||||
return {
|
||||
openState: 'current',
|
||||
isRunning: activeTab.state.isStreaming,
|
||||
location: 'current-view',
|
||||
tabIndex: this.getHistoryTabIndex(activeTab),
|
||||
};
|
||||
}
|
||||
|
||||
const localTab = this.findTabWithConversation(conversationId);
|
||||
if (localTab) {
|
||||
return {
|
||||
openState: 'open',
|
||||
isRunning: localTab.state.isStreaming,
|
||||
location: 'current-view',
|
||||
tabIndex: this.getHistoryTabIndex(localTab),
|
||||
};
|
||||
}
|
||||
|
||||
const crossViewResult = this.plugin.findConversationAcrossViews(conversationId);
|
||||
if (crossViewResult && crossViewResult.view !== this) {
|
||||
const crossViewTab = crossViewResult.view.getTabManager()?.getTab(crossViewResult.tabId);
|
||||
return {
|
||||
openState: 'open',
|
||||
isRunning: crossViewTab?.state.isStreaming ?? false,
|
||||
location: 'other-view',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
openState: 'closed',
|
||||
isRunning: false,
|
||||
location: 'current-view',
|
||||
};
|
||||
}
|
||||
|
||||
private findTabWithConversation(conversationId: string): TabData | null {
|
||||
const tabs = this.tabManager?.getAllTabs() ?? [];
|
||||
return tabs.find(tab => tab.conversationId === conversationId) ?? null;
|
||||
}
|
||||
|
||||
private getHistoryTabIndex(tab: TabData): number | undefined {
|
||||
const index = this.tabManager?.getAllTabs().findIndex(candidate => candidate.id === tab.id) ?? -1;
|
||||
return index >= 0 ? index + 1 : undefined;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Event Wiring
|
||||
// ============================================
|
||||
|
||||
private wireEventHandlers(): void {
|
||||
const activeDocument = this.containerEl.ownerDocument;
|
||||
|
||||
// Document-level click to close dropdowns
|
||||
this.registerDomEvent(activeDocument, 'click', () => {
|
||||
this.historyDropdown?.removeClass('visible');
|
||||
});
|
||||
|
||||
// View-level Shift+Tab to toggle plan mode (works from any focused element)
|
||||
this.registerDomEvent(this.containerEl, 'keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Tab' && e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
const activeTab = this.tabManager?.getActiveTab();
|
||||
if (!activeTab) return;
|
||||
const providerId = getTabProviderId(activeTab, this.plugin);
|
||||
if (!ProviderRegistry.getCapabilities(providerId).supportsPlanMode) return;
|
||||
const current = ProviderSettingsCoordinator.getProviderSettingsSnapshot(
|
||||
this.plugin.settings,
|
||||
providerId,
|
||||
).permissionMode as string;
|
||||
if (current === 'plan') {
|
||||
const restoreMode = activeTab.state.prePlanPermissionMode ?? 'normal';
|
||||
void updatePlanModeUI(activeTab, this.plugin, restoreMode)
|
||||
.finally(() => {
|
||||
const activeMode = ProviderSettingsCoordinator.getProviderSettingsSnapshot(
|
||||
this.plugin.settings,
|
||||
providerId,
|
||||
).permissionMode;
|
||||
if (activeMode !== 'plan') {
|
||||
activeTab.state.prePlanPermissionMode = null;
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
new Notice(error instanceof Error ? error.message : 'Failed to change permission mode.');
|
||||
});
|
||||
} else {
|
||||
activeTab.state.prePlanPermissionMode = current;
|
||||
void updatePlanModeUI(activeTab, this.plugin, 'plan').catch((error: unknown) => {
|
||||
const activeMode = ProviderSettingsCoordinator.getProviderSettingsSnapshot(
|
||||
this.plugin.settings,
|
||||
providerId,
|
||||
).permissionMode;
|
||||
if (activeMode !== 'plan') {
|
||||
activeTab.state.prePlanPermissionMode = null;
|
||||
}
|
||||
new Notice(error instanceof Error ? error.message : 'Failed to change permission mode.');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// View scopes are the Obsidian-owned boundary for main-area tab hotkeys.
|
||||
// Returning false consumes Escape before Obsidian uses it for pane navigation.
|
||||
this.scope = new Scope(this.app.scope);
|
||||
this.scope.register([], 'Escape', (e: KeyboardEvent) => {
|
||||
if (e.isComposing) return;
|
||||
if (!e.defaultPrevented) {
|
||||
const activeTab = this.tabManager?.getActiveTab();
|
||||
if (activeTab?.state.isStreaming) {
|
||||
activeTab.controllers.inputController?.cancelStreaming();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
this.scope.register(['Mod'], 'Enter', (e: KeyboardEvent) => {
|
||||
if (e.isComposing || e.defaultPrevented) return;
|
||||
const activeTab = this.tabManager?.getActiveTab();
|
||||
if (!activeTab) return;
|
||||
if (sendTabInputMessageFromExplicitEnterShortcut(activeTab, e, { requireInputFocus: true })) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
this.eventRefs.push(
|
||||
this.plugin.app.vault.on('create', () => this.mentionCacheCoordinator?.markStructureDirty()),
|
||||
this.plugin.app.vault.on('delete', () => this.mentionCacheCoordinator?.markStructureDirty()),
|
||||
this.plugin.app.vault.on('rename', () => this.mentionCacheCoordinator?.markStructureDirty()),
|
||||
this.plugin.app.vault.on('modify', () => this.mentionCacheCoordinator?.markFilesDirty())
|
||||
);
|
||||
|
||||
// File open event
|
||||
this.registerEvent(
|
||||
this.plugin.app.workspace.on('file-open', (file) => {
|
||||
if (file) {
|
||||
this.tabManager?.getActiveTab()?.ui.fileContextManager?.handleFileOpen(file);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Click outside to close mention dropdown
|
||||
this.registerDomEvent(activeDocument, 'click', (e) => {
|
||||
const activeTab = this.tabManager?.getActiveTab();
|
||||
if (activeTab) {
|
||||
const fcm = activeTab.ui.fileContextManager;
|
||||
if (fcm && !fcm.containsElement(e.target as Node) && e.target !== activeTab.dom.inputEl) {
|
||||
fcm.hideMentionDropdown();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Persistence
|
||||
// ============================================
|
||||
|
||||
private async restoreOrCreateTabs(): Promise<void> {
|
||||
if (!this.tabManager) return;
|
||||
|
||||
// Try to restore from persisted state
|
||||
const persistedState = await this.plugin.storage.getTabManagerState();
|
||||
if (persistedState && persistedState.openTabs.length > 0) {
|
||||
await this.tabManager.restoreState(persistedState);
|
||||
this.tabBar?.setExpandedTitleTabIds(persistedState.expandedTitleTabIds ?? []);
|
||||
this.updateTabBar();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: create a new empty tab
|
||||
await this.tabManager.createTab();
|
||||
}
|
||||
|
||||
private persistTabState(): void {
|
||||
|
||||
// Debounce persistence to avoid rapid writes (300ms delay)
|
||||
if (this.pendingPersist !== null) {
|
||||
window.clearTimeout(this.pendingPersist);
|
||||
}
|
||||
this.pendingPersist = window.setTimeout(() => {
|
||||
this.pendingPersist = null;
|
||||
const state = this.getPersistedTabState();
|
||||
if (!state) return;
|
||||
this.plugin.persistTabManagerState(state).catch(() => {
|
||||
// Silently ignore persistence errors
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/** Force immediate persistence (for onClose/onunload). */
|
||||
private async persistTabStateImmediate(): Promise<void> {
|
||||
// Cancel any pending debounced persist
|
||||
if (this.pendingPersist !== null) {
|
||||
window.clearTimeout(this.pendingPersist);
|
||||
this.pendingPersist = null;
|
||||
}
|
||||
const state = this.getPersistedTabState();
|
||||
if (!state) return;
|
||||
await this.plugin.persistTabManagerState(state);
|
||||
}
|
||||
|
||||
private getPersistedTabState(): AppTabManagerState | null {
|
||||
if (!this.tabManager) return null;
|
||||
|
||||
const state = this.tabManager.getPersistedState();
|
||||
const openTabIds = new Set(state.openTabs.map(tab => tab.tabId));
|
||||
const expandedTitleTabIds = (this.tabBar?.getExpandedTitleTabIds() ?? [])
|
||||
.filter(tabId => openTabIds.has(tabId));
|
||||
|
||||
return {
|
||||
...state,
|
||||
...(expandedTitleTabIds.length > 0 ? { expandedTitleTabIds } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Public API
|
||||
// ============================================
|
||||
|
||||
/** Gets the currently active tab. */
|
||||
getActiveTab(): TabData | null {
|
||||
return this.tabManager?.getActiveTab() ?? null;
|
||||
}
|
||||
|
||||
/** Gets the tab manager. */
|
||||
getTabManager(): TabManager | null {
|
||||
return this.tabManager;
|
||||
}
|
||||
|
||||
/** Gets shared view controls that should preserve active tab selection context. */
|
||||
getSharedSelectionFocusScopeEls(): HTMLElement[] {
|
||||
return [
|
||||
this.inputNavRowHostEl,
|
||||
].filter((el): el is HTMLElement => el !== null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/** Random flavor words shown when response completes (e.g., "Baked for 1:23"). */
|
||||
export const COMPLETION_FLAVOR_WORDS = [
|
||||
'Baked',
|
||||
'Cooked',
|
||||
'Crunched',
|
||||
'Brewed',
|
||||
'Crafted',
|
||||
'Forged',
|
||||
'Conjured',
|
||||
'Whipped up',
|
||||
'Stirred',
|
||||
'Simmered',
|
||||
'Toasted',
|
||||
'Sautéed',
|
||||
'Finagled',
|
||||
'Marinated',
|
||||
'Distilled',
|
||||
'Fermented',
|
||||
'Percolated',
|
||||
'Steeped',
|
||||
'Roasted',
|
||||
'Cured',
|
||||
'Smoked',
|
||||
'Cogitated',
|
||||
] as const;
|
||||
|
||||
/** Random flavor texts shown while Claude is thinking. */
|
||||
export const FLAVOR_TEXTS = [
|
||||
// Classic
|
||||
'Thinking...',
|
||||
'Pondering...',
|
||||
'Processing...',
|
||||
'Analyzing...',
|
||||
'Considering...',
|
||||
'Working on it...',
|
||||
'Vibing...',
|
||||
'One moment...',
|
||||
'On it...',
|
||||
// Thoughtful
|
||||
'Ruminating...',
|
||||
'Contemplating...',
|
||||
'Reflecting...',
|
||||
'Mulling it over...',
|
||||
'Let me think...',
|
||||
'Hmm...',
|
||||
'Cogitating...',
|
||||
'Deliberating...',
|
||||
'Weighing options...',
|
||||
'Gathering thoughts...',
|
||||
// Playful
|
||||
'Brewing ideas...',
|
||||
'Connecting dots...',
|
||||
'Assembling thoughts...',
|
||||
'Spinning up neurons...',
|
||||
'Loading brilliance...',
|
||||
'Consulting the oracle...',
|
||||
'Summoning knowledge...',
|
||||
'Crunching thoughts...',
|
||||
'Dusting off neurons...',
|
||||
'Wrangling ideas...',
|
||||
'Herding thoughts...',
|
||||
'Juggling concepts...',
|
||||
'Untangling this...',
|
||||
'Piecing it together...',
|
||||
// Cozy
|
||||
'Sipping coffee...',
|
||||
'Warming up...',
|
||||
'Getting cozy with this...',
|
||||
'Settling in...',
|
||||
'Making tea...',
|
||||
'Grabbing a snack...',
|
||||
// Technical
|
||||
'Parsing...',
|
||||
'Compiling thoughts...',
|
||||
'Running inference...',
|
||||
'Querying the void...',
|
||||
'Defragmenting brain...',
|
||||
'Allocating memory...',
|
||||
'Optimizing...',
|
||||
'Indexing...',
|
||||
'Syncing neurons...',
|
||||
// Zen
|
||||
'Breathing...',
|
||||
'Finding clarity...',
|
||||
'Channeling focus...',
|
||||
'Centering...',
|
||||
'Aligning chakras...',
|
||||
'Meditating on this...',
|
||||
// Whimsical
|
||||
'Asking the stars...',
|
||||
'Reading tea leaves...',
|
||||
'Shaking the magic 8-ball...',
|
||||
'Consulting ancient scrolls...',
|
||||
'Decoding the matrix...',
|
||||
'Communing with the ether...',
|
||||
'Peering into the abyss...',
|
||||
'Channeling the cosmos...',
|
||||
// Action
|
||||
'Diving in...',
|
||||
'Rolling up sleeves...',
|
||||
'Getting to work...',
|
||||
'Tackling this...',
|
||||
'On the case...',
|
||||
'Investigating...',
|
||||
'Exploring...',
|
||||
'Digging deeper...',
|
||||
// Casual
|
||||
'Bear with me...',
|
||||
'Hang tight...',
|
||||
'Just a sec...',
|
||||
'Working my magic...',
|
||||
'Almost there...',
|
||||
'Give me a moment...',
|
||||
];
|
||||
@@ -0,0 +1,277 @@
|
||||
import type { App, ItemView } from 'obsidian';
|
||||
|
||||
import type { BrowserSelectionContext } from '../../../utils/browser';
|
||||
import type { ComposerContextTray } from '../ui/ComposerContextTray';
|
||||
|
||||
const BROWSER_SELECTION_POLL_INTERVAL = 250;
|
||||
|
||||
type BrowserLikeWebview = HTMLElement & {
|
||||
executeJavaScript?: (code: string, userGesture?: boolean) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export class BrowserSelectionController {
|
||||
private app: App;
|
||||
private contextTray: ComposerContextTray;
|
||||
private inputEl: HTMLElement;
|
||||
private onVisibilityChange: (() => void) | null;
|
||||
private storedSelection: BrowserSelectionContext | null = null;
|
||||
private pollInterval: number | null = null;
|
||||
private pollInFlight = false;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
contextTray: ComposerContextTray,
|
||||
inputEl: HTMLElement,
|
||||
onVisibilityChange?: () => void
|
||||
) {
|
||||
this.app = app;
|
||||
this.contextTray = contextTray;
|
||||
this.inputEl = inputEl;
|
||||
this.onVisibilityChange = onVisibilityChange ?? null;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.pollInterval) return;
|
||||
this.pollInterval = window.setInterval(() => {
|
||||
void this.poll();
|
||||
}, BROWSER_SELECTION_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.pollInterval) {
|
||||
window.clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
this.clear();
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
if (this.pollInFlight) return;
|
||||
this.pollInFlight = true;
|
||||
try {
|
||||
const browserView = this.getActiveBrowserView();
|
||||
if (!browserView) {
|
||||
this.clearWhenInputIsNotFocused();
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedText = await this.extractSelectedText(browserView.containerEl);
|
||||
if (selectedText) {
|
||||
const nextContext = this.buildContext(browserView.view, browserView.viewType, browserView.containerEl, selectedText);
|
||||
if (!this.isSameSelection(nextContext, this.storedSelection)) {
|
||||
this.storedSelection = nextContext;
|
||||
this.updateIndicator();
|
||||
}
|
||||
} else {
|
||||
this.clearWhenInputIsNotFocused();
|
||||
}
|
||||
} catch {
|
||||
// Ignore transient polling errors to keep selection tracking resilient.
|
||||
} finally {
|
||||
this.pollInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private getActiveBrowserView(): { view: ItemView; viewType: string; containerEl: HTMLElement } | null {
|
||||
const activeLeaf = this.app.workspace.getMostRecentLeaf?.();
|
||||
const activeView = activeLeaf?.view as ItemView | undefined;
|
||||
const containerEl = (activeView as unknown as { containerEl?: HTMLElement }).containerEl;
|
||||
if (!activeView || !containerEl) return null;
|
||||
|
||||
const viewType = activeView.getViewType?.() ?? '';
|
||||
if (!this.isBrowserLikeView(viewType, containerEl)) return null;
|
||||
|
||||
return { view: activeView, viewType, containerEl };
|
||||
}
|
||||
|
||||
private isBrowserLikeView(viewType: string, containerEl: HTMLElement): boolean {
|
||||
const normalized = viewType.toLowerCase();
|
||||
if (
|
||||
normalized.includes('surfing')
|
||||
|| normalized.includes('browser')
|
||||
|| normalized.includes('webview')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(containerEl.querySelector('iframe, webview'));
|
||||
}
|
||||
|
||||
private async extractSelectedText(containerEl: HTMLElement): Promise<string | null> {
|
||||
const ownerDoc = containerEl.ownerDocument;
|
||||
const docSelection = this.extractSelectionFromDocument(ownerDoc, containerEl);
|
||||
if (docSelection) return docSelection;
|
||||
|
||||
const frameSelection = this.extractSelectionFromIframes(containerEl);
|
||||
if (frameSelection) return frameSelection;
|
||||
|
||||
return await this.extractSelectionFromWebviews(containerEl);
|
||||
}
|
||||
|
||||
private extractSelectionFromDocument(doc: Document, scopeEl: HTMLElement): string | null {
|
||||
const selection = doc.getSelection();
|
||||
const selectedText = selection?.toString().trim();
|
||||
if (selectedText) {
|
||||
const anchorNode = selection?.anchorNode;
|
||||
const focusNode = selection?.focusNode;
|
||||
if ((anchorNode && scopeEl.contains(anchorNode)) || (focusNode && scopeEl.contains(focusNode))) {
|
||||
return selectedText;
|
||||
}
|
||||
}
|
||||
|
||||
return this.extractSelectionFromActiveInput(doc, scopeEl);
|
||||
}
|
||||
|
||||
private extractSelectionFromActiveInput(doc: Document, scopeEl: HTMLElement): string | null {
|
||||
const activeEl = doc.activeElement;
|
||||
if (!activeEl || !scopeEl.contains(activeEl)) return null;
|
||||
|
||||
if (activeEl.instanceOf(HTMLTextAreaElement) || activeEl.instanceOf(HTMLInputElement)) {
|
||||
const { value, selectionStart, selectionEnd } = activeEl;
|
||||
if (typeof selectionStart !== 'number' || typeof selectionEnd !== 'number' || selectionStart === selectionEnd) return null;
|
||||
return value.slice(selectionStart, selectionEnd).trim() || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractSelectionFromIframes(containerEl: HTMLElement): string | null {
|
||||
const iframes = Array.from(containerEl.querySelectorAll('iframe'));
|
||||
for (const iframe of iframes) {
|
||||
try {
|
||||
const frameDoc = iframe.contentDocument ?? iframe.contentWindow?.document;
|
||||
if (!frameDoc || !frameDoc.body) continue;
|
||||
|
||||
const frameSelection = this.extractSelectionFromDocument(frameDoc, frameDoc.body);
|
||||
if (frameSelection) return frameSelection;
|
||||
} catch {
|
||||
// Ignore inaccessible iframe contexts (cross-origin restrictions).
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async extractSelectionFromWebviews(containerEl: HTMLElement): Promise<string | null> {
|
||||
const webviews = Array.from(containerEl.querySelectorAll<BrowserLikeWebview>('webview'));
|
||||
for (const webview of webviews) {
|
||||
if (typeof webview.executeJavaScript !== 'function') continue;
|
||||
try {
|
||||
const result = await webview.executeJavaScript(
|
||||
'window.getSelection ? window.getSelection().toString() : ""',
|
||||
true
|
||||
);
|
||||
if (typeof result === 'string' && result.trim()) {
|
||||
return result.trim();
|
||||
}
|
||||
} catch {
|
||||
// Ignore inaccessible webview contexts.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildContext(
|
||||
view: ItemView,
|
||||
viewType: string,
|
||||
containerEl: HTMLElement,
|
||||
selectedText: string
|
||||
): BrowserSelectionContext {
|
||||
const title = this.extractViewTitle(view);
|
||||
const url = this.extractViewUrl(view, containerEl);
|
||||
const source = url ? `browser:${url}` : `browser:${viewType || 'unknown'}`;
|
||||
|
||||
return {
|
||||
source,
|
||||
selectedText,
|
||||
title,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
private extractViewTitle(view: ItemView): string | undefined {
|
||||
const displayText = view.getDisplayText?.();
|
||||
if (displayText?.trim()) return displayText.trim();
|
||||
|
||||
const title = (view as unknown as { title?: unknown }).title;
|
||||
return typeof title === 'string' && title.trim() ? title.trim() : undefined;
|
||||
}
|
||||
|
||||
private extractViewUrl(view: ItemView, containerEl: HTMLElement): string | undefined {
|
||||
const rawView = view as unknown as Record<string, unknown>;
|
||||
const directCandidates = [
|
||||
rawView.url,
|
||||
rawView.currentUrl,
|
||||
rawView.currentURL,
|
||||
rawView.src,
|
||||
];
|
||||
|
||||
for (const candidate of directCandidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim()) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const embeddableEl = containerEl.querySelector<HTMLElement>('iframe[src], webview[src]');
|
||||
const embeddedSrc = embeddableEl?.getAttribute('src');
|
||||
if (embeddedSrc?.trim()) {
|
||||
return embeddedSrc.trim();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private isSameSelection(
|
||||
left: BrowserSelectionContext | null,
|
||||
right: BrowserSelectionContext | null
|
||||
): boolean {
|
||||
if (!left || !right) return false;
|
||||
return left.source === right.source
|
||||
&& left.selectedText === right.selectedText
|
||||
&& left.title === right.title
|
||||
&& left.url === right.url;
|
||||
}
|
||||
|
||||
private clearWhenInputIsNotFocused(): void {
|
||||
if (this.inputEl.ownerDocument.activeElement === this.inputEl) return;
|
||||
if (this.storedSelection) {
|
||||
this.storedSelection = null;
|
||||
this.updateIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
private updateIndicator(): void {
|
||||
if (this.storedSelection) {
|
||||
const lineCount = this.storedSelection.selectedText.split(/\r?\n/).length;
|
||||
const lineLabel = lineCount === 1 ? 'line' : 'lines';
|
||||
const label = `${lineCount} ${lineLabel} selected`;
|
||||
this.contextTray.setItems('browser-selection', [{
|
||||
id: 'browser-selection',
|
||||
kind: 'selection',
|
||||
label,
|
||||
icon: 'globe',
|
||||
ariaLabel: label,
|
||||
onRemove: () => this.clear(),
|
||||
}]);
|
||||
} else {
|
||||
this.contextTray.clearItems('browser-selection');
|
||||
}
|
||||
this.updateContextRowVisibility();
|
||||
}
|
||||
|
||||
updateContextRowVisibility(): void {
|
||||
this.onVisibilityChange?.();
|
||||
}
|
||||
|
||||
getContext(): BrowserSelectionContext | null {
|
||||
return this.storedSelection;
|
||||
}
|
||||
|
||||
hasSelection(): boolean {
|
||||
return this.storedSelection !== null;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.storedSelection = null;
|
||||
this.updateIndicator();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user