refactor: multi-provider architecture with Codex runtime support

Introduce a provider-neutral architecture layer (ProviderRegistry,
ProviderWorkspaceRegistry, ProviderSettingsCoordinator, capability-based
UI gating, model-based provider routing) and extract the entire Claude
implementation behind src/providers/claude/.

Add a full Codex provider backed by codex app-server JSON-RPC transport
with session file tailing, JSONL history reload, tool/subagent
normalization, plan mode, fork/compact, instruction mode, skill catalog,
vault subagent manager, inline edit, context gauge, WSL-aware execution,
and fast mode toggle.

Neutralize the feature layer so Conversation carries providerId + opaque
providerState, tab lifecycle uses a state machine with provider-scoped
resolution, and chat UI routes through ProviderChatUIConfig with no
Claude imports in src/features/.

Remove legacy JSONL conversation storage, barrel re-exports,
backward-compat shims, and dead security modules. Move shared vault
storage to .claudian/, split shared vs provider environment settings,
and upgrade dependencies for SDK compatibility.
This commit is contained in:
YishenTu
2026-04-06 20:36:10 +08:00
committed by GitHub
parent 21f9cb8002
commit fc6405bd07
400 changed files with 47497 additions and 19964 deletions
-69
View File
@@ -1,69 +0,0 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
ignorePatterns: ['dist/', 'node_modules/', 'coverage/', 'main.js'],
env: {
browser: true,
node: true,
es2021: true,
},
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['@typescript-eslint', 'jest', 'simple-import-sort'],
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
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',
},
overrides: [
{
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'],
env: { jest: true },
extends: ['plugin:jest/recommended'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
},
],
};
+2 -1
View File
@@ -1,6 +1,7 @@
# Build output
main.js
styles.css
.codex-vendor/
*.js.map
dist/
build/
@@ -91,4 +92,4 @@ dev
# package-lock.json
.claude/
.codex/
.codex/
+50 -43
View File
@@ -2,72 +2,79 @@
## Project Overview
Claudian - An Obsidian plugin that embeds Claude Code as a sidebar chat interface. The vault directory becomes Claude's working directory, giving it full agentic capabilities: file read/write, bash commands, and multi-step workflows.
Claudian is an Obsidian plugin that embeds provider-backed chat runtimes in a sidebar and inline-edit flow. Claude is the default provider. Codex is optional and joins the same conversation model through `Conversation.providerId` plus provider-owned `providerState`.
## Architecture Status
- Product status: Claudian is a multi-provider product. Claude is the full-feature provider. Codex is opt-in and currently supports send, stream, cancel, resume, history reload, fork, plan mode, image attachments, inline edit, `#` instruction mode, `$` skills, and subagents. Unsupported or gated Codex surfaces are rewind, runtime-discovered provider commands, in-app MCP management, and Claude plugin integration.
- App shell: `src/app/` owns shared settings defaults and plugin-level storage helpers. `src/core/` owns provider-neutral runtime, registry, tool, and type contracts.
- Provider boundary: `src/core/runtime/` and `src/core/providers/` define the chat-facing seam. `ProviderRegistry` creates runtimes and provider-owned auxiliary services. `ProviderWorkspaceRegistry` owns workspace services such as command catalogs, agent mention providers, CLI resolution, MCP managers, and provider settings tabs.
- Claude adaptor: `src/providers/claude/` owns the Claude runtime, prompt encoding, stream transforms, history hydration, CLI resolution, plugin and agent discovery, MCP storage, and Claude-specific settings UI. `ClaudeCommandCatalog` merges vault commands, vault skills, and runtime-supported commands behind the shared command catalog contract.
- Codex adaptor: `src/providers/codex/` owns the `codex app-server` runtime, JSON-RPC transport, prompt encoding, JSONL history reload, session tailing, settings reconciliation, normalization, skill cataloging, subagent storage, and Codex settings UI. `CodexSkillCatalog` provides `$` skill discovery from `.codex/skills/` and `.agents/skills/` without relying on runtime command discovery.
- Conversations: `Conversation` carries `providerId` and opaque `providerState`. Claude state is typed behind `ClaudeProviderState`. Codex state is typed behind `CodexProviderState` and currently stores `threadId`, `sessionFilePath`, and optional fork metadata.
## Commands
```bash
npm run dev # Development (watch mode)
npm run build # Production build
npm run typecheck # Type check
npm run lint # Lint code
npm run lint:fix # Lint and auto-fix
npm run test # Run tests
npm run test:watch # Run tests in watch mode
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
```
## Architecture
| Layer | Purpose | Details |
|-------|---------|---------|
| **core** | Infrastructure (no feature deps) | See [`src/core/CLAUDE.md`](src/core/CLAUDE.md) |
| **app** | Shared defaults and plugin-level storage helpers | `defaultSettings`, `ClaudianSettingsStorage`, `SharedStorageService` |
| **core** | Provider-neutral contracts and infrastructure | See [`src/core/CLAUDE.md`](src/core/CLAUDE.md) |
| **providers/claude** | Claude SDK adaptor | See [`src/providers/claude/CLAUDE.md`](src/providers/claude/CLAUDE.md) |
| **providers/codex** | Codex app-server adaptor | See [`src/providers/codex/CLAUDE.md`](src/providers/codex/CLAUDE.md) |
| **features/chat** | Main sidebar interface | See [`src/features/chat/CLAUDE.md`](src/features/chat/CLAUDE.md) |
| **features/inline-edit** | Inline edit modal | `InlineEditService`, read-only tools |
| **features/settings** | Settings tab | UI components for all settings |
| **shared** | Reusable UI | Dropdowns, instruction modal, fork target modal, @-mention, icons |
| **features/inline-edit** | Inline edit modal and provider-backed edit services | `InlineEditModal` plus provider-owned inline edit services |
| **features/settings** | Shared settings shell with provider tabs | General tab plus provider-owned Claude and Codex tab renderers |
| **shared** | Reusable UI building blocks | Dropdowns, modals, mention UI, icons |
| **i18n** | Internationalization | 10 locales |
| **utils** | Utility functions | date, path, env, editor, session, markdown, diff, context, sdkSession, frontmatter, slashCommand, mcp, claudeCli, externalContext, externalContextScanner, fileLink, imageEmbed, inlineEdit |
| **utils** | Cross-cutting utilities | env, path, markdown, diff, context, file-link, image, browser, canvas, session, subagent helpers |
| **style** | Modular CSS | See [`src/style/CLAUDE.md`](src/style/CLAUDE.md) |
## Tests
```bash
npm run test -- --selectProjects unit # Run unit tests
npm run test -- --selectProjects integration # Run integration tests
npm run test:coverage -- --selectProjects unit # Unit coverage
npm run test -- --selectProjects unit
npm run test -- --selectProjects integration
npm run test:coverage -- --selectProjects unit
```
Tests mirror `src/` structure in `tests/unit/` and `tests/integration/`.
Tests mirror the `src/` layout under `tests/unit/` and `tests/integration/`.
## Storage
| File | Contents |
| Path | Contents |
|------|----------|
| `.claude/settings.json` | CC-compatible: permissions, env, enabledPlugins |
| `.claude/claudian-settings.json` | Claudian-specific settings (model, UI, etc.) |
| `.claude/settings.local.json` | Local overrides (gitignored) |
| `.claude/mcp.json` | MCP server configs |
| `.claude/commands/*.md` | Slash commands (YAML frontmatter) |
| `.claude/agents/*.md` | Custom agents (YAML frontmatter) |
| `.claude/skills/*/SKILL.md` | Skill definitions |
| `.claude/sessions/*.meta.json` | Session metadata |
| `~/.claude/projects/{vault}/*.jsonl` | SDK-native session messages |
| `.claude/settings.json` | Claude Code-compatible project settings, permissions, and plugin overrides |
| `.claudian/claudian-settings.json` | Shared Claudian app settings plus provider-specific configuration |
| `.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 |
| `.claudian/sessions/*.meta.json` | Provider-neutral session metadata |
| `.codex/skills/*/SKILL.md` | Codex vault skills |
| `.agents/skills/*/SKILL.md` | Alternate Codex vault skill root |
| `.codex/agents/*.toml` | Codex vault subagent definitions |
| `~/.claude/projects/{vault}/*.jsonl` | Claude-native transcripts |
| `~/.codex/sessions/**/*.jsonl` | Codex-native transcripts |
## Development Notes
- **SDK-first**: Proactively use native Claude SDK features over custom implementations. If the SDK provides a capability, use it — do not reinvent it. This ensures compatibility with Claude Code.
- **SDK exploration**: When developing SDK-related features, write a throwaway test script (e.g., in `dev/`) that calls the real SDK to observe actual response shapes, event sequences, and edge cases. Real output lands in `~/.claude/` or `{vault}/.claude/` — inspect those files to understand patterns and formats. Run this before writing implementation or tests — real output beats guessing at types and formats. This is the default first step for any SDK integration work.
- **Comments**: Only comment WHY, not WHAT. No JSDoc that restates the function name (`/** Get servers. */` on `getServers()`), no narrating inline comments (`// Create the channel` before `new Channel()`), no module-level docs on barrel `index.ts` files. Keep JSDoc only when it adds non-obvious context (edge cases, constraints, surprising behavior).
- **TDD workflow**: For new functions/modules and bug fixes, follow red-green-refactor:
1. Write a failing test first in the mirrored path under `tests/unit/` (or `tests/integration/`)
2. Run it with `npm run test -- --selectProjects unit --testPathPattern <pattern>` to confirm it fails
3. Write the minimal implementation to make it pass
4. Refactor, keeping tests green
- For bug fixes, write a test that reproduces the bug before fixing it
- Test behavior and public API, not internal implementation details
- Skip TDD for trivial changes (renaming, moving files, config tweaks) — but still verify existing tests pass
- Run `npm run typecheck && npm run lint && npm run test && npm run build` after editing
- No `console.*` in production code
- use Obsidian's notification system if user should be notified
- use `console.log` for debugging, but remove it before committing
- Generated docs/test scripts go in `dev/`.
- **Provider-native first**: Prefer the official Claude SDK and Codex app-server behavior over reimplementing provider features locally. When the provider already owns a capability, adapt to it instead of shadowing it.
- **Runtime exploration**: For provider integrations, inspect real runtime output first. Claude data lands under `~/.claude/` and Codex data under `~/.codex/`. Real transcripts beat guessed event shapes. Put throwaway local scripts in `.context/`; only promote durable tooling into `dev/`.
- **Comments**: Comment why, not what. Avoid narration and redundant JSDoc.
- **TDD workflow**: For new behavior or bug fixes, write the failing test first in the mirrored `tests/` path, make it pass, then refactor.
- Run `npm run typecheck && npm run lint && npm run test && npm run build` after editing.
- No `console.*` in production code.
- Put non-committed notes, handoff files, and throwaway scripts in `.context/`.
+45 -144
View File
@@ -6,30 +6,31 @@
![Preview](Preview.png)
An Obsidian plugin that embeds Claude Code as an AI collaborator in your vault. Your vault becomes Claude's working directory, giving it full agentic capabilities: file read/write, search, bash commands, and multi-step workflows.
An Obsidian plugin that embeds AI coding agents (Claude Code, Codex, 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
## Features & Usage
- **Full Agentic Capabilities**: Leverage Claude Code's power to read, write, and edit files, search, and execute bash commands, all within your Obsidian vault.
- **Context-Aware**: Automatically attach the focused note, mention files with `@`, exclude notes by tag, include editor selection (Highlight), and access external directories for additional context.
- **Vision Support**: Analyze images by sending them via drag-and-drop, paste, or file path.
- **Inline Edit**: Edit selected text or insert content at cursor position directly in notes with word-level diff preview and read-only tool access for context.
- **Instruction Mode (`#`)**: Add refined custom instructions to your system prompt directly from the chat input, with review/edit in a modal.
- **Slash Commands**: Create reusable prompt templates triggered by `/command`, with argument placeholders, `@file` references, and optional inline bash substitutions.
- **Skills**: Extend Claudian with reusable capability modules that are automatically invoked based on context, compatible with Claude Code's skill format.
- **Custom Agents**: Define custom subagents that Claude can invoke, with support for tool restrictions and model overrides.
- **Claude Code Plugins**: Enable Claude Code plugins installed via the CLI, with automatic discovery from `~/.claude/plugins` and per-vault configuration. Plugin skills, agents, and slash commands integrate seamlessly.
- **MCP Support**: Connect external tools and data sources via Model Context Protocol servers (stdio, SSE, HTTP) with context-saving mode and `@`-mention activation.
- **Advanced Model Control**: Select between Haiku, Sonnet, and Opus, configure custom models via environment variables, fine-tune thinking budget, and enable Opus and Sonnet with 1M context window (requires Max subscription or extra usage).
- **Plan Mode**: Toggle plan mode via Shift+Tab in the chat input. Claudian explores and designs before implementing, presenting a plan for approval with options to approve in a new session, continue in the current session, or provide feedback.
- **Security**: Permission modes (YOLO/Safe/Plan), safety blocklist, and vault confinement with symlink-safe checks.
- **Claude in Chrome**: Allow Claude to interact with Chrome through the `claude-in-chrome` extension.
Open the chat sidebar from the ribbon icon or command palette. Select text and use the hotkey for inline edit. Everything works like Claude Code or Codex — 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 Code CLI](https://code.claude.com/docs/en/overview) installed (strongly recommend install Claude Code via Native Install)
- Obsidian v1.8.9+
- Claude subscription/API or Custom model provider that supports Anthropic API format ([Openrouter](https://openrouter.ai/docs/guides/guides/claude-code-integration), [Kimi](https://platform.moonshot.ai/docs/guide/agent-support), [GLM](https://docs.z.ai/devpack/tool/claude), [DeepSeek](https://api-docs.deepseek.com/guides/anthropic_api), etc.)
- **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.moonshot.ai/docs/guide/agent-support), etc.).
- **Codex provider** (optional): [Codex CLI](https://github.com/openai/codex) installed.
- Obsidian v1.4.5+
- Desktop only (macOS, Linux, Windows)
## Installation
@@ -88,93 +89,10 @@ npm run build
> **Tip**: Copy `.env.local.example` to `.env.local` or `npm install` and setup your vault path to auto-copy files during development.
## Usage
**Two modes:**
1. Click the bot icon in ribbon or use command palette to open chat
2. Select text + hotkey for inline edit
Use it like Claude Code—read, write, edit, search files in your vault.
### Context
- **File**: Auto-attaches focused note; type `@` to attach other files
- **@-mention dropdown**: Type `@` to see MCP servers, agents, external contexts, and vault files
- `@Agents/` shows custom agents for selection
- `@mcp-server` enables context-saving MCP servers
- `@folder/` filters to files from that external context (e.g., `@workspace/`)
- Vault files shown by default
- **Selection**: Select text in editor, or elements in canvas, then chat—selection included automatically
- **Images**: Drag-drop, paste, or type path; configure media folder for `![[image]]` embeds
- **External contexts**: Click folder icon in toolbar for access to directories outside vault
### Features
- **Inline Edit**: Select text + hotkey to edit directly in notes with word-level diff preview
- **Instruction Mode**: Type `#` to add refined instructions to system prompt
- **Slash Commands**: Type `/` for custom prompt templates or skills
- **Skills**: Add `skill/SKILL.md` files to `~/.claude/skills/` or `{vault}/.claude/skills/`, recommended to use Claude Code to manage skills
- **Custom Agents**: Add `agent.md` files to `~/.claude/agents/` (global) or `{vault}/.claude/agents/` (vault-specific); select via `@Agents/` in chat, or prompt Claudian to invoke agents
- **Claude Code Plugins**: Enable plugins via Settings → Claude Code Plugins, recommended to use Claude Code to manage plugins
- **MCP**: Add external tools via Settings → MCP Servers; use `@mcp-server` in chat to activate
## Configuration
### Settings
**Customization**
- **User name**: Your name for personalized greetings
- **Excluded tags**: Tags that prevent notes from auto-loading (e.g., `sensitive`, `private`)
- **Media folder**: Configure where vault stores attachments for embedded image support (e.g., `attachments`)
- **Custom system prompt**: Additional instructions appended to the default system prompt (Instruction Mode `#` saves here)
- **Enable auto-scroll**: Toggle automatic scrolling to bottom during streaming (default: on)
- **Auto-generate conversation titles**: Toggle AI-powered title generation after the first user message is sent
- **Title generation model**: Model used for auto-generating conversation titles (default: Auto/Haiku)
- **Vim-style navigation mappings**: Configure key bindings with lines like `map w scrollUp`, `map s scrollDown`, `map i focusInput`
**Hotkeys**
- **Inline edit hotkey**: Hotkey to trigger inline edit on selected text
- **Open chat hotkey**: Hotkey to open the chat sidebar
**Slash Commands**
- Create/edit/import/export custom `/commands` (optionally override model and allowed tools)
**MCP Servers**
- Add/edit/verify/delete MCP server configurations with context-saving mode
**Claude Code Plugins**
- Enable/disable Claude Code plugins discovered from `~/.claude/plugins`
- User-scoped plugins available in all vaults; project-scoped plugins only in matching vault
**Safety**
- **Load user Claude settings**: Load `~/.claude/settings.json` (user's Claude Code permission rules may bypass Safe mode)
- **Enable command blocklist**: Block dangerous bash commands (default: on)
- **Blocked commands**: Patterns to block (supports regex, platform-specific)
- **Allowed export paths**: Paths outside the vault where files can be exported (default: `~/Desktop`, `~/Downloads`). Supports `~`, `$VAR`, `${VAR}`, and `%VAR%` (Windows).
**Environment**
- **Custom variables**: Environment variables for Claude SDK (KEY=VALUE format, supports `export ` prefix)
- **Environment snippets**: Save and restore environment variable configurations
**Advanced**
- **Claude CLI path**: Custom path to Claude Code CLI (leave empty for auto-detection)
## Safety and Permissions
| Scope | Access |
|-------|--------|
| **Vault** | Full read/write (symlink-safe via `realpath`) |
| **Export paths** | Write-only (e.g., `~/Desktop`, `~/Downloads`) |
| **External contexts** | Full read/write (session-only, added via folder icon) |
- **YOLO mode**: No approval prompts; all tool calls execute automatically (default)
- **Safe mode**: Approval prompt per tool call; Bash requires exact match, file tools allow prefix match
- **Plan mode**: Explores and designs a plan before implementing. Toggle via Shift+Tab in the chat input
## Privacy & Data Use
- **Sent to API**: Your input, attached files, images, and tool call outputs. Default: Anthropic; custom endpoint via `ANTHROPIC_BASE_URL`.
- **Local storage**: Settings, session metadata, and commands stored in `vault/.claude/`; session messages in `~/.claude/projects/` (SDK-native); legacy sessions in `vault/.claude/sessions/`.
- **Sent to API**: Your input, attached files, images, and tool call outputs. Default: Anthropic (Claude) or OpenAI (Codex); configurable via environment variables.
- **Local storage**: Claudian settings and session metadata in `vault/.claudian/`; Claude provider files in `vault/.claude/`; transcripts in `~/.claude/projects/` (Claude) and `~/.codex/sessions/` (Codex).
- **No telemetry**: No tracking beyond your configured API provider.
## Troubleshooting
@@ -216,47 +134,29 @@ If different, GUI apps like Obsidian may not find Node.js.
```
src/
├── main.ts # Plugin entry point
├── core/ # Core infrastructure
│ ├── agent/ # Claude Agent SDK wrapper (ClaudianService)
│ ├── agents/ # Custom agent management (AgentManager)
│ ├── commands/ # Slash command management (SlashCommandManager)
│ ├── hooks/ # PreToolUse/PostToolUse hooks
── images/ # Image caching and loading
│ ├── mcp/ # MCP server config, service, and testing
│ ├── plugins/ # Claude Code plugin discovery and management
── prompts/ # System prompts for agents
│ ├── sdk/ # SDK message transformation
│ ├── security/ # Approval, blocklist, path validation
│ ├── storage/ # Distributed storage system
── tools/ # Tool constants and utilities
│ └── types/ # Type definitions
├── features/ # Feature modules
│ ├── chat/ # Main chat view + UI, rendering, controllers, tabs
│ ├── inline-edit/ # Inline edit service + UI
│ └── settings/ # Settings tab UI
├── shared/ # Shared UI components and modals
│ ├── components/ # Input toolbar bits, dropdowns, selection highlight
│ ├── mention/ # @-mention dropdown controller
│ ├── modals/ # Instruction modal
│ └── icons.ts # Shared SVG icons
├── 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
│ ├── 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
├── 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)
├── utils/ # Modular utility functions
└── style/ # Modular CSS (→ styles.css)
├── utils/ # Cross-cutting utilities
└── style/ # Modular CSS
```
## Roadmap
- [x] Claude Code Plugin support
- [x] Custom agent (subagent) support
- [x] Claude in Chrome support
- [x] `/compact` command
- [x] Plan mode
- [x] `rewind` and `fork` support (including `/fork` command)
- [x] `!command` support
- [x] Tool renderers refinement
- [x] 1M Opus and Sonnet models
- [ ] Codex SDK integration
- [ ] Hooks and other advanced features
- [x] Codex provider integration
- [ ] More to come!
## License
@@ -266,14 +166,15 @@ Licensed under the [MIT License](LICENSE).
## 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/svg?repos=YishenTu/claudian&type=date&legend=top-left&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=YishenTu/claudian&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=YishenTu/claudian&type=date&legend=top-left" />
</picture>
<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)
+32 -3
View File
@@ -2,7 +2,14 @@ import esbuild from 'esbuild';
import path from 'path';
import process from 'process';
import builtins from 'builtin-modules';
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'fs';
import {
copyFileSync,
existsSync,
mkdirSync,
promises as fsPromises,
readFileSync,
rmSync,
} from 'fs';
// Load .env.local if it exists
if (existsSync('.env.local')) {
@@ -17,6 +24,22 @@ if (existsSync('.env.local')) {
const prod = process.argv[2] === 'production';
const patchCodexSdkImportMeta = {
name: 'patch-codex-sdk-import-meta',
setup(build) {
build.onLoad(
{ filter: /[\\/]node_modules[\\/]@openai[\\/]codex-sdk[\\/]dist[\\/]index\.js$/ },
async (args) => {
const contents = await fsPromises.readFile(args.path, 'utf8');
return {
contents: contents.replace('createRequire(import.meta.url)', 'createRequire(__filename)'),
loader: 'js',
};
},
);
},
};
// 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)
@@ -28,7 +51,10 @@ const copyToObsidian = {
name: 'copy-to-obsidian',
setup(build) {
build.onEnd((result) => {
if (result.errors.length > 0 || !OBSIDIAN_PLUGIN_PATH) return;
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 });
@@ -41,6 +67,9 @@ const copyToObsidian = {
console.log(`Copied ${file} to Obsidian plugin folder`);
}
}
const pluginVendorRoot = path.join(OBSIDIAN_PLUGIN_PATH, '.codex-vendor');
rmSync(pluginVendorRoot, { recursive: true, force: true });
});
}
};
@@ -48,7 +77,7 @@ const copyToObsidian = {
const context = await esbuild.context({
entryPoints: ['src/main.ts'],
bundle: true,
plugins: [copyToObsidian],
plugins: [patchCodexSdkImportMeta, copyToObsidian],
external: [
'obsidian',
'electron',
+71
View File
@@ -0,0 +1,71 @@
import js from '@eslint/js';
import tseslint from '@typescript-eslint/eslint-plugin';
import jestPlugin from 'eslint-plugin-jest';
import simpleImportSort from 'eslint-plugin-simple-import-sort';
import { defineConfig } from 'eslint/config';
const jestRecommended = jestPlugin.configs['flat/recommended'];
export default defineConfig([
{
ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'main.js'],
},
js.configs.recommended,
...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/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',
},
},
]);
+1
View File
@@ -5,6 +5,7 @@ const baseConfig = {
transform: {
'^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.jest.json' }],
},
roots: ['<rootDir>/src', '<rootDir>/tests'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
+1815 -1562
View File
File diff suppressed because it is too large Load Diff
+16 -13
View File
@@ -26,24 +26,27 @@
"author": "Yishen Tu",
"license": "MIT",
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/jest": "^30.0.0",
"@types/node": "^20.0.0",
"@typescript-eslint/eslint-plugin": "^8.30.0",
"@typescript-eslint/parser": "^8.30.0",
"builtin-modules": "^3.3.0",
"esbuild": "^0.27.1",
"eslint": "^8.57.0",
"eslint-plugin-jest": "^28.11.0",
"@types/node": "^25.5.2",
"@typescript-eslint/eslint-plugin": "^8.58.0",
"@typescript-eslint/parser": "^8.58.0",
"builtin-modules": "^5.0.0",
"esbuild": "^0.28.0",
"eslint": "^10.2.0",
"eslint-plugin-jest": "^29.15.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"jest": "^30.3.0",
"jest-environment-jsdom": "^30.3.0",
"obsidian": "latest",
"ts-jest": "^29.4.6",
"typescript": "^5.0.0"
"ts-jest": "^29.4.9",
"tsx": "^4.21.0",
"typescript": "^6.0.2"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.76",
"@modelcontextprotocol/sdk": "~1.25.3",
"@anthropic-ai/claude-agent-sdk": "^0.2.92",
"@modelcontextprotocol/sdk": "~1.29.0",
"smol-toml": "^1.6.1",
"tslib": "^2.8.1"
}
}
+318
View File
@@ -0,0 +1,318 @@
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 type { VaultFileAdapter } from '../../core/storage/VaultFileAdapter';
import type {
ClaudianSettings,
EnvironmentScope,
EnvSnippet,
HiddenProviderCommands,
ProviderConfigMap,
} from '../../core/types/settings';
import {
getClaudeProviderSettings,
updateClaudeProviderSettings,
} from '../../providers/claude/settings';
import {
getCodexProviderSettings,
updateCodexProviderSettings,
} from '../../providers/codex/settings';
import { DEFAULT_CLAUDIAN_SETTINGS } from './defaultSettings';
export {
CLAUDIAN_SETTINGS_PATH,
LEGACY_CLAUDIAN_SETTINGS_PATH,
};
export type StoredClaudianSettings = ClaudianSettings;
const LEGACY_TOP_LEVEL_PROVIDER_FIELDS = [
'claudeSafeMode',
'codexSafeMode',
'claudeCliPath',
'claudeCliPathsByHost',
'codexCliPath',
'codexCliPathsByHost',
'codexReasoningSummary',
'loadUserClaudeSettings',
'codexEnabled',
'lastClaudeModel',
'enableChrome',
'enableBangBash',
'enableOpus1M',
'enableSonnet1M',
'environmentVariables',
'lastEnvHash',
'lastCodexEnvHash',
] as const;
function stripLegacyFields(settings: Record<string, unknown>): Record<string, unknown> {
const {
activeConversationId: _activeConversationId,
show1MModel: _show1MModel,
hiddenSlashCommands: _hiddenSlashCommands,
slashCommands: _slashCommands,
allowExternalAccess: _allowExternalAccess,
allowedExportPaths: _allowedExportPaths,
enableBlocklist: _enableBlocklist,
blockedCommands: _blockedCommands,
claudeSafeMode: _claudeSafeMode,
codexSafeMode: _codexSafeMode,
claudeCliPath: _claudeCliPath,
claudeCliPathsByHost: _claudeCliPathsByHost,
codexCliPath: _codexCliPath,
codexCliPathsByHost: _codexCliPathsByHost,
codexReasoningSummary: _codexReasoningSummary,
loadUserClaudeSettings: _loadUserClaudeSettings,
codexEnabled: _codexEnabled,
lastClaudeModel: _lastClaudeModel,
enableChrome: _enableChrome,
enableBangBash: _enableBangBash,
enableOpus1M: _enableOpus1M,
enableSonnet1M: _enableSonnet1M,
environmentVariables: _environmentVariables,
lastEnvHash: _lastEnvHash,
lastCodexEnvHash: _lastCodexEnvHash,
...cleaned
} = settings;
return cleaned;
}
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 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 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;
}
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),
});
}
return snippets;
}
function hasLegacyTopLevelProviderFields(stored: Record<string, unknown>): boolean {
return LEGACY_TOP_LEVEL_PROVIDER_FIELDS.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 providerConfigs = normalizeProviderConfigs(stored.providerConfigs);
const legacyProviderSettings = {
...stored,
hiddenProviderCommands,
providerConfigs,
};
const storedWithoutLegacy = stripLegacyFields({
...legacyProviderSettings,
});
const legacyNormalized = {
...storedWithoutLegacy,
sharedEnvironmentVariables: getSharedEnvironmentVariables(legacyProviderSettings),
envSnippets,
hiddenProviderCommands,
providerConfigs,
};
const merged = {
...this.getDefaults(),
...legacyNormalized,
} as StoredClaudianSettings;
updateClaudeProviderSettings(
merged as unknown as Record<string, unknown>,
getClaudeProviderSettings(legacyProviderSettings),
);
updateCodexProviderSettings(
merged as unknown as Record<string, unknown>,
getCodexProviderSettings(legacyProviderSettings),
);
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
|| JSON.stringify(envSnippets) !== JSON.stringify(stored.envSnippets ?? [])
)
) {
await this.save(merged);
}
return merged;
}
async save(settings: StoredClaudianSettings): Promise<void> {
const content = JSON.stringify(
stripLegacyFields(settings as unknown as Record<string, unknown>),
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 });
}
async setLastModel(model: string, isCustom: boolean): Promise<void> {
if (isCustom) {
await this.update({ lastCustomModel: model });
return;
}
const current = await this.load();
updateClaudeProviderSettings(
current as unknown as Record<string, unknown>,
{ lastModel: model },
);
await this.save(current);
}
async setLastEnvHash(hash: string): Promise<void> {
const current = await this.load();
updateClaudeProviderSettings(
current as unknown as Record<string, unknown>,
{ environmentHash: hash },
);
await this.save(current);
}
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);
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import { getDefaultHiddenProviderCommands } from '../../core/providers/commands/hiddenCommands';
import { type ClaudianSettings } from '../../core/types/settings';
import { DEFAULT_CLAUDE_PROVIDER_SETTINGS } from '../../providers/claude/settings';
import { DEFAULT_CODEX_PROVIDER_SETTINGS } from '../../providers/codex/settings';
export const DEFAULT_CLAUDIAN_SETTINGS: ClaudianSettings = {
userName: '',
permissionMode: 'yolo',
model: 'haiku',
thinkingBudget: 'off',
effortLevel: 'high',
serviceTier: 'default',
enableAutoTitleGeneration: true,
titleGenerationModel: '',
excludedTags: [],
mediaFolder: '',
systemPrompt: '',
persistentExternalContextPaths: [],
sharedEnvironmentVariables: '',
envSnippets: [],
customContextLimits: {},
keyboardNavigation: {
scrollUpKey: 'w',
scrollDownKey: 's',
focusInputKey: 'i',
},
locale: 'en',
providerConfigs: {
claude: { ...DEFAULT_CLAUDE_PROVIDER_SETTINGS },
codex: { ...DEFAULT_CODEX_PROVIDER_SETTINGS },
},
settingsProvider: 'claude',
savedProviderModel: {},
savedProviderEffort: {},
savedProviderServiceTier: {},
savedProviderThinkingBudget: {},
lastCustomModel: '',
maxTabs: 3,
tabBarPosition: 'input',
enableAutoScroll: true,
openInMainTab: false,
hiddenProviderCommands: getDefaultHiddenProviderCommands(),
};
+98
View File
@@ -0,0 +1,98 @@
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 { VaultFileAdapter } from '../../core/storage/VaultFileAdapter';
import { ClaudianSettingsStorage, type StoredClaudianSettings } from '../settings/ClaudianSettingsStorage';
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: { openTabs: Array<{ tabId: string; conversationId: string | null }>; activeTabId: string | null }): Promise<void> {
try {
const data = (await this.plugin.loadData()) || {};
data.tabManagerState = state;
await this.plugin.saveData(data);
} catch {
new Notice('Failed to save tab layout');
}
}
async getTabManagerState(): Promise<{ openTabs: Array<{ tabId: string; conversationId: string | null }>; activeTabId: string | null } | null> {
try {
const data = await this.plugin.loadData();
if (!data?.tabManagerState) {
return null;
}
return this.validateTabManagerState(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);
}
private validateTabManagerState(data: unknown): { openTabs: Array<{ tabId: string; conversationId: string | null }>; activeTabId: string | null } | null {
if (!data || typeof data !== 'object') {
return null;
}
const state = data as Record<string, unknown>;
if (!Array.isArray(state.openTabs)) {
return null;
}
const validatedTabs: Array<{ tabId: string; conversationId: string | null }> = [];
for (const tab of state.openTabs) {
if (!tab || typeof tab !== 'object') {
continue;
}
const tabObj = tab as Record<string, unknown>;
if (typeof tabObj.tabId !== 'string') {
continue;
}
validatedTabs.push({
tabId: tabObj.tabId,
conversationId: typeof tabObj.conversationId === 'string' ? tabObj.conversationId : null,
});
}
return {
openTabs: validatedTabs,
activeTabId: typeof state.activeTabId === 'string' ? state.activeTabId : null,
};
}
}
+55 -47
View File
@@ -1,73 +1,81 @@
# Core Infrastructure
Core modules have **no feature dependencies**. Features depend on core, never the reverse.
Core modules stay provider-neutral. Features depend on `core/`; providers implement the boundary behind it.
## Runtime Status
- `core/runtime/` and `core/providers/` define the chat-facing seam. `ChatRuntime` is the neutral runtime interface. `src/providers/claude/runtime/` and `src/providers/codex/runtime/` provide the concrete implementations.
- `ProviderRegistry` owns runtime and auxiliary-service factories. `ProviderWorkspaceRegistry` owns provider workspace services such as command catalogs, agent mentions, CLI resolution, MCP managers, and provider settings tabs.
- Claude-specific agents, plugins, MCP, runtime command discovery, and storage live under `src/providers/claude/`.
- Codex-specific skills, subagents, JSONL history hydration, session tailing, and workspace services live under `src/providers/codex/`.
## Modules
| Module | Purpose | Key Files |
|--------|---------|-----------|
| `agent/` | Claude Agent SDK wrapper | `ClaudianService` (incl. fork session tracking), `SessionManager`, `QueryOptionsBuilder` (incl. `resumeSessionAt`), `MessageChannel`, `customSpawn` |
| `agents/` | Custom agent discovery | `AgentManager`, `AgentStorage` |
| `commands/` | Built-in command actions | `builtInCommands` |
| `hooks/` | Security hooks | `SecurityHooks` |
| `images/` | Image caching | SHA-256 dedup, base64 encoding |
| `mcp/` | Model Context Protocol | `McpServerManager`, `McpTester` |
| `plugins/` | Claude Code plugins | `PluginManager` |
| `prompts/` | System prompts | `mainAgent`, `inlineEdit`, `instructionRefine`, `titleGeneration` |
| `sdk/` | SDK message transform | `transformSDKMessage`, `typeGuards`, `types` |
| `security/` | Access control | `ApprovalManager` (permission utilities), `BashPathValidator`, `BlocklistChecker` |
| `storage/` | Persistence layer | `StorageService`, `SessionStorage`, `CCSettingsStorage`, `ClaudianSettingsStorage`, `McpStorage`, `SkillStorage`, `SlashCommandStorage`, `VaultFileAdapter` |
| `tools/` | Tool utilities | `toolNames` (incl. plan mode tools), `toolIcons`, `toolInput`, `todo` |
| `types/` | Type definitions | `settings`, `agent`, `mcp`, `chat` (incl. `forkSource?: { sessionId, resumeAt }`), `tools`, `models`, `sdk`, `plugins`, `diff` |
| `bootstrap/` | Provider-neutral session metadata storage and shared app-storage contracts | `SessionStorage`, `storage.ts` |
| `commands/` | Built-in cross-provider commands | `builtInCommands` |
| `mcp/` | Provider-neutral MCP coordination and config parsing | `McpConfigParser`, `McpServerManager`, `McpTester`, `McpStorageAdapter` |
| `prompt/` | Shared prompt templates | `mainAgent`, `inlineEdit`, `titleGeneration`, `instructionRefine` |
| `providers/` | Registry, capability, environment, and workspace-service contracts | `ProviderRegistry`, `ProviderWorkspaceRegistry`, `ProviderSettingsCoordinator`, `providerEnvironment`, `providerConfig`, `modelRouting`, `types` |
| `providers/commands/` | Shared command catalog contracts | `ProviderCommandCatalog`, `ProviderCommandEntry`, `hiddenCommands` |
| `runtime/` | Provider-neutral runtime contracts | `ChatRuntime`, `ChatTurnRequest`, `PreparedChatTurn`, `SessionUpdateResult`, approval/query types |
| `security/` | Permission and approval helpers | `ApprovalManager` |
| `storage/` | Generic filesystem adapters | `VaultFileAdapter`, `HomeFileAdapter` |
| `tools/` | Shared tool constants and formatting helpers | `toolNames`, `toolIcons`, `toolInput`, `todo` |
| `types/` | Shared type definitions | `settings`, `mcp`, `chat`, `tools`, `diff`, `agent`, `plugins` |
## Dependency Rules
```
types/ ← (all modules can import)
storage/ ← security/, agent/, mcp/
security/ ← agent/
sdk/ ← agent/
hooks/ ← agent/
prompts/ ← agent/
```text
types/ <- all modules
storage/ <- bootstrap/, provider workspace services
runtime/ + providers/ <- provider implementations
features/ -> core contracts only
```
## Key Patterns
### ClaudianService
### ChatRuntime
```typescript
// One instance per tab (lazy init on first query)
const service = new ClaudianService(plugin, vaultPath);
await service.query(prompt, options); // Returns async iterator
service.abort(); // Cancel streaming
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.
}
```
### QueryOptionsBuilder
### Provider Factories
```typescript
// Builds SDK Options from settings
const builder = new QueryOptionsBuilder(plugin, settings);
const options = builder.build({ sessionId, maxThinkingTokens });
const titleService = ProviderRegistry.createTitleGenerationService(plugin, providerId);
const refineService = ProviderRegistry.createInstructionRefineService(plugin, providerId);
const inlineEditService = ProviderRegistry.createInlineEditService(plugin, providerId);
```
### Storage (Claude Code pattern)
```typescript
// Settings in vault/.claude/settings.json
await CCSettingsStorage.load(vaultPath);
await CCSettingsStorage.save(vaultPath, settings);
### Workspace Services
// Sessions: SDK-native (~/.claude/projects/) + metadata overlay (.meta.json)
await SessionStorage.loadSession(vaultPath, sessionId);
```typescript
const catalog = ProviderWorkspaceRegistry.getCommandCatalog(providerId);
const agentMentions = ProviderWorkspaceRegistry.getAgentMentionProvider(providerId);
const cliResolver = ProviderWorkspaceRegistry.getCliResolver(providerId);
```
### Security
- `BashPathValidator`: Vault-only by default, symlink-safe via `realpath`
- `ApprovalManager`: Permission utility functions (`buildPermissionUpdates`, `matchesRulePattern`, etc.)
- `BlocklistChecker`: Platform-specific dangerous commands
### Storage
- `core/storage/` provides generic vault/home adapters only
- Provider-owned workspace storage lives under `src/providers/claude/storage/` and `src/providers/codex/storage/`
- Provider-owned transcript hydration and deletion live under provider `history/` services
## Gotchas
- `ClaudianService` must be disposed on tab close (abort + cleanup)
- `SessionManager` handles SDK session resume via `sessionId`
- Fork uses `pendingForkSession` + `pendingResumeAt` on `ClaudianService` to pass `resumeSessionAt` to SDK; these are one-shot flags consumed on the next query
- Storage paths are encoded: non-alphanumeric → `-`
- `customSpawn` handles cross-platform process spawning
- Plan mode uses dedicated callbacks (`exitPlanModeCallback`, `permissionModeSyncCallback`) that bypass normal approval flow in `canUseTool`. `EnterPlanMode` is auto-approved by the SDK; the stream event is detected to sync UI state.
- `ChatRuntime.cleanup()` must run when a tab is disposed
- `Conversation.providerState` is intentionally opaque in feature code; provider-specific fields belong behind typed provider helpers
- Plan mode is capability-driven
- Claude enters and exits plan mode through provider/runtime events
- Codex sends `collaborationMode` on `turn/start` and uses post-stream plan approval metadata
- Command discovery differs by provider
- Claude merges runtime-discovered commands with vault commands and skills
- Codex skill discovery comes from `CodexSkillCatalog` and does not depend on runtime command discovery
-16
View File
@@ -1,16 +0,0 @@
export { type ApprovalCallback, type ApprovalCallbackOptions, ClaudianService, type QueryOptions } from './ClaudianService';
export { MessageChannel } from './MessageChannel';
export {
type ColdStartQueryContext,
type PersistentQueryContext,
QueryOptionsBuilder,
type QueryOptionsContext,
} from './QueryOptionsBuilder';
export { SessionManager } from './SessionManager';
export type {
ClosePersistentQueryOptions,
PersistentQueryConfig,
ResponseHandler,
SessionState,
UserContentBlock,
} from './types';
-2
View File
@@ -1,2 +0,0 @@
export { AgentManager } from './AgentManager';
export { buildAgentFromFrontmatter, parseAgentFile } from './AgentStorage';
+179
View File
@@ -0,0 +1,179 @@
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,
};
export class SessionStorage {
constructor(private adapter: VaultFileAdapter) {}
getMetadataPath(id: string): string {
return `${SESSIONS_PATH}/${id}.meta.json`;
}
getLegacyMetadataPath(id: string): string {
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> {
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 (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) {
try {
const content = await this.adapter.read(filePath);
const raw = JSON.parse(content) as SessionMetadata;
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 providerState = ProviderRegistry
.getConversationHistoryService(conversation.providerId)
.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,
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;
}
}
+7
View File
@@ -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`;
+20
View File
@@ -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;
}
+47 -10
View File
@@ -5,7 +5,12 @@
* 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;
@@ -16,6 +21,8 @@ export interface BuiltInCommand {
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 {
@@ -42,11 +49,13 @@ export const BUILT_IN_COMMANDS: BuiltInCommand[] = [
name: 'resume',
description: 'Resume a previous conversation',
action: 'resume',
requiredCapability: 'supportsNativeHistory',
},
{
name: 'fork',
description: 'Fork entire conversation to new session',
action: 'fork',
requiredCapability: 'supportsFork',
},
];
@@ -62,6 +71,32 @@ for (const cmd of BUILT_IN_COMMANDS) {
}
}
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.
@@ -84,21 +119,23 @@ export function detectBuiltInCommand(input: string): BuiltInCommandResult | null
}
/**
* Gets all built-in commands for dropdown display.
* Returns commands in a format compatible with SlashCommand interface.
* Gets built-in commands for dropdown display.
* When providerId is given, excludes commands restricted to other providers.
*/
export function getBuiltInCommandsForDropdown(): Array<{
export function getBuiltInCommandsForDropdown(context?: BuiltInCommandSupportContext): Array<{
id: string;
name: string;
description: string;
content: string;
argumentHint?: string;
}> {
return BUILT_IN_COMMANDS.map((cmd) => ({
id: `builtin:${cmd.name}`,
name: cmd.name,
description: cmd.description,
content: '', // Built-in commands don't have prompt content
argumentHint: cmd.argumentHint,
}));
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,
}));
}
-8
View File
@@ -1,8 +0,0 @@
export {
BUILT_IN_COMMANDS,
type BuiltInCommand,
type BuiltInCommandAction,
type BuiltInCommandResult,
detectBuiltInCommand,
getBuiltInCommandsForDropdown,
} from './builtInCommands';
-143
View File
@@ -1,143 +0,0 @@
/**
* Security Hooks
*
* PreToolUse hooks for enforcing blocklist and vault restriction.
*/
import type { HookCallbackMatcher } from '@anthropic-ai/claude-agent-sdk';
import { Notice } from 'obsidian';
import type { PathAccessType } from '../../utils/path';
import type { PathCheckContext } from '../security/BashPathValidator';
import { findBashCommandPathViolation } from '../security/BashPathValidator';
import { isCommandBlocked } from '../security/BlocklistChecker';
import { getPathFromToolInput } from '../tools/toolInput';
import { isEditTool, isFileTool, TOOL_BASH } from '../tools/toolNames';
import { getBashToolBlockedCommands, type PlatformBlockedCommands } from '../types';
export interface BlocklistContext {
blockedCommands: PlatformBlockedCommands;
enableBlocklist: boolean;
}
export interface VaultRestrictionContext {
getPathAccessType: (filePath: string) => PathAccessType;
}
/**
* Create a PreToolUse hook to enforce the command blocklist.
*/
export function createBlocklistHook(getContext: () => BlocklistContext): HookCallbackMatcher {
return {
matcher: TOOL_BASH,
hooks: [
async (hookInput) => {
const input = hookInput as {
tool_name: string;
tool_input: { command?: string };
};
const command = input.tool_input?.command || '';
const context = getContext();
const bashToolCommands = getBashToolBlockedCommands(context.blockedCommands);
if (isCommandBlocked(command, bashToolCommands, context.enableBlocklist)) {
new Notice('Command blocked by security policy');
return {
continue: false,
hookSpecificOutput: {
hookEventName: 'PreToolUse' as const,
permissionDecision: 'deny' as const,
permissionDecisionReason: `Command blocked by blocklist: ${command}`,
},
};
}
return { continue: true };
},
],
};
}
/**
* Create a PreToolUse hook to restrict file access to the vault.
*/
export function createVaultRestrictionHook(context: VaultRestrictionContext): HookCallbackMatcher {
return {
hooks: [
async (hookInput) => {
const input = hookInput as {
tool_name: string;
tool_input: Record<string, unknown>;
};
const toolName = input.tool_name;
// Bash: inspect command for paths that escape the vault
if (toolName === TOOL_BASH) {
const command = (input.tool_input?.command as string) || '';
const pathCheckContext: PathCheckContext = {
getPathAccessType: (p) => context.getPathAccessType(p),
};
const violation = findBashCommandPathViolation(command, pathCheckContext);
if (violation) {
const reason =
violation.type === 'export_path_read'
? `Access denied: Command path "${violation.path}" is in an allowed export directory, but export paths are write-only.`
: `Access denied: Command path "${violation.path}" is outside the vault. Agent is restricted to vault directory only.`;
return {
continue: false,
hookSpecificOutput: {
hookEventName: 'PreToolUse' as const,
permissionDecision: 'deny' as const,
permissionDecisionReason: reason,
},
};
}
return { continue: true };
}
if (!isFileTool(toolName)) {
return { continue: true };
}
const filePath = getPathFromToolInput(toolName, input.tool_input);
if (filePath) {
const accessType = context.getPathAccessType(filePath);
// Allow full access to vault, readwrite, and context paths
if (accessType === 'vault' || accessType === 'readwrite' || accessType === 'context') {
return { continue: true };
}
// Export paths are write-only
if (isEditTool(toolName) && accessType === 'export') {
return { continue: true };
}
if (!isEditTool(toolName) && accessType === 'export') {
return {
continue: false,
hookSpecificOutput: {
hookEventName: 'PreToolUse' as const,
permissionDecision: 'deny' as const,
permissionDecisionReason: `Access denied: Path "${filePath}" is in an allowed export directory, but export paths are write-only.`,
},
};
}
return {
continue: false,
hookSpecificOutput: {
hookEventName: 'PreToolUse' as const,
permissionDecision: 'deny' as const,
permissionDecisionReason: `Access denied: Path "${filePath}" is outside the vault. Agent is restricted to vault directory only.`,
},
};
}
return { continue: true };
},
],
};
}
-10
View File
@@ -1,10 +0,0 @@
export {
type BlocklistContext,
createBlocklistHook,
createVaultRestrictionHook,
type VaultRestrictionContext,
} from './SecurityHooks';
export {
createStopSubagentHook,
type SubagentHookState,
} from './SubagentHooks';
+98
View File
@@ -0,0 +1,98 @@
import type { McpServerConfig, ParsedMcpConfig } from '../types';
import { isValidMcpServerConfig } from '../types';
/**
* 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 = JSON.parse(json);
if (!parsed || typeof parsed !== 'object') {
throw new Error('Invalid JSON object');
}
// Format 1: Full Claude Code format
// { "mcpServers": { "server-name": { "command": "...", ... } } }
if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
const servers: Array<{ name: string; config: McpServerConfig }> = [];
for (const [name, config] of Object.entries(parsed.mcpServers)) {
if (isValidMcpServerConfig(config)) {
servers.push({ name, config: config as McpServerConfig });
}
}
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 as McpServerConfig }],
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: config as McpServerConfig }],
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: config as McpServerConfig });
}
}
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;
}
}
+6 -12
View File
@@ -1,19 +1,13 @@
/**
* McpServerManager - Core MCP server configuration management.
*
* Infrastructure layer for loading, filtering, and querying MCP server configurations.
*/
import { extractMcpMentions, transformMcpMentions } from '../../utils/mcp';
import type { ClaudianMcpServer, McpServerConfig } from '../types';
import type { ManagedMcpServer, McpServerConfig } from '../types';
/** Storage interface for loading MCP servers. */
export interface McpStorageAdapter {
load(): Promise<ClaudianMcpServer[]>;
load(): Promise<ManagedMcpServer[]>;
}
export class McpServerManager {
private servers: ClaudianMcpServer[] = [];
private servers: ManagedMcpServer[] = [];
private storage: McpStorageAdapter;
constructor(storage: McpStorageAdapter) {
@@ -24,7 +18,7 @@ export class McpServerManager {
this.servers = await this.storage.load();
}
getServers(): ClaudianMcpServer[] {
getServers(): ManagedMcpServer[] {
return this.servers;
}
@@ -81,7 +75,7 @@ export class McpServerManager {
return this.collectDisallowedTools().sort();
}
private collectDisallowedTools(filter?: (server: ClaudianMcpServer) => boolean): string[] {
private collectDisallowedTools(filter?: (server: ManagedMcpServer) => boolean): string[] {
const disallowed = new Set<string>();
for (const server of this.servers) {
@@ -103,7 +97,7 @@ export class McpServerManager {
return this.servers.length > 0;
}
getContextSavingServers(): ClaudianMcpServer[] {
getContextSavingServers(): ManagedMcpServer[] {
return this.servers.filter((s) => s.enabled && s.contextSaving);
}
+2 -2
View File
@@ -7,7 +7,7 @@ import * as https from 'https';
import { getEnhancedPath } from '../../utils/env';
import { parseCommand } from '../../utils/mcp';
import type { ClaudianMcpServer } from '../types';
import type { ManagedMcpServer } from '../types';
import { getMcpServerType } from '../types';
export interface McpTool {
@@ -208,7 +208,7 @@ async function getRequestBody(body: BodyInit | null | undefined): Promise<Buffer
const nodeFetch = createNodeFetch();
export async function testMcpServer(server: ClaudianMcpServer): Promise<McpTestResult> {
export async function testMcpServer(server: ManagedMcpServer): Promise<McpTestResult> {
const type = getMcpServerType(server.config);
let transport;
-2
View File
@@ -1,2 +0,0 @@
export { McpServerManager, type McpStorageAdapter } from './McpServerManager';
export { type McpTestResult, type McpTool, testMcpServer } from './McpTester';
-1
View File
@@ -1 +0,0 @@
export { PluginManager } from './PluginManager';
@@ -1,17 +1,83 @@
/**
* Claudian - Inline Edit System Prompt
*
* Builds the system prompt for inline text editing (read-only tools).
*/
import { appendContextFiles } from '../../utils/context';
import { getTodayDate } from '../../utils/date';
import type {
InlineEditCursorRequest,
InlineEditRequest,
InlineEditResult,
} from '../providers/types';
export function getInlineEditSystemPrompt(allowExternalAccess: boolean = false): string {
const pathRules = allowExternalAccess
? '- **Paths**: Prefer RELATIVE paths for vault files. Use absolute or `~` paths only when you intentionally need files outside the vault.'
: '- **Paths**: Must be RELATIVE to vault root (e.g., "notes/file.md").';
export function parseInlineEditResponse(responseText: string): InlineEditResult {
const replacementMatch = responseText.match(/<replacement>([\s\S]*?)<\/replacement>/);
if (replacementMatch) {
return { success: true, editedText: replacementMatch[1] };
}
return `Today is ${getTodayDate()}.
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.
@@ -1,12 +1,6 @@
/**
* Claudian - Instruction Refine System Prompt
*
* Builds the system prompt for instruction refinement.
*/
export function buildRefineSystemPrompt(existingInstructions: string): string {
const existingSection = existingInstructions.trim()
? `\n\nEXISTING INSTRUCTIONS (already in the user's system prompt):
const existingSection = existingInstructions.trim()
? `\n\nEXISTING INSTRUCTIONS (already in the user's system prompt):
\`\`\`
${existingInstructions.trim()}
\`\`\`
@@ -16,9 +10,9 @@ When refining the new instruction:
- 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.
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.
+215
View File
@@ -0,0 +1,215 @@
import { getTodayDate } from '../../utils/date';
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**: ${getTodayDate()}
- **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
<current_note>
path/to/note.md
</current_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.
- \`<current_note>\`: The note the user is currently viewing/focused on. Read this to understand 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** (\`![alt](url)\`):
- 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 \`![alt](url)\` 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('::');
}
@@ -1,9 +1,3 @@
/**
* Claudian - Title Generation System Prompt
*
* System prompt for generating conversation titles.
*/
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.
-358
View File
@@ -1,358 +0,0 @@
/**
* Claudian - Main Agent System Prompt
*
* Builds the system prompt for the Claude Agent SDK including
* Obsidian-specific instructions, tool guidance, and image handling.
*/
import { getTodayDate } from '../../utils/date';
export interface SystemPromptSettings {
mediaFolder?: string;
customPrompt?: string;
allowedExportPaths?: string[];
allowExternalAccess?: boolean;
vaultPath?: string;
userName?: string;
}
function getPathRules(vaultPath?: string, allowExternalAccess: boolean = false): string {
if (!allowExternalAccess) {
return `## Path Rules (MUST FOLLOW)
| Location | Access | Path Format | Example |
|----------|--------|-------------|---------|
| **Vault** | Read/Write | Relative from vault root | \`notes/my-note.md\`, \`.\` |
| **Export paths** | Write-only | \`~\` or absolute | \`~/Desktop/output.docx\` |
| **External contexts** | Full access | Absolute path | \`/Users/me/Workspace/file.ts\` |
**Vault files** (default):
- ✓ 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.
**Path specificity**: When paths overlap, the **more specific path wins**:
- If \`~/Desktop\` is export (write-only) and \`~/Desktop/Workspace\` is external context (full access)
- → Files in \`~/Desktop/Workspace\` have full read/write access
- → Files directly in \`~/Desktop\` remain write-only`;
}
return `## Path Rules (MUST FOLLOW)
| Location | Access | Path Format | Example |
|----------|--------|-------------|---------|
| **Vault** | Read/Write | Relative from vault root preferred | \`notes/my-note.md\`, \`.\` |
| **External paths** | Read/Write | \`~\` or absolute | \`~/Desktop/output.docx\`, \`/Users/me/Workspace/file.ts\` |
| **Session external contexts** | Full access | Absolute path | \`/Users/me/Workspace\` |
**Vault files**:
- Prefer relative paths for files inside the vault.
- Absolute vault paths are allowed when needed, but relative paths are usually simpler and less error-prone.
**External files**:
- Use absolute or \`~\` paths for files outside the vault.
- Be explicit about the target path and avoid broad filesystem operations unless they are necessary.
**Path specificity**:
- When multiple directories could match, use the narrowest path that fits the task.
- Prefer the most specific external directory instead of a broad parent path.`;
}
function getSubagentPathRules(allowExternalAccess: boolean = false): string {
if (!allowExternalAccess) {
return `**CRITICAL - Subagent Path Rules:**
- Subagents inherit the vault as their working directory.
- Reference files using **RELATIVE** paths.
- NEVER use absolute paths in subagent prompts.`;
}
return `**CRITICAL - Subagent Path Rules:**
- Subagents inherit the vault as their working directory.
- Reference vault files using **RELATIVE** paths.
- Use absolute or \`~\` paths only when you intentionally need files outside the vault.`;
}
function getBaseSystemPrompt(
vaultPath?: string,
userName?: string,
allowExternalAccess: boolean = false
): 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, allowExternalAccess);
const subagentPathRules = getSubagentPathRules(allowExternalAccess);
return `${userContext}## Time Context
- **Current Date**: ${getTodayDate()}
- **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
<current_note>
path/to/note.md
</current_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.
- \`<current_note>\`: The note the user is currently viewing/focused on. Read this to understand 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]]"
## Tool Usage Guidelines
Standard tools (Read, Write, Edit, Glob, Grep, LS, Bash, WebSearch, WebFetch, Skills) work as expected.
**Thinking Process:**
Before taking action, explicitly THINK about:
1. **Context**: Do I have enough information? (Use Read/Search if not).
2. **Impact**: What will this change affect? (Links, other files).
3. **Plan**: What are the steps? (Use TodoWrite for >2 steps).
**Tool-Specific Rules:**
- **Read**:
- Always Read a file before Editing it.
- Read can view images (PNG, JPG, GIF, WebP) for visual analysis.
- **Edit**:
- Requires **EXACT** \`old_string\` match including whitespace/indentation.
- If Edit fails, Read the file again to check the current content.
- **Bash**:
- Runs with vault as working directory.
- **Prefer** Read/Write/Edit over shell commands for file operations (safer).
- **Stdout-capable tools** (pandoc, jq, imagemagick): Prefer piping output directly instead of creating temporary files when the result will be used immediately.
- Use BashOutput/KillShell to manage background processes.
- **LS**: Uses "." for vault root.
- **WebFetch**: For text/HTML/PDF only. Avoid binaries.
### WebSearch
Use WebSearch strictly according to the following logic:
1. **Static/Historical**: Rely on internal knowledge for established facts, history, or older code libraries. Use WebSearch to confirm or expand on your knowledge.
2. **Dynamic/Recent**: **MUST** search for:
- "Latest" news, versions, docs.
- Events in the current/previous year.
- Volatile data (prices, weather).
3. **Date Awareness**: If user says "yesterday", calculate the date relative to **Current Date**.
4. **Ambiguity**: If unsure whether knowledge is outdated, SEARCH.
### Agent (Subagents)
Spawn subagents for complex multi-step tasks. Parameters: \`prompt\`, \`description\`, \`subagent_type\`, \`run_in_background\`.
${subagentPathRules}
**When to use:**
- Parallelizable work (main + subagent or multiple subagents)
- Preserve main agent's context window
- Offload contained tasks while continuing other work
**IMPORTANT:** Always explicitly set \`run_in_background\` - never omit it:
- \`run_in_background=false\` for sync (inline) tasks
- \`run_in_background=true\` for async (background) tasks
**Sync Mode (\`run_in_background=false\`)**:
- Runs inline, result returned directly.
- **DEFAULT** to this unless explicitly asked or the task is very long-running.
**Async Mode (\`run_in_background=true\`)**:
- Use ONLY when explicitly requested or task is clearly long-running.
- Returns \`task_id\` immediately.
- You **cannot end your turn** while async subagents are still running. The system will block you and remind you to retrieve results.
**Async workflow:**
1. Launch: \`Agent prompt="..." run_in_background=true\` → get \`task_id\`
2. Continue working on other tasks
3. Use \`TaskOutput task_id="..." block=true\` to wait for completion (blocks until result is ready)
4. Process the result and report to the user
**When to retrieve results:**
- Mid-turn between other tasks: use \`TaskOutput block=false\` to poll without blocking
- Idle with no other work: use \`TaskOutput block=true\` to wait
### TodoWrite
Track task progress. Parameter: \`todos\` (array of {content, status, activeForm}).
- Statuses: \`pending\`, \`in_progress\`, \`completed\`
- \`content\`: imperative ("Fix the bug")
- \`activeForm\`: present continuous ("Fixing the bug")
**Use for:** Tasks with 2+ steps, multi-file changes, complex operations.
Use proactively for any task meeting these criteria to keep progress visible.
**Workflow:**
1. **Plan**: Create the todo list at the start.
2. **Execute**: Mark \`in_progress\` -> do work -> Mark \`completed\`.
3. **Update**: If new tasks arise, add them.
**Example:** User asks "refactor auth and add tests"
\`\`\`
[
{content: "Analyze auth module", status: "in_progress", activeForm: "Analyzing auth module"},
{content: "Refactor auth code", status: "pending", activeForm: "Refactoring auth code"},
{content: "Add unit tests", status: "pending", activeForm: "Adding unit tests"}
]
\`\`\`
### Skills
Reusable capability modules. Use the \`Skill\` tool to invoke them when their description matches the user's need.
## 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** (\`![alt](url)\`):
- 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 \`![alt](url)\` with \`![[${examplePath}$img_name]]\` in the note.
**Benefits**: Image becomes a permanent vault asset, works offline, and uses Obsidian's native embed syntax.`;
}
function getExportInstructions(
allowedExportPaths: string[],
allowExternalAccess: boolean = false
): string {
if (allowedExportPaths.length === 0) {
return '';
}
const uniquePaths = Array.from(new Set(allowedExportPaths.map((p) => p.trim()).filter(Boolean)));
if (uniquePaths.length === 0) {
return '';
}
const formattedPaths = uniquePaths.map((p) => `- ${p}`).join('\n');
const heading = allowExternalAccess ? 'Preferred Export Paths' : 'Allowed Export Paths';
const description = allowExternalAccess
? 'Suggested destinations for exports outside the vault:'
: 'Write-only destinations outside the vault:';
return `
## ${heading}
${description}
${formattedPaths}
Examples:
\`\`\`bash
pandoc ./note.md -o ~/Desktop/note.docx # Direct export
pandoc ./note.md | head -100 # Pipe to stdout (no temp file)
cp ./note.md ~/Desktop/note.md
\`\`\``;
}
export function buildSystemPrompt(settings: SystemPromptSettings = {}): string {
const allowExternalAccess = settings.allowExternalAccess ?? false;
let prompt = getBaseSystemPrompt(settings.vaultPath, settings.userName, allowExternalAccess);
// Stable content (ordered for context cache optimization)
prompt += getImageInstructions(settings.mediaFolder || '');
prompt += getExportInstructions(settings.allowedExportPaths || [], allowExternalAccess);
if (settings.customPrompt?.trim()) {
prompt += '\n\n## Custom Instructions\n\n' + settings.customPrompt.trim();
}
return prompt;
}
+160
View File
@@ -0,0 +1,160 @@
import type ClaudianPlugin from '../../main';
import type { ChatRuntime } from '../runtime/ChatRuntime';
import {
type CreateChatRuntimeOptions,
DEFAULT_CHAT_PROVIDER_ID,
type InlineEditService,
type InstructionRefineService,
type ProviderCapabilities,
type ProviderChatUIConfig,
type ProviderConversationHistoryService,
type ProviderId,
type ProviderRegistration,
type ProviderSettingsReconciler,
type ProviderSubagentLifecycleAdapter,
type ProviderTaskResultInterpreter,
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: ClaudianPlugin, providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID): TitleGenerationService {
return this.getProviderRegistration(providerId).createTitleGenerationService(plugin);
}
static createInstructionRefineService(plugin: ClaudianPlugin, providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID): InstructionRefineService {
return this.getProviderRegistration(providerId).createInstructionRefineService(plugin);
}
static createInlineEditService(plugin: ClaudianPlugin, 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 getRegisteredProviderIds(): ProviderId[] {
return Object.keys(this.registrations) as ProviderId[];
}
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 as ProviderId;
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> = {},
): ProviderId {
for (const providerId of this.getRegisteredProviderIds()) {
if (providerId === DEFAULT_CHAT_PROVIDER_ID) {
continue;
}
if (this.getChatUIConfig(providerId).ownsModel(model, settings)) {
return providerId;
}
}
return DEFAULT_CHAT_PROVIDER_ID;
}
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;
}
}
@@ -0,0 +1,284 @@
import type { Conversation } from '../types';
import { ProviderRegistry } from './ProviderRegistry';
import type { ProviderId } from './types';
export interface SettingsReconciliationResult {
changed: boolean;
invalidatedConversations: Conversation[];
}
const PROJECTION_KEYS = new Set([
'model',
'effortLevel',
'serviceTier',
'thinkingBudget',
]);
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',
): ProviderProjectionMap {
const current = settings[key];
if (current && typeof current === 'object') {
return current as ProviderProjectionMap;
}
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) },
};
}
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;
}
}
export class ProviderSettingsCoordinator {
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');
if (typeof settings.model === 'string') {
savedModel[providerId] = settings.model;
}
if (typeof settings.effortLevel === 'string') {
savedEffort[providerId] = settings.effortLevel;
}
const serviceTierToggle = ProviderRegistry
.getChatUIConfig(providerId)
.getServiceTierToggle?.(settings) ?? null;
if (serviceTierToggle && typeof settings.serviceTier === 'string') {
savedServiceTier[providerId] = settings.serviceTier;
}
if (typeof settings.thinkingBudget === 'string') {
savedBudget[providerId] = settings.thinkingBudget;
}
}
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 currentModel = typeof settings.model === 'string' ? settings.model : '';
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 shouldPreferCurrentProjection = providerId === getSettingsProviderId(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 fallbackModel = canReuseCurrentModel
? currentModel
: (modelOptions[0]?.value ?? currentModel);
const savedModelValue = 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;
if (savedEffort?.[providerId] !== undefined) {
settings.effortLevel = savedEffort[providerId];
} else if (canReuseCurrentProjection && currentEffort !== undefined) {
settings.effortLevel = currentEffort;
} else if (model && uiConfig.isAdaptiveReasoningModel(model)) {
settings.effortLevel = uiConfig.getDefaultReasoningValue(model);
}
if (serviceTierToggle) {
if (savedServiceTier?.[providerId] !== undefined) {
settings.serviceTier = savedServiceTier[providerId];
} else if (canReuseCurrentProjection && currentServiceTier !== undefined) {
settings.serviceTier = currentServiceTier;
} else {
settings.serviceTier = serviceTierToggle.inactiveValue;
}
} else {
if (savedServiceTier?.[providerId] !== undefined) {
settings.serviceTier = savedServiceTier[providerId];
} else if (canReuseCurrentProjection && currentServiceTier !== undefined) {
settings.serviceTier = currentServiceTier;
} else {
settings.serviceTier = 'default';
}
}
if (savedBudget?.[providerId] !== undefined) {
settings.thinkingBudget = savedBudget[providerId];
} else if (canReuseCurrentProjection && currentBudget !== undefined) {
settings.thinkingBudget = currentBudget;
} else if (model && !uiConfig.isAdaptiveReasoningModel(model)) {
settings.thinkingBudget = uiConfig.getDefaultReasoningValue(model);
}
}
/** 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);
}
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);
}
}
}
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,109 @@
import type ClaudianPlugin from '../../main';
import { HomeFileAdapter } from '../storage/HomeFileAdapter';
import type { ProviderCommandCatalog } from './commands/ProviderCommandCatalog';
import type {
AgentMentionProvider,
ProviderCliResolver,
ProviderId,
ProviderSettingsTabRenderer,
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: ClaudianPlugin): Promise<void> {
const providerIds = Object.keys(this.registrations) as ProviderId[];
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 getCliResolver(providerId: ProviderId): ProviderCliResolver | null {
return this.getServices(providerId)?.cliResolver ?? 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<ProviderId | 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()));
}
+6
View File
@@ -0,0 +1,6 @@
import { ProviderRegistry } from './ProviderRegistry';
import type { ProviderId } from './types';
export function getProviderForModel(model: string, settings?: Record<string, unknown>): ProviderId {
return ProviderRegistry.resolveProviderForModel(model, settings);
}
+34
View File
@@ -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;
}
+364
View File
@@ -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 [];
}
+445
View File
@@ -0,0 +1,445 @@
import type ClaudianPlugin from '../../main';
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';
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: ClaudianPlugin;
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: ClaudianPlugin) => TitleGenerationService;
createInstructionRefineService: (plugin: ClaudianPlugin) => InstructionRefineService;
createInlineEditService: (plugin: ClaudianPlugin) => InlineEditService;
historyService: ProviderConversationHistoryService;
taskResultInterpreter: ProviderTaskResultInterpreter;
subagentLifecycleAdapter?: ProviderSubagentLifecycleAdapter;
}
export interface ProviderSettingsReconciler {
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 }>;
activeTabId: string | null;
}
/** 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 | null;
}
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;
}
/** SVG icon descriptor for provider branding in selectors. */
export interface ProviderIconSvg {
viewBox: string;
path: string;
}
/** 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;
}
/** Static UI configuration owned by the provider (model list, reasoning, context window). */
export interface ProviderChatUIConfig {
/** Model options for the selector dropdown. Provider extracts what it needs from the settings bag. */
getModelOptions(settings: Record<string, unknown>): ProviderUIOption[];
/** 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): boolean;
/** Reasoning options for the current model (effort levels if adaptive, budgets otherwise). */
getReasoningOptions(model: string): ProviderReasoningOption[];
/** Default reasoning value for the model. */
getDefaultReasoningValue(model: string): string;
/** Context window size in tokens. */
getContextWindowSize(model: string, customLimits?: Record<string, number>): 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;
/** 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 service-tier toggle descriptor. Return null when the provider exposes no fast/standard UI. */
getServiceTierToggle?(settings: Record<string, unknown>): ProviderServiceTierToggleConfig | null;
/** 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 ProviderCliResolver {
resolveFromSettings(settings: Record<string, unknown>): string | null;
reset(): void;
}
export interface ProviderWorkspaceServices {
commandCatalog?: ProviderCommandCatalog | null;
agentMentionProvider?: AgentMentionProvider | null;
cliResolver?: ProviderCliResolver | null;
mcpServerManager?: McpServerManager | null;
settingsTabRenderer?: ProviderSettingsTabRenderer | null;
refreshAgentMentions?(): Promise<void>;
}
export interface ProviderSettingsTabRendererContext {
plugin: ClaudianPlugin;
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: ClaudianPlugin;
storage: SharedAppStorage;
vaultAdapter: VaultFileAdapter;
homeAdapter: HomeFileAdapter;
}
export interface ProviderWorkspaceRegistration<
TServices extends ProviderWorkspaceServices = ProviderWorkspaceServices,
> {
initialize(context: ProviderWorkspaceInitContext): Promise<TServices>;
}
export interface ProviderConversationHistoryService {
hydrateConversationHistory(
conversation: Conversation,
vaultPath: string | null,
): Promise<void>;
deleteConversationSession(
conversation: Conversation,
vaultPath: string | null,
): 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 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 {
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 {
resetConversation(): void;
editText(request: InlineEditRequest): Promise<InlineEditResult>;
continueConversation(message: string, contextFiles?: string[]): Promise<InlineEditResult>;
cancel(): void;
}
+64
View File
@@ -0,0 +1,64 @@
import type { ProviderCapabilities, ProviderId } from '../providers/types';
import type { ChatMessage, Conversation, SlashCommand, StreamChunk, ToolCallInfo } from '../types';
import type {
ApprovalCallback,
AskUserQuestionCallback,
AutoTurnResult,
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[]>;
cleanup(): void;
rewind(userMessageId: string, assistantMessageId: string): 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: ((result: AutoTurnResult) => void) | 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>;
}
+116
View File
@@ -0,0 +1,116 @@
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 {
sessionId?: string;
externalContextPaths?: string[];
force?: boolean;
preserveHandlers?: boolean;
}
export type ChatRuntimeConversationState = Pick<
Conversation,
'sessionId' | 'providerState'
>;
export interface SessionUpdateResult {
updates: Partial<Conversation>;
}
export interface ChatRewindResult {
canRewind: boolean;
error?: string;
filesChanged?: string[];
insertions?: number;
deletions?: number;
}
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 {
ApprovalDecision,
ExitPlanModeCallback,
};
-4
View File
@@ -1,4 +0,0 @@
export type { TransformOptions } from './transformSDKMessage';
export { transformSDKMessage } from './transformSDKMessage';
export { isSessionInitEvent, isStreamChunk } from './typeGuards';
export type { SessionInitEvent, TransformEvent } from './types';
-10
View File
@@ -1,10 +0,0 @@
import type { StreamChunk } from '../types';
import type { SessionInitEvent, TransformEvent } from './types';
export function isSessionInitEvent(event: TransformEvent): event is SessionInitEvent {
return event.type === 'session_init';
}
export function isStreamChunk(event: TransformEvent): event is StreamChunk {
return event.type !== 'session_init';
}
-10
View File
@@ -1,10 +0,0 @@
import type { StreamChunk } from '../types';
export interface SessionInitEvent {
type: 'session_init';
sessionId: string;
agents?: string[];
permissionMode?: string;
}
export type TransformEvent = StreamChunk | SessionInitEvent;
-55
View File
@@ -1,7 +1,5 @@
/** Permission utilities for tool action approval. */
import type { PermissionUpdate, PermissionUpdateDestination } from '@anthropic-ai/claude-agent-sdk';
import {
TOOL_BASH,
TOOL_EDIT,
@@ -142,56 +140,3 @@ function matchesBashPrefix(action: string, prefix: string): boolean {
return action.startsWith(`${prefix} `);
}
/**
* Convert a user allow decision + SDK suggestions into PermissionUpdate[].
*
* Only handles allow decisions — deny results use the SDK's bare deny path
* (PermissionResult deny variant has no updatedPermissions field).
*
* Overrides destination on addRules/replaceRules suggestions to match the user's choice.
* Other suggestion entries keep their original destinations (they may carry
* specific semantics about where the update should be applied).
* "always" destinations go to projectSettings; "allow" stays session.
* Falls back to constructing an addRules entry from the action pattern
* when no addRules/replaceRules suggestion is present.
*/
export function buildPermissionUpdates(
toolName: string,
input: Record<string, unknown>,
decision: 'allow' | 'allow-always',
suggestions?: PermissionUpdate[]
): PermissionUpdate[] {
const destination: PermissionUpdateDestination = decision === 'allow-always' ? 'projectSettings' : 'session';
const processed: PermissionUpdate[] = [];
let hasRuleUpdate = false;
if (suggestions) {
for (const s of suggestions) {
if (s.type === 'addRules' || s.type === 'replaceRules') {
hasRuleUpdate = true;
processed.push({ ...s, behavior: 'allow', destination });
} else {
processed.push(s);
}
}
}
if (!hasRuleUpdate) {
const pattern = getActionPattern(toolName, input);
const ruleValue: { toolName: string; ruleContent?: string } = { toolName };
if (pattern && !pattern.startsWith('{')) {
ruleValue.ruleContent = pattern;
}
processed.unshift({
type: 'addRules',
behavior: 'allow',
rules: [ruleValue],
destination,
});
}
return processed;
}
-407
View File
@@ -1,407 +0,0 @@
/**
* Bash Path Validator
*
* Pure functions for parsing bash commands and validating path access.
* Extracted from ClaudianService for better testability and separation of concerns.
*/
import * as path from 'path';
import type { PathAccessType } from '../../utils/path';
export type PathViolation =
| { type: 'outside_vault'; path: string }
| { type: 'export_path_read'; path: string };
/** Context for path validation - allows dependency injection of access rules */
export interface PathCheckContext {
getPathAccessType: (filePath: string) => PathAccessType;
}
/**
* Split a bash command into tokens.
* This is a best-effort tokenizer (quotes/backticks are handled; full bash parsing is out of scope).
*/
export function tokenizeBashCommand(command: string): string[] {
const tokens: string[] = [];
// Only handle single and double quotes as string delimiters.
// Backticks are command substitution, not quoting -- handled by subshell extraction.
const tokenRegex = /(['"])(.*?)\1|[^\s]+/g;
let match: RegExpExecArray | null;
while ((match = tokenRegex.exec(command)) !== null) {
const token = match[2] ?? match[0];
const cleaned = token.trim();
if (!cleaned) continue;
tokens.push(cleaned);
}
return tokens;
}
/**
* Split tokens into segments by common bash operators.
* Each segment is treated as an independent command for output-target heuristics.
*/
export function splitBashTokensIntoSegments(tokens: string[]): string[][] {
const separators = new Set(['&&', '||', ';', '|']);
const segments: string[][] = [];
let current: string[] = [];
for (const token of tokens) {
if (separators.has(token)) {
if (current.length > 0) {
segments.push(current);
current = [];
}
continue;
}
current.push(token);
}
if (current.length > 0) {
segments.push(current);
}
return segments;
}
export function getBashSegmentCommandName(segment: string[]): { cmdName: string; cmdIndex: number } {
const wrappers = new Set(['command', 'env', 'sudo']);
let cmdIndex = 0;
while (cmdIndex < segment.length) {
const token = segment[cmdIndex];
if (wrappers.has(token)) {
cmdIndex += 1;
continue;
}
if (!token.startsWith('-') && token.includes('=')) {
cmdIndex += 1;
continue;
}
break;
}
const rawCmd = segment[cmdIndex] || '';
const cmdName = path.basename(rawCmd);
return { cmdName, cmdIndex };
}
const OUTPUT_REDIRECT_OPS = new Set(['>', '>>', '1>', '1>>', '2>', '2>>', '&>', '&>>', '>|']);
const INPUT_REDIRECT_OPS = new Set(['<', '<<', '0<', '0<<']);
const OUTPUT_OPTION_FLAGS = new Set(['-o', '--output', '--out', '--outfile', '--output-file']);
export function isBashOutputRedirectOperator(token: string): boolean {
return OUTPUT_REDIRECT_OPS.has(token);
}
export function isBashInputRedirectOperator(token: string): boolean {
return INPUT_REDIRECT_OPS.has(token);
}
export function isBashOutputOptionExpectingValue(token: string): boolean {
return OUTPUT_OPTION_FLAGS.has(token);
}
/** Clean a path token by stripping quotes and delimiters */
export function cleanPathToken(raw: string): string | null {
let token = raw.trim();
if (!token) return null;
token = stripQuoteChars(token);
if (!token) return null;
// Trim common delimiters from shells / subshells.
while (token.startsWith('(') || token.startsWith('[') || token.startsWith('{')) {
token = token.slice(1).trim();
}
while (
token.endsWith(')') ||
token.endsWith(']') ||
token.endsWith('}') ||
token.endsWith(';') ||
token.endsWith(',')
) {
token = token.slice(0, -1).trim();
}
if (!token) return null;
token = stripQuoteChars(token);
if (!token) return null;
if (token === '.' || token === '/' || token === '\\' || token === '--') return null;
return token;
}
const QUOTE_CHARS = new Set(["'", '"', '`']);
function stripQuoteChars(token: string): string {
// Strip matched quotes first
if (
token.length >= 2 &&
QUOTE_CHARS.has(token[0]) &&
token[0] === token[token.length - 1]
) {
return token.slice(1, -1).trim();
}
// Strip unmatched leading/trailing quote characters
while (token.length > 0 && QUOTE_CHARS.has(token[0])) {
token = token.slice(1);
}
while (token.length > 0 && QUOTE_CHARS.has(token[token.length - 1])) {
token = token.slice(0, -1);
}
return token.trim();
}
export function isPathLikeToken(token: string): boolean {
const cleaned = token.trim();
if (!cleaned) return false;
if (cleaned === '.' || cleaned === '/' || cleaned === '\\' || cleaned === '--') return false;
const isWindows = process.platform === 'win32';
return (
// Home directory paths (Unix and Windows style)
cleaned === '~' ||
cleaned.startsWith('~/') ||
(isWindows && cleaned.startsWith('~\\')) ||
// Relative paths
cleaned.startsWith('./') ||
cleaned.startsWith('../') ||
cleaned === '..' ||
(isWindows && (cleaned.startsWith('.\\') || cleaned.startsWith('..\\'))) ||
// Absolute paths (Unix)
cleaned.startsWith('/') ||
// Absolute paths (Windows drive letters)
(isWindows && /^[A-Za-z]:[\\/]/.test(cleaned)) ||
// Absolute paths (Windows UNC)
(isWindows && (cleaned.startsWith('\\\\') || cleaned.startsWith('//'))) ||
// Contains path separators
cleaned.includes('/') ||
(isWindows && cleaned.includes('\\'))
);
}
/**
* Check if a path has valid access permissions.
* Returns a violation if the path is outside vault and not an allowed export/context path.
*/
export function checkBashPathAccess(
candidate: string,
access: 'read' | 'write',
context: PathCheckContext
): PathViolation | null {
const cleaned = cleanPathToken(candidate);
if (!cleaned) return null;
const accessType = context.getPathAccessType(cleaned);
if (accessType === 'vault' || accessType === 'readwrite') {
return null;
}
if (accessType === 'context') {
return null; // Context paths have full read/write access
}
if (accessType === 'export') {
return access === 'write' ? null : { type: 'export_path_read', path: cleaned };
}
return { type: 'outside_vault', path: cleaned };
}
/**
* Find path violations in a single bash command segment.
* Analyzes redirects, output options, and positional arguments.
*/
export function findBashPathViolationInSegment(
segment: string[],
context: PathCheckContext
): PathViolation | null {
if (segment.length === 0) return null;
const { cmdName, cmdIndex } = getBashSegmentCommandName(segment);
// Some commands have a clear destination argument that should be treated as a write target.
const destinationCommands = new Set(['cp', 'mv', 'rsync']);
let destinationTokenIndex: number | null = null;
if (destinationCommands.has(cmdName)) {
const pathArgIndices: number[] = [];
let seenDoubleDash = false;
for (let i = cmdIndex + 1; i < segment.length; i += 1) {
const token = segment[i];
if (!seenDoubleDash && token === '--') {
seenDoubleDash = true;
continue;
}
// Skip options (best-effort).
if (!seenDoubleDash && token.startsWith('-')) {
continue;
}
if (isPathLikeToken(token)) {
pathArgIndices.push(i);
}
}
if (pathArgIndices.length > 0) {
destinationTokenIndex = pathArgIndices[pathArgIndices.length - 1];
}
}
let expectWriteNext = false;
for (let i = 0; i < segment.length; i += 1) {
const token = segment[i];
// Standalone redirection operators.
if (isBashOutputRedirectOperator(token)) {
expectWriteNext = true;
continue;
}
if (isBashInputRedirectOperator(token)) {
expectWriteNext = false;
continue;
}
// Standalone output options.
if (isBashOutputOptionExpectingValue(token)) {
expectWriteNext = true;
continue;
}
// Embedded redirection operators, e.g. ">/tmp/out", "2>>~/Desktop/log".
const embeddedOutputRedirect = token.match(/^(?:&>>|&>|\d*>>|\d*>\||\d*>|>>|>\||>)(.+)$/);
if (embeddedOutputRedirect) {
const violation = checkBashPathAccess(embeddedOutputRedirect[1], 'write', context);
if (violation) return violation;
continue;
}
const embeddedInputRedirect = token.match(/^(?:\d*<<|\d*<|<<|<)(.+)$/);
if (embeddedInputRedirect) {
const violation = checkBashPathAccess(embeddedInputRedirect[1], 'read', context);
if (violation) return violation;
continue;
}
// Embedded output options, e.g. "--output=/tmp/out", "-o/tmp/out", "-o~/Desktop/out".
const embeddedLongOutput = token.match(/^--(?:output|out|outfile|output-file)=(.+)$/);
if (embeddedLongOutput) {
const violation = checkBashPathAccess(embeddedLongOutput[1], 'write', context);
if (violation) return violation;
continue;
}
const embeddedShortOutput = token.match(/^-o(.+)$/);
if (embeddedShortOutput) {
const violation = checkBashPathAccess(embeddedShortOutput[1], 'write', context);
if (violation) return violation;
continue;
}
// Generic KEY=VALUE where VALUE looks like a path.
// We treat this as a read access since it is ambiguous and can be used to smuggle paths.
const eqIndex = token.indexOf('=');
if (eqIndex > 0) {
const key = token.slice(0, eqIndex);
const value = token.slice(eqIndex + 1);
if (key.startsWith('-') && isPathLikeToken(value)) {
const violation = checkBashPathAccess(value, 'read', context);
if (violation) return violation;
}
}
if (!isPathLikeToken(token)) {
expectWriteNext = false;
continue;
}
const access: 'read' | 'write' =
i === destinationTokenIndex || expectWriteNext ? 'write' : 'read';
const violation = checkBashPathAccess(token, access, context);
if (violation) return violation;
expectWriteNext = false;
}
return null;
}
/** Extract inner commands from command substitution patterns ($(...) and backticks) */
function extractSubshellCommands(command: string): string[] {
const results: string[] = [];
// Extract $(...) content, handling nested parens
let i = 0;
while (i < command.length) {
if (command[i] === '$' && command[i + 1] === '(') {
let depth = 1;
const start = i + 2;
let j = start;
while (j < command.length && depth > 0) {
if (command[j] === '(') depth++;
else if (command[j] === ')') depth--;
j++;
}
if (depth === 0) {
results.push(command.slice(start, j - 1));
}
i = j;
} else {
i++;
}
}
// Extract backtick content (already handled by tokenizer, but we also check
// raw command for cases where backticks span the whole token)
const backtickRegex = /`([^`]+)`/g;
let match: RegExpExecArray | null;
while ((match = backtickRegex.exec(command)) !== null) {
results.push(match[1]);
}
return results;
}
/**
* Find the first path violation in a bash command.
* Main entry point for bash command validation.
*
* @param command - The bash command to analyze
* @param context - Path checking context with vault/export path validators
* @returns The first violation found, or null if command is safe
*/
export function findBashCommandPathViolation(
command: string,
context: PathCheckContext
): PathViolation | null {
if (!command) return null;
// Recursively check subshell commands first
const subshellCommands = extractSubshellCommands(command);
for (const subCmd of subshellCommands) {
const violation = findBashCommandPathViolation(subCmd, context);
if (violation) return violation;
}
const tokens = tokenizeBashCommand(command);
const segments = splitBashTokensIntoSegments(tokens);
for (const segment of segments) {
const violation = findBashPathViolationInSegment(segment, context);
if (violation) {
return violation;
}
}
return null;
}
-30
View File
@@ -1,30 +0,0 @@
/**
* Blocklist Checker
*
* Checks bash commands against user-defined blocklist patterns.
* Patterns are treated as case-insensitive regex with fallback to substring match.
*/
const MAX_PATTERN_LENGTH = 500;
export function isCommandBlocked(
command: string,
patterns: string[],
enableBlocklist: boolean
): boolean {
if (!enableBlocklist) {
return false;
}
return patterns.some((pattern) => {
if (pattern.length > MAX_PATTERN_LENGTH) {
return command.toLowerCase().includes(pattern.toLowerCase());
}
try {
return new RegExp(pattern, 'i').test(command);
} catch {
// Invalid regex - fall back to substring match
return command.toLowerCase().includes(pattern.toLowerCase());
}
});
}
-24
View File
@@ -1,24 +0,0 @@
export {
buildPermissionUpdates,
getActionDescription,
getActionPattern,
matchesRulePattern,
} from './ApprovalManager';
export {
checkBashPathAccess,
cleanPathToken,
findBashCommandPathViolation,
findBashPathViolationInSegment,
getBashSegmentCommandName,
isBashInputRedirectOperator,
isBashOutputOptionExpectingValue,
isBashOutputRedirectOperator,
isPathLikeToken,
type PathCheckContext,
type PathViolation,
splitBashTokensIntoSegments,
tokenizeBashCommand,
} from './BashPathValidator';
export {
isCommandBlocked,
} from './BlocklistChecker';
-190
View File
@@ -1,190 +0,0 @@
/**
* ClaudianSettingsStorage - Handles claudian-settings.json read/write.
*
* Manages the .claude/claudian-settings.json file for Claudian-specific settings.
* These settings are NOT shared with Claude Code CLI.
*
* Includes:
* - User preferences (userName)
* - Security (blocklist, permission mode)
* - Model & thinking settings
* - Content settings (tags, media, prompts)
* - Environment (string format, snippets)
* - UI settings (keyboard navigation)
* - CLI paths
* - State (merged from data.json)
*/
import type { ClaudeModel, ClaudianSettings, PlatformBlockedCommands } from '../types';
import { DEFAULT_SETTINGS, getDefaultBlockedCommands } from '../types';
import type { VaultFileAdapter } from './VaultFileAdapter';
/** Path to Claudian settings file relative to vault root. */
export const CLAUDIAN_SETTINGS_PATH = '.claude/claudian-settings.json';
/** Fields that are loaded separately (slash commands from .claude/commands/). */
type SeparatelyLoadedFields = 'slashCommands';
/** Settings stored in .claude/claudian-settings.json. */
export type StoredClaudianSettings = Omit<ClaudianSettings, SeparatelyLoadedFields>;
function normalizeCommandList(value: unknown, fallback: string[]): string[] {
if (!Array.isArray(value)) {
return [...fallback];
}
return value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
export function normalizeBlockedCommands(value: unknown): PlatformBlockedCommands {
const defaults = getDefaultBlockedCommands();
// Migrate old string[] format to new platform-keyed structure
if (Array.isArray(value)) {
return {
unix: normalizeCommandList(value, defaults.unix),
windows: [...defaults.windows],
};
}
if (!value || typeof value !== 'object') {
return defaults;
}
const candidate = value as Record<string, unknown>;
return {
unix: normalizeCommandList(candidate.unix, defaults.unix),
windows: normalizeCommandList(candidate.windows, defaults.windows),
};
}
function normalizeHostnameCliPaths(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object') {
return {};
}
const result: Record<string, string> = {};
for (const [key, val] of Object.entries(value)) {
if (typeof val === 'string' && val.trim()) {
result[key] = val.trim();
}
}
return result;
}
export class ClaudianSettingsStorage {
constructor(private adapter: VaultFileAdapter) { }
/**
* Load Claudian settings from .claude/claudian-settings.json.
* Returns default settings if file doesn't exist.
* Throws if file exists but cannot be read or parsed.
*/
async load(): Promise<StoredClaudianSettings> {
if (!(await this.adapter.exists(CLAUDIAN_SETTINGS_PATH))) {
return this.getDefaults();
}
const content = await this.adapter.read(CLAUDIAN_SETTINGS_PATH);
const stored = JSON.parse(content) as Record<string, unknown>;
const { activeConversationId: _activeConversationId, show1MModel: _show1MModel, ...storedWithoutLegacy } = stored;
// Remove legacy show1MModel from persisted file (replaced by enableOpus1M/enableSonnet1M)
if ('show1MModel' in stored) {
await this.adapter.write(CLAUDIAN_SETTINGS_PATH, JSON.stringify(storedWithoutLegacy, null, 2));
}
const blockedCommands = normalizeBlockedCommands(stored.blockedCommands);
const hostnameCliPaths = normalizeHostnameCliPaths(stored.claudeCliPathsByHost);
const legacyCliPath = typeof stored.claudeCliPath === 'string' ? stored.claudeCliPath : '';
return {
...this.getDefaults(),
...storedWithoutLegacy,
blockedCommands,
claudeCliPath: legacyCliPath,
claudeCliPathsByHost: hostnameCliPaths,
} as StoredClaudianSettings;
}
async save(settings: StoredClaudianSettings): Promise<void> {
const content = JSON.stringify(settings, null, 2);
await this.adapter.write(CLAUDIAN_SETTINGS_PATH, content);
}
async exists(): Promise<boolean> {
return this.adapter.exists(CLAUDIAN_SETTINGS_PATH);
}
async update(updates: Partial<StoredClaudianSettings>): Promise<void> {
const current = await this.load();
await this.save({ ...current, ...updates });
}
/**
* Read legacy activeConversationId from claudian-settings.json, if present.
* Used only for one-time migration to tabManagerState.
*/
async getLegacyActiveConversationId(): Promise<string | null> {
if (!(await this.adapter.exists(CLAUDIAN_SETTINGS_PATH))) {
return null;
}
const content = await this.adapter.read(CLAUDIAN_SETTINGS_PATH);
const stored = JSON.parse(content) as Record<string, unknown>;
const value = stored.activeConversationId;
if (typeof value === 'string') {
return value;
}
return null;
}
/**
* Remove legacy activeConversationId from claudian-settings.json.
*/
async clearLegacyActiveConversationId(): Promise<void> {
if (!(await this.adapter.exists(CLAUDIAN_SETTINGS_PATH))) {
return;
}
const content = await this.adapter.read(CLAUDIAN_SETTINGS_PATH);
const stored = JSON.parse(content) as Record<string, unknown>;
if (!('activeConversationId' in stored)) {
return;
}
delete stored.activeConversationId;
const nextContent = JSON.stringify(stored, null, 2);
await this.adapter.write(CLAUDIAN_SETTINGS_PATH, nextContent);
}
async setLastModel(model: ClaudeModel, isCustom: boolean): Promise<void> {
if (isCustom) {
await this.update({ lastCustomModel: model });
} else {
await this.update({ lastClaudeModel: model });
}
}
async setLastEnvHash(hash: string): Promise<void> {
await this.update({ lastEnvHash: hash });
}
/**
* Get default settings (excluding separately loaded fields).
*/
private getDefaults(): StoredClaudianSettings {
const {
slashCommands: _,
...defaults
} = DEFAULT_SETTINGS;
return defaults;
}
}
+75
View File
@@ -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 });
}
}
-255
View File
@@ -1,255 +0,0 @@
/**
* McpStorage - Handles .claude/mcp.json read/write
*
* MCP server configurations are stored in Claude Code-compatible format
* with optional Claudian-specific metadata in _claudian field.
*
* File format:
* {
* "mcpServers": {
* "server-name": { "command": "...", "args": [...] }
* },
* "_claudian": {
* "servers": {
* "server-name": { "enabled": true, "contextSaving": true, "disabledTools": ["tool"], "description": "..." }
* }
* }
* }
*/
import type {
ClaudianMcpConfigFile,
ClaudianMcpServer,
McpServerConfig,
ParsedMcpConfig,
} from '../types';
import { DEFAULT_MCP_SERVER, isValidMcpServerConfig } from '../types';
import type { VaultFileAdapter } from './VaultFileAdapter';
/** Path to MCP config file relative to vault root. */
export const MCP_CONFIG_PATH = '.claude/mcp.json';
export class McpStorage {
constructor(private adapter: VaultFileAdapter) {}
async load(): Promise<ClaudianMcpServer[]> {
try {
if (!(await this.adapter.exists(MCP_CONFIG_PATH))) {
return [];
}
const content = await this.adapter.read(MCP_CONFIG_PATH);
const file = JSON.parse(content) as ClaudianMcpConfigFile;
if (!file.mcpServers || typeof file.mcpServers !== 'object') {
return [];
}
const claudianMeta = file._claudian?.servers ?? {};
const servers: ClaudianMcpServer[] = [];
for (const [name, config] of Object.entries(file.mcpServers)) {
if (!isValidMcpServerConfig(config)) {
continue;
}
const meta = claudianMeta[name] ?? {};
const disabledTools = Array.isArray(meta.disabledTools)
? meta.disabledTools.filter((tool) => typeof tool === 'string')
: undefined;
const normalizedDisabledTools =
disabledTools && disabledTools.length > 0 ? disabledTools : undefined;
servers.push({
name,
config,
enabled: meta.enabled ?? DEFAULT_MCP_SERVER.enabled,
contextSaving: meta.contextSaving ?? DEFAULT_MCP_SERVER.contextSaving,
disabledTools: normalizedDisabledTools,
description: meta.description,
});
}
return servers;
} catch {
return [];
}
}
async save(servers: ClaudianMcpServer[]): Promise<void> {
const mcpServers: Record<string, McpServerConfig> = {};
const claudianServers: Record<
string,
{ enabled?: boolean; contextSaving?: boolean; disabledTools?: string[]; description?: string }
> = {};
for (const server of servers) {
mcpServers[server.name] = server.config;
// Only store Claudian metadata if different from defaults
const meta: {
enabled?: boolean;
contextSaving?: boolean;
disabledTools?: string[];
description?: string;
} = {};
if (server.enabled !== DEFAULT_MCP_SERVER.enabled) {
meta.enabled = server.enabled;
}
if (server.contextSaving !== DEFAULT_MCP_SERVER.contextSaving) {
meta.contextSaving = server.contextSaving;
}
const normalizedDisabledTools = server.disabledTools
?.map((tool) => tool.trim())
.filter((tool) => tool.length > 0);
if (normalizedDisabledTools && normalizedDisabledTools.length > 0) {
meta.disabledTools = normalizedDisabledTools;
}
if (server.description) {
meta.description = server.description;
}
if (Object.keys(meta).length > 0) {
claudianServers[server.name] = meta;
}
}
let existing: Record<string, unknown> | null = null;
if (await this.adapter.exists(MCP_CONFIG_PATH)) {
try {
const raw = await this.adapter.read(MCP_CONFIG_PATH);
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
existing = parsed as Record<string, unknown>;
}
} catch {
existing = null;
}
}
const file: Record<string, unknown> = existing ? { ...existing } : {};
file.mcpServers = mcpServers;
const existingClaudian =
existing && typeof existing._claudian === 'object'
? (existing._claudian as Record<string, unknown>)
: null;
if (Object.keys(claudianServers).length > 0) {
file._claudian = { ...(existingClaudian ?? {}), servers: claudianServers };
} else if (existingClaudian) {
const { servers: _servers, ...rest } = existingClaudian;
if (Object.keys(rest).length > 0) {
file._claudian = rest;
} else {
delete file._claudian;
}
} else {
delete file._claudian;
}
const content = JSON.stringify(file, null, 2);
await this.adapter.write(MCP_CONFIG_PATH, content);
}
async exists(): Promise<boolean> {
return this.adapter.exists(MCP_CONFIG_PATH);
}
/**
* 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": "..." }
*/
static parseClipboardConfig(json: string): ParsedMcpConfig {
try {
const parsed = JSON.parse(json);
if (!parsed || typeof parsed !== 'object') {
throw new Error('Invalid JSON object');
}
// Format 1: Full Claude Code format
// { "mcpServers": { "server-name": { "command": "...", ... } } }
if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
const servers: Array<{ name: string; config: McpServerConfig }> = [];
for (const [name, config] of Object.entries(parsed.mcpServers)) {
if (isValidMcpServerConfig(config)) {
servers.push({ name, config: config as McpServerConfig });
}
}
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 as McpServerConfig }],
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: config as McpServerConfig }],
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: config as McpServerConfig });
}
}
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');
}
throw error;
}
}
/**
* Try to parse clipboard content as MCP config.
* Returns null if not valid MCP config.
*/
static tryParseClipboardConfig(text: string): ParsedMcpConfig | null {
// Quick check - must look like JSON
const trimmed = text.trim();
if (!trimmed.startsWith('{')) {
return null;
}
try {
return McpStorage.parseClipboardConfig(trimmed);
} catch {
return null;
}
}
}
-428
View File
@@ -1,428 +0,0 @@
/**
* SessionStorage - Handles chat session files in vault/.claude/sessions/
*
* Each conversation is stored as a JSONL (JSON Lines) file.
* First line contains metadata, subsequent lines contain messages.
*
* JSONL format:
* ```
* {"type":"meta","id":"conv-123","title":"Fix bug","createdAt":1703500000,"sessionId":"sdk-xyz"}
* {"type":"message","id":"msg-1","role":"user","content":"...","timestamp":1703500001}
* {"type":"message","id":"msg-2","role":"assistant","content":"...","timestamp":1703500002}
* ```
*/
import { isSubagentToolName } from '../tools/toolNames';
import type {
ChatMessage,
Conversation,
ConversationMeta,
SessionMetadata,
SubagentInfo,
UsageInfo,
} from '../types';
import type { VaultFileAdapter } from './VaultFileAdapter';
/** Path to sessions folder relative to vault root. */
export const SESSIONS_PATH = '.claude/sessions';
/** Metadata record stored as first line of JSONL. */
interface SessionMetaRecord {
type: 'meta';
id: string;
title: string;
createdAt: number;
updatedAt: number;
lastResponseAt?: number;
sessionId: string | null;
currentNote?: string;
usage?: UsageInfo;
titleGenerationStatus?: 'pending' | 'success' | 'failed';
}
/** Message record stored as subsequent lines. */
interface SessionMessageRecord {
type: 'message';
message: ChatMessage;
}
/** Union type for JSONL records. */
type SessionRecord = SessionMetaRecord | SessionMessageRecord;
export class SessionStorage {
constructor(private adapter: VaultFileAdapter) { }
async loadConversation(id: string): Promise<Conversation | null> {
const filePath = this.getFilePath(id);
try {
if (!(await this.adapter.exists(filePath))) {
return null;
}
const content = await this.adapter.read(filePath);
return this.parseJSONL(content);
} catch {
return null;
}
}
async saveConversation(conversation: Conversation): Promise<void> {
const filePath = this.getFilePath(conversation.id);
const content = this.serializeToJSONL(conversation);
await this.adapter.write(filePath, content);
}
async deleteConversation(id: string): Promise<void> {
const filePath = this.getFilePath(id);
await this.adapter.delete(filePath);
}
/** List all conversation metadata (without loading full messages). */
async listConversations(): Promise<ConversationMeta[]> {
const metas: ConversationMeta[] = [];
try {
const files = await this.adapter.listFiles(SESSIONS_PATH);
for (const filePath of files) {
if (!filePath.endsWith('.jsonl')) continue;
try {
const meta = await this.loadMetaOnly(filePath);
if (meta) {
metas.push(meta);
}
} catch {
// Skip files that fail to load
}
}
// Sort by updatedAt descending (most recent first)
metas.sort((a, b) => b.updatedAt - a.updatedAt);
} catch {
// Return empty list if directory listing fails
}
return metas;
}
async loadAllConversations(): Promise<{ conversations: Conversation[]; failedCount: number }> {
const conversations: Conversation[] = [];
let failedCount = 0;
try {
const files = await this.adapter.listFiles(SESSIONS_PATH);
for (const filePath of files) {
if (!filePath.endsWith('.jsonl')) continue;
try {
const content = await this.adapter.read(filePath);
const conversation = this.parseJSONL(content);
if (conversation) {
conversations.push(conversation);
} else {
failedCount++;
}
} catch {
failedCount++;
}
}
conversations.sort((a, b) => b.updatedAt - a.updatedAt);
} catch {
// Return empty list if directory listing fails
}
return { conversations, failedCount };
}
async hasSessions(): Promise<boolean> {
const files = await this.adapter.listFiles(SESSIONS_PATH);
return files.some(f => f.endsWith('.jsonl'));
}
getFilePath(id: string): string {
return `${SESSIONS_PATH}/${id}.jsonl`;
}
private async loadMetaOnly(filePath: string): Promise<ConversationMeta | null> {
const content = await this.adapter.read(filePath);
// Handle both Unix (LF) and Windows (CRLF) line endings
const firstLine = content.split(/\r?\n/)[0];
if (!firstLine) return null;
try {
const record = JSON.parse(firstLine) as SessionRecord;
if (record.type !== 'meta') return null;
// Count messages by counting remaining lines
const lines = content.split(/\r?\n/).filter(l => l.trim());
const messageCount = lines.length - 1;
// Get preview from first user message
let preview = 'New conversation';
for (let i = 1; i < lines.length; i++) {
try {
const msgRecord = JSON.parse(lines[i]) as SessionRecord;
if (msgRecord.type === 'message' && msgRecord.message.role === 'user') {
const content = msgRecord.message.content;
preview = content.substring(0, 50) + (content.length > 50 ? '...' : '');
break;
}
} catch {
continue;
}
}
return {
id: record.id,
title: record.title,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
lastResponseAt: record.lastResponseAt,
messageCount,
preview,
titleGenerationStatus: record.titleGenerationStatus,
};
} catch {
return null;
}
}
private parseJSONL(content: string): Conversation | null {
// Handle both Unix (LF) and Windows (CRLF) line endings
const lines = content.split(/\r?\n/).filter(l => l.trim());
if (lines.length === 0) return null;
let meta: SessionMetaRecord | null = null;
const messages: ChatMessage[] = [];
for (const line of lines) {
try {
const record = JSON.parse(line) as SessionRecord;
if (record.type === 'meta') {
meta = record;
} else if (record.type === 'message') {
messages.push(record.message);
}
} catch {
// Skip invalid JSONL lines
}
}
if (!meta) return null;
return {
id: meta.id,
title: meta.title,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
lastResponseAt: meta.lastResponseAt,
sessionId: meta.sessionId,
messages,
currentNote: meta.currentNote,
usage: meta.usage,
titleGenerationStatus: meta.titleGenerationStatus,
};
}
private serializeToJSONL(conversation: Conversation): string {
const lines: string[] = [];
// First line: metadata
const meta: SessionMetaRecord = {
type: 'meta',
id: conversation.id,
title: conversation.title,
createdAt: conversation.createdAt,
updatedAt: conversation.updatedAt,
lastResponseAt: conversation.lastResponseAt,
sessionId: conversation.sessionId,
currentNote: conversation.currentNote,
usage: conversation.usage,
titleGenerationStatus: conversation.titleGenerationStatus,
};
lines.push(JSON.stringify(meta));
// Subsequent lines: messages
for (const message of conversation.messages) {
const record: SessionMessageRecord = {
type: 'message',
message,
};
lines.push(JSON.stringify(record));
}
return lines.join('\n');
}
/**
* Detects if a session uses SDK-native storage.
* A session is "native" if no legacy JSONL file exists.
*
* Legacy sessions have id.jsonl (and optionally id.meta.json).
* Native sessions have only id.meta.json or no files yet (SDK stores messages).
*/
async isNativeSession(id: string): Promise<boolean> {
const legacyPath = `${SESSIONS_PATH}/${id}.jsonl`;
const legacyExists = await this.adapter.exists(legacyPath);
// Native if no legacy JSONL exists (new conversation or meta-only)
return !legacyExists;
}
getMetadataPath(id: string): string {
return `${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);
}
async loadMetadata(id: string): Promise<SessionMetadata | null> {
const filePath = this.getMetadataPath(id);
try {
if (!(await this.adapter.exists(filePath))) {
return null;
}
const content = await this.adapter.read(filePath);
return JSON.parse(content) as SessionMetadata;
} catch {
return null;
}
}
async deleteMetadata(id: string): Promise<void> {
const filePath = this.getMetadataPath(id);
await this.adapter.delete(filePath);
}
/** List all native session metadata (.meta.json files without .jsonl counterparts). */
async listNativeMetadata(): Promise<SessionMetadata[]> {
const metas: SessionMetadata[] = [];
try {
const files = await this.adapter.listFiles(SESSIONS_PATH);
const metaFiles = files.filter(f => f.endsWith('.meta.json'));
for (const filePath of metaFiles) {
// Extract ID from path: .claude/sessions/{id}.meta.json
const fileName = filePath.split('/').pop() || '';
const id = fileName.replace('.meta.json', '');
// Check if this is truly native (no legacy .jsonl exists)
const legacyPath = `${SESSIONS_PATH}/${id}.jsonl`;
const legacyExists = await this.adapter.exists(legacyPath);
if (legacyExists) {
// Skip - this has legacy storage, meta.json is supplementary
continue;
}
try {
const content = await this.adapter.read(filePath);
const meta = JSON.parse(content) as SessionMetadata;
metas.push(meta);
} catch {
// Skip files that fail to load
}
}
} catch {
// Return empty list if directory listing fails
}
return metas;
}
/**
* List all conversations, merging legacy JSONL and native metadata sources.
* Legacy conversations take precedence if both exist.
*/
async listAllConversations(): Promise<ConversationMeta[]> {
const metas: ConversationMeta[] = [];
// 1. Load legacy conversations (existing .jsonl files)
const legacyMetas = await this.listConversations();
metas.push(...legacyMetas);
// 2. Load native metadata (.meta.json files)
const nativeMetas = await this.listNativeMetadata();
// 3. Merge, avoiding duplicates (legacy takes precedence)
const legacyIds = new Set(legacyMetas.map(m => m.id));
for (const meta of nativeMetas) {
if (!legacyIds.has(meta.id)) {
metas.push({
id: meta.id,
title: meta.title,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
lastResponseAt: meta.lastResponseAt,
messageCount: 0, // Native sessions don't track message count in metadata
preview: 'SDK session', // SDK stores messages, we don't parse them for preview
titleGenerationStatus: meta.titleGenerationStatus,
isNative: true,
});
}
}
// 4. Sort by lastResponseAt descending (fallback to createdAt)
return metas.sort((a, b) =>
(b.lastResponseAt ?? b.createdAt) - (a.lastResponseAt ?? a.createdAt)
);
}
toSessionMetadata(conversation: Conversation): SessionMetadata {
const subagentData = this.extractSubagentData(conversation.messages);
return {
id: conversation.id,
title: conversation.title,
titleGenerationStatus: conversation.titleGenerationStatus,
createdAt: conversation.createdAt,
updatedAt: conversation.updatedAt,
lastResponseAt: conversation.lastResponseAt,
sessionId: conversation.sessionId,
sdkSessionId: conversation.sdkSessionId,
previousSdkSessionIds: conversation.previousSdkSessionIds,
currentNote: conversation.currentNote,
externalContextPaths: conversation.externalContextPaths,
enabledMcpServers: conversation.enabledMcpServers,
usage: conversation.usage,
legacyCutoffAt: conversation.legacyCutoffAt,
subagentData: Object.keys(subagentData).length > 0 ? subagentData : undefined,
resumeSessionAt: conversation.resumeSessionAt,
forkSource: conversation.forkSource,
};
}
/**
* Extracts subagentData from messages for persistence.
* Collects subagent info from Agent tool calls, including legacy Task transcripts.
*/
private extractSubagentData(messages: ChatMessage[]): Record<string, SubagentInfo> {
const result: Record<string, SubagentInfo> = {};
for (const msg of messages) {
if (msg.role !== 'assistant') continue;
if (msg.toolCalls) {
for (const toolCall of msg.toolCalls) {
if (!isSubagentToolName(toolCall.name) || !toolCall.subagent) continue;
result[toolCall.subagent.id] = toolCall.subagent;
}
}
}
return result;
}
}
-533
View File
@@ -1,533 +0,0 @@
/**
* StorageService - Main coordinator for distributed storage system.
*
* Manages:
* - CC settings in .claude/settings.json (CC-compatible, shareable)
* - Claudian settings in .claude/claudian-settings.json (Claudian-specific)
* - Slash commands in .claude/commands/*.md
* - Chat sessions in .claude/sessions/*.jsonl
* - MCP configs in .claude/mcp.json
*
* Handles migration from legacy formats:
* - Old settings.json with Claudian fields → split into CC + Claudian files
* - Old permissions array → CC permissions object
* - data.json state → claudian-settings.json
*/
import type { App, Plugin } from 'obsidian';
import { Notice } from 'obsidian';
import type {
CCPermissions,
CCSettings,
ClaudeModel,
Conversation,
LegacyPermission,
SlashCommand,
} from '../types';
import {
createPermissionRule,
DEFAULT_CC_PERMISSIONS,
DEFAULT_SETTINGS,
legacyPermissionsToCCPermissions,
} from '../types';
import { AGENTS_PATH, AgentVaultStorage } from './AgentVaultStorage';
import { CC_SETTINGS_PATH, CCSettingsStorage, isLegacyPermissionsFormat } from './CCSettingsStorage';
import {
ClaudianSettingsStorage,
normalizeBlockedCommands,
type StoredClaudianSettings,
} from './ClaudianSettingsStorage';
import { McpStorage } from './McpStorage';
import {
CLAUDIAN_ONLY_FIELDS,
convertEnvObjectToString,
mergeEnvironmentVariables,
} from './migrationConstants';
import { SESSIONS_PATH, SessionStorage } from './SessionStorage';
import { SKILLS_PATH, SkillStorage } from './SkillStorage';
import { COMMANDS_PATH, SlashCommandStorage } from './SlashCommandStorage';
import { VaultFileAdapter } from './VaultFileAdapter';
/** Base path for all Claudian storage. */
export const CLAUDE_PATH = '.claude';
/** Legacy settings path (now CC settings). */
export const SETTINGS_PATH = CC_SETTINGS_PATH;
/**
* Combined settings for the application.
* Merges CC settings (permissions) with Claudian settings.
*/
export interface CombinedSettings {
/** CC-compatible settings (permissions, etc.) */
cc: CCSettings;
/** Claudian-specific settings */
claudian: StoredClaudianSettings;
}
/** Legacy data format (pre-split migration). */
interface LegacySettingsJson {
// Old Claudian fields that were in settings.json
userName?: string;
enableBlocklist?: boolean;
allowExternalAccess?: boolean;
blockedCommands?: unknown;
model?: string;
thinkingBudget?: string;
permissionMode?: string;
lastNonPlanPermissionMode?: string;
permissions?: LegacyPermission[];
excludedTags?: string[];
mediaFolder?: string;
environmentVariables?: string;
envSnippets?: unknown[];
systemPrompt?: string;
allowedExportPaths?: string[];
keyboardNavigation?: unknown;
claudeCliPath?: string;
claudeCliPaths?: unknown;
loadUserClaudeSettings?: boolean;
enableAutoTitleGeneration?: boolean;
titleGenerationModel?: string;
// CC fields
$schema?: string;
env?: Record<string, string>;
}
/** Legacy data.json format. */
interface LegacyDataJson {
activeConversationId?: string | null;
lastEnvHash?: string;
lastClaudeModel?: ClaudeModel;
lastCustomModel?: ClaudeModel;
conversations?: Conversation[];
slashCommands?: SlashCommand[];
migrationVersion?: number;
// May also contain old settings if not yet migrated
[key: string]: unknown;
}
// CLAUDIAN_ONLY_FIELDS is imported from ./migrationConstants
export class StorageService {
readonly ccSettings: CCSettingsStorage;
readonly claudianSettings: ClaudianSettingsStorage;
readonly commands: SlashCommandStorage;
readonly skills: SkillStorage;
readonly sessions: SessionStorage;
readonly mcp: McpStorage;
readonly agents: AgentVaultStorage;
private adapter: VaultFileAdapter;
private plugin: Plugin;
private app: App;
constructor(plugin: Plugin) {
this.plugin = plugin;
this.app = plugin.app;
this.adapter = new VaultFileAdapter(this.app);
this.ccSettings = new CCSettingsStorage(this.adapter);
this.claudianSettings = new ClaudianSettingsStorage(this.adapter);
this.commands = new SlashCommandStorage(this.adapter);
this.skills = new SkillStorage(this.adapter);
this.sessions = new SessionStorage(this.adapter);
this.mcp = new McpStorage(this.adapter);
this.agents = new AgentVaultStorage(this.adapter);
}
async initialize(): Promise<CombinedSettings> {
await this.ensureDirectories();
await this.runMigrations();
const cc = await this.ccSettings.load();
const claudian = await this.claudianSettings.load();
return { cc, claudian };
}
private async runMigrations(): Promise<void> {
const ccExists = await this.ccSettings.exists();
const claudianExists = await this.claudianSettings.exists();
const dataJson = await this.loadDataJson();
// Check if old settings.json has Claudian fields that need migration
if (ccExists && !claudianExists) {
await this.migrateFromOldSettingsJson();
}
if (dataJson) {
const hasState = this.hasStateToMigrate(dataJson);
const hasLegacyContent = this.hasLegacyContentToMigrate(dataJson);
// Migrate data.json state to claudian-settings.json
if (hasState) {
await this.migrateFromDataJson(dataJson);
}
// Migrate slash commands and conversations from data.json
let legacyContentHadErrors = false;
if (hasLegacyContent) {
const result = await this.migrateLegacyDataJsonContent(dataJson);
legacyContentHadErrors = result.hadErrors;
}
// Clear legacy data.json only after successful migrations
if ((hasState || hasLegacyContent) && !legacyContentHadErrors) {
await this.clearLegacyDataJson();
}
}
}
private hasStateToMigrate(data: LegacyDataJson): boolean {
return (
data.lastEnvHash !== undefined ||
data.lastClaudeModel !== undefined ||
data.lastCustomModel !== undefined
);
}
private hasLegacyContentToMigrate(data: LegacyDataJson): boolean {
return (
(data.slashCommands?.length ?? 0) > 0 ||
(data.conversations?.length ?? 0) > 0
);
}
/**
* Migrate from old settings.json (with Claudian fields) to split format.
*
* Handles:
* - Legacy Claudian fields (userName, model, etc.) → claudian-settings.json
* - Legacy permissions array → CC permissions object
* - CC env object → Claudian environmentVariables string
* - Preserves existing CC permissions if already in CC format
*/
private async migrateFromOldSettingsJson(): Promise<void> {
const content = await this.adapter.read(CC_SETTINGS_PATH);
const oldSettings = JSON.parse(content) as LegacySettingsJson;
const hasClaudianFields = Array.from(CLAUDIAN_ONLY_FIELDS).some(
field => (oldSettings as Record<string, unknown>)[field] !== undefined
);
if (!hasClaudianFields) {
return;
}
// Handle environment variables: merge Claudian string format with CC object format
let environmentVariables = oldSettings.environmentVariables ?? '';
if (oldSettings.env && typeof oldSettings.env === 'object') {
const envFromCC = convertEnvObjectToString(oldSettings.env);
if (envFromCC) {
environmentVariables = mergeEnvironmentVariables(environmentVariables, envFromCC);
}
}
const claudianFields: Partial<StoredClaudianSettings> = {
userName: oldSettings.userName ?? DEFAULT_SETTINGS.userName,
enableBlocklist: oldSettings.enableBlocklist ?? DEFAULT_SETTINGS.enableBlocklist,
allowExternalAccess: oldSettings.allowExternalAccess ?? DEFAULT_SETTINGS.allowExternalAccess,
blockedCommands: normalizeBlockedCommands(oldSettings.blockedCommands),
model: (oldSettings.model as ClaudeModel) ?? DEFAULT_SETTINGS.model,
thinkingBudget: (oldSettings.thinkingBudget as StoredClaudianSettings['thinkingBudget']) ?? DEFAULT_SETTINGS.thinkingBudget,
permissionMode: (oldSettings.permissionMode as StoredClaudianSettings['permissionMode']) ?? DEFAULT_SETTINGS.permissionMode,
excludedTags: oldSettings.excludedTags ?? DEFAULT_SETTINGS.excludedTags,
mediaFolder: oldSettings.mediaFolder ?? DEFAULT_SETTINGS.mediaFolder,
environmentVariables, // Merged from both sources
envSnippets: oldSettings.envSnippets as StoredClaudianSettings['envSnippets'] ?? DEFAULT_SETTINGS.envSnippets,
systemPrompt: oldSettings.systemPrompt ?? DEFAULT_SETTINGS.systemPrompt,
allowedExportPaths: oldSettings.allowedExportPaths ?? DEFAULT_SETTINGS.allowedExportPaths,
persistentExternalContextPaths: DEFAULT_SETTINGS.persistentExternalContextPaths,
keyboardNavigation: oldSettings.keyboardNavigation as StoredClaudianSettings['keyboardNavigation'] ?? DEFAULT_SETTINGS.keyboardNavigation,
claudeCliPath: oldSettings.claudeCliPath ?? DEFAULT_SETTINGS.claudeCliPath,
claudeCliPathsByHost: DEFAULT_SETTINGS.claudeCliPathsByHost, // Migration to hostname-based handled in main.ts
loadUserClaudeSettings: oldSettings.loadUserClaudeSettings ?? DEFAULT_SETTINGS.loadUserClaudeSettings,
enableAutoTitleGeneration: oldSettings.enableAutoTitleGeneration ?? DEFAULT_SETTINGS.enableAutoTitleGeneration,
titleGenerationModel: oldSettings.titleGenerationModel ?? DEFAULT_SETTINGS.titleGenerationModel,
lastClaudeModel: DEFAULT_SETTINGS.lastClaudeModel,
lastCustomModel: DEFAULT_SETTINGS.lastCustomModel,
lastEnvHash: DEFAULT_SETTINGS.lastEnvHash,
};
// Save Claudian settings FIRST (before stripping from settings.json)
await this.claudianSettings.save(claudianFields as StoredClaudianSettings);
// Verify Claudian settings were saved
const savedClaudian = await this.claudianSettings.load();
if (!savedClaudian || savedClaudian.userName === undefined) {
throw new Error('Failed to verify claudian-settings.json was saved correctly');
}
// Handle permissions: convert legacy format OR preserve existing CC format
let ccPermissions: CCPermissions;
if (isLegacyPermissionsFormat(oldSettings)) {
ccPermissions = legacyPermissionsToCCPermissions(oldSettings.permissions);
} else if (oldSettings.permissions && typeof oldSettings.permissions === 'object' && !Array.isArray(oldSettings.permissions)) {
// Already in CC format - preserve it including defaultMode and additionalDirectories
const existingPerms = oldSettings.permissions as unknown as CCPermissions;
ccPermissions = {
allow: existingPerms.allow ?? [],
deny: existingPerms.deny ?? [],
ask: existingPerms.ask ?? [],
defaultMode: existingPerms.defaultMode,
additionalDirectories: existingPerms.additionalDirectories,
};
} else {
ccPermissions = { ...DEFAULT_CC_PERMISSIONS };
}
// Rewrite settings.json with only CC fields
const ccSettings: CCSettings = {
$schema: 'https://json.schemastore.org/claude-code-settings.json',
permissions: ccPermissions,
};
// Pass true to strip Claudian-only fields during migration
await this.ccSettings.save(ccSettings, true);
}
private async migrateFromDataJson(dataJson: LegacyDataJson): Promise<void> {
const claudian = await this.claudianSettings.load();
// Only migrate if not already set (claudian-settings.json takes precedence)
if (dataJson.lastEnvHash !== undefined && !claudian.lastEnvHash) {
claudian.lastEnvHash = dataJson.lastEnvHash;
}
if (dataJson.lastClaudeModel !== undefined && !claudian.lastClaudeModel) {
claudian.lastClaudeModel = dataJson.lastClaudeModel;
}
if (dataJson.lastCustomModel !== undefined && !claudian.lastCustomModel) {
claudian.lastCustomModel = dataJson.lastCustomModel;
}
await this.claudianSettings.save(claudian);
}
private async migrateLegacyDataJsonContent(dataJson: LegacyDataJson): Promise<{ hadErrors: boolean }> {
let hadErrors = false;
if (dataJson.slashCommands && dataJson.slashCommands.length > 0) {
for (const command of dataJson.slashCommands) {
try {
const filePath = this.commands.getFilePath(command);
if (await this.adapter.exists(filePath)) {
continue;
}
await this.commands.save(command);
} catch {
hadErrors = true;
}
}
}
if (dataJson.conversations && dataJson.conversations.length > 0) {
for (const conversation of dataJson.conversations) {
try {
const filePath = this.sessions.getFilePath(conversation.id);
if (await this.adapter.exists(filePath)) {
continue;
}
await this.sessions.saveConversation(conversation);
} catch {
hadErrors = true;
}
}
}
return { hadErrors };
}
private async clearLegacyDataJson(): Promise<void> {
const dataJson = await this.loadDataJson();
if (!dataJson) {
return;
}
const cleaned: Record<string, unknown> = { ...dataJson };
delete cleaned.lastEnvHash;
delete cleaned.lastClaudeModel;
delete cleaned.lastCustomModel;
delete cleaned.conversations;
delete cleaned.slashCommands;
delete cleaned.migrationVersion;
if (Object.keys(cleaned).length === 0) {
await this.plugin.saveData({});
return;
}
await this.plugin.saveData(cleaned);
}
private async loadDataJson(): Promise<LegacyDataJson | null> {
try {
const data = await this.plugin.loadData();
return data || null;
} catch {
// data.json may not exist on fresh installs
return null;
}
}
async ensureDirectories(): Promise<void> {
await this.adapter.ensureFolder(CLAUDE_PATH);
await this.adapter.ensureFolder(COMMANDS_PATH);
await this.adapter.ensureFolder(SKILLS_PATH);
await this.adapter.ensureFolder(SESSIONS_PATH);
await this.adapter.ensureFolder(AGENTS_PATH);
}
async loadAllSlashCommands(): Promise<SlashCommand[]> {
const commands = await this.commands.loadAll();
const skills = await this.skills.loadAll();
return [...commands, ...skills];
}
getAdapter(): VaultFileAdapter {
return this.adapter;
}
async getPermissions(): Promise<CCPermissions> {
return this.ccSettings.getPermissions();
}
async updatePermissions(permissions: CCPermissions): Promise<void> {
return this.ccSettings.updatePermissions(permissions);
}
async addAllowRule(rule: string): Promise<void> {
return this.ccSettings.addAllowRule(createPermissionRule(rule));
}
async addDenyRule(rule: string): Promise<void> {
return this.ccSettings.addDenyRule(createPermissionRule(rule));
}
/**
* Remove a permission rule from all lists.
*/
async removePermissionRule(rule: string): Promise<void> {
return this.ccSettings.removeRule(createPermissionRule(rule));
}
async updateClaudianSettings(updates: Partial<StoredClaudianSettings>): Promise<void> {
return this.claudianSettings.update(updates);
}
async saveClaudianSettings(settings: StoredClaudianSettings): Promise<void> {
return this.claudianSettings.save(settings);
}
async loadClaudianSettings(): Promise<StoredClaudianSettings> {
return this.claudianSettings.load();
}
/**
* Get legacy activeConversationId from storage (claudian-settings.json or data.json).
*/
async getLegacyActiveConversationId(): Promise<string | null> {
const fromSettings = await this.claudianSettings.getLegacyActiveConversationId();
if (fromSettings) {
return fromSettings;
}
const dataJson = await this.loadDataJson();
if (dataJson && typeof dataJson.activeConversationId === 'string') {
return dataJson.activeConversationId;
}
return null;
}
/**
* Remove legacy activeConversationId from storage after migration.
*/
async clearLegacyActiveConversationId(): Promise<void> {
await this.claudianSettings.clearLegacyActiveConversationId();
const dataJson = await this.loadDataJson();
if (!dataJson || !('activeConversationId' in dataJson)) {
return;
}
const cleaned: Record<string, unknown> = { ...dataJson };
delete cleaned.activeConversationId;
await this.plugin.saveData(cleaned);
}
/**
* Get tab manager state from data.json with runtime validation.
*/
async getTabManagerState(): Promise<TabManagerPersistedState | null> {
try {
const data = await this.plugin.loadData();
if (data?.tabManagerState) {
return this.validateTabManagerState(data.tabManagerState);
}
return null;
} catch {
return null;
}
}
/**
* Validates and sanitizes tab manager state from storage.
* Returns null if the data is invalid or corrupted.
*/
private validateTabManagerState(data: unknown): TabManagerPersistedState | null {
if (!data || typeof data !== 'object') {
return null;
}
const state = data as Record<string, unknown>;
if (!Array.isArray(state.openTabs)) {
return null;
}
const validatedTabs: Array<{ tabId: string; conversationId: string | null }> = [];
for (const tab of state.openTabs) {
if (!tab || typeof tab !== 'object') {
continue; // Skip invalid entries
}
const tabObj = tab as Record<string, unknown>;
if (typeof tabObj.tabId !== 'string') {
continue; // Skip entries without valid tabId
}
validatedTabs.push({
tabId: tabObj.tabId,
conversationId:
typeof tabObj.conversationId === 'string' ? tabObj.conversationId : null,
});
}
const activeTabId =
typeof state.activeTabId === 'string' ? state.activeTabId : null;
return {
openTabs: validatedTabs,
activeTabId,
};
}
async setTabManagerState(state: TabManagerPersistedState): Promise<void> {
try {
const data = (await this.plugin.loadData()) || {};
data.tabManagerState = state;
await this.plugin.saveData(data);
} catch {
new Notice('Failed to save tab layout');
}
}
}
/**
* Persisted state for the tab manager.
* Stored in data.json (machine-specific, not shared).
*/
export interface TabManagerPersistedState {
openTabs: Array<{ tabId: string; conversationId: string | null }>;
activeTabId: string | null;
}
-18
View File
@@ -1,18 +0,0 @@
export { AGENTS_PATH, AgentVaultStorage } from './AgentVaultStorage';
export { CC_SETTINGS_PATH, CCSettingsStorage, isLegacyPermissionsFormat } from './CCSettingsStorage';
export {
CLAUDIAN_SETTINGS_PATH,
ClaudianSettingsStorage,
type StoredClaudianSettings,
} from './ClaudianSettingsStorage';
export { MCP_CONFIG_PATH, McpStorage } from './McpStorage';
export { SESSIONS_PATH, SessionStorage } from './SessionStorage';
export { SKILLS_PATH, SkillStorage } from './SkillStorage';
export { COMMANDS_PATH, SlashCommandStorage } from './SlashCommandStorage';
export {
CLAUDE_PATH,
type CombinedSettings,
SETTINGS_PATH,
StorageService,
} from './StorageService';
export { VaultFileAdapter } from './VaultFileAdapter';
-147
View File
@@ -1,147 +0,0 @@
/**
* Migration Constants - Shared constants for storage migration.
*
* Single source of truth for fields that need to be migrated
* from settings.json to claudian-settings.json.
*/
/**
* Fields that are Claudian-specific and should NOT be in CC settings.json.
* These are migrated to claudian-settings.json and stripped from settings.json.
*
* IMPORTANT: Keep this list updated when adding new Claudian settings!
*/
export const CLAUDIAN_ONLY_FIELDS = new Set([
// User preferences
'userName',
// Security settings
'enableBlocklist',
'allowExternalAccess',
'blockedCommands',
'permissionMode',
'lastNonPlanPermissionMode',
// Model & thinking
'model',
'thinkingBudget',
'effortLevel',
'enableAutoTitleGeneration',
'titleGenerationModel',
// Content settings
'excludedTags',
'mediaFolder',
'systemPrompt',
'allowedExportPaths',
'persistentExternalContextPaths',
// Environment (Claudian uses string format + snippets)
'environmentVariables',
'envSnippets',
// UI settings
'keyboardNavigation',
// CLI paths
'claudeCliPath',
'claudeCliPaths',
'loadUserClaudeSettings',
// Deprecated fields (removed completely, not migrated)
'allowedContextPaths',
'showToolUse',
'toolCallExpandedByDefault',
]);
/**
* Fields that are Claudian-specific and should be migrated.
* Excludes deprecated fields which are just removed.
*/
export const MIGRATABLE_CLAUDIAN_FIELDS = new Set([
'userName',
'enableBlocklist',
'allowExternalAccess',
'blockedCommands',
'permissionMode',
'lastNonPlanPermissionMode',
'model',
'thinkingBudget',
'effortLevel',
'enableAutoTitleGeneration',
'titleGenerationModel',
'excludedTags',
'mediaFolder',
'systemPrompt',
'allowedExportPaths',
'persistentExternalContextPaths',
'environmentVariables',
'envSnippets',
'env', // Converted to environmentVariables
'keyboardNavigation',
'claudeCliPath',
'claudeCliPaths',
'loadUserClaudeSettings',
]);
/**
* Deprecated fields that are removed completely (not migrated).
*/
export const DEPRECATED_FIELDS = new Set([
'allowedContextPaths',
'showToolUse',
'toolCallExpandedByDefault',
]);
/**
* Convert CC env object format to Claudian environmentVariables string format.
*
* @example
* { ANTHROPIC_API_KEY: "xxx", MY_VAR: "value" }
* → "ANTHROPIC_API_KEY=xxx\nMY_VAR=value"
*/
export function convertEnvObjectToString(env: Record<string, string> | undefined): string {
if (!env || typeof env !== 'object') {
return '';
}
return Object.entries(env)
.filter(([key, value]) => typeof key === 'string' && typeof value === 'string')
.map(([key, value]) => `${key}=${value}`)
.join('\n');
}
/**
* Merge two environmentVariables strings, removing duplicates.
* Later values override earlier ones for the same key.
*/
export function mergeEnvironmentVariables(existing: string, additional: string): string {
const envMap = new Map<string, string>();
for (const line of existing.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.slice(0, eqIndex);
const value = trimmed.slice(eqIndex + 1);
envMap.set(key, value);
}
}
// Parse additional (overrides existing)
for (const line of additional.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.slice(0, eqIndex);
const value = trimmed.slice(eqIndex + 1);
envMap.set(key, value);
}
}
return Array.from(envMap.entries())
.map(([key, value]) => `${key}=${value}`)
.join('\n');
}
-60
View File
@@ -1,60 +0,0 @@
export {
extractLastTodosFromMessages,
parseTodoInput,
type TodoItem,
} from './todo';
export { getToolIcon, MCP_ICON_MARKER } from './toolIcons';
export {
extractResolvedAnswers,
extractResolvedAnswersFromResultText,
getPathFromToolInput,
} from './toolInput';
export {
BASH_TOOLS,
type BashToolName,
EDIT_TOOLS,
type EditToolName,
FILE_TOOLS,
type FileToolName,
isBashTool,
isEditTool,
isFileTool,
isMcpTool,
isReadOnlyTool,
isSubagentToolName,
isWriteEditTool,
MCP_TOOLS,
type McpToolName,
READ_ONLY_TOOLS,
type ReadOnlyToolName,
skipsBlockedDetection,
SUBAGENT_TOOL_NAMES,
type SubagentToolName,
TOOL_AGENT_OUTPUT,
TOOL_ASK_USER_QUESTION,
TOOL_BASH,
TOOL_BASH_OUTPUT,
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_SKILL,
TOOL_SUBAGENT,
TOOL_SUBAGENT_LEGACY,
TOOL_TASK,
TOOL_TODO_WRITE,
TOOL_WEB_FETCH,
TOOL_WEB_SEARCH,
TOOL_WRITE,
TOOLS_SKIP_BLOCKED_DETECTION,
WRITE_EDIT_TOOLS,
type WriteEditToolName,
} from './toolNames';
+17
View File
@@ -1,8 +1,10 @@
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,
@@ -15,14 +17,20 @@ import {
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> = {
@@ -50,6 +58,15 @@ const TOOL_ICONS: Record<string, string> = {
[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. */
+14 -3
View File
@@ -21,14 +21,21 @@ export function extractResolvedAnswers(toolUseResult: unknown): AskUserAnswers |
return normalizeAnswersObject(r.answers);
}
function normalizeAnswerValue(value: unknown): string | undefined {
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)
.join(', ');
return normalized || undefined;
.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;
@@ -55,6 +62,10 @@ function parseAnswersFromJsonObject(resultText: string): AskUserAnswers | undefi
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;
+38
View File
@@ -26,6 +26,44 @@ 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 = [
+2 -36
View File
@@ -1,59 +1,25 @@
export const AGENT_PERMISSION_MODES = ['default', 'acceptEdits', 'dontAsk', 'bypassPermissions', 'plan', 'delegate'] as const;
export type AgentPermissionMode = typeof AGENT_PERMISSION_MODES[number];
/**
* Agent definition loaded from markdown files with YAML frontmatter.
* Matches Claude Code's agent format for compatibility.
*/
export interface AgentDefinition {
/** Unique identifier. Namespaced for plugins: "plugin-name:agent-name" */
id: string;
/** Display name (from YAML `name` field) */
name: string;
description: string;
/** System prompt for the agent (markdown body after frontmatter) */
prompt: string;
/** Allowed tools. If undefined, inherits all tools from parent */
tools?: string[];
/** Disallowed tools. Removed from inherited or specified tools list */
disallowedTools?: string[];
/** Model override. 'inherit' (default) uses parent's model */
model?: 'sonnet' | 'opus' | 'haiku' | 'inherit';
model?: string;
source: 'plugin' | 'vault' | 'global' | 'builtin';
/** Plugin name (only for plugin-sourced agents) */
pluginName?: string;
/** Absolute path to the source .md file (undefined for built-in agents) */
filePath?: string;
/** Skills available to this agent (pass-through to SDK) */
skills?: string[];
permissionMode?: AgentPermissionMode;
/** Parsed from frontmatter; round-tripped on save so the SDK reads hooks from the agent file */
permissionMode?: string;
hooks?: Record<string, unknown>;
/** Frontmatter keys not recognized by Claudian, preserved on round-trip */
extraFrontmatter?: Record<string, unknown>;
}
export interface AgentFrontmatter {
name: string;
description: string;
/** Tools list: comma-separated string or array from YAML */
tools?: string | string[];
/** Disallowed tools: comma-separated string or array from YAML */
disallowedTools?: string | string[];
/** Model: validated at parse time, invalid values fall back to 'inherit' */
model?: string;
skills?: string[];
permissionMode?: string;
+58 -81
View File
@@ -1,11 +1,8 @@
/**
* Chat and conversation type definitions.
*/
import type { SDKToolUseResult } from './diff';
import type { SubagentInfo, SubagentMode, ToolCallInfo } from './tools';
import type { ProviderId } from './provider';
import type { SubagentMode, ToolCallInfo } from './tools';
/** Fork origin reference: identifies the source session and resume point. */
/** Fork origin reference: identifies the source session and checkpoint. */
export interface ForkSource {
sessionId: string;
resumeAt: string;
@@ -36,7 +33,7 @@ export type ContentBlock =
| { type: 'tool_use'; toolId: string }
| { type: 'thinking'; content: string; durationSeconds?: number }
| { type: 'subagent'; subagentId: string; mode?: SubagentMode }
| { type: 'compact_boundary' };
| { type: 'context_compacted' };
/** Chat message with content, tool calls, and attachments. */
export interface ChatMessage {
@@ -58,33 +55,24 @@ export interface ChatMessage {
durationSeconds?: number;
/** Flavor word used for duration display (e.g., "Baked", "Cooked"). */
durationFlavorWord?: string;
/** SDK user message UUID for rewind. */
sdkUserUuid?: string;
/** SDK assistant message UUID for resumeSessionAt. */
sdkAssistantUuid?: 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;
/**
* Current SDK session ID for native sessions.
* May differ from sessionId when SDK creates a new session (session expired, API key changed).
* Used for loading messages from SDK storage. Falls back to sessionId if not set.
*/
sdkSessionId?: string;
/**
* Previous SDK session IDs from session rebuilds.
* When resume fails and SDK creates a new session, the old sdkSessionId is moved here.
* Used to load and merge messages from all session files for display.
*/
previousSdkSessionIds?: 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. */
@@ -95,26 +83,14 @@ export interface Conversation {
titleGenerationStatus?: 'pending' | 'success' | 'failed';
/** UI-enabled MCP servers for this session (context-saving servers activated via selector). */
enabledMcpServers?: string[];
/** True if this conversation uses SDK-native storage (messages in ~/.claude/projects/). */
isNative?: boolean;
/** Timestamp of the last legacy JSONL message (used to merge SDK history). */
legacyCutoffAt?: number;
/** Internal flag to avoid reloading SDK history repeatedly. */
sdkMessagesLoaded?: boolean;
/**
* Cached subagent data for Task tool operations.
* Loaded from metadata for native sessions to restore tool count and status on reload.
*/
subagentData?: Record<string, SubagentInfo>;
/** Assistant UUID for resumeSessionAt after rewind. */
resumeSessionAt?: string;
/** Fork origin: source session to resume + fork from. Cleared after first SDK session init. */
forkSource?: ForkSource;
/** 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;
@@ -124,76 +100,77 @@ export interface ConversationMeta {
preview: string;
/** Status of AI title generation. */
titleGenerationStatus?: 'pending' | 'success' | 'failed';
/** True if this conversation uses SDK-native storage. */
isNative?: boolean;
}
/**
* Session metadata overlay for SDK-native storage.
* Stored in vault/.claude/sessions/{id}.meta.json
* SDK handles message storage; this stores UI-only state.
* 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 SDK resume (may be cleared when invalidated). */
/** Session ID used for provider resume (may be cleared when invalidated). */
sessionId?: string | null;
/**
* Current SDK session ID. May differ from id when SDK creates a new session.
* Used to locate the correct SDK session file for message loading.
*/
sdkSessionId?: string;
/**
* Previous SDK session IDs from session rebuilds.
* When resume fails and SDK creates a new session, the old sdkSessionId is moved here.
* Used to load and merge messages from all session files for display.
*/
previousSdkSessionIds?: string[];
/** Opaque provider-owned state bag. */
providerState?: Record<string, unknown>;
currentNote?: string;
externalContextPaths?: string[];
enabledMcpServers?: string[];
usage?: UsageInfo;
/** Timestamp of the last legacy JSONL message (used to merge SDK history). */
legacyCutoffAt?: number;
/**
* Subagent data for Task tool operations.
* Maps toolUseId to subagent info (tool count, status, result).
* Stored here because SDK session files don't preserve this Claudian-specific data.
*/
subagentData?: Record<string, SubagentInfo>;
/** Assistant UUID for resumeSessionAt after rewind. */
resumeSessionAt?: string;
/** Fork origin: source session to resume + fork from. Cleared after first SDK session init. */
forkSource?: ForkSource;
/** Assistant checkpoint identifier for resumeAtMessageId after rewind. */
resumeAtMessageId?: string;
}
/** Normalized stream chunk from the Claude Agent SDK. */
/**
* 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: 'text'; content: string; parentToolUseId?: string | null }
| { type: 'thinking'; content: string; parentToolUseId?: string | null }
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown>; parentToolUseId?: string | null }
| { type: 'tool_result'; id: string; content: string; isError?: boolean; parentToolUseId?: string | null; toolUseResult?: SDKToolUseResult }
| { 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 }
| { type: 'blocked'; content: string }
| { type: 'notice'; content: string; level?: 'info' | 'warning' }
| { type: 'done' }
| { type: 'usage'; usage: UsageInfo; sessionId?: string | null }
| { type: 'compact_boundary' }
| { type: 'sdk_user_uuid'; uuid: string }
| { type: 'sdk_user_sent'; uuid: string }
| { type: 'sdk_assistant_uuid'; uuid: string }
| { type: 'context_window_update'; contextWindow: number };
| { type: 'context_compacted' }
| { 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. */
/**
* 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;
cacheCreationInputTokens: number;
cacheReadInputTokens: 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;
}
+17 -62
View File
@@ -12,63 +12,22 @@ export {
type UsageInfo,
VIEW_TYPE_CLAUDIAN,
} from './chat';
export { type ProviderId } from './provider';
// Model types
export {
type ClaudeModel,
CONTEXT_WINDOW_1M,
CONTEXT_WINDOW_STANDARD,
DEFAULT_CLAUDE_MODELS,
DEFAULT_EFFORT_LEVEL,
DEFAULT_THINKING_BUDGET,
EFFORT_LEVELS,
type EffortLevel,
filterVisibleModelOptions,
getContextWindowSize,
isAdaptiveThinkingModel,
normalizeVisibleModelVariant,
THINKING_BUDGETS,
type ThinkingBudget,
} from './models';
// SDK types
export { type SDKMessage } from './sdk';
// Settings types
// Settings and command types
export {
type ApprovalDecision,
type CCPermissions,
type CCSettings,
type ClaudianSettings,
type CliPlatformKey,
createPermissionRule,
DEFAULT_CC_PERMISSIONS,
DEFAULT_CC_SETTINGS,
DEFAULT_SETTINGS,
type EnvironmentScope,
type EnvSnippet,
getBashToolBlockedCommands,
getCliPlatformKey, // Kept for migration
getCurrentPlatformBlockedCommands,
getCurrentPlatformKey,
getDefaultBlockedCommands,
type HostnameCliPaths,
type InstructionRefineResult,
type KeyboardNavigationSettings,
type LegacyPermission,
legacyPermissionsToCCPermissions,
legacyPermissionToCCRule,
parseCCPermissionRule,
type PermissionMode,
type PermissionRule,
type PlatformBlockedCommands,
type PlatformCliPaths, // Kept for migration
type SlashCommand,
type TabBarPosition,
} from './settings';
// Re-export getHostnameKey from utils (moved from settings for architecture compliance)
export { getHostnameKey } from '../../utils/env';
// Diff types
export {
type DiffLine,
@@ -91,13 +50,25 @@ export {
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 {
type ClaudianMcpConfigFile,
type ClaudianMcpServer,
DEFAULT_MCP_SERVER,
getMcpServerType,
isValidMcpServerConfig,
type ManagedMcpConfigFile,
type ManagedMcpServer,
type McpConfigFile,
type McpHttpServerConfig,
type McpServerConfig,
@@ -106,19 +77,3 @@ export {
type McpStdioServerConfig,
type ParsedMcpConfig,
} from './mcp';
// Plugin types
export {
type ClaudianPlugin,
type InstalledPluginEntry,
type InstalledPluginsFile,
type PluginScope,
} from './plugins';
// Agent types
export {
AGENT_PERMISSION_MODES,
type AgentDefinition,
type AgentFrontmatter,
type AgentPermissionMode,
} from './agent';
+8 -12
View File
@@ -1,8 +1,4 @@
/**
* Claudian - MCP (Model Context Protocol) type definitions
*
* Types for configuring and managing MCP servers that extend Claude's capabilities.
*/
/** MCP (Model Context Protocol) type definitions used by the shared manager/UI. */
/** Stdio server configuration (local command-line programs). */
export interface McpStdioServerConfig {
@@ -35,8 +31,8 @@ export type McpServerConfig =
/** Server type identifier. */
export type McpServerType = 'stdio' | 'sse' | 'http';
/** Extended server configuration with Claudian-specific options. */
export interface ClaudianMcpServer {
/** Managed MCP server configuration with UI/runtime metadata. */
export interface ManagedMcpServer {
/** Unique server name (key in mcpServers record). */
name: string;
config: McpServerConfig;
@@ -48,15 +44,15 @@ export interface ClaudianMcpServer {
description?: string;
}
/** MCP configuration file format (Claude Code compatible). */
/** MCP configuration file format used by the current CLI integrations. */
export interface McpConfigFile {
mcpServers: Record<string, McpServerConfig>;
}
/** Extended config file with Claudian metadata. */
export interface ClaudianMcpConfigFile extends McpConfigFile {
/** Extended config file with app-owned server metadata. */
export interface ManagedMcpConfigFile extends McpConfigFile {
_claudian?: {
/** Per-server Claudian-specific settings. */
/** Per-server UI/runtime settings. */
servers: Record<
string,
{
@@ -95,7 +91,7 @@ export function isValidMcpServerConfig(obj: unknown): obj is McpServerConfig {
return false;
}
export const DEFAULT_MCP_SERVER: Omit<ClaudianMcpServer, 'name' | 'config'> = {
export const DEFAULT_MCP_SERVER: Omit<ManagedMcpServer, 'name' | 'config'> = {
enabled: true,
contextSaving: true,
};
+1 -17
View File
@@ -1,25 +1,9 @@
export type PluginScope = 'user' | 'project';
export interface ClaudianPlugin {
/** e.g., "plugin-name@source" */
export interface PluginInfo {
id: string;
name: string;
enabled: boolean;
scope: PluginScope;
installPath: string;
}
export interface InstalledPluginEntry {
scope: 'user' | 'project';
installPath: string;
version: string;
installedAt: string;
lastUpdated: string;
gitCommitSha?: string;
projectPath?: string;
}
export interface InstalledPluginsFile {
version: number;
plugins: Record<string, InstalledPluginEntry[]>;
}
+1
View File
@@ -0,0 +1 @@
export type ProviderId = string;
+83 -395
View File
@@ -1,193 +1,17 @@
/**
* Settings type definitions.
*/
export type HiddenProviderCommands = Record<string, string[]>;
import type { Locale } from '../../i18n/types';
import type { ClaudeModel, EffortLevel, ThinkingBudget } from './models';
const UNIX_BLOCKED_COMMANDS = [
'rm -rf',
'chmod 777',
'chmod -R 777',
];
/** Platform-specific blocked commands (Windows - both CMD and PowerShell). */
const WINDOWS_BLOCKED_COMMANDS = [
// CMD commands
'del /s /q',
'rd /s /q',
'rmdir /s /q',
'format',
'diskpart',
// PowerShell Remove-Item variants (full and abbreviated flags)
'Remove-Item -Recurse -Force',
'Remove-Item -Force -Recurse',
'Remove-Item -r -fo',
'Remove-Item -fo -r',
'Remove-Item -Recurse',
'Remove-Item -r',
// PowerShell aliases for Remove-Item
'ri -Recurse',
'ri -r',
'ri -Force',
'ri -fo',
'rm -r -fo',
'rm -Recurse',
'rm -Force',
'del -Recurse',
'del -Force',
'erase -Recurse',
'erase -Force',
// PowerShell directory removal aliases
'rd -Recurse',
'rmdir -Recurse',
// Dangerous disk/volume commands
'Format-Volume',
'Clear-Disk',
'Initialize-Disk',
'Remove-Partition',
];
export interface PlatformBlockedCommands {
unix: string[];
windows: string[];
export interface ApprovalSelectionDecision {
type: 'select-option';
value: string;
}
export function getDefaultBlockedCommands(): PlatformBlockedCommands {
return {
unix: [...UNIX_BLOCKED_COMMANDS],
windows: [...WINDOWS_BLOCKED_COMMANDS],
};
}
export function getCurrentPlatformKey(): keyof PlatformBlockedCommands {
return process.platform === 'win32' ? 'windows' : 'unix';
}
export function getCurrentPlatformBlockedCommands(commands: PlatformBlockedCommands): string[] {
return commands[getCurrentPlatformKey()];
}
/**
* Get blocked commands for the Bash tool.
*
* On Windows, the Bash tool runs in a Git Bash/MSYS2 environment but can still
* invoke Windows commands (e.g., via `cmd /c` or `powershell`), so both Unix
* and Windows blocklist patterns are merged.
*/
export function getBashToolBlockedCommands(commands: PlatformBlockedCommands): string[] {
if (process.platform === 'win32') {
return Array.from(new Set([...commands.unix, ...commands.windows]));
}
return getCurrentPlatformBlockedCommands(commands);
}
/**
* Platform-specific Claude CLI paths.
* @deprecated Use HostnameCliPaths instead. Kept for migration from older versions.
*/
export interface PlatformCliPaths {
macos: string;
linux: string;
windows: string;
}
/** Platform key for CLI paths. Used for migration only. */
export type CliPlatformKey = keyof PlatformCliPaths;
/**
* Map process.platform to CLI platform key.
* @deprecated Used for migration only.
*/
export function getCliPlatformKey(): CliPlatformKey {
switch (process.platform) {
case 'darwin':
return 'macos';
case 'win32':
return 'windows';
default:
return 'linux';
}
}
/**
* Hostname-keyed CLI paths for per-device configuration.
* Each device stores its path using its hostname as key.
* This allows settings to sync across devices without conflicts.
*/
export type HostnameCliPaths = Record<string, string>;
/** Permission mode for tool execution. */
export type PermissionMode = 'yolo' | 'plan' | 'normal';
/** User decision from the approval modal. */
export type ApprovalDecision = 'allow' | 'allow-always' | 'deny' | 'cancel';
/**
* Legacy permission format (pre-CC compatibility).
* @deprecated Use CCPermissions instead
*/
export interface LegacyPermission {
toolName: string;
pattern: string;
approvedAt: number;
scope: 'session' | 'always';
}
/**
* CC-compatible permission rule string.
* Format: "Tool(pattern)" or "Tool" for all
* Examples: "Bash(git *)", "Read(*.md)", "WebFetch(domain:github.com)"
*/
export type PermissionRule = string & { readonly __brand: 'PermissionRule' };
/**
* Create a PermissionRule from a string.
* @internal Use legacyPermissionToCCRule instead.
*/
export function createPermissionRule(rule: string): PermissionRule {
return rule as PermissionRule;
}
/**
* CC-compatible permissions object.
* Stored in .claude/settings.json for interoperability with Claude Code CLI.
*/
export interface CCPermissions {
/** Rules that auto-approve tool actions */
allow?: PermissionRule[];
/** Rules that auto-deny tool actions (highest persistent priority) */
deny?: PermissionRule[];
/** Rules that always prompt for confirmation */
ask?: PermissionRule[];
/** Default permission mode */
defaultMode?: 'acceptEdits' | 'bypassPermissions' | 'default' | 'plan';
/** Additional directories to include in permission scope */
additionalDirectories?: string[];
}
/**
* CC-compatible settings stored in .claude/settings.json.
* These settings are shared with Claude Code CLI.
*/
export interface CCSettings {
/** JSON Schema reference */
$schema?: string;
/** Tool permissions (CC format) */
permissions?: CCPermissions;
/** Model override */
model?: string;
/** Environment variables (object format) */
env?: Record<string, string>;
/** MCP server settings */
enableAllProjectMcpServers?: boolean;
enabledMcpjsonServers?: string[];
disabledMcpjsonServers?: string[];
/** Plugin enabled state (CC format: { "plugin-id": true/false }) */
enabledPlugins?: Record<string, boolean>;
/** Allow additional properties for CC compatibility */
[key: string]: unknown;
}
export type ApprovalDecision =
| 'allow'
| 'allow-always'
| 'deny'
| 'cancel'
| ApprovalSelectionDecision;
/** Saved environment variable configuration. */
export interface EnvSnippet {
@@ -195,23 +19,25 @@ export interface EnvSnippet {
name: string;
description: string;
envVars: string;
scope?: EnvironmentScope;
contextLimits?: Record<string, number>; // Optional: context limits for custom models
}
/** Source of a slash command. */
export type SlashCommandSource = 'builtin' | 'user' | 'plugin' | 'sdk';
/** Slash command configuration with Claude Code compatibility. */
/** 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?: ClaudeModel; // Override model for this command
model?: string; // Optional provider-specific model override
content: string; // Prompt template with placeholders
source?: SlashCommandSource; // Origin of the command (builtin, user, plugin, sdk)
// Skill fields (from .claude/skills/ definitions)
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
@@ -229,161 +55,6 @@ export interface KeyboardNavigationSettings {
/** Tab bar position setting. */
export type TabBarPosition = 'input' | 'header';
/**
* Claudian-specific settings stored in .claude/claudian-settings.json.
* These settings are NOT shared with Claude Code CLI.
*/
export interface ClaudianSettings {
// User preferences
userName: string;
// Security (Claudian-specific, CC uses permissions.deny instead)
enableBlocklist: boolean;
allowExternalAccess: boolean;
blockedCommands: PlatformBlockedCommands;
permissionMode: PermissionMode;
// Model & thinking (Claudian uses enum, CC uses full model ID string)
model: ClaudeModel;
thinkingBudget: ThinkingBudget; // Legacy token budget for custom models
effortLevel: EffortLevel; // Effort level for adaptive thinking models
enableAutoTitleGeneration: boolean;
titleGenerationModel: string; // Model for auto title generation (empty = auto)
enableChrome: boolean; // Enable Chrome extension support (passes --chrome flag)
enableBangBash: boolean; // Enable ! bash mode for direct command execution
enableOpus1M: boolean; // Show Opus 1M model variant (opus[1m])
enableSonnet1M: boolean; // Show Sonnet 1M model variant (sonnet[1m])
// Content settings
excludedTags: string[];
mediaFolder: string;
systemPrompt: string;
allowedExportPaths: string[];
persistentExternalContextPaths: string[]; // Paths that persist across all sessions
// Environment (string format, CC uses object format in settings.json)
environmentVariables: string;
envSnippets: EnvSnippet[];
/**
* Custom context window limits for models configured via environment variables.
* Keys are model IDs (from ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_*_MODEL env vars).
* Values are token counts in range [1000, 10000000].
* Empty object means all models use default context limits (200k).
*/
customContextLimits: Record<string, number>;
// UI settings
keyboardNavigation: KeyboardNavigationSettings;
// Internationalization
locale: Locale; // UI language setting
// CLI paths
claudeCliPath: string; // Legacy: single CLI path (for backwards compatibility)
claudeCliPathsByHost: HostnameCliPaths; // Per-device paths keyed by hostname (preferred)
loadUserClaudeSettings: boolean; // Load ~/.claude/settings.json (may override permissions)
// State (merged from data.json)
lastClaudeModel?: ClaudeModel;
lastCustomModel?: ClaudeModel;
lastEnvHash?: string;
// Slash commands (loaded separately from .claude/commands/)
slashCommands: SlashCommand[];
// UI preferences
maxTabs: number; // Maximum number of chat tabs (3-10, default 3)
tabBarPosition: TabBarPosition; // Where to show tab bar ('input' or 'header')
enableAutoScroll: boolean; // Enable auto-scroll during streaming (default: true)
openInMainTab: boolean; // Open chat panel in main editor area instead of sidebar
// Slash commands
hiddenSlashCommands: string[]; // Command names to hide from dropdown (user preference)
}
/** Default Claudian-specific settings. */
export const DEFAULT_SETTINGS: ClaudianSettings = {
// User preferences
userName: '',
// Security
enableBlocklist: true,
allowExternalAccess: false,
blockedCommands: getDefaultBlockedCommands(),
permissionMode: 'yolo',
// Model & thinking
model: 'haiku',
thinkingBudget: 'off',
effortLevel: 'high',
enableAutoTitleGeneration: true,
titleGenerationModel: '', // Empty = auto (ANTHROPIC_DEFAULT_HAIKU_MODEL or claude-haiku-4-5)
enableChrome: false, // Disabled by default
enableBangBash: false, // Disabled by default
enableOpus1M: false, // Disabled by default
enableSonnet1M: false, // Disabled by default
// Content settings
excludedTags: [],
mediaFolder: '',
systemPrompt: '',
allowedExportPaths: ['~/Desktop', '~/Downloads'],
persistentExternalContextPaths: [],
// Environment
environmentVariables: '',
envSnippets: [],
customContextLimits: {},
// UI settings
keyboardNavigation: {
scrollUpKey: 'w',
scrollDownKey: 's',
focusInputKey: 'i',
},
// Internationalization
locale: 'en', // Default to English
// CLI paths
claudeCliPath: '', // Legacy field (empty = not migrated)
claudeCliPathsByHost: {}, // Per-device paths keyed by hostname
loadUserClaudeSettings: true, // Default on for compatibility
lastClaudeModel: 'haiku',
lastCustomModel: '',
lastEnvHash: '',
// Slash commands (loaded separately)
slashCommands: [],
// UI preferences
maxTabs: 3, // Default to 3 tabs (safe resource usage)
tabBarPosition: 'input', // Default to input mode (current behavior)
enableAutoScroll: true, // Default to auto-scroll enabled
openInMainTab: false, // Default to sidebar (current behavior)
// Slash commands
hiddenSlashCommands: [], // No commands hidden by default
};
/** Default CC-compatible settings. */
export const DEFAULT_CC_SETTINGS: CCSettings = {
$schema: 'https://json.schemastore.org/claude-code-settings.json',
permissions: {
allow: [],
deny: [],
ask: [],
},
};
/** Default CC permissions. */
export const DEFAULT_CC_PERMISSIONS: CCPermissions = {
allow: [],
deny: [],
ask: [],
};
/** Result from instruction refinement agent query. */
export interface InstructionRefineResult {
success: boolean;
@@ -392,62 +63,79 @@ export interface InstructionRefineResult {
error?: string; // Error message (if failed)
}
/**
* Convert a legacy permission to CC permission rule format.
* Examples:
* { toolName: "Bash", pattern: "git *" } → "Bash(git *)"
* { toolName: "Read", pattern: "/path/to/file" } → "Read(/path/to/file)"
* { toolName: "WebSearch", pattern: "*" } → "WebSearch"
*/
export function legacyPermissionToCCRule(legacy: LegacyPermission): PermissionRule {
const pattern = legacy.pattern.trim();
/** Permission mode for tool execution. */
export type PermissionMode = 'yolo' | 'plan' | 'normal';
// If pattern is empty, wildcard, or JSON object (old format), just use tool name
if (!pattern || pattern === '*' || pattern.startsWith('{')) {
return createPermissionRule(legacy.toolName);
}
/** Scope for environment variable storage and snippets. */
export type EnvironmentScope = 'shared' | `provider:${string}`;
return createPermissionRule(`${legacy.toolName}(${pattern})`);
}
/** Hostname-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>>>;
/**
* Convert legacy permissions array to CC permissions object.
* Only 'always' scope permissions are converted (session = ephemeral).
* 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 function legacyPermissionsToCCPermissions(
legacyPermissions: LegacyPermission[]
): CCPermissions {
const allow: PermissionRule[] = [];
export interface ClaudianSettings {
// User preferences
userName: string;
for (const perm of legacyPermissions) {
if (perm.scope === 'always') {
allow.push(legacyPermissionToCCRule(perm));
}
}
// Security
permissionMode: PermissionMode;
return {
allow: [...new Set(allow)], // Deduplicate
deny: [],
ask: [],
};
}
/**
* Parse a CC permission rule into tool name and pattern.
* Examples:
* "Bash(git *)" → { tool: "Bash", pattern: "git *" }
* "Read" → { tool: "Read", pattern: undefined }
* "WebFetch(domain:github.com)" → { tool: "WebFetch", pattern: "domain:github.com" }
*/
export function parseCCPermissionRule(rule: PermissionRule): {
tool: string;
pattern?: string;
} {
const match = rule.match(/^(\w+)(?:\((.+)\))?$/);
if (!match) {
return { tool: rule };
}
const [, tool, pattern] = match;
return { tool, pattern };
// 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>;
// UI settings
keyboardNavigation: KeyboardNavigationSettings;
// 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>>;
// State (provider-specific, round-tripped opaquely)
lastCustomModel?: string;
// UI preferences
maxTabs: number;
tabBarPosition: TabBarPosition;
enableAutoScroll: boolean;
openInMainTab: boolean;
// Provider command visibility
hiddenProviderCommands: HiddenProviderCommands;
// Allow provider-specific extension fields
[key: string]: unknown;
}
+6 -6
View File
@@ -1,7 +1,3 @@
/**
* Tool-related type definitions.
*/
import type { DiffLine, DiffStats } from './diff';
/** Diff data for Write/Edit tool operations (pre-computed from SDK structuredPatch). */
@@ -15,18 +11,22 @@ export interface ToolDiffData {
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. */
export type AskUserAnswers = Record<string, string>;
/** 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 {
+97 -74
View File
@@ -1,113 +1,136 @@
# Chat Feature
Main sidebar chat interface. `ClaudianView` is a thin shell; logic lives in controllers and services.
Main sidebar chat interface. `ClaudianView` assembles tabs, controllers, renderers, and provider-backed services around the shared `ChatRuntime` boundary.
## Provider Boundary Status
- Chat features depend on `ChatRuntime`, `ProviderCapabilities`, and provider-neutral conversation data. `InputController` builds `ChatTurnRequest`; runtimes own prompt encoding through `prepareTurn()`.
- Session bookkeeping lives in `Conversation.providerState` and is usually updated through `ChatRuntime.buildSessionUpdates()`, with fork/bootstrap state also seeded through provider history services. Feature code must not read provider-specific fields directly.
- Provider-owned services are resolved through registries
- `ProviderRegistry`: runtime, title generation, instruction refinement, inline edit, task-result interpretation
- `ProviderWorkspaceRegistry`: command catalogs, agent mention providers, MCP managers, CLI resolution
- Current feature split
- Claude exposes rewind, instruction mode, runtime command discovery, and in-app MCP controls
- Codex exposes fork, history reload, plan mode, instruction mode, images, inline edit, `$` skills, and subagents, but not rewind
## Architecture
```
```text
ClaudianView (lifecycle + assembly)
├── ChatState (centralized state)
├── ChatState
├── Controllers
│ ├── ConversationController # History, session switching
│ ├── StreamController # Streaming, auto-scroll, abort
│ ├── InputController # Text input, file context, images
│ ├── SelectionController # Editor selection awareness
── NavigationController # Keyboard navigation (vim-style)
│ ├── ConversationController
│ ├── StreamController
│ ├── InputController
│ ├── SelectionController
── BrowserSelectionController
│ ├── CanvasSelectionController
│ └── NavigationController
├── Services
│ ├── TitleGenerationService # Auto-generate conversation titles
── SubagentManager # Unified sync/async subagent lifecycle
│ ├── InstructionRefineService # "#" instruction mode
│ └── BangBashService # Direct bash execution ("!" mode)
│ ├── SubagentManager
── BangBashService
├── Rendering
│ ├── MessageRenderer # Main rendering orchestrator
│ ├── ToolCallRenderer # Tool use blocks
│ ├── ThinkingBlockRenderer # Extended thinking
│ ├── WriteEditRenderer # File write/edit with diff
│ ├── DiffRenderer # Inline diff display
│ ├── TodoListRenderer # Todo panel
│ ├── SubagentRenderer # Subagent status panel
│ ├── InlineExitPlanMode # Plan mode approval card
│ ├── InlineAskUserQuestion # AskUserQuestion inline card
│ └── collapsible # Collapsible block utility
│ ├── MessageRenderer
│ ├── ToolCallRenderer
│ ├── ThinkingBlockRenderer
│ ├── WriteEditRenderer
│ ├── DiffRenderer
│ ├── TodoListRenderer
│ ├── SubagentRenderer
│ ├── InlineExitPlanMode
│ ├── InlinePlanApproval
│ └── InlineAskUserQuestion
├── Tabs
│ ├── TabManager # Multi-tab orchestration
│ ├── TabBar # Tab UI component
│ └── Tab # Individual tab state + fork request handling
│ ├── TabManager
│ ├── TabBar
│ └── Tab
└── UI Components
├── InputToolbar # Model selector, thinking, permissions, context meter
├── FileContext # @-mention chips and dropdown
├── ImageContext # Image attachments
├── StatusPanel # Todo/command output panels container
├── InstructionModeManager # "#" mode UI
── BangBashModeManager # "!" bash mode UI
├── InputToolbar
├── FileContextManager
├── ImageContextManager
├── StatusPanel
├── NavigationSidebar
── InstructionModeManager
└── BangBashModeManager
```
## State Flow
```text
User Input
-> InputController
-> ensure runtime for active provider
-> ChatRuntime.prepareTurn()
-> ChatRuntime.query()
-> StreamController
-> MessageRenderer + ChatState persistence
```
User Input → InputController → ClaudianService.query()
StreamController (handle messages)
MessageRenderer (update DOM)
ChatState (persist)
```
The feature layer consumes provider-neutral `StreamChunk` values. Providers own prompt encoding, history/session fallback, and task-result interpretation.
## Controllers
| Controller | Responsibility |
|------------|----------------|
| `ConversationController` | Load/save sessions, history panel, session switching, fork session setup |
| `StreamController` | Process SDK messages, auto-scroll, streaming UI state |
| `InputController` | Input textarea, file/image attachments, slash commands |
| `SelectionController` | Poll editor selection (250ms), CM6 decoration |
| `NavigationController` | Vim-style keyboard navigation (j/k scroll, i focus) |
| `ConversationController` | Session switching, history reload, save, and rewind |
| `StreamController` | Consume stream chunks, update streaming state, auto-scroll, abort handling |
| `InputController` | Text input, mentions, images, resume dispatch, command dispatch, and post-plan approval flow |
| `SelectionController` | Editor selection polling and CM6 decorations |
| `BrowserSelectionController` | Browser view selection tracking |
| `CanvasSelectionController` | Canvas selection tracking |
| `NavigationController` | Vim-style keyboard navigation |
## Rendering Pipeline
| Renderer | Handles |
|----------|---------|
| `MessageRenderer` | Orchestrates all rendering, manages message containers, fork button on user messages |
| `ToolCallRenderer` | Tool use blocks with status, input display |
| `ThinkingBlockRenderer` | Extended thinking with collapse/expand |
| `WriteEditRenderer` | File operations with before/after diff |
| `DiffRenderer` | Hunked inline diffs (del/ins highlighting) |
| `InlineExitPlanMode` | Plan mode approval card (approve/feedback/new session) |
| `InlineAskUserQuestion` | AskUserQuestion inline card |
| `TodoListRenderer` | Todo items with status icons |
| `SubagentRenderer` | Background agent progress |
| `MessageRenderer` | Main message orchestration, rewind/fork affordances, interrupt markers |
| `ToolCallRenderer` | Tool blocks and tool state |
| `ThinkingBlockRenderer` | Thinking / reasoning summaries |
| `WriteEditRenderer` | File writes and edits with diff previews |
| `DiffRenderer` | Inline diff rendering |
| `InlineExitPlanMode` | Claude tool-driven exit-plan approval |
| `InlinePlanApproval` | Shared post-plan approval flow driven by consumed turn metadata (currently Codex) |
| `InlineAskUserQuestion` | Ask-user cards emitted by provider runtimes |
| `TodoListRenderer` | Todo items and status icons |
| `SubagentRenderer` | Background agent lifecycle rendering |
## Key Patterns
### Lazy Tab Initialization
```typescript
// ClaudianService created on first query, not on tab create
tab.ensureService(); // Creates service if needed
```
### Lazy Runtime Initialization
Tabs stay cold until the first send. The tab wiring exposes `ensureServiceInitialized()` so provider runtime creation happens only when needed.
### Message Streaming
### Message Rendering
```typescript
// StreamController receives SDK messages
for await (const message of response) {
this.messageRenderer.render(message); // Updates DOM
this.chatState.appendMessage(message); // Persists
const preparedTurn = runtime.prepareTurn(request);
for await (const chunk of runtime.query(preparedTurn, history)) {
streamController.handleStreamChunk(chunk);
}
```
### Auto-Scroll
- Enabled by default during streaming
- User scroll-up disables; scroll-to-bottom re-enables
- Resets to setting value on new query
- User scroll-up disables it
- Scroll-to-bottom re-enables it
- Resets to the saved setting on a new query
## Gotchas
- `ClaudianView.onClose()` must abort all tabs and dispose services
- Tab switching preserves scroll position per-tab
- `ChatState` is per-tab; `TabManager` coordinates across tabs (including fork orchestration)
- Title generation runs concurrently per-conversation (separate AbortControllers)
- `FileContext` has nested state in `ui/file-context/state/`
- `/compact` has a special code path: `InputController` skips context XML appending so the SDK recognizes the built-in command; `StreamController` handles the `compact_boundary` chunk as a standalone separator; `sdkSession.ts` prevents merge with adjacent assistant messages; ESC during compact produces an SDK stderr (`Compaction canceled`) that `sdkSession.ts` maps to `isInterrupt` for persistent rendering
- Plan mode: `EnterPlanMode` is auto-approved by the SDK (detected in stream to sync UI); `ExitPlanMode` uses a dedicated callback in `canUseTool` that bypasses normal approval flow. Shift+Tab toggles plan mode and saves/restores the previous permission mode. "Approve (new session)" stops the current session and auto-sends plan content as the first message in a fresh session.
- Bang-bash mode: `!` in empty input triggers direct bash execution (bypasses Claude). `BangBashModeManager` manages input mode; `BangBashService` runs commands via `child_process.exec` (30s timeout, 1MB buffer). Output displays in `StatusPanel` command panel. ESC exits mode; Enter submits.
- Fork conversation: `Tab.handleForkRequest()` validates eligibility (not streaming, both user and preceding assistant messages have SDK UUIDs), deep clones messages up to the fork point, then delegates to `TabManager`. `/fork` command triggers `Tab.handleForkAll()`, which forks the entire conversation (all messages, resuming at the last assistant UUID). Both handlers share `resolveForkSource()` for session ID resolution and conversation metadata lookup. `TabManager` shows `ForkTargetModal` (new tab vs current tab), creates the fork conversation with `forkSource: { sessionId, resumeAt }` metadata, sets `sdkMessagesLoaded` to prevent duplicate message loading, and propagates title/currentNote. `ConversationController.switchTo()` detects fork metadata and sets `pendingForkSession`/`pendingResumeAt` on `ClaudianService` so the SDK resumes at the correct point. Fork titles are deduplicated across existing tabs.
- `ClaudianView.onClose()` must abort active tabs and dispose runtimes
- `ChatState` is per-tab; `TabManager` coordinates tab-level operations such as fork targets and provider-aware command catalogs
- Title generation runs concurrently per conversation
- `/compact`
- Claude skips context injection so the provider recognizes the built-in command and persists the compaction boundary
- Codex routes compact turns to `thread/compact/start` and persists the durable `context_compacted` boundary from JSONL history
- Plan mode
- Claude uses provider/runtime events for enter and exit plan mode
- Codex sets `collaborationMode` on `turn/start` and triggers shared post-plan approval from consumed turn metadata
- Bang-bash mode bypasses provider runtimes and executes a local shell command directly
- It is available only when an enabled provider exposes it in `ProviderChatUIConfig` (currently Claude)
- Forking is provider-owned under the hood
- Both Claude and Codex support fork
- `ChatRuntime.resolveSessionIdForFork()` and provider history services own the provider-specific fork/session mapping
+78 -57
View File
@@ -1,11 +1,17 @@
import type { EventRef, WorkspaceLeaf } from 'obsidian';
import { ItemView, Notice, Scope, setIcon } from 'obsidian';
import { getContextWindowSize, VIEW_TYPE_CLAUDIAN } from '../../core/types';
import { getHiddenProviderCommandSet } from '../../core/providers/commands/hiddenCommands';
import { ProviderRegistry } from '../../core/providers/ProviderRegistry';
import { ProviderSettingsCoordinator } from '../../core/providers/ProviderSettingsCoordinator';
import { DEFAULT_CHAT_PROVIDER_ID, type ProviderId } from '../../core/providers/types';
import { VIEW_TYPE_CLAUDIAN } from '../../core/types';
import type ClaudianPlugin from '../../main';
import { LOGO_SVG } from './constants';
import { TabBar, TabManager, updatePlanModeUI } from './tabs';
import { getTabProviderId, onProviderAvailabilityChanged, 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';
export class ClaudianView extends ItemView {
private plugin: ClaudianPlugin;
@@ -50,7 +56,6 @@ export class ClaudianView extends ItemView {
value: async () => {
// Ensure containerEl exists before any patched load code tries to use it
if (!this.containerEl) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this as any).containerEl = createDiv({ cls: 'view-content' });
}
// Wrap in try-catch to prevent Hover Editor errors from breaking our view
@@ -79,27 +84,43 @@ export class ClaudianView extends ItemView {
/** Refreshes model-dependent UI across all tabs (used after settings/env changes). */
refreshModelSelector(): void {
const model = this.plugin.settings.model;
const contextWindow = getContextWindowSize(model, this.plugin.settings.customContextLimits);
for (const tab of this.tabManager?.getAllTabs() ?? []) {
onProviderAvailabilityChanged(tab, this.plugin);
const providerId = getTabProviderId(tab, this.plugin);
const providerSettings = ProviderSettingsCoordinator.getProviderSettingsSnapshot(
this.plugin.settings as unknown as Record<string, unknown>,
providerId,
);
const model = providerSettings.model as string;
const uiConfig = ProviderRegistry.getChatUIConfig(providerId);
const capabilities = ProviderRegistry.getCapabilities(providerId);
const contextWindow = uiConfig.getContextWindowSize(
model,
providerSettings.customContextLimits as Record<string, number> | undefined,
);
if (tab.state.usage) {
const percentage = Math.min(100, Math.max(0, Math.round((tab.state.usage.contextTokens / contextWindow) * 100)));
tab.state.usage = { ...tab.state.usage, model, contextWindow, percentage };
tab.state.usage = recalculateUsageForModel(tab.state.usage, model, contextWindow);
}
tab.ui.modelSelector?.updateDisplay();
tab.ui.modelSelector?.renderOptions();
tab.ui.thinkingBudgetSelector?.updateDisplay();
tab.ui.permissionToggle?.updateDisplay();
tab.ui.serviceTierToggle?.updateDisplay();
tab.dom.inputWrapper.toggleClass(
'claudian-input-plan-mode',
this.plugin.settings.permissionMode === 'plan' && capabilities.supportsPlanMode,
);
}
}
/** Updates hidden slash commands on all tabs (used after settings change). */
updateHiddenSlashCommands(): void {
const hiddenCommands = new Set(
(this.plugin.settings.hiddenSlashCommands || []).map(c => c.toLowerCase())
);
/** Updates provider-scoped hidden commands on all tabs after settings changes. */
updateHiddenProviderCommands(): void {
for (const tab of this.tabManager?.getAllTabs() ?? []) {
tab.ui.slashCommandDropdown?.setHiddenCommands(hiddenCommands);
tab.ui.slashCommandDropdown?.setHiddenCommands(
getHiddenProviderCommandSet(this.plugin.settings, getTabProviderId(tab, this.plugin)),
);
}
}
@@ -125,20 +146,14 @@ export class ClaudianView extends ItemView {
this.viewContainerEl.empty();
this.viewContainerEl.addClass('claudian-container');
// Build header (logo only, tab bar and actions moved to nav row)
const header = this.viewContainerEl.createDiv({ cls: 'claudian-header' });
this.buildHeader(header);
// Build nav row content (tab badges + header actions)
this.navRowContent = this.buildNavRowContent();
// Tab content container (TabManager will populate this)
this.tabContentEl = this.viewContainerEl.createDiv({ cls: 'claudian-tab-content-container' });
// Initialize TabManager
this.tabManager = new TabManager(
this.plugin,
this.plugin.mcpManager,
this.tabContentEl,
this,
{
@@ -146,12 +161,14 @@ export class ClaudianView extends ItemView {
this.updateTabBar();
this.updateNavRowLocation();
this.persistTabState();
this.syncProviderBrandColor();
},
onTabSwitched: () => {
this.updateTabBar();
this.updateHistoryDropdown();
this.updateNavRowLocation();
this.persistTabState();
this.syncProviderBrandColor();
},
onTabClosed: () => {
this.updateTabBar();
@@ -162,41 +179,36 @@ export class ClaudianView extends ItemView {
onTabAttentionChanged: () => this.updateTabBar(),
onTabConversationChanged: () => {
this.persistTabState();
this.syncProviderBrandColor();
},
onTabProviderChanged: () => {
this.syncProviderBrandColor();
},
}
);
// Wire up view-level event handlers
this.wireEventHandlers();
// Restore tabs from persisted state or create default tab
await this.restoreOrCreateTabs();
// Apply initial layout based on tabBarPosition setting
this.syncProviderBrandColor();
this.updateLayoutForPosition();
}
async onClose() {
// Cancel any pending tab bar update
if (this.pendingTabBarUpdate !== null) {
cancelAnimationFrame(this.pendingTabBarUpdate);
this.pendingTabBarUpdate = null;
}
// Cleanup event refs
for (const ref of this.eventRefs) {
this.plugin.app.vault.offref(ref);
}
this.eventRefs = [];
// Persist tab state before cleanup (immediate, not debounced)
await this.persistTabStateImmediate();
// Destroy tab manager and all tabs
await this.tabManager?.destroy();
this.tabManager = null;
// Cleanup tab bar
this.tabBar?.destroy();
this.tabBar = null;
}
@@ -211,18 +223,9 @@ export class ClaudianView extends ItemView {
// Title slot container (logo + title or tabs)
this.titleSlotEl = header.createDiv({ cls: 'claudian-title-slot' });
// Logo (hidden when 2+ tabs)
// Logo (hidden when 2+ tabs) — populated by syncHeaderLogo()
this.logoEl = this.titleSlotEl.createSpan({ cls: 'claudian-logo' });
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', LOGO_SVG.viewBox);
svg.setAttribute('width', LOGO_SVG.width);
svg.setAttribute('height', LOGO_SVG.height);
svg.setAttribute('fill', 'none');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', LOGO_SVG.path);
path.setAttribute('fill', LOGO_SVG.fill);
svg.appendChild(path);
this.logoEl.appendChild(svg);
this.syncHeaderLogo(DEFAULT_CHAT_PROVIDER_ID);
// Title text (hidden in header mode when 2+ tabs)
this.titleTextEl = this.titleSlotEl.createEl('h4', { text: 'Claudian', cls: 'claudian-title-text' });
@@ -412,6 +415,37 @@ export class ClaudianView extends ItemView {
}
}
/** 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 NS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', icon.viewBox);
svg.setAttribute('width', '18');
svg.setAttribute('height', '18');
svg.setAttribute('fill', 'none');
svg.setAttribute('data-provider', providerId);
const path = document.createElementNS(NS, 'path');
path.setAttribute('d', icon.path);
path.setAttribute('fill', 'currentColor');
svg.appendChild(path);
this.logoEl.appendChild(svg);
}
// ============================================
// History Dropdown
// ============================================
@@ -486,6 +520,8 @@ export class ClaudianView extends ItemView {
e.preventDefault();
const activeTab = this.tabManager?.getActiveTab();
if (!activeTab) return;
const providerId = getTabProviderId(activeTab, this.plugin);
if (!ProviderRegistry.getCapabilities(providerId).supportsPlanMode) return;
const current = this.plugin.settings.permissionMode;
if (current === 'plan') {
const restoreMode = activeTab.state.prePlanPermissionMode ?? 'normal';
@@ -556,26 +592,11 @@ export class ClaudianView extends ItemView {
const persistedState = await this.plugin.storage.getTabManagerState();
if (persistedState && persistedState.openTabs.length > 0) {
await this.tabManager.restoreState(persistedState);
await this.plugin.storage.clearLegacyActiveConversationId();
return;
}
// No persisted state - migrate legacy activeConversationId if present
const legacyActiveId = await this.plugin.storage.getLegacyActiveConversationId();
if (legacyActiveId) {
const conversation = await this.plugin.getConversationById(legacyActiveId);
if (conversation) {
await this.tabManager.createTab(conversation.id);
} else {
await this.tabManager.createTab();
}
await this.plugin.storage.clearLegacyActiveConversationId();
return;
}
// Fallback: create a new empty tab
await this.tabManager.createTab();
await this.plugin.storage.clearLegacyActiveConversationId();
}
private persistTabState(): void {
-8
View File
@@ -1,11 +1,3 @@
export const LOGO_SVG = {
viewBox: '0 -.01 39.5 39.53',
width: '18',
height: '18',
path: 'm7.75 26.27 7.77-4.36.13-.38-.13-.21h-.38l-1.3-.08-4.44-.12-3.85-.16-3.73-.2-.94-.2-.88-1.16.09-.58.79-.53 1.13.1 2.5.17 3.75.26 2.72.16 4.03.42h.64l.09-.26-.22-.16-.17-.16-3.88-2.63-4.2-2.78-2.2-1.6-1.19-.81-.6-.76-.26-1.66 1.08-1.19 1.45.1.37.1 1.47 1.13 3.14 2.43 4.1 3.02.6.5.24-.17.03-.12-.27-.45-2.23-4.03-2.38-4.1-1.06-1.7-.28-1.02c-.1-.42-.17-.77-.17-1.2l1.23-1.67.68-.22 1.64.22.69.6 1.02 2.33 1.65 3.67 2.56 4.99.75 1.48.4 1.37.15.42h.26v-.24l.21-2.81.39-3.45.38-4.44.13-1.25.62-1.5 1.23-.81.96.46.79 1.13-.11.73-.47 3.05-.92 4.78-.6 3.2h.35l.4-.4 1.62-2.15 2.72-3.4 1.2-1.35 1.4-1.49.9-.71h1.7l1.25 1.86-.56 1.92-1.75 2.22-1.45 1.88-2.08 2.8-1.3 2.24.12.18.31-.03 4.7-1 2.54-.46 3.03-.52 1.37.64.15.65-.54 1.33-3.24.8-3.8.76-5.66 1.34-.07.05.08.1 2.55.24 1.09.06h2.67l4.97.37 1.3.86.78 1.05-.13.8-2 1.02-2.7-.64-6.3-1.5-2.16-.54h-.3v.18l1.8 1.76 3.3 2.98 4.13 3.84.21.95-.53.75-.56-.08-3.63-2.73-1.4-1.23-3.17-2.67h-.21v.28l.73 1.07 3.86 5.8.2 1.78-.28.58-1 .35-1.1-.2-2.26-3.17-2.33-3.57-1.88-3.2-.23.13-1.11 11.95-.52.61-1.2.46-1-.76-.53-1.23.53-2.43.64-3.17.52-2.52.47-3.13.28-1.04-.02-.07-.23.03-2.36 3.24-3.59 4.85-2.84 3.04-.68.27-1.18-.61.11-1.09.66-.97 3.93-5 2.37-3.1 1.53-1.79-.01-.26h-.09l-10.44 6.78-1.86.24-.8-.75.1-1.23.38-.4 3.14-2.16z',
fill: '#d97757',
} as const;
/** Random flavor words shown when response completes (e.g., "Baked for 1:23"). */
export const COMPLETION_FLAVOR_WORDS = [
'Baked',
@@ -1,17 +1,20 @@
import { Notice, setIcon } from 'obsidian';
import type { ClaudianService } from '../../../core/agent';
import type { TitleGenerationService } from '../../../core/providers/types';
import type { ChatRuntime } from '../../../core/runtime/ChatRuntime';
import type { Conversation } from '../../../core/types';
import { t } from '../../../i18n';
import { t } from '../../../i18n/i18n';
import type ClaudianPlugin from '../../../main';
import { confirm } from '../../../shared/modals/ConfirmModal';
import { cleanupThinkingBlock } from '../rendering';
import type { MessageRenderer } from '../rendering/MessageRenderer';
import { cleanupThinkingBlock } from '../rendering/ThinkingBlockRenderer';
import { findRewindContext } from '../rewind';
import type { SubagentManager } from '../services/SubagentManager';
import type { TitleGenerationService } from '../services/TitleGenerationService';
import type { ChatState } from '../state/ChatState';
import type { ExternalContextSelector, FileContextManager, ImageContextManager, McpServerSelector, StatusPanel } from '../ui';
import type { FileContextManager } from '../ui/FileContext';
import type { ImageContextManager } from '../ui/ImageContext';
import type { ExternalContextSelector, McpServerSelector } from '../ui/InputToolbar';
import type { StatusPanel } from '../ui/StatusPanel';
export interface ConversationCallbacks {
onNewConversation?: () => void;
@@ -36,11 +39,13 @@ export interface ConversationControllerDeps {
clearQueuedMessage: () => void;
getTitleGenerationService: () => TitleGenerationService | null;
getStatusPanel: () => StatusPanel | null;
getAgentService?: () => ClaudianService | null;
getAgentService?: () => ChatRuntime | null;
ensureServiceForConversation?: (conversation: Conversation | null) => Promise<void>;
dismissPendingInlinePrompts?: () => void;
}
type SaveOptions = {
resumeSessionAt?: string;
resumeAtMessageId?: string;
};
export class ConversationController {
@@ -52,7 +57,7 @@ export class ConversationController {
this.callbacks = callbacks;
}
private getAgentService(): ClaudianService | null {
private getAgentService(): ChatRuntime | null {
return this.deps.getAgentService?.() ?? null;
}
@@ -77,6 +82,8 @@ export class ConversationController {
state.isCreatingConversation = true;
try {
this.deps.dismissPendingInlinePrompts?.();
if (force && state.isStreaming) {
state.cancelRequested = true;
state.bumpStreamGeneration();
@@ -113,7 +120,7 @@ export class ConversationController {
// Reset agent service session (no session ID for entry point)
// Pass persistent paths to prevent stale external contexts
this.getAgentService()?.setSessionId(
this.getAgentService()?.syncConversationState(
null,
plugin.settings.persistentExternalContextPaths || []
);
@@ -173,7 +180,7 @@ export class ConversationController {
state.autoScrollEnabled = plugin.settings.enableAutoScroll ?? true;
// Pass persistent paths to prevent stale external contexts
this.getAgentService()?.setSessionId(
this.getAgentService()?.syncConversationState(
null,
plugin.settings.persistentExternalContextPaths || []
);
@@ -200,52 +207,8 @@ export class ConversationController {
return;
}
// Load existing conversation
state.currentConversationId = conversation.id;
state.messages = [...conversation.messages];
state.usage = conversation.usage ?? null;
state.autoScrollEnabled = plugin.settings.enableAutoScroll ?? true;
// Clear status panels (auto-hide: panels reappear when agent creates new todos)
state.currentTodos = null;
const hasMessages = state.messages.length > 0;
// Determine external context paths for this session
// Empty session: use persistent paths; session with messages: use saved paths
const externalContextPaths = hasMessages
? conversation.externalContextPaths || []
: plugin.settings.persistentExternalContextPaths || [];
this.getAgentService()?.setSessionId(conversation.sessionId ?? null, externalContextPaths);
const fileCtx = this.deps.getFileContextManager();
fileCtx?.resetForLoadedConversation(hasMessages);
if (conversation.currentNote) {
fileCtx?.setCurrentNote(conversation.currentNote);
} else if (!hasMessages) {
fileCtx?.autoAttachActiveFile();
}
// Restore external context paths based on session state
this.restoreExternalContextPaths(
conversation.externalContextPaths,
!hasMessages
);
// Restore enabled MCP servers (or clear for new conversation)
const mcpServerSelector = this.deps.getMcpServerSelector();
if (conversation.enabledMcpServers && conversation.enabledMcpServers.length > 0) {
mcpServerSelector?.setEnabledServers(conversation.enabledMcpServers);
} else {
mcpServerSelector?.clearEnabled();
}
const welcomeEl = renderer.renderMessages(
state.messages,
() => this.getGreeting()
);
this.deps.setWelcomeEl(welcomeEl);
await this.deps.ensureServiceForConversation?.(conversation);
this.restoreConversation(conversation, { autoAttachFile: true });
this.updateWelcomeVisibility();
this.callbacks.onConversationLoaded?.();
@@ -253,7 +216,7 @@ export class ConversationController {
/** Switches to a different conversation. */
async switchTo(id: string): Promise<void> {
const { plugin, state, renderer, subagentManager } = this.deps;
const { plugin, state, subagentManager } = this.deps;
if (id === state.currentConversationId) return;
if (state.isStreaming) return;
@@ -263,6 +226,7 @@ export class ConversationController {
state.isSwitchingConversation = true;
try {
this.deps.dismissPendingInlinePrompts?.();
await this.save();
subagentManager.orphanAllActive();
@@ -273,58 +237,12 @@ export class ConversationController {
return;
}
state.currentConversationId = conversation.id;
state.messages = [...conversation.messages];
state.usage = conversation.usage ?? null;
state.autoScrollEnabled = plugin.settings.enableAutoScroll ?? true;
// Clear status panels (auto-hide: panels reappear when agent creates new todos)
state.currentTodos = null;
const hasMessages = state.messages.length > 0;
// Determine external context paths for this session
// Empty session: use persistent paths; session with messages: use saved paths
const externalContextPaths = hasMessages
? conversation.externalContextPaths || []
: plugin.settings.persistentExternalContextPaths || [];
// Update agent service session ID with correct external contexts
const agentService = this.getAgentService();
if (agentService) {
const resolvedSessionId = agentService.applyForkState(conversation);
agentService.setSessionId(resolvedSessionId, externalContextPaths);
}
await this.deps.ensureServiceForConversation?.(conversation);
this.deps.getInputEl().value = '';
this.deps.clearQueuedMessage();
const fileCtx = this.deps.getFileContextManager();
fileCtx?.resetForLoadedConversation(hasMessages);
if (conversation.currentNote) {
fileCtx?.setCurrentNote(conversation.currentNote);
}
// Restore external context paths based on session state
this.restoreExternalContextPaths(
conversation.externalContextPaths,
!hasMessages
);
// Restore enabled MCP servers (or clear if none)
const mcpServerSelector = this.deps.getMcpServerSelector();
if (conversation.enabledMcpServers && conversation.enabledMcpServers.length > 0) {
mcpServerSelector?.setEnabledServers(conversation.enabledMcpServers);
} else {
mcpServerSelector?.clearEnabled();
}
const welcomeEl = renderer.renderMessages(
state.messages,
() => this.getGreeting()
);
this.deps.setWelcomeEl(welcomeEl);
this.restoreConversation(conversation);
this.deps.getHistoryDropdown()?.removeClass('visible');
this.updateWelcomeVisibility();
@@ -338,6 +256,12 @@ export class ConversationController {
async rewind(userMessageId: string): Promise<void> {
const { plugin, state, renderer } = this.deps;
const agentServiceForCheck = this.getAgentService();
if (agentServiceForCheck && !agentServiceForCheck.getCapabilities().supportsRewind) {
new Notice(t('chat.rewind.failed', { error: 'Rewind is not supported by this provider.' }));
return;
}
if (state.isStreaming) {
new Notice(t('chat.rewind.unavailableStreaming'));
return;
@@ -350,7 +274,7 @@ export class ConversationController {
return;
}
const userMsg = msgs[userIdx];
if (!userMsg.sdkUserUuid) {
if (!userMsg.userMessageId) {
new Notice(t('chat.rewind.unavailableNoUuid'));
return;
}
@@ -382,7 +306,7 @@ export class ConversationController {
let result;
try {
result = await agentService.rewind(userMsg.sdkUserUuid, prevAssistantUuid);
result = await agentService.rewind(userMsg.userMessageId, prevAssistantUuid);
} catch (e) {
new Notice(t('chat.rewind.failed', { error: e instanceof Error ? e.message : 'Unknown error' }));
return;
@@ -405,7 +329,7 @@ export class ConversationController {
const filesChanged = result.filesChanged?.length ?? 0;
let saveError: string | null = null;
try {
await this.save(false, { resumeSessionAt: prevAssistantUuid });
await this.save(false, { resumeAtMessageId: prevAssistantUuid });
} catch (e) {
saveError = e instanceof Error ? e.message : 'Failed to save';
}
@@ -436,13 +360,16 @@ export class ConversationController {
}
const agentService = this.getAgentService();
const sessionId = agentService?.getSessionId() ?? null;
const sessionInvalidated = agentService?.consumeSessionInvalidation?.() ?? false;
// Entry point with messages - create conversation lazily
// New conversations always use SDK-native storage.
if (!state.currentConversationId && state.messages.length > 0) {
const conversation = await plugin.createConversation(sessionId ?? undefined);
const initialSessionId = agentService?.getSessionId() ?? undefined;
const conversation = await plugin.createConversation({
providerId: agentService?.providerId,
sessionId: initialSessionId,
});
state.currentConversationId = conversation.id;
}
@@ -453,49 +380,15 @@ export class ConversationController {
const mcpServerSelector = this.deps.getMcpServerSelector();
const enabledMcpServers = mcpServerSelector ? Array.from(mcpServerSelector.getEnabledServers()) : [];
// Check if this is a native session and promote legacy sessions after first SDK session capture
const conversation = await plugin.getConversationById(state.currentConversationId!);
const wasNative = conversation?.isNative ?? false;
const shouldPromote = !wasNative && !!sessionId;
const isNative = wasNative || shouldPromote;
const legacyMessages = conversation?.messages ?? [];
const legacyCutoffAt = shouldPromote
? legacyMessages[legacyMessages.length - 1]?.timestamp
: conversation?.legacyCutoffAt;
const conversation = plugin.getConversationSync(state.currentConversationId!);
// Detect session change (resume failed, SDK created new session)
// Move old sdkSessionId to previousSdkSessionIds for history merging on reload
// Use Set to deduplicate in case of race conditions or repeated session changes
const oldSdkSessionId = conversation?.sdkSessionId;
const sessionChanged = isNative && sessionId && oldSdkSessionId && sessionId !== oldSdkSessionId;
const previousSdkSessionIds = sessionChanged
? [...new Set([...(conversation?.previousSdkSessionIds || []), oldSdkSessionId])]
: conversation?.previousSdkSessionIds;
// Don't persist the fork source session ID as the conversation's own session.
// The agent service holds it for resume purposes only; the conversation gets
// its own ID after SDK captureSession() returns a new session.
const isForkSourceOnly = !!conversation?.forkSource &&
!conversation?.sdkSessionId &&
sessionId === conversation.forkSource.sessionId;
let resolvedSessionId: string | null;
if (sessionInvalidated) {
resolvedSessionId = null;
} else if (isForkSourceOnly) {
resolvedSessionId = conversation?.sessionId ?? null;
} else {
resolvedSessionId = sessionId ?? conversation?.sessionId ?? null;
}
const { updates: sessionUpdates } = agentService
? agentService.buildSessionUpdates({ conversation, sessionInvalidated })
: { updates: {} };
const updates: Partial<Conversation> = {
messages: isNative ? state.messages : state.getPersistedMessages(),
sessionId: resolvedSessionId,
sdkSessionId: isNative && sessionId && !isForkSourceOnly ? sessionId : conversation?.sdkSessionId,
previousSdkSessionIds,
isNative: isNative || undefined,
legacyCutoffAt,
sdkMessagesLoaded: isNative ? true : undefined,
...sessionUpdates,
messages: state.messages,
currentNote: currentNote,
externalContextPaths: externalContextPaths.length > 0 ? externalContextPaths : undefined,
usage: state.usage ?? undefined,
@@ -507,21 +400,65 @@ export class ConversationController {
}
if (options) {
updates.resumeSessionAt = options.resumeSessionAt;
updates.resumeAtMessageId = options.resumeAtMessageId;
}
// Clear fork metadata after first save with a new session ID (one-time use)
if (conversation?.forkSource && sessionId && sessionId !== conversation.forkSource.sessionId) {
updates.forkSource = undefined;
// Don't add forkSource.sessionId to previousSdkSessionIds
// (the source session belongs to the original conversation)
}
// At this point, currentConversationId is guaranteed to be set
// (either existed before or was created lazily above)
await plugin.updateConversation(state.currentConversationId!, updates);
}
/**
* Shared logic for restoring a conversation into the current tab.
* Used by both loadActive() and switchTo() to avoid duplication.
*/
private restoreConversation(
conversation: Conversation,
options?: { autoAttachFile?: boolean }
): void {
const { plugin, state, renderer } = this.deps;
state.currentConversationId = conversation.id;
state.messages = [...conversation.messages];
state.usage = conversation.usage ?? null;
state.autoScrollEnabled = plugin.settings.enableAutoScroll ?? true;
// Clear status panels (auto-hide: panels reappear when agent creates new todos)
state.currentTodos = null;
const hasMessages = state.messages.length > 0;
// Determine external context paths for this session
// Empty session: use persistent paths; session with messages: use saved paths
const externalContextPaths = hasMessages
? conversation.externalContextPaths || []
: plugin.settings.persistentExternalContextPaths || [];
this.getAgentService()?.syncConversationState(conversation, externalContextPaths);
const fileCtx = this.deps.getFileContextManager();
fileCtx?.resetForLoadedConversation(hasMessages);
if (conversation.currentNote) {
fileCtx?.setCurrentNote(conversation.currentNote);
} else if (!hasMessages && options?.autoAttachFile) {
fileCtx?.autoAttachActiveFile();
}
this.restoreExternalContextPaths(conversation.externalContextPaths, !hasMessages);
const mcpServerSelector = this.deps.getMcpServerSelector();
if (conversation.enabledMcpServers && conversation.enabledMcpServers.length > 0) {
mcpServerSelector?.setEnabledServers(conversation.enabledMcpServers);
} else {
mcpServerSelector?.clearEnabled();
}
const welcomeEl = renderer.renderMessages(
state.messages,
() => this.getGreeting()
);
this.deps.setWelcomeEl(welcomeEl);
}
/**
* Restores external context paths based on session state.
* New or empty sessions get current persistent paths from settings.
@@ -828,13 +765,14 @@ export class ConversationController {
async regenerateTitle(conversationId: string): Promise<void> {
const { plugin } = this.deps;
if (!plugin.settings.enableAutoTitleGeneration) return;
const titleService = this.deps.getTitleGenerationService();
if (!titleService) return;
// Get the full conversation from cache
// Title generation is delegated to the active provider service
const fullConv = await plugin.getConversationById(conversationId);
if (!fullConv || fullConv.messages.length < 1) return;
const titleService = this.deps.getTitleGenerationService();
if (!titleService) return;
// Find first user message by role (not by index)
const firstUserMsg = fullConv.messages.find(m => m.role === 'user');
if (!firstUserMsg) return;
File diff suppressed because it is too large Load Diff
+294 -80
View File
@@ -1,13 +1,21 @@
import { TFile } from 'obsidian';
import type { ClaudianService } from '../../../core/agent';
import { extractResolvedAnswers, extractResolvedAnswersFromResultText, parseTodoInput } from '../../../core/tools';
import { ProviderSettingsCoordinator } from '../../../core/providers/ProviderSettingsCoordinator';
import {
DEFAULT_CHAT_PROVIDER_ID,
type ProviderId,
type ProviderSubagentLifecycleAdapter,
} from '../../../core/providers/types';
import type { ChatRuntime } from '../../../core/runtime/ChatRuntime';
import { parseTodoInput } from '../../../core/tools/todo';
import { extractResolvedAnswers, extractResolvedAnswersFromResultText } from '../../../core/tools/toolInput';
import {
isEditTool,
isSubagentToolName,
isWriteEditTool,
skipsBlockedDetection,
TOOL_AGENT_OUTPUT,
TOOL_APPLY_PATCH,
TOOL_ASK_USER_QUESTION,
TOOL_TASK,
TOOL_TODO_WRITE,
@@ -19,25 +27,34 @@ import type ClaudianPlugin from '../../../main';
import { formatDurationMmSs } from '../../../utils/date';
import { extractDiffData } from '../../../utils/diff';
import { getVaultPath, normalizePathForVault } from '../../../utils/path';
import { loadSubagentFinalResult, loadSubagentToolCalls } from '../../../utils/sdkSession';
import { FLAVOR_TEXTS } from '../constants';
import type { MessageRenderer } from '../rendering/MessageRenderer';
import { resolveSubagentLifecycleAdapter } from '../rendering/subagentLifecycleResolution';
import {
createSubagentBlock,
finalizeSubagentBlock,
type SubagentState,
} from '../rendering/SubagentRenderer';
import {
appendThinkingContent,
createThinkingBlock,
createWriteEditBlock,
finalizeThinkingBlock,
finalizeWriteEditBlock,
} from '../rendering/ThinkingBlockRenderer';
import {
getToolName,
getToolSummary,
isBlockedToolResult,
renderToolCall,
updateToolCallResult,
} from '../rendering/ToolCallRenderer';
import {
createWriteEditBlock,
finalizeWriteEditBlock,
updateWriteEditWithDiff,
} from '../rendering';
import type { MessageRenderer } from '../rendering/MessageRenderer';
} from '../rendering/WriteEditRenderer';
import type { SubagentManager } from '../services/SubagentManager';
import type { ChatState } from '../state/ChatState';
import type { FileContextManager } from '../ui';
import type { FileContextManager } from '../ui/FileContext';
export interface StreamControllerDeps {
plugin: ClaudianPlugin;
@@ -48,7 +65,7 @@ export interface StreamControllerDeps {
getFileContextManager: () => FileContextManager | null;
updateQueueIndicator: () => void;
/** Get the agent service from the tab. */
getAgentService?: () => ClaudianService | null;
getAgentService?: () => ChatRuntime | null;
}
export class StreamController {
@@ -56,10 +73,22 @@ export class StreamController {
private deps: StreamControllerDeps;
// Provider lifecycle agent tracking (spawn → wait/close lifecycle)
private lifecycleSubagentStates = new Map<string, SubagentState>(); // spawn callId → SubagentState
private lifecycleAgentIdToSpawnId = new Map<string, string>(); // agentId → spawn callId
constructor(deps: StreamControllerDeps) {
this.deps = deps;
}
private getActiveProviderId(): ProviderId {
return this.deps.getAgentService?.()?.providerId ?? DEFAULT_CHAT_PROVIDER_ID;
}
private getSubagentLifecycleAdapter(toolName?: string): ProviderSubagentLifecycleAdapter | null {
return resolveSubagentLifecycleAdapter(this.getActiveProviderId(), toolName);
}
// ============================================
// Stream Chunk Handling
// ============================================
@@ -67,13 +96,6 @@ export class StreamController {
async handleStreamChunk(chunk: StreamChunk, msg: ChatMessage): Promise<void> {
const { state } = this.deps;
// Route subagent chunks
if ('parentToolUseId' in chunk && chunk.parentToolUseId) {
await this.handleSubagentChunk(chunk, msg);
this.scrollToBottom();
return;
}
switch (chunk.type) {
case 'thinking':
// Flush pending tools before rendering new content type
@@ -112,6 +134,16 @@ export class StreamController {
break;
}
const subagentLifecycleAdapter = this.getSubagentLifecycleAdapter(chunk.name);
if (subagentLifecycleAdapter?.isSpawnTool(chunk.name)) {
this.handleProviderSubagentSpawn(chunk, msg, subagentLifecycleAdapter);
break;
}
if (subagentLifecycleAdapter?.isHiddenTool(chunk.name)) {
this.handleProviderHiddenSubagentTool(chunk, msg);
break;
}
this.handleRegularToolUse(chunk, msg);
break;
}
@@ -121,10 +153,18 @@ export class StreamController {
break;
}
case 'blocked':
// Flush pending tools before rendering blocked message
case 'subagent_tool_use':
case 'subagent_tool_result':
await this.handleSubagentChunk(chunk, msg);
break;
case 'tool_output':
this.handleToolOutput(chunk, msg);
break;
case 'notice':
this.flushPendingTools();
await this.appendText(`\n\n⚠️ **Blocked:** ${chunk.content}`);
await this.appendText(`\n\n⚠️ **${chunk.level === 'warning' ? 'Blocked' : 'Notice'}:** ${chunk.content}`);
break;
case 'error':
@@ -138,26 +178,18 @@ export class StreamController {
this.flushPendingTools();
break;
case 'compact_boundary': {
case 'context_compacted': {
this.flushPendingTools();
if (state.currentThinkingState) {
this.finalizeCurrentThinkingBlock(msg);
}
this.finalizeCurrentTextBlock(msg);
msg.contentBlocks = msg.contentBlocks || [];
msg.contentBlocks.push({ type: 'compact_boundary' });
msg.contentBlocks.push({ type: 'context_compacted' });
this.renderCompactBoundary();
break;
}
case 'sdk_assistant_uuid':
msg.sdkAssistantUuid = chunk.uuid;
break;
case 'sdk_user_uuid':
case 'sdk_user_sent':
break;
case 'usage': {
// Skip usage updates from other sessions or when flagged (during session reset)
const currentSessionId = this.deps.getAgentService?.()?.getSessionId() ?? null;
@@ -173,21 +205,16 @@ export class StreamController {
break;
}
if (!state.ignoreUsageUpdates) {
state.usage = chunk.usage;
const activeModel = this.getActiveProviderModel();
state.usage = activeModel && !chunk.usage.model
? { ...chunk.usage, model: activeModel }
: chunk.usage;
}
break;
}
case 'context_window_update': {
// Authoritative context window from SDK result — override heuristic value
if (state.usage && chunk.contextWindow > 0) {
const contextWindow = chunk.contextWindow;
const percentage = Math.min(100, Math.max(0, Math.round((state.usage.contextTokens / contextWindow) * 100)));
state.usage = { ...state.usage, contextWindow, percentage };
}
default:
break;
}
}
this.scrollToBottom();
@@ -269,7 +296,7 @@ export class StreamController {
}
}
// Track Write to ~/.claude/plans/ for plan mode (used by approve-new-session)
// Track Write to provider plan directory for plan mode (used by approve-new-session)
if (chunk.name === TOOL_WRITE) {
this.capturePlanFilePath(chunk.input);
}
@@ -284,9 +311,25 @@ export class StreamController {
}
}
private getActiveProviderModel(): string | undefined {
const providerId = this.deps.getAgentService?.()?.providerId;
if (!providerId) {
return undefined;
}
const settings = ProviderSettingsCoordinator.getProviderSettingsSnapshot(
this.deps.plugin.settings as unknown as Record<string, unknown>,
providerId,
);
return typeof settings.model === 'string' ? settings.model : undefined;
}
private capturePlanFilePath(input: Record<string, unknown>): void {
const filePath = input.file_path as string | undefined;
if (filePath && filePath.replace(/\\/g, '/').includes('/.claude/plans/')) {
if (!filePath) return;
const planPathPrefix = this.deps.getAgentService?.()?.getCapabilities().planPathPrefix;
if (planPathPrefix && filePath.replace(/\\/g, '/').includes(planPathPrefix)) {
this.deps.state.planFilePath = filePath;
}
}
@@ -330,6 +373,157 @@ export class StreamController {
state.pendingTools.delete(toolId);
}
private handleToolOutput(
chunk: { type: 'tool_output'; id: string; content: string },
msg: ChatMessage,
): void {
const { state } = this.deps;
if (state.pendingTools.has(chunk.id)) {
this.renderPendingTool(chunk.id);
}
const existingToolCall = msg.toolCalls?.find(tc => tc.id === chunk.id);
if (!existingToolCall) {
return;
}
existingToolCall.result = (existingToolCall.result ?? '') + chunk.content;
updateToolCallResult(chunk.id, existingToolCall, state.toolCallElements);
this.showThinkingIndicator();
}
// ============================================
// Provider lifecycle subagents (spawn → wait/close)
// ============================================
private handleProviderSubagentSpawn(
chunk: { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> },
msg: ChatMessage,
adapter: ProviderSubagentLifecycleAdapter,
): void {
const { state } = this.deps;
const toolCall: ToolCallInfo = {
id: chunk.id,
name: chunk.name,
input: chunk.input,
status: 'running',
isExpanded: false,
};
msg.toolCalls = msg.toolCalls || [];
msg.toolCalls.push(toolCall);
msg.contentBlocks = msg.contentBlocks || [];
msg.contentBlocks.push({ type: 'tool_use', toolId: chunk.id });
// Render as subagent block immediately
if (state.currentContentEl) {
this.flushPendingTools();
const subagentInfo = adapter.buildSubagentInfo(toolCall, msg.toolCalls);
const subagentState = createSubagentBlock(state.currentContentEl, chunk.id, {
description: subagentInfo.description,
prompt: subagentInfo.prompt,
});
this.lifecycleSubagentStates.set(chunk.id, subagentState);
}
}
private handleProviderHiddenSubagentTool(
chunk: { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> },
msg: ChatMessage
): void {
// Track in toolCalls for data completeness, but don't create DOM or content block
const toolCall: ToolCallInfo = {
id: chunk.id,
name: chunk.name,
input: chunk.input,
status: 'running',
isExpanded: false,
};
msg.toolCalls = msg.toolCalls || [];
msg.toolCalls.push(toolCall);
}
/**
* Handles tool_result for provider lifecycle subagent tools.
* Returns true if the result was consumed (caller should return early).
*/
private handleProviderSubagentResult(
chunk: { type: 'tool_result'; id: string; content: string; isError?: boolean },
msg: ChatMessage
): boolean {
const existingToolCall = msg.toolCalls?.find(tc => tc.id === chunk.id);
if (!existingToolCall) return false;
const adapter = this.getSubagentLifecycleAdapter(existingToolCall.name);
if (!adapter) return false;
if (adapter.isSpawnTool(existingToolCall.name)) {
existingToolCall.status = chunk.isError ? 'error' : 'completed';
existingToolCall.result = chunk.content;
const spawnResult = adapter.extractSpawnResult(chunk.content);
if (spawnResult.agentId) {
this.lifecycleAgentIdToSpawnId.set(spawnResult.agentId, chunk.id);
}
const subagentInfo = adapter.buildSubagentInfo(existingToolCall, msg.toolCalls ?? []);
const subagentState = this.lifecycleSubagentStates.get(chunk.id);
if (subagentState) {
subagentState.info.description = subagentInfo.description;
subagentState.info.prompt = subagentInfo.prompt;
subagentState.labelEl.setText(
subagentInfo.description.length > 40
? subagentInfo.description.substring(0, 40) + '...'
: subagentInfo.description
);
}
if (chunk.isError) {
if (subagentState) {
finalizeSubagentBlock(subagentState, chunk.content || 'Error', true);
}
}
return true;
}
if (adapter.isWaitTool(existingToolCall.name)) {
existingToolCall.status = chunk.isError ? 'error' : 'completed';
existingToolCall.result = chunk.content;
for (const spawnId of adapter.resolveSpawnToolIds(
existingToolCall,
this.lifecycleAgentIdToSpawnId,
)) {
const spawnToolCall = msg.toolCalls?.find(tc => tc.id === spawnId);
const subagentState = this.lifecycleSubagentStates.get(spawnId);
if (!spawnToolCall || !subagentState) continue;
const subagentInfo = adapter.buildSubagentInfo(spawnToolCall, msg.toolCalls ?? []);
subagentState.info.description = subagentInfo.description;
subagentState.info.prompt = subagentInfo.prompt;
if (subagentInfo.status === 'completed' || subagentInfo.status === 'error') {
finalizeSubagentBlock(
subagentState,
subagentInfo.result || (subagentInfo.status === 'error' ? 'Error' : 'DONE'),
subagentInfo.status === 'error'
);
}
}
return true;
}
if (adapter.isCloseTool(existingToolCall.name)) {
existingToolCall.status = chunk.isError ? 'error' : 'completed';
existingToolCall.result = chunk.content;
return true;
}
return false;
}
private async handleToolResult(
chunk: { type: 'tool_result'; id: string; content: string; isError?: boolean; toolUseResult?: SDKToolUseResult },
msg: ChatMessage
@@ -360,6 +554,11 @@ export class StreamController {
return;
}
if (this.handleProviderSubagentResult(chunk, msg)) {
this.showThinkingIndicator();
return;
}
// Check if tool is still pending (buffered) - render it now before applying result
if (state.pendingTools.has(chunk.id)) {
this.renderPendingTool(chunk.id);
@@ -407,6 +606,11 @@ export class StreamController {
if (!chunk.isError && !isBlocked && isEditTool(existingToolCall.name)) {
this.notifyVaultFileChange(existingToolCall.input);
}
// Runtime apply_patch: refresh each changed file path
if (!chunk.isError && !isBlocked && existingToolCall.name === TOOL_APPLY_PATCH) {
this.notifyApplyPatchFileChanges(existingToolCall.input);
}
}
this.showThinkingIndicator();
@@ -569,11 +773,11 @@ export class StreamController {
}
}
private async handleSubagentChunk(chunk: StreamChunk, msg: ChatMessage): Promise<void> {
if (!('parentToolUseId' in chunk) || !chunk.parentToolUseId) {
return;
}
const parentToolUseId = chunk.parentToolUseId;
private async handleSubagentChunk(
chunk: Extract<StreamChunk, { type: 'subagent_tool_use' | 'subagent_tool_result' }>,
msg: ChatMessage,
): Promise<void> {
const parentToolUseId = chunk.subagentId;
const { subagentManager } = this.deps;
// If parent Agent call is still pending, child chunk confirms it's sync - render now
@@ -588,7 +792,7 @@ export class StreamController {
}
switch (chunk.type) {
case 'tool_use': {
case 'subagent_tool_use': {
const toolCall: ToolCallInfo = {
id: chunk.id,
name: chunk.name,
@@ -601,7 +805,7 @@ export class StreamController {
break;
}
case 'tool_result': {
case 'subagent_tool_result': {
const toolCall = subagentState.info.toolCalls.find((tc: ToolCallInfo) => tc.id === chunk.id);
if (toolCall) {
const isBlocked = isBlockedToolResult(chunk.content, chunk.isError);
@@ -612,8 +816,7 @@ export class StreamController {
break;
}
case 'text':
case 'thinking':
default:
break;
}
}
@@ -707,16 +910,12 @@ export class StreamController {
const asyncStatus = subagent.asyncStatus ?? subagent.status;
if (asyncStatus !== 'completed' && asyncStatus !== 'error') return;
const sessionId = this.deps.getAgentService?.()?.getSessionId();
if (!sessionId) return;
const vaultPath = getVaultPath(this.deps.plugin.app);
if (!vaultPath) return;
const runtime = this.deps.getAgentService?.();
if (!runtime) return;
const { hasHydrated, finalResultHydrated } = await this.tryHydrateAsyncSubagent(
subagent,
vaultPath,
sessionId,
runtime,
true
);
@@ -725,25 +924,22 @@ export class StreamController {
}
if (!finalResultHydrated) {
this.scheduleAsyncSubagentResultRetry(subagent, vaultPath, sessionId, 0);
this.scheduleAsyncSubagentResultRetry(subagent, runtime, 0);
}
}
private async tryHydrateAsyncSubagent(
subagent: SubagentInfo,
vaultPath: string,
sessionId: string,
runtime: ChatRuntime,
hydrateToolCalls: boolean
): Promise<{ hasHydrated: boolean; finalResultHydrated: boolean }> {
let hasHydrated = false;
let finalResultHydrated = false;
if (hydrateToolCalls && !subagent.toolCalls?.length) {
const recoveredToolCalls = await loadSubagentToolCalls(
vaultPath,
sessionId,
const recoveredToolCalls = await runtime.loadSubagentToolCalls?.(
subagent.agentId || ''
);
) ?? [];
if (recoveredToolCalls.length > 0) {
subagent.toolCalls = recoveredToolCalls.map((toolCall) => ({
...toolCall,
@@ -753,11 +949,9 @@ export class StreamController {
}
}
const recoveredFinalResult = await loadSubagentFinalResult(
vaultPath,
sessionId,
const recoveredFinalResult = await runtime.loadSubagentFinalResult?.(
subagent.agentId || ''
);
) ?? null;
if (recoveredFinalResult && recoveredFinalResult.trim().length > 0) {
finalResultHydrated = true;
if (recoveredFinalResult !== subagent.result) {
@@ -771,8 +965,7 @@ export class StreamController {
private scheduleAsyncSubagentResultRetry(
subagent: SubagentInfo,
vaultPath: string,
sessionId: string,
runtime: ChatRuntime,
attempt: number
): void {
if (!subagent.agentId) return;
@@ -780,14 +973,13 @@ export class StreamController {
const delay = StreamController.ASYNC_SUBAGENT_RESULT_RETRY_DELAYS_MS[attempt];
setTimeout(() => {
void this.retryAsyncSubagentResult(subagent, vaultPath, sessionId, attempt);
void this.retryAsyncSubagentResult(subagent, runtime, attempt);
}, delay);
}
private async retryAsyncSubagentResult(
subagent: SubagentInfo,
vaultPath: string,
sessionId: string,
runtime: ChatRuntime,
attempt: number
): Promise<void> {
if (!subagent.agentId) return;
@@ -796,8 +988,7 @@ export class StreamController {
const { hasHydrated, finalResultHydrated } = await this.tryHydrateAsyncSubagent(
subagent,
vaultPath,
sessionId,
runtime,
false
);
if (hasHydrated) {
@@ -805,7 +996,7 @@ export class StreamController {
}
if (!finalResultHydrated) {
this.scheduleAsyncSubagentResultRetry(subagent, vaultPath, sessionId, attempt + 1);
this.scheduleAsyncSubagentResultRetry(subagent, runtime, attempt + 1);
}
}
@@ -945,9 +1136,6 @@ export class StreamController {
}
state.flavorTimerInterval = setInterval(updateTimer, 1000);
// Queue indicator line (initially hidden)
state.queueIndicatorEl = state.thinkingEl.createDiv({ cls: 'claudian-queue-indicator' });
this.deps.updateQueueIndicator();
}, StreamController.THINKING_INDICATOR_DELAY);
}
@@ -968,7 +1156,6 @@ export class StreamController {
state.thinkingEl.remove();
state.thinkingEl = null;
}
state.queueIndicatorEl = null;
}
// ============================================
@@ -1014,6 +1201,33 @@ export class StreamController {
}, 200);
}
/** Refreshes vault for each file path in an apply_patch changes array or patch text. */
private notifyApplyPatchFileChanges(input: Record<string, unknown>): void {
const notified = new Set<string>();
// Legacy changes array
const changes = input.changes;
if (Array.isArray(changes)) {
for (const change of changes) {
if (change && typeof change === 'object' && typeof change.path === 'string') {
notified.add(change.path);
this.notifyVaultFileChange({ file_path: change.path });
}
}
}
// Parse file paths from patch text markers (current custom_tool_call format)
const patchText = typeof input.patch === 'string' ? input.patch : '';
if (patchText) {
for (const match of patchText.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)) {
const filePath = match[1]?.trim();
if (filePath && !notified.has(filePath)) {
this.notifyVaultFileChange({ file_path: filePath });
}
}
}
}
/** Scrolls messages to bottom if auto-scroll is enabled. */
private scrollToBottom(): void {
const { state, plugin } = this.deps;
-7
View File
@@ -1,7 +0,0 @@
export { BrowserSelectionController } from './BrowserSelectionController';
export { CanvasSelectionController } from './CanvasSelectionController';
export { type ConversationCallbacks, ConversationController, type ConversationControllerDeps } from './ConversationController';
export { InputController, type InputControllerDeps } from './InputController';
export { NavigationController, type NavigationControllerDeps } from './NavigationController';
export { SelectionController } from './SelectionController';
export { StreamController, type StreamControllerDeps } from './StreamController';
@@ -13,7 +13,7 @@ export interface InlineAskQuestionConfig {
export class InlineAskUserQuestion {
private containerEl: HTMLElement;
private input: Record<string, unknown>;
private resolveCallback: (result: Record<string, string> | null) => void;
private resolveCallback: (result: Record<string, string | string[]> | null) => void;
private resolved = false;
private signal?: AbortSignal;
private config: Required<Omit<InlineAskQuestionConfig, 'headerEl'>> & { headerEl?: HTMLElement };
@@ -37,7 +37,7 @@ export class InlineAskUserQuestion {
constructor(
containerEl: HTMLElement,
input: Record<string, unknown>,
resolve: (result: Record<string, string> | null) => void,
resolve: (result: Record<string, string | string[]> | null) => void,
signal?: AbortSignal,
config?: InlineAskQuestionConfig,
) {
@@ -46,7 +46,7 @@ export class InlineAskUserQuestion {
this.resolveCallback = resolve;
this.signal = signal;
this.config = {
title: config?.title ?? 'Claude has a question',
title: config?.title ?? 'Question',
headerEl: config?.headerEl,
showCustomInput: config?.showCustomInput ?? true,
immediateSelect: config?.immediateSelect ?? false,
@@ -112,18 +112,28 @@ export class InlineAskUserQuestion {
return raw
.filter(
(q): q is { question: string; header?: string; options: unknown[]; multiSelect?: boolean } =>
(q): q is {
question: string;
header?: string;
options?: unknown[] | null;
multiSelect?: boolean;
isOther?: boolean;
isSecret?: boolean;
id?: string;
} =>
typeof q === 'object' &&
q !== null &&
typeof q.question === 'string' &&
Array.isArray(q.options) &&
q.options.length > 0,
((Array.isArray(q.options) && q.options.length > 0) || q.isOther === true),
)
.map((q, idx) => ({
question: q.question,
id: typeof (q as Record<string, unknown>).id === 'string' ? (q as Record<string, unknown>).id as string : undefined,
header: typeof q.header === 'string' ? q.header.slice(0, 12) : `Q${idx + 1}`,
options: this.deduplicateOptions(q.options.map((o) => this.coerceOption(o))),
options: this.deduplicateOptions((q.options ?? []).map((o) => this.coerceOption(o))),
multiSelect: q.multiSelect === true,
isOther: q.isOther === true,
isSecret: q.isSecret === true,
}));
}
@@ -132,7 +142,8 @@ export class InlineAskUserQuestion {
const obj = opt as Record<string, unknown>;
const label = this.extractLabel(obj);
const description = typeof obj.description === 'string' ? obj.description : '';
return { label, description };
const value = this.extractValue(obj, label);
return { label, description, ...(value !== label ? { value } : {}) };
}
return { label: typeof opt === 'string' ? opt : String(opt), description: '' };
}
@@ -154,6 +165,12 @@ export class InlineAskUserQuestion {
return String(obj);
}
private extractValue(obj: Record<string, unknown>, fallback: string): string {
if (typeof obj.value === 'string') return obj.value;
if (typeof obj.id === 'string') return obj.id;
return fallback;
}
private renderTabBar(): void {
this.tabBar.empty();
this.tabElements = [];
@@ -223,7 +240,8 @@ export class InlineAskUserQuestion {
for (let optIdx = 0; optIdx < q.options.length; optIdx++) {
const option = q.options[optIdx];
const isFocused = optIdx === this.focusedItemIndex;
const isSelected = selected.has(option.label);
const optionValue = this.getOptionValue(option);
const isSelected = selected.has(optionValue);
const row = listEl.createDiv({ cls: 'claudian-ask-item' });
if (isFocused) row.addClass('is-focused');
@@ -251,13 +269,13 @@ export class InlineAskUserQuestion {
row.addEventListener('click', () => {
this.focusedItemIndex = optIdx;
this.updateFocusIndicator();
this.selectOption(idx, option.label);
this.selectOption(idx, option);
});
this.currentItems.push(row);
}
if (this.config.showCustomInput) {
if (this.canShowCustomInputForQuestion(q)) {
const customIdx = q.options.length;
const customFocused = customIdx === this.focusedItemIndex;
const customText = this.customInputs.get(idx) ?? '';
@@ -274,11 +292,11 @@ export class InlineAskUserQuestion {
}
const inputEl = customRow.createEl('input', {
type: 'text',
cls: 'claudian-ask-custom-text',
placeholder: 'Type something.',
value: customText,
});
inputEl.setAttribute('type', q.isSecret ? 'password' : 'text');
inputEl.setAttribute('placeholder', q.isSecret ? 'Enter secret.' : 'Type something.');
inputEl.addEventListener('input', () => {
this.customInputs.set(idx, inputEl.value);
@@ -366,36 +384,38 @@ export class InlineAskUserQuestion {
}
private getAnswerText(idx: number): string {
const selected = this.answers.get(idx)!;
const selected = this.getSelectedLabels(idx);
const custom = this.customInputs.get(idx)!;
const parts: string[] = [];
if (selected.size > 0) parts.push([...selected].join(', '));
if (selected.length > 0) parts.push(selected.join(', '));
if (custom.trim()) parts.push(custom.trim());
return parts.join(', ');
}
private selectOption(qIdx: number, label: string): void {
private selectOption(qIdx: number, option: AskUserQuestionOption): void {
const q = this.questions[qIdx];
const selected = this.answers.get(qIdx)!;
const isMulti = q.multiSelect;
const optionValue = this.getOptionValue(option);
if (isMulti) {
if (selected.has(label)) {
selected.delete(label);
if (selected.has(optionValue)) {
selected.delete(optionValue);
} else {
selected.add(label);
selected.add(optionValue);
}
} else {
selected.clear();
selected.add(label);
selected.add(optionValue);
this.customInputs.set(qIdx, '');
}
this.updateOptionVisuals(qIdx);
if (this.config.immediateSelect) {
const key = q.id ?? q.question;
const result: Record<string, string> = {};
result[q.question] = label;
result[key] = optionValue;
this.handleResolve(result);
return;
}
@@ -421,7 +441,7 @@ export class InlineAskUserQuestion {
for (let i = 0; i < q.options.length; i++) {
const item = this.currentItems[i];
const isSelected = selected.has(q.options[i].label);
const isSelected = selected.has(this.getOptionValue(q.options[i]));
item.toggleClass('is-selected', isSelected);
@@ -563,7 +583,7 @@ export class InlineAskUserQuestion {
e.preventDefault();
e.stopPropagation();
if (this.focusedItemIndex <= maxIdx) {
this.selectOption(this.activeTabIndex, q.options[this.focusedItemIndex].label);
this.selectOption(this.activeTabIndex, q.options[this.focusedItemIndex]);
}
}
return;
@@ -573,7 +593,7 @@ export class InlineAskUserQuestion {
const q = this.questions[this.activeTabIndex];
const maxFocusIndex = isSubmitTab
? 1
: (this.config.showCustomInput ? q.options.length : q.options.length - 1);
: (this.canShowCustomInputForQuestion(q) ? q.options.length : q.options.length - 1);
if (this.handleNavigationKey(e, maxFocusIndex)) return;
@@ -598,8 +618,8 @@ export class InlineAskUserQuestion {
e.preventDefault();
e.stopPropagation();
if (this.focusedItemIndex < q.options.length) {
this.selectOption(this.activeTabIndex, q.options[this.focusedItemIndex].label);
} else if (this.config.showCustomInput) {
this.selectOption(this.activeTabIndex, q.options[this.focusedItemIndex]);
} else if (this.canShowCustomInputForQuestion(q)) {
this.isInputFocused = true;
const input = this.contentArea.querySelector(
'.claudian-ask-custom-text',
@@ -614,14 +634,44 @@ export class InlineAskUserQuestion {
const allAnswered = this.questions.every((_, i) => this.isQuestionAnswered(i));
if (!allAnswered) return;
const result: Record<string, string> = {};
const result: Record<string, string | string[]> = {};
for (let i = 0; i < this.questions.length; i++) {
result[this.questions[i].question] = this.getAnswerText(i);
const question = this.questions[i];
const key = question.id ?? question.question;
const selectedValues = [...this.answers.get(i)!];
const customInput = this.customInputs.get(i)!.trim();
if (question.multiSelect) {
const answers = [...selectedValues];
if (customInput) {
answers.push(customInput);
}
result[key] = answers;
continue;
}
result[key] = customInput || selectedValues[0] || '';
}
this.handleResolve(result);
}
private handleResolve(result: Record<string, string> | null): void {
private canShowCustomInputForQuestion(question: AskUserQuestionItem): boolean {
return this.config.showCustomInput && question.isOther === true;
}
private getOptionValue(option: AskUserQuestionOption): string {
return option.value ?? option.label;
}
private getSelectedLabels(idx: number): string[] {
const selected = this.answers.get(idx)!;
const question = this.questions[idx];
return question.options
.filter(option => selected.has(this.getOptionValue(option)))
.map(option => option.label);
}
private handleResolve(result: Record<string, string | string[]> | null): void {
if (!this.resolved) {
this.resolved = true;
this.rootEl?.removeEventListener('keydown', this.boundKeyDown);
@@ -12,6 +12,7 @@ export class InlineExitPlanMode {
private resolved = false;
private signal?: AbortSignal;
private renderContent?: RenderContentFn;
private planPathPrefix?: string;
private planContent: string | null = null;
private planReadError: string | null = null;
@@ -29,12 +30,14 @@ export class InlineExitPlanMode {
resolve: (decision: ExitPlanModeDecision | null) => void,
signal?: AbortSignal,
renderContent?: RenderContentFn,
planPathPrefix?: string,
) {
this.containerEl = containerEl;
this.input = input;
this.resolveCallback = resolve;
this.signal = signal;
this.renderContent = renderContent;
this.planPathPrefix = planPathPrefix;
this.boundKeyDown = this.handleKeyDown.bind(this);
}
@@ -138,7 +141,7 @@ export class InlineExitPlanMode {
if (!planFilePath) return null;
const resolved = nodePath.resolve(planFilePath).replace(/\\/g, '/');
if (!resolved.includes('/.claude/plans/')) {
if (!this.planPathPrefix || !resolved.includes(this.planPathPrefix)) {
this.planReadError = 'path outside allowed plan directory';
return null;
}
@@ -0,0 +1,183 @@
export type PlanApprovalDecision =
| { type: 'implement' }
| { type: 'revise'; text: string }
| { type: 'cancel' };
const HINTS_TEXT = 'Arrow keys to navigate \u00B7 Enter to select \u00B7 Esc to cancel';
export class InlinePlanApproval {
private containerEl: HTMLElement;
private resolveCallback: (decision: PlanApprovalDecision | null) => void;
private resolved = false;
private rootEl!: HTMLElement;
private focusedIndex = 0;
private items: HTMLElement[] = [];
private feedbackInput!: HTMLInputElement;
private isInputFocused = false;
private boundKeyDown: (e: KeyboardEvent) => void;
constructor(
containerEl: HTMLElement,
resolve: (decision: PlanApprovalDecision | null) => void,
) {
this.containerEl = containerEl;
this.resolveCallback = resolve;
this.boundKeyDown = this.handleKeyDown.bind(this);
}
render(): void {
this.rootEl = this.containerEl.createDiv({ cls: 'claudian-plan-approval-inline' });
this.rootEl.createDiv({ cls: 'claudian-plan-inline-title', text: 'Plan complete' });
const actionsEl = this.rootEl.createDiv({ cls: 'claudian-ask-list' });
// 1. Implement
const implementRow = actionsEl.createDiv({ cls: 'claudian-ask-item' });
implementRow.addClass('is-focused');
implementRow.createSpan({ text: '\u203A', cls: 'claudian-ask-cursor' });
implementRow.createSpan({ text: '1. ', cls: 'claudian-ask-item-num' });
implementRow.createSpan({ text: 'Implement', cls: 'claudian-ask-item-label' });
implementRow.addEventListener('click', () => {
this.focusedIndex = 0;
this.updateFocus();
this.handleResolve({ type: 'implement' });
});
this.items.push(implementRow);
// 2. Revise (with feedback input)
const reviseRow = actionsEl.createDiv({ cls: 'claudian-ask-item claudian-ask-custom-item' });
reviseRow.createSpan({ text: '\u00A0', cls: 'claudian-ask-cursor' });
reviseRow.createSpan({ text: '2. ', cls: 'claudian-ask-item-num' });
this.feedbackInput = reviseRow.createEl('input', {
type: 'text',
cls: 'claudian-ask-custom-text',
placeholder: 'Enter feedback to revise plan...',
});
this.feedbackInput.addEventListener('focus', () => { this.isInputFocused = true; });
this.feedbackInput.addEventListener('blur', () => { this.isInputFocused = false; });
reviseRow.addEventListener('click', () => {
this.focusedIndex = 1;
this.updateFocus();
});
this.items.push(reviseRow);
// 3. Cancel
const cancelRow = actionsEl.createDiv({ cls: 'claudian-ask-item' });
cancelRow.createSpan({ text: '\u00A0', cls: 'claudian-ask-cursor' });
cancelRow.createSpan({ text: '3. ', cls: 'claudian-ask-item-num' });
cancelRow.createSpan({ text: 'Cancel', cls: 'claudian-ask-item-label' });
cancelRow.addEventListener('click', () => {
this.focusedIndex = 2;
this.updateFocus();
this.handleResolve({ type: 'cancel' });
});
this.items.push(cancelRow);
this.rootEl.createDiv({ text: HINTS_TEXT, cls: 'claudian-ask-hints' });
this.rootEl.setAttribute('tabindex', '0');
this.rootEl.addEventListener('keydown', this.boundKeyDown);
requestAnimationFrame(() => {
this.rootEl.focus();
this.rootEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
}
destroy(): void {
this.handleResolve(null);
}
private handleKeyDown(e: KeyboardEvent): void {
if (this.isInputFocused) {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
this.isInputFocused = false;
this.feedbackInput.blur();
this.rootEl.focus();
return;
}
if (e.key === 'Enter' && this.feedbackInput.value.trim()) {
e.preventDefault();
e.stopPropagation();
this.handleResolve({ type: 'revise', text: this.feedbackInput.value.trim() });
return;
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
e.stopPropagation();
this.focusedIndex = Math.min(this.focusedIndex + 1, this.items.length - 1);
this.updateFocus();
break;
case 'ArrowUp':
e.preventDefault();
e.stopPropagation();
this.focusedIndex = Math.max(this.focusedIndex - 1, 0);
this.updateFocus();
break;
case 'Enter':
e.preventDefault();
e.stopPropagation();
if (this.focusedIndex === 0) {
this.handleResolve({ type: 'implement' });
} else if (this.focusedIndex === 1) {
this.feedbackInput.focus();
} else if (this.focusedIndex === 2) {
this.handleResolve({ type: 'cancel' });
}
break;
case 'Escape':
e.preventDefault();
e.stopPropagation();
this.handleResolve(null);
break;
}
}
private updateFocus(): void {
for (let i = 0; i < this.items.length; i++) {
const item = this.items[i];
const cursor = item.querySelector('.claudian-ask-cursor');
if (i === this.focusedIndex) {
item.addClass('is-focused');
if (cursor) cursor.textContent = '\u203A';
item.scrollIntoView({ block: 'nearest' });
if (item.hasClass('claudian-ask-custom-item')) {
const input = item.querySelector('.claudian-ask-custom-text') as HTMLInputElement;
if (input) {
input.focus();
this.isInputFocused = true;
}
}
} else {
item.removeClass('is-focused');
if (cursor) cursor.textContent = '\u00A0';
if (item.hasClass('claudian-ask-custom-item') && this.isInputFocused) {
const input = item.querySelector('.claudian-ask-custom-text') as HTMLInputElement;
if (input) {
input.blur();
this.isInputFocused = false;
}
}
}
}
}
private handleResolve(decision: PlanApprovalDecision | null): void {
if (!this.resolved) {
this.resolved = true;
this.rootEl?.removeEventListener('keydown', this.boundKeyDown);
this.rootEl?.remove();
this.resolveCallback(decision);
}
}
}
+133 -22
View File
@@ -1,14 +1,20 @@
import type { App, Component } from 'obsidian';
import { MarkdownRenderer, Notice } from 'obsidian';
import { isSubagentToolName, isWriteEditTool, TOOL_AGENT_OUTPUT } from '../../../core/tools/toolNames';
import { DEFAULT_CHAT_PROVIDER_ID, type ProviderCapabilities } from '../../../core/providers/types';
import {
isSubagentToolName,
isWriteEditTool,
TOOL_AGENT_OUTPUT,
} from '../../../core/tools/toolNames';
import type { ChatMessage, ImageAttachment, SubagentInfo, ToolCallInfo } from '../../../core/types';
import { t } from '../../../i18n';
import { t } from '../../../i18n/i18n';
import type ClaudianPlugin from '../../../main';
import { formatDurationMmSs } from '../../../utils/date';
import { processFileLinks, registerFileLinkHandler } from '../../../utils/fileLink';
import { replaceImageEmbedsWithHtml } from '../../../utils/imageEmbed';
import { findRewindContext } from '../rewind';
import { resolveSubagentLifecycleAdapter } from './subagentLifecycleResolution';
import {
renderStoredAsyncSubagent,
renderStoredSubagent,
@@ -25,6 +31,7 @@ export class MessageRenderer {
private component: Component;
private messagesEl: HTMLElement;
private rewindCallback?: (messageId: string) => Promise<void>;
private getCapabilities: () => ProviderCapabilities;
private forkCallback?: (messageId: string) => Promise<void>;
private liveMessageEls = new Map<string, HTMLElement>();
@@ -38,6 +45,7 @@ export class MessageRenderer {
messagesEl: HTMLElement,
rewindCallback?: (messageId: string) => Promise<void>,
forkCallback?: (messageId: string) => Promise<void>,
getCapabilities?: () => ProviderCapabilities,
) {
this.app = plugin.app;
this.plugin = plugin;
@@ -45,6 +53,20 @@ export class MessageRenderer {
this.messagesEl = messagesEl;
this.rewindCallback = rewindCallback;
this.forkCallback = forkCallback;
this.getCapabilities = getCapabilities ?? (() => ({
providerId: DEFAULT_CHAT_PROVIDER_ID,
supportsPersistentRuntime: false,
supportsNativeHistory: false,
supportsPlanMode: false,
supportsRewind: false,
supportsFork: false,
supportsProviderCommands: false,
supportsImageAttachments: false,
supportsInstructionMode: false,
supportsMcpTools: false,
supportsTurnSteer: false,
reasoningControl: 'none' as const,
}));
// Register delegated click handler for file links
registerFileLinkHandler(this.app, this.messagesEl, this.component);
@@ -55,6 +77,10 @@ export class MessageRenderer {
this.messagesEl = el;
}
private getSubagentLifecycleAdapter(toolName?: string) {
return resolveSubagentLifecycleAdapter(this.getCapabilities().providerId, toolName);
}
// ============================================
// Streaming Message Rendering
// ============================================
@@ -105,6 +131,51 @@ export class MessageRenderer {
return msgEl;
}
updateLiveUserMessage(msg: ChatMessage): void {
if (msg.role !== 'user') {
return;
}
const msgEl = this.liveMessageEls.get(msg.id)
?? this.messagesEl.querySelector(`[data-message-id="${msg.id}"]`) as HTMLElement | null;
if (!msgEl) {
return;
}
const contentEl = msgEl.querySelector('.claudian-message-content') as HTMLElement | null;
if (!contentEl) {
return;
}
contentEl.empty();
const textToShow = msg.displayContent ?? msg.content;
if (textToShow) {
const textEl = contentEl.createDiv({ cls: 'claudian-text-block' });
void this.renderContent(textEl, textToShow);
}
const toolbar = msgEl.querySelector('.claudian-user-msg-actions') as HTMLElement | null;
if (toolbar) {
toolbar.querySelectorAll('.claudian-user-msg-copy-btn').forEach((el) => el.remove());
}
if (textToShow) {
this.addUserCopyButton(msgEl, textToShow);
}
}
removeMessage(messageId: string): void {
const msgEl = this.liveMessageEls.get(messageId)
?? this.messagesEl.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement | null;
if (!msgEl) {
return;
}
msgEl.remove();
this.liveMessageEls.delete(messageId);
}
// ============================================
// Stored Message Rendering (Batch/Replay)
// ============================================
@@ -135,8 +206,10 @@ export class MessageRenderer {
}
renderStoredMessage(msg: ChatMessage, allMessages?: ChatMessage[], index?: number): void {
// Render interrupt messages with special styling (not as user bubbles)
if (msg.isInterrupt) {
// Bare interrupt marker: user-role interrupts (Claude bracket markers) always render
// as a standalone indicator. Assistant-role interrupts (Codex partial responses)
// only use the bare marker when there's no content to preserve.
if (msg.isInterrupt && (msg.role === 'user' || !this.hasVisibleContent(msg))) {
this.renderInterruptMessage();
return;
}
@@ -177,7 +250,7 @@ export class MessageRenderer {
void this.renderContent(textEl, textToShow);
this.addUserCopyButton(msgEl, textToShow);
}
if (msg.sdkUserUuid && this.isRewindEligible(allMessages, index)) {
if (msg.userMessageId && this.isRewindEligible(allMessages, index)) {
if (this.rewindCallback) {
this.addRewindButton(msgEl, msg.id);
}
@@ -187,22 +260,32 @@ export class MessageRenderer {
}
} else if (msg.role === 'assistant') {
this.renderAssistantContent(msg, contentEl);
if (msg.isInterrupt) {
this.appendInterruptIndicator(contentEl);
}
}
}
private hasVisibleContent(msg: ChatMessage): boolean {
if (msg.content && msg.content.trim().length > 0) return true;
if (msg.toolCalls && msg.toolCalls.length > 0) return true;
if (msg.contentBlocks && msg.contentBlocks.length > 0) return true;
return false;
}
private isRewindEligible(allMessages?: ChatMessage[], index?: number): boolean {
if (!allMessages || index === undefined) return false;
const ctx = findRewindContext(allMessages, index);
return !!ctx.prevAssistantUuid && ctx.hasResponse;
}
/**
* Renders an interrupt indicator (stored interrupts from SDK history).
* Uses the same styling as streaming interrupts.
*/
private renderInterruptMessage(): void {
const msgEl = this.messagesEl.createDiv({ cls: 'claudian-message claudian-message-assistant' });
const contentEl = msgEl.createDiv({ cls: 'claudian-message-content', attr: { dir: 'auto' } });
this.appendInterruptIndicator(contentEl);
}
private appendInterruptIndicator(contentEl: HTMLElement): void {
const textEl = contentEl.createDiv({ cls: 'claudian-text-block' });
textEl.innerHTML = '<span class="claudian-interrupted">Interrupted</span> <span class="claudian-interrupted-hint">· What should Claudian do instead?</span>';
}
@@ -232,10 +315,10 @@ export class MessageRenderer {
} else if (block.type === 'tool_use') {
const toolCall = msg.toolCalls?.find(tc => tc.id === block.toolId);
if (toolCall) {
this.renderToolCall(contentEl, toolCall);
this.renderToolCall(contentEl, toolCall, msg);
renderedToolIds.add(toolCall.id);
}
} else if (block.type === 'compact_boundary') {
} else if (block.type === 'context_compacted') {
const boundaryEl = contentEl.createDiv({ cls: 'claudian-compact-boundary' });
boundaryEl.createSpan({ cls: 'claudian-compact-boundary-label', text: 'Conversation compacted' });
} else if (block.type === 'subagent') {
@@ -253,7 +336,7 @@ export class MessageRenderer {
if (msg.toolCalls && msg.toolCalls.length > 0) {
for (const toolCall of msg.toolCalls) {
if (renderedToolIds.has(toolCall.id)) continue;
this.renderToolCall(contentEl, toolCall);
this.renderToolCall(contentEl, toolCall, msg);
renderedToolIds.add(toolCall.id);
}
}
@@ -266,13 +349,13 @@ export class MessageRenderer {
}
if (msg.toolCalls) {
for (const toolCall of msg.toolCalls) {
this.renderToolCall(contentEl, toolCall);
this.renderToolCall(contentEl, toolCall, msg);
}
}
}
// Render response duration footer (skip when message contains a compaction boundary)
const hasCompactBoundary = msg.contentBlocks?.some(b => b.type === 'compact_boundary');
const hasCompactBoundary = msg.contentBlocks?.some(b => b.type === 'context_compacted');
if (msg.durationSeconds && msg.durationSeconds > 0 && !hasCompactBoundary) {
const flavorWord = msg.durationFlavorWord || 'Baked';
const footerEl = contentEl.createDiv({ cls: 'claudian-response-footer' });
@@ -284,18 +367,22 @@ export class MessageRenderer {
}
/**
* Renders a tool call with special handling for Write/Edit and Agent (subagent).
* TaskOutput is hidden as it's an internal tool for async subagent communication.
* Renders a tool call with special handling for Write/Edit, Agent (subagent),
* and Codex collab agent lifecycle tools.
*/
private renderToolCall(contentEl: HTMLElement, toolCall: ToolCallInfo): void {
// Skip TaskOutput - it's invisible (internal async subagent communication)
if (toolCall.name === TOOL_AGENT_OUTPUT) {
return;
}
private renderToolCall(contentEl: HTMLElement, toolCall: ToolCallInfo, msg?: ChatMessage): void {
const subagentLifecycleAdapter = this.getSubagentLifecycleAdapter(toolCall.name);
// Skip invisible internal tools
if (toolCall.name === TOOL_AGENT_OUTPUT) return;
if (subagentLifecycleAdapter?.isHiddenTool(toolCall.name)) return;
if (isWriteEditTool(toolCall.name)) {
renderStoredWriteEdit(contentEl, toolCall);
} else if (isSubagentToolName(toolCall.name)) {
this.renderTaskSubagent(contentEl, toolCall);
} else if (subagentLifecycleAdapter?.isSpawnTool(toolCall.name) && msg) {
this.renderProviderLifecycleSubagent(contentEl, toolCall, msg);
} else {
renderStoredToolCall(contentEl, toolCall);
}
@@ -314,6 +401,28 @@ export class MessageRenderer {
renderStoredSubagent(contentEl, subagentInfo);
}
/**
* Consolidates provider lifecycle tools (spawn + wait/close)
* into a single subagent block with prompt and result.
*/
private renderProviderLifecycleSubagent(
contentEl: HTMLElement,
spawnToolCall: ToolCallInfo,
msg: ChatMessage,
): void {
const subagentLifecycleAdapter = this.getSubagentLifecycleAdapter(spawnToolCall.name);
if (!subagentLifecycleAdapter) {
renderStoredToolCall(contentEl, spawnToolCall);
return;
}
const subagentInfo = subagentLifecycleAdapter.buildSubagentInfo(
spawnToolCall,
msg.toolCalls ?? [],
);
renderStoredSubagent(contentEl, subagentInfo);
}
private resolveTaskSubagent(toolCall: ToolCallInfo, modeHint?: 'sync' | 'async'): SubagentInfo {
if (toolCall.subagent) {
if (!modeHint || toolCall.subagent.mode === modeHint) {
@@ -577,7 +686,7 @@ export class MessageRenderer {
}
refreshActionButtons(msg: ChatMessage, allMessages?: ChatMessage[], index?: number): void {
if (!msg.sdkUserUuid) return;
if (!msg.userMessageId) return;
if (!this.isRewindEligible(allMessages, index)) return;
const msgEl = this.liveMessageEls.get(msg.id);
if (!msgEl) return;
@@ -633,6 +742,7 @@ export class MessageRenderer {
}
private addRewindButton(msgEl: HTMLElement, messageId: string): void {
if (!this.getCapabilities().supportsRewind) return;
const toolbar = this.getOrCreateActionsToolbar(msgEl);
const btn = toolbar.createSpan({ cls: 'claudian-message-rewind-btn' });
if (toolbar.firstChild !== btn) toolbar.insertBefore(btn, toolbar.firstChild);
@@ -649,6 +759,7 @@ export class MessageRenderer {
}
private addForkButton(msgEl: HTMLElement, messageId: string): void {
if (!this.getCapabilities().supportsFork) return;
const toolbar = this.getOrCreateActionsToolbar(msgEl);
const btn = toolbar.createSpan({ cls: 'claudian-message-fork-btn' });
if (toolbar.firstChild !== btn) toolbar.insertBefore(btn, toolbar.firstChild);
@@ -1,6 +1,7 @@
import { setIcon } from 'obsidian';
import { getToolIcon, TOOL_TASK } from '../../../core/tools';
import { getToolIcon } from '../../../core/tools/toolIcons';
import { TOOL_TASK } from '../../../core/tools/toolNames';
import type { SubagentInfo, ToolCallInfo } from '../../../core/types';
import { setupCollapsible } from './collapsible';
import {
@@ -98,13 +99,13 @@ function updateSyncHeaderAria(state: SubagentState): void {
function renderSubagentToolContent(contentEl: HTMLElement, toolCall: ToolCallInfo): void {
contentEl.empty();
if (!toolCall.result) {
if (!toolCall.result && toolCall.status === 'running') {
const emptyEl = contentEl.createDiv({ cls: 'claudian-subagent-tool-empty' });
emptyEl.setText(toolCall.status === 'running' ? 'Running...' : 'No output recorded');
emptyEl.setText('Running...');
return;
}
renderExpandedContent(contentEl, toolCall.name, toolCall.result);
renderExpandedContent(contentEl, toolCall.name, toolCall.result, toolCall.input);
}
function setSubagentToolStatus(view: SubagentToolView, status: ToolCallInfo['status']): void {
+436 -38
View File
@@ -1,8 +1,11 @@
import { setIcon } from 'obsidian';
import { extractResolvedAnswersFromResultText, type TodoItem } from '../../../core/tools';
import type { TodoItem } from '../../../core/tools/todo';
import { getToolIcon, MCP_ICON_MARKER } from '../../../core/tools/toolIcons';
import { extractResolvedAnswersFromResultText } from '../../../core/tools/toolInput';
import {
isAgentLifecycleTool,
TOOL_APPLY_PATCH,
TOOL_ASK_USER_QUESTION,
TOOL_BASH,
TOOL_EDIT,
@@ -18,10 +21,13 @@ import {
TOOL_WEB_FETCH,
TOOL_WEB_SEARCH,
TOOL_WRITE,
TOOL_WRITE_STDIN,
} from '../../../core/tools/toolNames';
import type { ToolCallInfo } from '../../../core/types';
import type { AskUserQuestionItem, AskUserQuestionOption, ToolCallInfo } from '../../../core/types';
import { MCP_ICON_SVG } from '../../../shared/icons';
import { parseApplyPatchDiffs } from '../../../utils/diff';
import { setupCollapsible } from './collapsible';
import { renderDiffContent } from './DiffRenderer';
import { renderTodoItems } from './todoUtils';
export function setToolIcon(el: HTMLElement, name: string): void {
@@ -68,7 +74,7 @@ export function getToolSummary(name: string, input: Record<string, unknown>): st
case TOOL_GREP:
return (input.pattern as string) || '';
case TOOL_WEB_SEARCH:
return truncateText((input.query as string) || '', 60);
return getWebSearchSummary(input, 60);
case TOOL_WEB_FETCH:
return truncateText((input.url as string) || '', 60);
case TOOL_LS:
@@ -79,7 +85,14 @@ export function getToolSummary(name: string, input: Record<string, unknown>): st
return truncateText(parseToolSearchQuery(input.query as string | undefined), 60);
case TOOL_TODO_WRITE:
return '';
case TOOL_APPLY_PATCH:
return getApplyPatchSummary(input);
case TOOL_WRITE_STDIN:
return getWriteStdinSummary(input);
default:
if (isAgentLifecycleTool(name)) {
return getAgentLifecycleSummary(name, input);
}
return '';
}
}
@@ -102,8 +115,7 @@ export function getToolLabel(name: string, input: Record<string, unknown>): stri
case TOOL_GREP:
return `Grep: ${input.pattern || 'pattern'}`;
case TOOL_WEB_SEARCH: {
const query = (input.query as string) || 'search';
return `WebSearch: ${query.length > 40 ? query.substring(0, 40) + '...' : query}`;
return getWebSearchLabel(input, 40);
}
case TOOL_WEB_FETCH: {
const url = (input.url as string) || 'url';
@@ -131,7 +143,19 @@ export function getToolLabel(name: string, input: Record<string, unknown>): stri
return 'Entering plan mode';
case TOOL_EXIT_PLAN_MODE:
return 'Plan complete';
case TOOL_APPLY_PATCH: {
const summary = getApplyPatchSummary(input);
return summary ? `apply_patch: ${summary}` : 'apply_patch';
}
case TOOL_WRITE_STDIN: {
const summary = getWriteStdinSummary(input);
return summary ? `write_stdin: ${summary}` : 'write_stdin';
}
default:
if (isAgentLifecycleTool(name)) {
const summary = getAgentLifecycleSummary(name, input);
return summary ? `${name}: ${summary}` : name;
}
return name;
}
}
@@ -142,6 +166,62 @@ export function fileNameOnly(filePath: string): string {
return normalized.split('/').pop() ?? normalized;
}
function getApplyPatchSummary(input: Record<string, unknown>): string {
// Extract file paths from patch text markers
const patchText = typeof input.patch === 'string' ? input.patch : '';
const patchFiles = [...patchText.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)]
.map(m => m[1]?.trim() ?? '');
// Also check changes array
const changes = input.changes;
const changeFiles = Array.isArray(changes)
? (changes as Array<{ path?: string }>)
.map(c => c.path)
.filter((p): p is string => !!p)
: [];
const files = [...new Set([...patchFiles, ...changeFiles])];
if (files.length === 0) return patchText ? 'patch' : '';
if (files.length === 1) return fileNameOnly(files[0]);
return `${files.length} files`;
}
function getWriteStdinSummary(input: Record<string, unknown>): string {
const sessionId = input.session_id ?? input.sessionId;
const chars = typeof input.chars === 'string' ? input.chars.replace(/\n/g, '\\n') : '';
if (chars) {
const preview = chars.length > 24 ? `${chars.slice(0, 24)}...` : chars;
return sessionId ? `#${String(sessionId)} ${preview}` : preview;
}
return sessionId ? `#${String(sessionId)}` : '';
}
function getAgentLifecycleSummary(name: string, input: Record<string, unknown>): string {
switch (name) {
case 'spawn_agent': {
const msg = typeof input.message === 'string' ? input.message : '';
return msg.length > 50 ? `${msg.slice(0, 50)}...` : msg;
}
case 'send_input': {
const msg = typeof input.message === 'string' ? input.message : '';
return msg.length > 40 ? `${msg.slice(0, 40)}...` : msg;
}
case 'wait': {
const ids = Array.isArray(input.ids) ? input.ids.length : 0;
const timeoutMs = typeof input.timeout_ms === 'number' ? input.timeout_ms : undefined;
const parts: string[] = [];
if (ids > 0) parts.push(`${ids} agent${ids === 1 ? '' : 's'}`);
if (timeoutMs !== undefined) parts.push(`${Math.round(timeoutMs / 1000)}s`);
return parts.join(', ');
}
case 'resume_agent':
case 'close_agent':
return '';
default:
return '';
}
}
function shortenPath(filePath: string | undefined): string {
if (!filePath) return '';
const normalized = filePath.replace(/\\/g, '/');
@@ -167,6 +247,77 @@ interface WebSearchLink {
url: string;
}
interface WebSearchDisplayData {
actionType: string;
query: string;
queries: string[];
url: string;
pattern: string;
}
function normalizeWebSearchDisplayData(input: Record<string, unknown>): WebSearchDisplayData {
const queries = Array.isArray(input.queries)
? input.queries
.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
.map(entry => entry.trim())
: [];
const query = typeof input.query === 'string' && input.query.trim()
? input.query.trim()
: queries[0] ?? '';
const url = typeof input.url === 'string' && input.url.trim() ? input.url.trim() : '';
const pattern = typeof input.pattern === 'string' && input.pattern.trim() ? input.pattern.trim() : '';
const explicitActionType = typeof input.actionType === 'string' && input.actionType.trim()
? input.actionType.trim()
: '';
const actionType = explicitActionType
|| (url && pattern ? 'find_in_page' : url ? 'open_page' : (query || queries.length > 0) ? 'search' : '');
return { actionType, query, queries, url, pattern };
}
function getWebSearchSummary(input: Record<string, unknown>, maxLength: number): string {
const data = normalizeWebSearchDisplayData(input);
switch (data.actionType) {
case 'open_page':
return truncateText(`Open ${data.url || 'page'}`, maxLength);
case 'find_in_page': {
const target = data.pattern ? `Find "${data.pattern}"` : 'Find in page';
const suffix = data.url ? ` in ${data.url}` : '';
return truncateText(target + suffix, maxLength);
}
case 'search':
return truncateText(data.query || data.queries[0] || '', maxLength);
default:
return truncateText(data.query || data.url || data.pattern || '', maxLength);
}
}
function getWebSearchLabel(input: Record<string, unknown>, maxLength: number): string {
const summary = getWebSearchSummary(input, maxLength);
return `WebSearch: ${summary || 'search'}`;
}
function appendToolLink(parent: HTMLElement, title: string, url: string): void {
const linkEl = parent.createEl('a', { cls: 'claudian-tool-link' });
linkEl.setAttribute('href', url);
linkEl.setAttribute('target', '_blank');
linkEl.setAttribute('rel', 'noopener noreferrer');
const iconEl = linkEl.createSpan({ cls: 'claudian-tool-link-icon' });
setIcon(iconEl, 'external-link');
linkEl.createSpan({ cls: 'claudian-tool-link-title', text: title });
}
function isPlaceholderWebSearchResult(result: string | undefined): boolean {
if (!result) return true;
const normalized = result.trim().toLowerCase();
return normalized === '' || normalized === 'search complete';
}
function parseWebSearchResult(result: string): { links: WebSearchLink[]; summary: string } | null {
const linksMatch = result.match(/Links:\s*(\[[\s\S]*?\])(?:\n|$)/);
if (!linksMatch) return null;
@@ -183,30 +334,103 @@ function parseWebSearchResult(result: string): { links: WebSearchLink[]; summary
}
}
function renderWebSearchExpanded(container: HTMLElement, result: string): void {
const parsed = parseWebSearchResult(result);
if (!parsed || parsed.links.length === 0) {
function renderWebSearchActionExpanded(container: HTMLElement, input: Record<string, unknown>): boolean {
const data = normalizeWebSearchDisplayData(input);
const hasStructuredData = Boolean(data.actionType || data.query || data.queries.length || data.url || data.pattern);
if (!hasStructuredData) {
return false;
}
const linesEl = container.createDiv({ cls: 'claudian-tool-lines' });
switch (data.actionType) {
case 'open_page':
linesEl.createDiv({ cls: 'claudian-tool-line', text: 'Open page' });
if (data.url) {
appendToolLink(linesEl, data.url, data.url);
} else {
linesEl.createDiv({ cls: 'claudian-tool-line', text: 'URL unavailable' });
}
return true;
case 'find_in_page':
linesEl.createDiv({ cls: 'claudian-tool-line', text: 'Find in page' });
if (data.url) {
appendToolLink(linesEl, data.url, data.url);
} else {
linesEl.createDiv({ cls: 'claudian-tool-line', text: 'URL unavailable' });
}
if (data.pattern) {
linesEl.createDiv({ cls: 'claudian-tool-line', text: `Pattern: ${data.pattern}` });
}
return true;
case 'search':
default: {
const primaryQuery = data.query || data.queries[0];
linesEl.createDiv({
cls: 'claudian-tool-line',
text: primaryQuery ? `Query: ${primaryQuery}` : 'Search web',
});
const alternateQueries = data.queries.filter(query => query !== primaryQuery);
for (const query of alternateQueries.slice(0, 4)) {
linesEl.createDiv({ cls: 'claudian-tool-line', text: `Alt query: ${query}` });
}
if (alternateQueries.length > 4) {
linesEl.createDiv({
cls: 'claudian-tool-truncated',
text: `... ${alternateQueries.length - 4} more queries`,
});
}
return true;
}
}
}
function renderWebSearchExpanded(
container: HTMLElement,
input: Record<string, unknown>,
result: string | undefined,
): void {
const parsed = result ? parseWebSearchResult(result) : null;
if (parsed && parsed.links.length > 0) {
const linksEl = container.createDiv({ cls: 'claudian-tool-lines' });
for (const link of parsed.links) {
appendToolLink(linksEl, link.title, link.url);
}
if (parsed.summary) {
const summaryEl = container.createDiv({ cls: 'claudian-tool-web-summary' });
summaryEl.setText(parsed.summary.length > 800 ? parsed.summary.slice(0, 800) + '...' : parsed.summary);
}
return;
}
const data = normalizeWebSearchDisplayData(input);
const shouldRenderAction = Boolean(data.actionType || data.query || data.queries.length || data.url || data.pattern)
&& (!result
|| isPlaceholderWebSearchResult(result)
|| data.actionType === 'open_page'
|| data.actionType === 'find_in_page');
if (shouldRenderAction && renderWebSearchActionExpanded(container, input)) {
if (result && !isPlaceholderWebSearchResult(result)) {
renderLinesExpanded(container, result, 12);
}
return;
}
if (result) {
renderLinesExpanded(container, result, 20);
return;
}
const linksEl = container.createDiv({ cls: 'claudian-tool-lines' });
for (const link of parsed.links) {
const linkEl = linksEl.createEl('a', { cls: 'claudian-tool-link' });
linkEl.setAttribute('href', link.url);
linkEl.setAttribute('target', '_blank');
linkEl.setAttribute('rel', 'noopener noreferrer');
const iconEl = linkEl.createSpan({ cls: 'claudian-tool-link-icon' });
setIcon(iconEl, 'external-link');
linkEl.createSpan({ cls: 'claudian-tool-link-title', text: link.title });
if (renderWebSearchActionExpanded(container, input)) {
return;
}
if (parsed.summary) {
const summaryEl = container.createDiv({ cls: 'claudian-tool-web-summary' });
summaryEl.setText(parsed.summary.length > 800 ? parsed.summary.slice(0, 800) + '...' : parsed.summary);
}
container.createDiv({ cls: 'claudian-tool-empty', text: 'No result' });
}
function renderFileSearchExpanded(container: HTMLElement, result: string): void {
@@ -288,35 +512,150 @@ function renderWebFetchExpanded(container: HTMLElement, result: string): void {
}
}
export function renderExpandedContent(container: HTMLElement, toolName: string, result: string | undefined): void {
if (!result) {
function renderApplyPatchExpanded(
container: HTMLElement,
input: Record<string, unknown>,
result: string | undefined,
): void {
const patchText = typeof input.patch === 'string' ? input.patch : '';
const parsedDiffs = patchText ? parseApplyPatchDiffs(patchText) : [];
if (result && /verification failed|^[Ee]rror:/.test(result.trim())) {
renderLinesExpanded(container, result, 20);
}
if (parsedDiffs.length > 0) {
for (const fileDiff of parsedDiffs) {
const sectionEl = container.createDiv({ cls: 'claudian-tool-patch-section' });
const statsSuffix = fileDiff.stats.added || fileDiff.stats.removed
? ` (+${fileDiff.stats.added} -${fileDiff.stats.removed})`
: '';
const pathText = fileDiff.movedTo
? `${fileDiff.filePath} -> ${fileDiff.movedTo}`
: fileDiff.filePath;
sectionEl.createDiv({
cls: 'claudian-tool-patch-header',
text: `${fileDiff.operation}: ${pathText}${statsSuffix}`,
});
if (fileDiff.operation === 'delete' && fileDiff.diffLines.length === 0) {
sectionEl.createDiv({ cls: 'claudian-tool-empty', text: 'File deleted' });
continue;
}
if (fileDiff.diffLines.length === 0) {
sectionEl.createDiv({ cls: 'claudian-tool-empty', text: 'No textual diff available' });
continue;
}
const diffRow = sectionEl.createDiv({ cls: 'claudian-write-edit-diff-row' });
const diffEl = diffRow.createDiv({ cls: 'claudian-write-edit-diff' });
renderDiffContent(diffEl, fileDiff.diffLines);
}
return;
}
const changes = Array.isArray(input.changes) ? input.changes : [];
if (changes.length > 0) {
const linesEl = container.createDiv({ cls: 'claudian-tool-lines' });
for (const change of changes) {
if (!change || typeof change !== 'object') continue;
const path = typeof change.path === 'string' ? change.path : '';
const kind = typeof change.kind === 'string' ? change.kind : 'change';
if (!path) continue;
linesEl.createDiv({ cls: 'claudian-tool-line', text: `${kind}: ${path}` });
}
return;
}
if (patchText) {
renderLinesExpanded(container, patchText, 80);
return;
}
if (result) {
const fileMatches = [...result.matchAll(/(?:update|add|delete|create|modify|Applied:\s*)(?:\w+:\s*)?([^\n,]+)/gi)];
if (fileMatches.length > 0) {
const linesEl = container.createDiv({ cls: 'claudian-tool-lines' });
for (const match of fileMatches) {
const filePath = match[1]?.trim();
if (filePath) {
const lineEl = linesEl.createDiv({ cls: 'claudian-tool-line' });
lineEl.setText(filePath);
}
}
return;
}
renderLinesExpanded(container, result, 20);
return;
}
container.createDiv({ cls: 'claudian-tool-empty', text: 'No result' });
}
function renderAgentLifecycleExpanded(container: HTMLElement, result: string): void {
// Try to parse as JSON for structured display
const trimmed = result.trim();
if (trimmed.startsWith('{')) {
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const linesEl = container.createDiv({ cls: 'claudian-tool-lines' });
for (const [key, value] of Object.entries(parsed)) {
const lineEl = linesEl.createDiv({ cls: 'claudian-tool-line' });
const displayValue = typeof value === 'object' ? JSON.stringify(value) : String(value);
lineEl.setText(`${key}: ${displayValue}`);
}
return;
} catch { /* fall through to plain text */ }
}
renderLinesExpanded(container, result, 20);
}
export function renderExpandedContent(
container: HTMLElement,
toolName: string,
result: string | undefined,
input: Record<string, unknown> = {},
): void {
if (!result && toolName !== TOOL_WEB_SEARCH) {
container.createDiv({ cls: 'claudian-tool-empty', text: 'No result' });
return;
}
const resolvedResult = result ?? '';
if (isAgentLifecycleTool(toolName)) {
renderAgentLifecycleExpanded(container, resolvedResult);
return;
}
switch (toolName) {
case TOOL_BASH:
renderLinesExpanded(container, result, 20);
case TOOL_WRITE_STDIN:
renderLinesExpanded(container, resolvedResult, 20);
break;
case TOOL_READ:
renderLinesExpanded(container, result, 15);
renderLinesExpanded(container, resolvedResult, 15);
break;
case TOOL_GLOB:
case TOOL_GREP:
case TOOL_LS:
renderFileSearchExpanded(container, result);
renderFileSearchExpanded(container, resolvedResult);
break;
case TOOL_WEB_SEARCH:
renderWebSearchExpanded(container, result);
renderWebSearchExpanded(container, input, result);
break;
case TOOL_WEB_FETCH:
renderWebFetchExpanded(container, result);
renderWebFetchExpanded(container, resolvedResult);
break;
case TOOL_TOOL_SEARCH:
renderToolSearchExpanded(container, result);
renderToolSearchExpanded(container, resolvedResult);
break;
case TOOL_APPLY_PATCH:
renderApplyPatchExpanded(container, input, result);
break;
default:
renderLinesExpanded(container, result, 20);
renderLinesExpanded(container, resolvedResult, 20);
break;
}
}
@@ -386,7 +725,6 @@ export function renderTodoWriteResult(
export function isBlockedToolResult(content: string, isError?: boolean): boolean {
const lower = content.toLowerCase();
if (lower.includes('blocked by blocklist')) return true;
if (lower.includes('outside the vault')) return true;
if (lower.includes('access denied')) return true;
if (lower.includes('user denied')) return true;
@@ -457,14 +795,16 @@ function resolveAskUserAnswers(toolCall: ToolCallInfo): Record<string, unknown>
function renderAskUserQuestionResult(container: HTMLElement, toolCall: ToolCallInfo): boolean {
container.empty();
const questions = toolCall.input.questions as Array<{ question: string }> | undefined;
const questions = toolCall.input.questions as AskUserQuestionItem[] | undefined;
const answers = resolveAskUserAnswers(toolCall);
if (!questions || !Array.isArray(questions) || !answers) return false;
const reviewEl = container.createDiv({ cls: 'claudian-ask-review' });
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
const answer = formatAnswer(answers[q.question]);
const answer = formatAnswer(
(q.id ? answers[q.id] : undefined) ?? answers[q.question]
);
const pairEl = reviewEl.createDiv({ cls: 'claudian-ask-review-pair' });
pairEl.createDiv({ text: `${i + 1}.`, cls: 'claudian-ask-review-num' });
const bodyEl = pairEl.createDiv({ cls: 'claudian-ask-review-body' });
@@ -479,7 +819,65 @@ function renderAskUserQuestionResult(container: HTMLElement, toolCall: ToolCallI
}
function renderAskUserQuestionFallback(container: HTMLElement, toolCall: ToolCallInfo, initialText?: string): void {
contentFallback(container, initialText || toolCall.result || 'Waiting for answer...');
container.empty();
const questions = Array.isArray(toolCall.input.questions)
? toolCall.input.questions as AskUserQuestionItem[]
: [];
if (questions.length === 0) {
contentFallback(container, initialText || toolCall.result || 'Waiting for answer...');
return;
}
if (initialText || toolCall.result) {
container.createDiv({
cls: 'claudian-ask-review-prompt',
text: initialText || toolCall.result || 'Waiting for answer...',
});
}
for (let questionIndex = 0; questionIndex < questions.length; questionIndex++) {
const question = questions[questionIndex];
const reviewEl = container.createDiv({ cls: 'claudian-ask-review' });
const pairEl = reviewEl.createDiv({ cls: 'claudian-ask-review-pair' });
pairEl.createDiv({ text: `${questionIndex + 1}.`, cls: 'claudian-ask-review-num' });
const bodyEl = pairEl.createDiv({ cls: 'claudian-ask-review-body' });
bodyEl.createDiv({ text: question.question, cls: 'claudian-ask-review-q-text' });
if (!Array.isArray(question.options) || question.options.length === 0) {
bodyEl.createDiv({ cls: 'claudian-ask-review-empty', text: 'No options recorded' });
continue;
}
const listEl = bodyEl.createDiv({ cls: 'claudian-ask-list' });
question.options.forEach((option, optionIndex) => {
renderAskUserQuestionOption(listEl, option, optionIndex, question.multiSelect === true);
});
}
}
function renderAskUserQuestionOption(
parentEl: HTMLElement,
option: AskUserQuestionOption,
optionIndex: number,
isMultiSelect: boolean,
): void {
const itemEl = parentEl.createDiv({ cls: 'claudian-ask-item is-disabled' });
if (isMultiSelect) {
itemEl.createDiv({ cls: 'claudian-ask-check', text: '[ ] ' });
} else {
itemEl.createDiv({ cls: 'claudian-ask-item-num', text: `${optionIndex + 1}. ` });
}
const contentEl = itemEl.createDiv({ cls: 'claudian-ask-item-content' });
const labelRowEl = contentEl.createDiv({ cls: 'claudian-ask-label-row' });
labelRowEl.createDiv({ cls: 'claudian-ask-item-label', text: option.label });
if (option.description) {
contentEl.createDiv({ cls: 'claudian-ask-item-desc', text: option.description });
}
}
function contentFallback(container: HTMLElement, text: string): void {
@@ -534,7 +932,7 @@ function renderToolContent(
} else if (initialText) {
contentFallback(content, initialText);
} else {
renderExpandedContent(content, toolCall.name, toolCall.result);
renderExpandedContent(content, toolCall.name, toolCall.result, toolCall.input);
}
}
@@ -616,7 +1014,7 @@ export function updateToolCallResult(
const content = toolEl.querySelector('.claudian-tool-content') as HTMLElement;
if (content) {
content.empty();
renderExpandedContent(content, toolCall.name, toolCall.result);
renderExpandedContent(content, toolCall.name, toolCall.result, toolCall.input);
}
}
@@ -1,6 +1,6 @@
import { setIcon } from 'obsidian';
import { getToolIcon } from '../../../core/tools';
import { getToolIcon } from '../../../core/tools/toolIcons';
import type { ToolCallInfo, ToolDiffData } from '../../../core/types';
import type { DiffLine, DiffStats } from '../../../core/types/diff';
import { setupCollapsible } from './collapsible';
-46
View File
@@ -1,46 +0,0 @@
export { MessageRenderer } from './MessageRenderer';
export {
addSubagentToolCall,
type AsyncSubagentState,
createAsyncSubagentBlock,
createSubagentBlock,
finalizeAsyncSubagent,
finalizeSubagentBlock,
markAsyncSubagentOrphaned,
renderStoredAsyncSubagent,
renderStoredSubagent,
type SubagentState,
updateAsyncSubagentRunning,
updateSubagentToolResult,
} from './SubagentRenderer';
export {
appendThinkingContent,
cleanupThinkingBlock,
createThinkingBlock,
finalizeThinkingBlock,
type RenderContentFn,
renderStoredThinkingBlock,
type ThinkingBlockState,
} from './ThinkingBlockRenderer';
export {
extractLastTodosFromMessages,
parseTodoInput,
type TodoItem,
} from './TodoListRenderer';
export {
getToolLabel,
getToolName,
getToolSummary,
isBlockedToolResult,
renderStoredToolCall,
renderToolCall,
setToolIcon,
updateToolCallResult,
} from './ToolCallRenderer';
export {
createWriteEditBlock,
finalizeWriteEditBlock,
renderStoredWriteEdit,
updateWriteEditWithDiff,
type WriteEditState,
} from './WriteEditRenderer';
@@ -0,0 +1,25 @@
import { ProviderRegistry } from '../../../core/providers/ProviderRegistry';
import type { ProviderId, ProviderSubagentLifecycleAdapter } from '../../../core/providers/types';
/**
* Resolves the lifecycle adapter owned by the active provider.
*/
export function resolveSubagentLifecycleAdapter(
activeProviderId: ProviderId,
toolName?: string,
): ProviderSubagentLifecycleAdapter | null {
const activeAdapter = ProviderRegistry.getSubagentLifecycleAdapter(activeProviderId);
if (!toolName) {
return activeAdapter;
}
return activeAdapter && adapterOwnsTool(activeAdapter, toolName) ? activeAdapter : null;
}
function adapterOwnsTool(adapter: ProviderSubagentLifecycleAdapter, toolName: string): boolean {
return adapter.isSpawnTool(toolName)
|| adapter.isHiddenTool(toolName)
|| adapter.isWaitTool(toolName)
|| adapter.isCloseTool(toolName);
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { setIcon } from 'obsidian';
import type { TodoItem } from '../../../core/tools';
import type { TodoItem } from '../../../core/tools/todo';
export function getTodoStatusIcon(status: TodoItem['status']): string {
return status === 'completed' ? 'check' : 'dot';
+3 -3
View File
@@ -12,8 +12,8 @@ export interface RewindContext {
export function findRewindContext(messages: ChatMessage[], userIndex: number): RewindContext {
let prevAssistantUuid: string | undefined;
for (let i = userIndex - 1; i >= 0; i--) {
if (messages[i].role === 'assistant' && messages[i].sdkAssistantUuid) {
prevAssistantUuid = messages[i].sdkAssistantUuid;
if (messages[i].role === 'assistant' && messages[i].assistantMessageId) {
prevAssistantUuid = messages[i].assistantMessageId;
break;
}
}
@@ -21,7 +21,7 @@ export function findRewindContext(messages: ChatMessage[], userIndex: number): R
let hasResponse = false;
for (let i = userIndex + 1; i < messages.length; i++) {
if (messages[i].role === 'user') break;
if (messages[i].role === 'assistant' && messages[i].sdkAssistantUuid) {
if (messages[i].role === 'assistant' && messages[i].assistantMessageId) {
hasResponse = true;
break;
}
@@ -1,178 +0,0 @@
import type { Options } from '@anthropic-ai/claude-agent-sdk';
import { query as agentQuery } from '@anthropic-ai/claude-agent-sdk';
import { createCustomSpawnFunction } from '../../../core/agent/customSpawn';
import { buildRefineSystemPrompt } from '../../../core/prompts/instructionRefine';
import { type InstructionRefineResult, isAdaptiveThinkingModel, THINKING_BUDGETS } from '../../../core/types';
import type ClaudianPlugin from '../../../main';
import { getEnhancedPath, getMissingNodeError, parseEnvironmentVariables } from '../../../utils/env';
import { getVaultPath } from '../../../utils/path';
export type RefineProgressCallback = (update: InstructionRefineResult) => void;
export class InstructionRefineService {
private plugin: ClaudianPlugin;
private abortController: AbortController | null = null;
private sessionId: string | null = null;
private existingInstructions: string = '';
constructor(plugin: ClaudianPlugin) {
this.plugin = plugin;
}
/** Resets conversation state for a new refinement session. */
resetConversation(): void {
this.sessionId = null;
}
/** Refines a raw instruction from user input. */
async refineInstruction(
rawInstruction: string,
existingInstructions: string,
onProgress?: RefineProgressCallback
): Promise<InstructionRefineResult> {
this.sessionId = null;
this.existingInstructions = existingInstructions;
const prompt = `Please refine this instruction: "${rawInstruction}"`;
return this.sendMessage(prompt, onProgress);
}
/** Continues conversation with a follow-up message (for clarifications). */
async continueConversation(
message: string,
onProgress?: RefineProgressCallback
): Promise<InstructionRefineResult> {
if (!this.sessionId) {
return { success: false, error: 'No active conversation to continue' };
}
return this.sendMessage(message, onProgress);
}
/** Cancels any ongoing query. */
cancel(): void {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
private async sendMessage(
prompt: string,
onProgress?: RefineProgressCallback
): Promise<InstructionRefineResult> {
const vaultPath = getVaultPath(this.plugin.app);
if (!vaultPath) {
return { success: false, error: 'Could not determine vault path' };
}
const resolvedClaudePath = this.plugin.getResolvedClaudeCliPath();
if (!resolvedClaudePath) {
return { success: false, error: 'Claude CLI not found. Please install Claude Code CLI.' };
}
this.abortController = new AbortController();
// Parse custom environment variables
const customEnv = parseEnvironmentVariables(this.plugin.getActiveEnvironmentVariables());
const enhancedPath = getEnhancedPath(customEnv.PATH, resolvedClaudePath);
const missingNodeError = getMissingNodeError(resolvedClaudePath, enhancedPath);
if (missingNodeError) {
return { success: false, error: missingNodeError };
}
const options: Options = {
cwd: vaultPath,
systemPrompt: buildRefineSystemPrompt(this.existingInstructions),
model: this.plugin.settings.model,
abortController: this.abortController,
pathToClaudeCodeExecutable: resolvedClaudePath,
env: {
...process.env,
...customEnv,
PATH: enhancedPath,
},
tools: [], // No tools needed for instruction refinement
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: this.plugin.settings.loadUserClaudeSettings
? ['user', 'project']
: ['project'],
spawnClaudeCodeProcess: createCustomSpawnFunction(enhancedPath),
};
if (this.sessionId) {
options.resume = this.sessionId;
}
if (isAdaptiveThinkingModel(this.plugin.settings.model)) {
options.thinking = { type: 'adaptive' };
options.effort = this.plugin.settings.effortLevel;
} else {
const budgetConfig = THINKING_BUDGETS.find(b => b.value === this.plugin.settings.thinkingBudget);
if (budgetConfig && budgetConfig.tokens > 0) {
options.maxThinkingTokens = budgetConfig.tokens;
}
}
try {
const response = agentQuery({ prompt, options });
let responseText = '';
for await (const message of response) {
if (this.abortController?.signal.aborted) {
await response.interrupt();
return { success: false, error: 'Cancelled' };
}
if (message.type === 'system' && message.subtype === 'init' && message.session_id) {
this.sessionId = message.session_id;
}
const text = this.extractTextFromMessage(message);
if (text) {
responseText += text;
// Stream progress updates
if (onProgress) {
const partialResult = this.parseResponse(responseText);
onProgress(partialResult);
}
}
}
return this.parseResponse(responseText);
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: msg };
} finally {
this.abortController = null;
}
}
/** Parses response text for <instruction> tag. */
private parseResponse(responseText: string): InstructionRefineResult {
const instructionMatch = responseText.match(/<instruction>([\s\S]*?)<\/instruction>/);
if (instructionMatch) {
return { success: true, refinedInstruction: instructionMatch[1].trim() };
}
// No instruction tag - treat as clarification question
const trimmed = responseText.trim();
if (trimmed) {
return { success: true, clarification: trimmed };
}
return { success: false, error: 'Empty response' };
}
/** Extracts text content from SDK message. */
private extractTextFromMessage(message: { type: string; message?: { content?: Array<{ type: string; text?: string }> } }): string {
if (message.type !== 'assistant' || !message.message?.content) {
return '';
}
return message.message.content
.filter((block): block is { type: 'text'; text: string } => block.type === 'text' && !!block.text)
.map(block => block.text)
.join('');
}
}
+25 -131
View File
@@ -2,17 +2,14 @@ import { existsSync, readFileSync, realpathSync } from 'fs';
import { tmpdir } from 'os';
import { isAbsolute, sep } from 'path';
import { ProviderRegistry } from '../../../core/providers/ProviderRegistry';
import type { ProviderTaskResultInterpreter } from '../../../core/providers/types';
import { TOOL_TASK } from '../../../core/tools/toolNames';
import type {
SubagentInfo,
SubagentMode,
ToolCallInfo,
} from '../../../core/types';
import {
extractAgentIdFromToolUseResult,
extractXmlTag,
resolveToolUseResultStatus,
} from '../../../utils/sdkSession';
import { extractFinalResultFromSubagentJsonl } from '../../../utils/subagentJsonl';
import {
addSubagentToolCall,
@@ -25,7 +22,7 @@ import {
type SubagentState,
updateAsyncSubagentRunning,
updateSubagentToolResult,
} from '../rendering';
} from '../rendering/SubagentRenderer';
import type { PendingToolCall } from '../state/types';
export type SubagentStateChangeCallback = (subagent: SubagentInfo) => void;
@@ -55,15 +52,24 @@ export class SubagentManager {
private asyncDomStates: Map<string, AsyncSubagentState> = new Map();
private onStateChange: SubagentStateChangeCallback;
private taskResultInterpreter: ProviderTaskResultInterpreter;
constructor(onStateChange: SubagentStateChangeCallback) {
constructor(
onStateChange: SubagentStateChangeCallback,
taskResultInterpreter: ProviderTaskResultInterpreter = ProviderRegistry.getTaskResultInterpreter(),
) {
this.onStateChange = onStateChange;
this.taskResultInterpreter = taskResultInterpreter;
}
public setCallback(callback: SubagentStateChangeCallback): void {
this.onStateChange = callback;
}
public setTaskResultInterpreter(interpreter: ProviderTaskResultInterpreter): void {
this.taskResultInterpreter = interpreter;
}
// ============================================
// Unified Subagent Entry Point
// ============================================
@@ -305,7 +311,7 @@ export class SubagentManager {
return;
}
const agentId = this.extractAgentIdFromTaskToolUseResult(toolUseResult) ?? this.parseAgentId(result);
const agentId = this.taskResultInterpreter.extractAgentId(toolUseResult) ?? this.parseAgentId(result);
if (!agentId) {
const truncatedResult = result.length > 100 ? result.substring(0, 100) + '...' : result;
@@ -375,11 +381,10 @@ export class SubagentManager {
// The chunk's is_error flag can be unreliable for async subagent results
// (SDK may set is_error on the content block even when the agent succeeded).
// Prefer the structured toolUseResult to determine actual error status.
const resolvedStatus = resolveToolUseResultStatus(
const finalStatus = this.taskResultInterpreter.resolveTerminalStatus(
toolUseResult,
isError ? 'error' : 'completed'
isError ? 'error' : 'completed',
);
const finalStatus = resolvedStatus === 'error' ? 'error' : 'completed';
subagent.asyncStatus = finalStatus;
subagent.status = finalStatus;
@@ -596,7 +601,7 @@ export class SubagentManager {
if (isError) {
return 'sync';
}
if (this.hasAsyncMarkerInToolUseResult(taskToolUseResult)) {
if (this.taskResultInterpreter.hasAsyncLaunchMarker(taskToolUseResult)) {
return 'async';
}
// Use strict async markers only; avoid broad ID heuristics.
@@ -652,58 +657,6 @@ export class SubagentManager {
return null;
}
private hasAsyncMarkerInToolUseResult(taskToolUseResult?: unknown): boolean {
if (!taskToolUseResult || typeof taskToolUseResult !== 'object') {
return false;
}
const record = taskToolUseResult as Record<string, unknown>;
if (record.isAsync === true) {
return true;
}
const directAgentId = record.agentId ?? record.agent_id;
if (typeof directAgentId === 'string' && directAgentId.length > 0) {
return true;
}
const data = record.data;
if (data && typeof data === 'object') {
const nestedRecord = data as Record<string, unknown>;
const nestedAgentId = nestedRecord.agent_id ?? nestedRecord.agentId;
if (typeof nestedAgentId === 'string' && nestedAgentId.length > 0) {
return true;
}
}
if (typeof record.status === 'string' && record.status.toLowerCase() === 'async_launched') {
return true;
}
if (typeof record.outputFile === 'string' && record.outputFile.length > 0) {
return true;
}
if (Array.isArray(record.content)) {
for (const block of record.content) {
if (block && typeof block === 'object') {
const text = (block as Record<string, unknown>).text;
if (typeof text === 'string' && this.extractAgentIdFromString(text)) {
return true;
}
} else if (typeof block === 'string' && this.extractAgentIdFromString(block)) {
return true;
}
}
}
if (typeof record.content === 'string' && this.extractAgentIdFromString(record.content)) {
return true;
}
return false;
}
// ============================================
// Private: Async DOM State Updates
// ============================================
@@ -796,7 +749,11 @@ export class SubagentManager {
}
private extractAgentResult(result: string, agentId: string, toolUseResult?: unknown): string {
const structuredResult = this.extractResultFromToolUseResult(toolUseResult);
const structuredResult = this.taskResultInterpreter.extractStructuredResult(toolUseResult);
const normalizedStructuredResult = this.extractResultFromCandidateString(structuredResult);
if (normalizedStructuredResult) {
return normalizedStructuredResult;
}
if (structuredResult) {
return structuredResult;
}
@@ -862,36 +819,6 @@ export class SubagentManager {
return payload;
}
private extractResultFromToolUseResult(toolUseResult: unknown): string | null {
if (!toolUseResult || typeof toolUseResult !== 'object') {
return null;
}
const record = toolUseResult as Record<string, unknown>;
if (record.retrieval_status === 'error') {
const errorMsg = typeof record.error === 'string' ? record.error : 'Task retrieval failed';
return `Error: ${errorMsg}`;
}
const result = this.extractResultFromTaskObject(record.task)
?? this.extractResultFromCandidateString(record.result)
?? this.extractResultFromCandidateString(record.output);
if (result) return result;
// SDK subagent format: { status, content: [{type:"text",text:"..."}], agentId, ... }
if (Array.isArray(record.content)) {
const firstText = (record.content as Array<Record<string, unknown>>)
.find((b) => b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string');
if (firstText) {
const text = (firstText.text as string).trim();
if (text.length > 0) return text;
}
}
return null;
}
private extractResultFromTaskObject(task: unknown): string | null {
if (!task || typeof task !== 'object') {
return null;
@@ -962,39 +889,6 @@ export class SubagentManager {
return null;
}
private extractAgentIdFromTaskToolUseResult(toolUseResult: unknown): string | null {
// Shared utility handles the common agentId/agent_id and data.agent_id paths
const directId = extractAgentIdFromToolUseResult(toolUseResult);
if (directId) return directId;
// Streaming-specific fallback: scan content blocks for agent ID strings
if (!toolUseResult || typeof toolUseResult !== 'object') return null;
const record = toolUseResult as Record<string, unknown>;
if (Array.isArray(record.content)) {
for (const block of record.content) {
if (typeof block === 'string') {
const extracted = this.extractAgentIdFromString(block);
if (extracted) return extracted;
continue;
}
if (!block || typeof block !== 'object') {
continue;
}
const blockRecord = block as Record<string, unknown>;
if (typeof blockRecord.text === 'string') {
const extracted = this.extractAgentIdFromString(blockRecord.text);
if (extracted) return extracted;
}
}
} else if (typeof record.content === 'string') {
const extracted = this.extractAgentIdFromString(record.content);
if (extracted) return extracted;
}
return null;
}
private inferAgentIdFromResult(result: string): string | null {
try {
const parsed = JSON.parse(result);
@@ -1026,16 +920,16 @@ export class SubagentManager {
}
private extractResultFromTaggedPayload(payload: string): string | null {
const directResult = extractXmlTag(payload, 'result');
const directResult = this.taskResultInterpreter.extractTagValue(payload, 'result');
if (directResult) return directResult;
const outputContent = extractXmlTag(payload, 'output');
const outputContent = this.taskResultInterpreter.extractTagValue(payload, 'output');
if (!outputContent) return null;
const extractedFromJsonl = this.extractResultFromOutputJsonl(outputContent);
if (extractedFromJsonl) return extractedFromJsonl;
const nestedResult = extractXmlTag(outputContent, 'result');
const nestedResult = this.taskResultInterpreter.extractTagValue(outputContent, 'result');
if (nestedResult) return nestedResult;
const trimmed = outputContent.trim();
@@ -1,221 +0,0 @@
import type { Options } from '@anthropic-ai/claude-agent-sdk';
import { query as agentQuery } from '@anthropic-ai/claude-agent-sdk';
import { createCustomSpawnFunction } from '../../../core/agent/customSpawn';
import { TITLE_GENERATION_SYSTEM_PROMPT } from '../../../core/prompts/titleGeneration';
import type ClaudianPlugin from '../../../main';
import { getEnhancedPath, getMissingNodeError, parseEnvironmentVariables } from '../../../utils/env';
import { getVaultPath } from '../../../utils/path';
export type TitleGenerationResult =
| { success: true; title: string }
| { success: false; error: string };
export type TitleGenerationCallback = (
conversationId: string,
result: TitleGenerationResult
) => Promise<void>;
export class TitleGenerationService {
private plugin: ClaudianPlugin;
private activeGenerations: Map<string, AbortController> = new Map();
constructor(plugin: ClaudianPlugin) {
this.plugin = plugin;
}
/**
* Generates a title for a conversation based on the first user message.
* Non-blocking: calls callback when complete.
*/
async generateTitle(
conversationId: string,
userMessage: string,
callback: TitleGenerationCallback
): Promise<void> {
const vaultPath = getVaultPath(this.plugin.app);
if (!vaultPath) {
await this.safeCallback(callback, conversationId, {
success: false,
error: 'Could not determine vault path',
});
return;
}
const envVars = parseEnvironmentVariables(
this.plugin.getActiveEnvironmentVariables()
);
const resolvedClaudePath = this.plugin.getResolvedClaudeCliPath();
if (!resolvedClaudePath) {
await this.safeCallback(callback, conversationId, {
success: false,
error: 'Claude CLI not found',
});
return;
}
const enhancedPath = getEnhancedPath(envVars.PATH, resolvedClaudePath);
const missingNodeError = getMissingNodeError(resolvedClaudePath, enhancedPath);
if (missingNodeError) {
await this.safeCallback(callback, conversationId, {
success: false,
error: missingNodeError,
});
return;
}
// Get the appropriate model with fallback chain:
// 1. User's titleGenerationModel setting (if set)
// 2. ANTHROPIC_DEFAULT_HAIKU_MODEL env var
// 3. claude-haiku-4-5 default
const titleModel =
this.plugin.settings.titleGenerationModel ||
envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL ||
'claude-haiku-4-5';
// Cancel any existing generation for this conversation
const existingController = this.activeGenerations.get(conversationId);
if (existingController) {
existingController.abort();
}
// Create a new local AbortController for this generation
const abortController = new AbortController();
this.activeGenerations.set(conversationId, abortController);
// Truncate message if too long (save tokens)
const truncatedUser = this.truncateText(userMessage, 500);
const prompt = `User's request:
"""
${truncatedUser}
"""
Generate a title for this conversation:`;
const options: Options = {
cwd: vaultPath,
systemPrompt: TITLE_GENERATION_SYSTEM_PROMPT,
model: titleModel,
abortController,
pathToClaudeCodeExecutable: resolvedClaudePath,
env: {
...process.env,
...envVars,
PATH: enhancedPath,
},
tools: [], // No tools needed for title generation
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: this.plugin.settings.loadUserClaudeSettings
? ['user', 'project']
: ['project'],
persistSession: false, // Don't save title generation queries to session history
spawnClaudeCodeProcess: createCustomSpawnFunction(enhancedPath),
};
try {
const response = agentQuery({ prompt, options });
let responseText = '';
for await (const message of response) {
if (abortController.signal.aborted) {
await this.safeCallback(callback, conversationId, {
success: false,
error: 'Cancelled',
});
return;
}
const text = this.extractTextFromMessage(message);
if (text) {
responseText += text;
}
}
const title = this.parseTitle(responseText);
if (title) {
await this.safeCallback(callback, conversationId, { success: true, title });
} else {
await this.safeCallback(callback, conversationId, {
success: false,
error: 'Failed to parse title from response',
});
}
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
await this.safeCallback(callback, conversationId, { success: false, error: msg });
} finally {
// Clean up the controller for this conversation
this.activeGenerations.delete(conversationId);
}
}
/** Cancels all ongoing title generations. */
cancel(): void {
for (const controller of this.activeGenerations.values()) {
controller.abort();
}
this.activeGenerations.clear();
}
/** Truncates text to a maximum length with ellipsis. */
private truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
}
/** Extracts text content from SDK message. */
private extractTextFromMessage(
message: { type: string; message?: { content?: Array<{ type: string; text?: string }> } }
): string {
if (message.type !== 'assistant' || !message.message?.content) {
return '';
}
return message.message.content
.filter((block): block is { type: 'text'; text: string } =>
block.type === 'text' && !!block.text
)
.map((block) => block.text)
.join('');
}
/** Parses and cleans the title from response. */
private parseTitle(responseText: string): string | null {
const trimmed = responseText.trim();
if (!trimmed) return null;
// Remove surrounding quotes if present
let title = trimmed;
if (
(title.startsWith('"') && title.endsWith('"')) ||
(title.startsWith("'") && title.endsWith("'"))
) {
title = title.slice(1, -1);
}
// Remove trailing punctuation
title = title.replace(/[.!?:;,]+$/, '');
// Truncate to max 50 characters
if (title.length > 50) {
title = title.substring(0, 47) + '...';
}
return title || null;
}
/** Safely invokes callback with try-catch to prevent unhandled errors. */
private async safeCallback(
callback: TitleGenerationCallback,
conversationId: string,
result: TitleGenerationResult
): Promise<void> {
try {
await callback(conversationId, result);
} catch {
// Silently ignore callback errors
}
}
}
+2 -3
View File
@@ -4,7 +4,6 @@ import type {
ChatStateCallbacks,
ChatStateData,
PendingToolCall,
PermissionMode,
QueuedMessage,
ThinkingBlockState,
TodoItem,
@@ -341,11 +340,11 @@ export class ChatState {
this.state.planFilePath = value;
}
get prePlanPermissionMode(): PermissionMode | null {
get prePlanPermissionMode(): string | null {
return this.state.prePlanPermissionMode;
}
set prePlanPermissionMode(value: PermissionMode | null) {
set prePlanPermissionMode(value: string | null) {
this.state.prePlanPermissionMode = value;
}
-15
View File
@@ -1,15 +0,0 @@
export { ChatState, createInitialState } from './ChatState';
export type {
ChatMessage,
ChatStateCallbacks,
ChatStateData,
EditorSelectionContext,
ImageAttachment,
QueryOptions,
QueuedMessage,
StoredSelection,
SubagentInfo,
ThinkingBlockState,
ToolCallInfo,
WriteEditState,
} from './types';
+7 -17
View File
@@ -1,10 +1,10 @@
import type { EditorView } from '@codemirror/view';
import type { TodoItem } from '../../../core/tools';
import type { ChatRuntimeQueryOptions } from '../../../core/runtime/types';
import type { TodoItem } from '../../../core/tools/todo';
import type {
ChatMessage,
ImageAttachment,
PermissionMode,
SubagentInfo,
ToolCallInfo,
UsageInfo,
@@ -12,10 +12,8 @@ import type {
import type { BrowserSelectionContext } from '../../../utils/browser';
import type { CanvasSelectionContext } from '../../../utils/canvas';
import type { EditorSelectionContext } from '../../../utils/editor';
import type {
ThinkingBlockState,
WriteEditState,
} from '../rendering';
import type { ThinkingBlockState } from '../rendering/ThinkingBlockRenderer';
import type { WriteEditState } from '../rendering/WriteEditRenderer';
/** Queued message waiting to be sent after current streaming completes. */
export interface QueuedMessage {
@@ -101,11 +99,11 @@ export interface ChatStateData {
// Pending plan content for approve-new-session (auto-sends in new session after stream ends)
pendingNewSessionPlan: string | null;
// Plan file path captured from Write tool calls to ~/.claude/plans/ during plan mode
// Plan file path captured from Write tool calls to provider plan directory during plan mode
planFilePath: string | null;
// Saved permission mode before entering plan mode (for Shift+Tab toggle restore)
prePlanPermissionMode: PermissionMode | null;
prePlanPermissionMode: string | null;
}
/** Callbacks for ChatState changes. */
@@ -120,21 +118,13 @@ export interface ChatStateCallbacks {
}
/** Options for query execution. */
export interface QueryOptions {
allowedTools?: string[];
model?: string;
mcpMentions?: Set<string>;
enabledMcpServers?: Set<string>;
forceColdStart?: boolean;
externalContextPaths?: string[];
}
export type QueryOptions = ChatRuntimeQueryOptions;
// Re-export types that are used across the chat feature
export type {
ChatMessage,
EditorSelectionContext,
ImageAttachment,
PermissionMode,
SubagentInfo,
ThinkingBlockState,
TodoItem,
File diff suppressed because it is too large Load Diff
+134 -82
View File
@@ -1,11 +1,13 @@
import { Notice } from 'obsidian';
import type { ClaudianService } from '../../../core/agent';
import type { McpServerManager } from '../../../core/mcp';
import { ProviderRegistry } from '../../../core/providers/ProviderRegistry';
import { ProviderWorkspaceRegistry } from '../../../core/providers/ProviderWorkspaceRegistry';
import type { ChatRuntime } from '../../../core/runtime/ChatRuntime';
import type { SlashCommand } from '../../../core/types';
import { t } from '../../../i18n';
import { t } from '../../../i18n/i18n';
import type ClaudianPlugin from '../../../main';
import { chooseForkTarget } from '../../../shared/modals/ForkTargetModal';
import { getTabProviderId } from './providerResolution';
import {
activateTab,
createTab,
@@ -14,9 +16,7 @@ import {
type ForkContext,
getTabTitle,
initializeTabControllers,
initializeTabService,
initializeTabUI,
setupServiceCallbacks,
wireTabInputEvents,
} from './Tab';
import {
@@ -33,12 +33,17 @@ import {
type TabManagerViewHost,
} from './types';
function isTabManagerViewHost(value: unknown): value is TabManagerViewHost {
return !!value
&& typeof value === 'object'
&& 'getTabManager' in (value as Record<string, unknown>);
}
/**
* TabManager coordinates multiple chat tabs.
*/
export class TabManager implements TabManagerInterface {
private plugin: ClaudianPlugin;
private mcpManager: McpServerManager;
private containerEl: HTMLElement;
private view: TabManagerViewHost;
@@ -60,16 +65,36 @@ export class TabManager implements TabManagerInterface {
constructor(
plugin: ClaudianPlugin,
mcpManager: McpServerManager,
containerEl: HTMLElement,
view: TabManagerViewHost,
callbacks: TabManagerCallbacks = {}
callbacks?: TabManagerCallbacks,
);
constructor(
plugin: ClaudianPlugin,
legacyArg: unknown,
containerEl: HTMLElement,
view: TabManagerViewHost,
callbacks?: TabManagerCallbacks,
);
constructor(
plugin: ClaudianPlugin,
arg2: HTMLElement | unknown,
arg3: HTMLElement | TabManagerViewHost,
arg4?: TabManagerViewHost | TabManagerCallbacks,
arg5: TabManagerCallbacks = {},
) {
this.plugin = plugin;
this.mcpManager = mcpManager;
this.containerEl = containerEl;
this.view = view;
this.callbacks = callbacks;
if (isTabManagerViewHost(arg3)) {
this.containerEl = arg2 as HTMLElement;
this.view = arg3;
this.callbacks = (arg4 as TabManagerCallbacks | undefined) ?? {};
return;
}
this.containerEl = arg3 as HTMLElement;
this.view = arg4 as TabManagerViewHost;
this.callbacks = arg5;
}
// ============================================
@@ -92,12 +117,18 @@ export class TabManager implements TabManagerInterface {
? await this.plugin.getConversationById(conversationId)
: undefined;
// Inherit the active tab's provider so the new blank tab picks up its model
const activeTab = this.getActiveTab();
const defaultProviderId = conversation
? undefined
: (activeTab ? getTabProviderId(activeTab, this.plugin) : undefined);
const tab = createTab({
plugin: this.plugin,
mcpManager: this.mcpManager,
containerEl: this.containerEl,
conversation: conversation ?? undefined,
tabId,
defaultProviderId,
onStreamingChanged: (isStreaming) => {
this.callbacks.onTabStreamingChanged?.(tab.id, isStreaming);
},
@@ -114,19 +145,21 @@ export class TabManager implements TabManagerInterface {
},
});
// Initialize UI components with shared SDK commands callback
// Initialize UI components with provider catalog
initializeTabUI(tab, this.plugin, {
getSdkCommands: () => this.getSdkCommands(),
getProviderCatalogConfig: () => this.getProviderCatalogConfig(tab),
onProviderChanged: (providerId) => {
this.callbacks.onTabProviderChanged?.(tab.id, providerId);
},
});
// Initialize controllers (pass mcpManager for lazy service initialization)
initializeTabControllers(
tab,
this.plugin,
this.view,
this.mcpManager,
(forkContext) => this.handleForkRequest(forkContext),
(conversationId) => this.openConversation(conversationId),
() => this.getProviderCatalogConfig(tab),
);
// Wire input event handlers
@@ -172,24 +205,19 @@ export class TabManager implements TabManagerInterface {
this.activeTabId = tabId;
activateTab(tab);
// Service initialization is now truly lazy - happens on first query via
// ensureServiceInitialized() in InputController.sendMessage()
// Load conversation if not already loaded
if (tab.conversationId && tab.state.messages.length === 0) {
await tab.controllers.conversationController?.switchTo(tab.conversationId);
} else if (tab.conversationId && tab.state.messages.length > 0 && tab.service) {
// Tab already has messages loaded - sync service session to conversation
// This handles the case where user switches between tabs with different sessions
const conversation = await this.plugin.getConversationById(tab.conversationId);
// Tab already has messages loaded and runtime exists — passive sync only
const conversation = this.plugin.getConversationSync(tab.conversationId);
if (conversation) {
const hasMessages = conversation.messages.length > 0;
const externalContextPaths = hasMessages
? conversation.externalContextPaths || []
: (this.plugin.settings.persistentExternalContextPaths || []);
const resolvedSessionId = tab.service.applyForkState(conversation);
tab.service.setSessionId(resolvedSessionId, externalContextPaths);
tab.service.syncConversationState(conversation, externalContextPaths);
}
} else if (!tab.conversationId && tab.state.messages.length === 0) {
// New tab with no conversation - initialize welcome greeting
@@ -220,8 +248,7 @@ export class TabManager implements TabManagerInterface {
}
// If this is the last tab and it's already empty (no conversation),
// don't close it - it's already a fresh session with a warm service.
// Closing and recreating would waste the pre-warmed connection.
// don't close it - it's already a blank draft container.
if (this.tabs.size === 1 && !tab.conversationId && tab.state.messages.length === 0) {
return false;
}
@@ -250,18 +277,11 @@ export class TabManager implements TabManagerInterface {
if (fallbackTabId && this.tabs.has(fallbackTabId)) {
await this.switchToTab(fallbackTabId);
// If this is now the only tab and it's not warm, pre-warm immediately
// User expects the active tab to be ready for chat
if (this.tabs.size === 1) {
await this.initializeActiveTabService();
}
// No pre-warm: replacement tabs stay cold until send
}
} else {
// Create a new empty tab and pre-warm immediately
// This is the only tab, so it should be ready for chat
// Create a replacement blank tab (stays cold)
await this.createTab();
await this.initializeActiveTabService();
}
}
@@ -438,18 +458,25 @@ export class TabManager implements TabManagerInterface {
}
private async createForkConversation(context: ForkContext): Promise<string> {
const conversation = await this.plugin.createConversation();
const conversation = await this.plugin.createConversation({
providerId: context.providerId,
});
const title = context.sourceTitle
? this.buildForkTitle(context.sourceTitle, context.forkAtUserMessage)
: undefined;
const forkProviderState = ProviderRegistry
.getConversationHistoryService(conversation.providerId)
.buildForkProviderState(
context.sourceSessionId,
context.resumeAt,
context.sourceProviderState,
);
await this.plugin.updateConversation(conversation.id, {
messages: context.messages,
forkSource: { sessionId: context.sourceSessionId, resumeAt: context.resumeAt },
// Prevent immediate SDK message load from merging duplicates with the copied messages.
// This is in-memory only (not persisted in metadata).
sdkMessagesLoaded: true,
providerState: forkProviderState,
...(title && { title }),
...(context.currentNote && { currentNote: context.currentNote }),
});
@@ -523,28 +550,7 @@ export class TabManager implements TabManagerInterface {
await this.createTab();
}
// Pre-initialize the active tab's service so it's ready immediately
// Other tabs stay lazy until first query
await this.initializeActiveTabService();
}
/**
* Initializes the active tab's service if not already done.
* Called after restore to ensure the visible tab is ready immediately.
*/
private async initializeActiveTabService(): Promise<void> {
const activeTab = this.getActiveTab();
if (!activeTab || activeTab.serviceInitialized) {
return;
}
try {
// initializeTabService() handles session ID resolution from tab.conversationId
await initializeTabService(activeTab, this.plugin, this.mcpManager);
setupServiceCallbacks(activeTab, this.plugin);
} catch {
// Non-fatal - service will be initialized on first query
}
// No pre-warm: all tabs stay cold until first send
}
// ============================================
@@ -552,18 +558,64 @@ export class TabManager implements TabManagerInterface {
// ============================================
/**
* Gets SDK supported commands from any ready service.
* The command list is the same for all tabs, so we just need one ready service.
* Gets provider-scoped SDK supported commands for a tab.
* Reuses a ready runtime from the same provider when available to avoid
* leaking commands across providers in mixed-provider workspaces.
* @returns Array of SDK commands, or empty array if no service is ready.
*/
async getSdkCommands(): Promise<SlashCommand[]> {
// Find any tab with a ready service
for (const tab of this.tabs.values()) {
if (tab.service?.isReady()) {
return tab.service.getSupportedCommands();
async getSdkCommands(tabId?: TabId): Promise<SlashCommand[]> {
const targetTab = (tabId ? this.tabs.get(tabId) : this.getActiveTab()) ?? null;
if (!targetTab) {
return [];
}
const providerId = getTabProviderId(targetTab, this.plugin);
const staticCapabilities = ProviderRegistry.getCapabilities(providerId);
if (!staticCapabilities.supportsProviderCommands) {
return [];
}
let sdkCommands: SlashCommand[] = [];
const targetService = targetTab.service;
if (targetService?.providerId === providerId && targetService.isReady()) {
sdkCommands = await targetService.getSupportedCommands();
} else {
for (const tab of this.tabs.values()) {
if (tab.id === targetTab.id) {
continue;
}
if (tab.service?.providerId === providerId && tab.service.isReady()) {
sdkCommands = await tab.service.getSupportedCommands();
break;
}
}
}
return [];
const catalog = ProviderWorkspaceRegistry.getCommandCatalog(providerId);
if (catalog) {
catalog.setRuntimeCommands(sdkCommands);
}
return sdkCommands;
}
// ============================================
// Provider Command Catalog
// ============================================
private getProviderCatalogConfig(tab: TabData) {
const providerId = getTabProviderId(tab, this.plugin);
const catalog = ProviderWorkspaceRegistry.getCommandCatalog(providerId);
if (!catalog) return null;
return {
config: catalog.getDropdownConfig(),
getEntries: async () => {
await this.getSdkCommands(tab.id);
return catalog.listDropdownEntries({ includeBuiltIns: false });
},
};
}
// ============================================
@@ -571,11 +623,11 @@ export class TabManager implements TabManagerInterface {
// ============================================
/**
* Broadcasts a function call to all tabs' ClaudianService instances.
* Broadcasts a function call to all initialized tab runtimes.
* Used by settings managers to apply configuration changes to all tabs.
* @param fn Function to call on each service.
* @param fn Function to call on each runtime.
*/
async broadcastToAllTabs(fn: (service: ClaudianService) => Promise<void>): Promise<void> {
async broadcastToAllTabs(fn: (service: ChatRuntime) => Promise<void>): Promise<void> {
const promises: Promise<void>[] = [];
for (const tab of this.tabs.values()) {
@@ -597,15 +649,15 @@ export class TabManager implements TabManagerInterface {
/** Destroys all tabs and cleans up resources. */
async destroy(): Promise<void> {
// Save all conversations
for (const tab of this.tabs.values()) {
await tab.controllers.conversationController?.save();
}
// Save all conversations in parallel (independent per-tab)
await Promise.all(
Array.from(this.tabs.values()).map(
tab => tab.controllers.conversationController?.save() ?? Promise.resolve()
)
);
// Destroy all tabs (async for proper cleanup)
for (const tab of this.tabs.values()) {
await destroyTab(tab);
}
// Destroy all tabs in parallel (independent per-tab, must run after saves complete)
await Promise.all(Array.from(this.tabs.values()).map(tab => destroyTab(tab)));
this.tabs.clear();
this.activeTabId = null;
-4
View File
@@ -1,4 +0,0 @@
export * from './Tab';
export * from './TabBar';
export * from './TabManager';
export * from './types';
@@ -0,0 +1,34 @@
import { getProviderForModel } from '../../../core/providers/modelRouting';
import type { ProviderId } from '../../../core/providers/types';
import type { Conversation } from '../../../core/types';
import type ClaudianPlugin from '../../../main';
import type { TabProviderContext } from './types';
function getStoredConversationProviderId(
tab: TabProviderContext,
plugin: ClaudianPlugin,
): ProviderId {
if (tab.conversationId) {
const conversation = plugin.getConversationSync(tab.conversationId);
if (conversation?.providerId) {
return conversation.providerId;
}
}
if (tab.lifecycleState === 'blank' && tab.draftModel) {
return getProviderForModel(
tab.draftModel,
plugin.settings as unknown as Record<string, unknown>,
);
}
return tab.service?.providerId ?? tab.providerId;
}
export function getTabProviderId(
tab: TabProviderContext,
plugin: ClaudianPlugin,
conversation?: Conversation | null,
): ProviderId {
return conversation?.providerId ?? getStoredConversationProviderId(tab, plugin);
}
+51 -25
View File
@@ -1,41 +1,39 @@
import type { Component, WorkspaceLeaf } from 'obsidian';
import type { ClaudianService } from '../../../core/agent';
import type { InstructionRefineService, ProviderId, TitleGenerationService } from '../../../core/providers/types';
import type { ChatRuntime } from '../../../core/runtime/ChatRuntime';
import type { SlashCommandDropdown } from '../../../shared/components/SlashCommandDropdown';
import type {
BrowserSelectionController,
CanvasSelectionController,
ConversationController,
InputController,
NavigationController,
SelectionController,
StreamController,
} from '../controllers';
import type { MessageRenderer } from '../rendering';
import type { InstructionRefineService } from '../services/InstructionRefineService';
import type { BrowserSelectionController } from '../controllers/BrowserSelectionController';
import type { CanvasSelectionController } from '../controllers/CanvasSelectionController';
import type { ConversationController } from '../controllers/ConversationController';
import type { InputController } from '../controllers/InputController';
import type { NavigationController } from '../controllers/NavigationController';
import type { SelectionController } from '../controllers/SelectionController';
import type { StreamController } from '../controllers/StreamController';
import type { MessageRenderer } from '../rendering/MessageRenderer';
import type { SubagentManager } from '../services/SubagentManager';
import type { TitleGenerationService } from '../services/TitleGenerationService';
import type { ChatState } from '../state';
import type { ChatState } from '../state/ChatState';
import type { BangBashModeManager } from '../ui/BangBashModeManager';
import type { FileContextManager } from '../ui/FileContext';
import type { ImageContextManager } from '../ui/ImageContext';
import type {
BangBashModeManager,
ContextUsageMeter,
ExternalContextSelector,
FileContextManager,
ImageContextManager,
InstructionModeManager,
McpServerSelector,
ModelSelector,
PermissionToggle,
StatusPanel,
ServiceTierToggle,
ThinkingBudgetSelector,
} from '../ui';
import type { NavigationSidebar } from '../ui';
} from '../ui/InputToolbar';
import type { InstructionModeManager } from '../ui/InstructionModeManager';
import type { NavigationSidebar } from '../ui/NavigationSidebar';
import type { StatusPanel } from '../ui/StatusPanel';
/**
* Default number of tabs allowed.
*
* Set to 3 to balance usability with resource usage:
* - Each tab has its own ClaudianService and persistent query
* - Each tab has its own chat runtime and persistent query
* - More tabs = more memory and potential SDK processes
* - 3 tabs allows multi-tasking without excessive overhead
*/
@@ -131,6 +129,7 @@ export interface TabUIComponents {
externalContextSelector: ExternalContextSelector | null;
mcpServerSelector: McpServerSelector | null;
permissionToggle: PermissionToggle | null;
serviceTierToggle: ServiceTierToggle | null;
slashCommandDropdown: SlashCommandDropdown | null;
instructionModeManager: InstructionModeManager | null;
bangBashModeManager: BangBashModeManager | null;
@@ -151,6 +150,7 @@ export interface TabDOMElements {
statusPanelContainerEl: HTMLElement;
inputContainerEl: HTMLElement;
queueIndicatorEl: HTMLElement;
inputWrapper: HTMLElement;
inputEl: HTMLTextAreaElement;
@@ -168,19 +168,40 @@ export interface TabDOMElements {
eventCleanups: Array<() => void>;
}
/**
* Tab lifecycle states:
* - `blank`: No conversation binding, no runtime. Draft model selection only.
* - `bound_cold`: Bound to a conversation, but runtime not started yet.
* - `bound_active`: Bound to a conversation with a running runtime.
* - `closing`: Tab is being torn down.
*/
export type TabLifecycleState = 'blank' | 'bound_cold' | 'bound_active' | 'closing';
/**
* Represents a single tab in the multi-tab system.
* Each tab is an independent chat session with its own agent service.
* Each tab is an independent chat session with its own runtime instance.
*/
export interface TabData {
/** Unique tab identifier. */
id: TabId;
/** Explicit lifecycle state. */
lifecycleState: TabLifecycleState;
/**
* Draft model selected in a blank tab (before first send).
* Used to derive provider on first send. Null after binding.
*/
draftModel: string | null;
/** Active provider for this tab's current conversation/runtime. */
providerId: ProviderId;
/** Conversation ID bound to this tab (null for new/empty tabs). */
conversationId: string | null;
/** Per-tab ClaudianService instance for independent streaming. */
service: ClaudianService | null;
/** Per-tab chat runtime instance for independent streaming. */
service: ChatRuntime | null;
/** Whether the service has been initialized (lazy start). */
serviceInitialized: boolean;
@@ -204,6 +225,8 @@ export interface TabData {
renderer: MessageRenderer | null;
}
export type TabProviderContext = Pick<TabData, 'conversationId' | 'service' | 'providerId' | 'lifecycleState' | 'draftModel'>;
/**
* Persisted tab state for restoration on plugin reload.
*/
@@ -244,6 +267,9 @@ export interface TabManagerCallbacks {
/** Called when a tab's conversation changes (loaded different conversation in same tab). */
onTabConversationChanged?: (tabId: TabId, conversationId: string | null) => void;
/** Called when the active provider changes within a tab (blank tab model selection). */
onTabProviderChanged?: (tabId: TabId, providerId: ProviderId) => void;
}
/**
+1 -1
View File
@@ -1,6 +1,6 @@
import { Notice } from 'obsidian';
import { t } from '../../../i18n';
import { t } from '../../../i18n/i18n';
export interface BangBashModeCallbacks {
onSubmit: (command: string) => Promise<void>;

Some files were not shown because too many files have changed in this diff Show More