25 KiB
Architecture Documentation
Overview
Obsidian plugin for AI agent interaction via ACP. useAgent facade hook composes sub-hooks (useAgentSession + useAgentMessages) and subscribes to a single onSessionUpdate channel. ChatPanel orchestrates hooks and renders children directly. Services are injected via React Context. ACP protocol details are isolated in the acp/ layer.
Directory Structure
src/
├── types/ # Type Definitions (no logic, no dependencies)
│ ├── chat.ts # ChatMessage, MessageContent, PromptContent, AttachedFile, ActivePermission
│ ├── session.ts # ChatSession, SessionUpdate (12-type union), SessionInfo, Capabilities
│ ├── agent.ts # AgentConfig, agent settings (preset/custom)
│ ├── errors.ts # AcpError, ProcessError, ErrorInfo
│ └── obsidian-internals.d.ts # Type augmentation for unofficial Obsidian APIs
│
├── acp/ # ACP Protocol Layer (SDK dependency confined here)
│ ├── acp-client.ts # Process lifecycle, UI-facing API (AcpClient class)
│ ├── acp-handler.ts # SDK event handler + sessionId filter + listener broadcast
│ ├── type-converter.ts # ACP SDK types ↔ internal types
│ ├── permission-handler.ts # Permission queue, auto-approve, Promise resolution
│ └── terminal-handler.ts # Terminal process create/output/kill
│
├── services/ # Business Logic (non-React, no React imports)
│ ├── vault-service.ts # Vault access + fuzzy search + CM6 selection tracking
│ ├── settings-service.ts # Reactive settings store (observer pattern only)
│ ├── session-storage.ts # Session metadata + message file I/O (sessions/*.json)
│ ├── preset-agents.ts # Static registry of preset (built-in) agents (PRESET_AGENTS)
│ ├── settings-normalizer.ts # Settings validation helpers (str, bool, num, enumVal, etc.)
│ ├── session-helpers.ts # Agent enumeration/resolution, enabled/default policy, API key injection (pure functions)
│ ├── session-state.ts # Session state updates (legacy mode/model, config restore)
│ ├── message-state.ts # Message array transforms (upsert, merge, streaming apply)
│ ├── message-sender.ts # Prompt preparation + sending (pure functions)
│ ├── chat-exporter.ts # Markdown export with frontmatter
│ ├── view-registry.ts # Multi-view management, focus, broadcast
│ └── update-checker.ts # Agent + plugin version checking (checkAgentUpdate / checkPluginUpdate)
│
├── hooks/ # React Custom Hooks (state + logic)
│ ├── useAgent.ts # Facade: composes useAgentSession + useAgentMessages
│ ├── useAgentSession.ts # Session lifecycle, config options, optimistic updates
│ ├── useAgentMessages.ts # Message state, streaming (RAF batch), permissions
│ ├── useSuggestions.ts # @[[note]] mentions + /command suggestions (unified)
│ ├── useSessionHistory.ts # Session list/load/resume/fork, 5-min cache
│ ├── useChatActions.ts # Business callbacks (send, newChat, export, restart, etc.)
│ ├── useHistoryModal.ts # Session history modal lifecycle
│ └── useSettings.ts # Settings subscription (useSettings + useSettingsSelector, useSyncExternalStore)
│
├── ui/ # React Components
│ ├── ChatContext.ts # React Context (plugin, acpClient, vaultService, settingsService)
│ ├── ChatPanel.tsx # Orchestrator: calls hooks, workspace events, rendering
│ ├── ChatView.tsx # Sidebar view (ItemView + Context Provider)
│ ├── FloatingChatView.tsx # Floating window (position/drag/resize + Context Provider)
│ ├── CodeBlockChatView.tsx # Embedded chat in markdown code blocks (```agent-client / ```agent)
│ ├── AgentButtonBlock.tsx # Quick-action button block (type: button → runPromptInChat)
│ ├── SessionManagerView.tsx # Session manager view (observes open chat views, not a chat itself)
│ ├── FloatingButton.tsx # Draggable launch button
│ ├── ChatHeader.tsx # Header (sidebar + floating + embedded variants)
│ ├── MessageList.tsx # Virtualized message list (@tanstack/react-virtual)
│ ├── MessageBubble.tsx # Single message (content dispatch, copy button)
│ ├── ToolCallBlock.tsx # Tool call display + diff (word-level highlighting) + collapsible text output panel
│ ├── TerminalBlock.tsx # Terminal output polling
│ ├── InputArea.tsx # Textarea, attachments, mentions, history
│ ├── InputToolbar.tsx # Config/mode/model selectors, usage, send button
│ ├── SuggestionPopup.tsx # Mention/command dropdown
│ ├── PermissionBanner.tsx # Permission request buttons
│ ├── ErrorBanner.tsx # Error/notification overlay
│ ├── SessionHistoryModal.tsx # Session history modal (list + confirm delete)
│ ├── EditTitleModal.ts # Session title rename modal
│ ├── ChangeDirectoryModal.ts # Working directory picker modal (Electron dialog)
│ ├── SettingsTab.ts # Plugin settings UI
│ ├── view-host.ts # IChatViewHost interface
│ └── shared/
│ ├── IconButton.tsx # Icon button + Lucide icon wrapper
│ ├── MarkdownRenderer.tsx # Obsidian markdown rendering
│ └── AttachmentStrip.tsx # Attachment preview strip
│
├── utils/ # Shared Utilities (mostly pure; some Obsidian/Node coupling)
│ ├── platform.ts # Shell, WSL, Windows env, command building (execSync + Obsidian Platform)
│ ├── paths.ts # Path resolution, file:// URI
│ ├── error-utils.ts # ACP error conversion
│ ├── mention-parser.ts # @[[note]] detection/extraction
│ ├── agent-block-parser.ts # YAML parser for agent-client/agent code blocks (chat | button)
│ ├── wikilink-resolver.ts # Wikilink resolution via Obsidian metadata cache (App-dependent)
│ ├── wikilink-formatter.ts # <obsidian_note_links> block formatting (pure)
│ ├── text.ts # Text helpers (truncateTitle)
│ └── logger.ts # Debug-mode logger
│
├── plugin.ts # Obsidian plugin lifecycle, commands, view management, AcpClient pool, code block processors
└── main.ts # Entry point (re-exports plugin)
Architectural Layers
1. Types Layer (src/types/)
Purpose: Pure type definitions. No logic, no dependencies.
| File | Contents |
|---|---|
chat.ts |
ChatMessage, MessageContent (8+ type union), Role, ToolCallStatus, ToolKind, AttachedFile, ActivePermission, PromptContent |
session.ts |
ChatSession, SessionState, SessionUpdate (12-type union incl. ProcessErrorUpdate), SessionConfigOption, Capabilities, SessionInfo |
agent.ts |
AgentEnvVar, BaseAgentSettings, PresetAgentUserSettings, CustomAgentSettings |
errors.ts |
AcpErrorCode, AcpError, ProcessError, ErrorInfo |
2. ACP Layer (src/acp/)
Purpose: Isolate ACP protocol dependency. All @agentclientprotocol/sdk imports are confined here.
| File | Purpose |
|---|---|
acp-client.ts |
UI-facing API: process spawn/kill, JSON-RPC communication, session management. Owns AcpHandler + managers. Single exit point: onSessionUpdate (multiple listeners via Set). |
acp-handler.ts |
SDK-facing: receives sessionUpdate, requestPermission, terminal ops. Filters by currentSessionId (when it is null, ALL updates pass through — spawn-failure process_error arrives with an empty sessionId). Broadcasts to all listeners. |
type-converter.ts |
Converts ACP SDK types to internal types (change buffer for protocol updates) |
permission-handler.ts |
Permission request queue, auto-approve, Promise-based resolution. All UI updates via onSessionUpdate (no separate callback path). |
terminal-handler.ts |
Terminal process create/output/kill, stdout/stderr buffering |
Key design: All agent events (messages, session updates, permissions, errors) flow through a single onSessionUpdate channel. No special paths.
3. Services Layer (src/services/)
Purpose: Non-React business logic. Classes and pure functions. No React imports.
| File | Purpose |
|---|---|
vault-service.ts |
VaultService class — vault note access, fuzzy search, CM6 selection tracking. Exports IVaultAccess, NoteMetadata. |
settings-service.ts |
SettingsService class — reactive settings store (observer pattern). Delegates session storage to SessionStorage. Exports ISettingsAccess. |
session-storage.ts |
SessionStorage class — session metadata CRUD (in plugin settings) + message file I/O (sessions/*.json). |
settings-normalizer.ts |
Pure functions — settings validation helpers (str, bool, num, enumVal, obj, strRecord, xyPoint), toAgentConfig, parseChatFontSize. |
preset-agents.ts |
Static registry (PRESET_AGENTS) of preset agent definitions — identity, spawn defaults, API-key wiring, install hints, settings-UI copy |
session-helpers.ts |
Pure functions — agent config building, API key injection, agent enumeration/resolution, enabled/default-agent policy (isAgentEnabled, firstEnabledAgentId, repairNoEnabledAgents, getDefaultAgentId, getCurrentAgent), computeSessionTitle, Gemini deprecation notice builder |
session-state.ts |
Pure functions — legacy mode/model application, config option restoration |
message-state.ts |
Pure functions — message array transforms (streaming apply, tool call upsert with O(1) index, permission scanning) |
message-sender.ts |
Pure functions — prompt preparation (embedded context vs XML text, shared helpers), sending with auth retry |
chat-exporter.ts |
ChatExporter class — markdown export with frontmatter, image handling |
view-registry.ts |
ChatViewRegistry class — multi-view focus tracking, broadcast commands. Exports IChatViewContainer. |
update-checker.ts |
Agent version checking via npm registry + plugin self-update check (checkPluginUpdate) |
4. Hooks Layer (src/hooks/)
Purpose: React state management. Hook composition via useAgent facade.
| Hook | Responsibility |
|---|---|
useAgent |
Facade: composes useAgentSession + useAgentMessages. Single onSessionUpdate subscription. Return is useMemo-wrapped. |
useAgentSession |
Session lifecycle (create/close/restart), mode/configOption with optimistic updates (no setModel — model selection goes through the category "model" config option). Uses sessionRef pattern. |
useAgentMessages |
Message state, RAF-batched streaming, permissions (activePermission derivation, approve/reject) |
useSuggestions |
@note mentions + /command suggestions (unified). Return is useMemo-wrapped. |
useSessionHistory |
Session list/load/resume/fork, local session storage, 5-min cache. Restore prefers locally saved messages; agent history replay is the fallback. Return is useMemo-wrapped. |
useChatActions |
Business callbacks (send, newChat, export, restart, config changes). Individual method deps for stability. |
useHistoryModal |
Session history modal lifecycle (lazy creation, props sync) |
useSettings |
Settings subscription via useSyncExternalStore. Also exports useSettingsSelector (slice subscription; used by ChatPanel) |
Dependency Rule: Hooks import from types/, acp/, services/, utils/. Never from ui/.
5. UI Layer (src/ui/)
Purpose: React components. Rendering and user interaction.
Core Architecture
ChatContext provides shared services to the component tree:
interface ChatContextValue {
plugin: AgentClientPlugin;
acpClient: AcpClient;
vaultService: VaultService;
settingsService: SettingsService;
}
ChatPanel is the central orchestrator:
- Calls hooks: useAgent, useSuggestions, useSessionHistory, useChatActions, useHistoryModal, useSettings
- Does NOT route session updates (useAgent handles that internally)
- Handles workspace events via ref pattern (stable event registration)
- Renders ChatHeader, MessageList, InputArea directly
ChatView (sidebar), FloatingChatView (floating window), and CodeBlockChatView (embedded in markdown code blocks) are thin wrappers:
- Obtain AcpClient from the plugin-owned pool (
plugin.getOrCreateAcpClient(viewId)); create VaultService per view - Provide ChatContext
- Render ChatPanel with
variantprop ("sidebar" | "floating" | "embedded") - Implement IChatViewContainer for broadcast commands
AcpClient instances are owned by the plugin (plugin._acpClients: Map<viewId, AcpClient>), not by views. Embedded views call acquireAcpClient/releaseAcpClient; release schedules teardown with a 250ms grace window so markdown re-renders keep the agent process alive.
Embedded chat & agent button: plugin.ts registers markdown code block processors for the languages agent-client and agent (renderAgentBlock). The fence body is YAML (utils/agent-block-parser.ts), dispatched by type: chat (default) mounts CodeBlockChatView — ensureEmbedId writes a stable id back into the block so the session can be re-associated via getSavedSessionByEmbedId; button mounts AgentButtonBlock, which calls plugin.runPromptInChat to open/focus a chat view and deliver the prompt (queued if the panel has not mounted yet).
Component Tree
ChatView / FloatingChatView / CodeBlockChatView
└── ChatContextProvider
└── ChatPanel (variant="sidebar" | "floating" | "embedded")
├── ChatHeader (variant-based rendering)
├── MessageList (virtualized via @tanstack/react-virtual)
│ └── MessageBubble (per message, React.memo)
│ ├── ToolCallBlock (React.memo) → PermissionBanner
│ ├── TerminalBlock (React.memo)
│ └── MarkdownRenderer
├── InputArea
│ ├── SuggestionPopup (mentions / commands)
│ ├── ErrorBanner
│ ├── AttachmentStrip
│ └── InputToolbar (config/mode/model/usage/send)
└── SessionHistoryModal (imperative, via useHistoryModal)
6. Utils Layer (src/utils/)
Purpose: Shared utility functions. No React. Mostly pure, but some files carry Obsidian/Node coupling (noted below).
| File | Purpose |
|---|---|
platform.ts |
Shell detection, WSL path conversion, Windows PATH from registry, platform-specific command preparation (uses execSync and Obsidian's Platform) |
paths.ts |
Path resolution (which/where), file:// URI building, relative path conversion |
error-utils.ts |
ACP error code → user-friendly title/suggestion conversion |
mention-parser.ts |
@note detection, replacement, extraction from text |
agent-block-parser.ts |
YAML parser for agent-client/agent code blocks (chat | button; uses Obsidian's parseYaml only) |
wikilink-resolver.ts |
Wikilink resolution via Obsidian's metadata cache (depends on App) |
wikilink-formatter.ts |
<obsidian_note_links> context block formatting (pure) |
text.ts |
Text helpers (truncateTitle) |
logger.ts |
Singleton logger respecting debugMode setting |
Dependency Flow
┌─────────────────────────────────────────────────────────────┐
│ UI Layer │
│ │
│ ChatView / FloatingChatView (Context Providers) │
│ └── ChatPanel (hook composition + rendering) │
│ ├── ChatHeader, MessageList, InputArea │
│ └── MessageBubble, ToolCallBlock, etc. │
└─────────────────────────────┬───────────────────────────────┘
↓ calls hooks
┌─────────────────────────────┴───────────────────────────────┐
│ Hooks Layer │
│ useAgent (facade) → useAgentSession + useAgentMessages │
│ useSuggestions, useSessionHistory, useChatActions, │
│ useHistoryModal, useSettings │
└───────────┬─────────────────────────────┬───────────────────┘
↓ calls ↓ reads types
┌───────────┴───────────┐ ┌─────────────┴───────────────────┐
│ Services Layer │ │ Types Layer │
│ VaultService │ │ chat.ts, session.ts, │
│ SettingsService │ │ agent.ts, errors.ts │
│ SessionStorage │ └─────────────────────────────────┘
│ settings-normalizer │
│ session-helpers │
│ session-state │
│ message-state │
│ message-sender │
│ chat-exporter │
│ view-registry │
└───────────┬───────────┘
↓ communicates
┌───────────┴───────────┐
│ ACP Layer │
│ acp-client.ts │
│ acp-handler.ts │
│ type-converter.ts │
│ permission-handler │
│ terminal-handler │
└───────────────────────┘
↑
@agentclientprotocol/sdk
Design Patterns
1. useAgent Facade Pattern
useAgentcomposesuseAgentSession+useAgentMessages- Single
onSessionUpdatesubscription, dispatches to both sub-hooks - ChatPanel calls useAgent, not sub-hooks directly
- Return is
useMemo-wrapped for referential stability
2. React Context for Services
ChatContextprovides plugin, acpClient, vaultService, settingsService- Value is stable (service instances don't change)
- Eliminates prop drilling for shared dependencies
3. Single Event Channel
- All agent events flow through
onSessionUpdate(messages, session updates, permissions, errors) - No special callback paths (onUpdateMessage, onError removed)
- AcpHandler filters by
currentSessionIdbefore broadcasting
4. ACP Isolation
- All
@agentclientprotocol/sdkimports confined toacp/ AcpClient(UI-facing) andAcpHandler(SDK-facing) separate concernstype-converter.tsis the change buffer for protocol updates
5. Performance Patterns
- useMemo for return stability: useAgent, useSuggestions, useSessionHistory wrap returns in useMemo
- sessionRef pattern: useAgentSession stores session in useRef, reads in callbacks without adding to deps
- Individual method deps: useChatActions uses
agent.sendMessagenotagentobject in deps - Workspace event refs: ChatPanel stores handler callbacks in refs, keeping useEffect deps minimal
- RAF batching: useAgentMessages batches streaming updates per animation frame
- React.memo: MessageBubble, ToolCallBlock, TerminalBlock for skip-render optimization
- Virtual scroll: MessageList uses @tanstack/react-virtual
- O(1) tool call index: Map<string, number> for tool call upsert
6. Observer Pattern
SettingsServicenotifies subscribers on change- React components use
useSyncExternalStore
7. Ref Pattern for Callbacks
- IChatViewContainer callbacks use refs for latest values
- Workspace event handlers use refs to avoid re-registration
- Unmount cleanup uses refs to access latest state
Key Benefits
1. Flat and Readable
- 4 layers (types → acp/services → hooks → ui)
- No port/adapter indirection
- File names reflect functionality
2. ACP Change Resistance
- Only
acp/directory needs changes for protocol updates type-converter.tslocalizes type mapping changes
3. Easy Feature Addition
- New hook: create in
hooks/, call inChatPanel, wrap return inuseMemo - New message type: add to
types/session.ts, handle inuseAgentMessagesormessage-state.ts, render inMessageBubble - New preset agent: add one entry to
PRESET_AGENTSinservices/preset-agents.ts(registry-driven)
4. Maintainability
- ~23,100 lines across 67 files
- Services testable without React (zero React imports)
- Clear dependency direction (no circular dependencies)
File Naming Conventions
| Pattern | Example |
|---|---|
| Types | kebab-case.ts in types/ |
| ACP | kebab-case.ts in acp/ |
| Services | kebab-case.ts in services/ |
| Hooks | use*.ts in hooks/ |
| Components | PascalCase.tsx in ui/ |
| Utilities | kebab-case.ts in utils/ |
Adding New Features
Adding a New Hook
- Create
hooks/use[Feature].ts - Define state with useState/useReducer
- Call the hook in
ui/ChatPanel.tsx - Pass state/callbacks to child components as props
- Wrap return object in
useMemoif passed as dependency to other hooks
Adding a New Session Update Type
- Add interface to
types/session.ts, add toSessionUpdateunion - Handle in
acp/acp-handler.tssessionUpdate()switch - Convert from ACP type in
acp/type-converter.tsif needed - Handle in
hooks/useAgentSession.tshandleSessionUpdate()(for session-level) - Or handle via
applySingleUpdate()inservices/message-state.ts(for message-level) - No routing needed in ChatPanel — useAgent handles dispatch internally
Adding a New Preset Agent
- Add one entry to
PRESET_AGENTSinservices/preset-agents.ts(presetId, defaults, optional API-key wiring, install hint, settings copy). Settings storage, enumeration, API key injection, and the settings UI are all registry-driven — no per-agent code elsewhere. - Add the documentation pages (see the "Add Preset Agent" checklist in CLAUDE.md for the full file list).
Migration Notes
March 2026: Simplified Architecture Refactoring
Refactored from Port/Adapter Architecture to simplified layered architecture:
- Removed:
domain/models/(9 files →types/4 files),domain/ports/(5 files → interfaces moved to implementation files),adapters/directory,components/directory,shared/directory - Added:
types/,acp/,services/,ui/,utils/flat directories,ChatPanel+ChatContext - Merged: VaultAdapter + MentionService → VaultService, useMentions + useAutoMention → useMentions
- Removed: useChatController (god hook → ChatPanel component), Port files (no implementation swapping planned)
- Result: 76 → 50 files, 5 → 4 layers, flat directory structure
April 2026: Simplification & Performance Refactoring
Refactored data flow, hooks, services, and performance:
- ACP wiring: 3 exit points (onSessionUpdate, onError, setUpdateMessageCallback) → 1 (onSessionUpdate only). Multiple listeners via Set. SessionId filter in AcpHandler.
- Hook consolidation: 7 hooks → 4 public hooks. useSession + useMessages + usePermission → useAgent (facade) + useAgentSession + useAgentMessages. useMentions + useSlashCommands → useSuggestions. New: useChatActions, useHistoryModal.
- ChatPanel slimmed: 1,483 → 936 lines. Session update routing removed (moved to useAgent). Business callbacks extracted to useChatActions. History modal extracted to useHistoryModal. Workspace events stabilized with refs.
- Services split: settings-service.ts (722 lines) → settings-service (285) + session-storage (267) + settings-normalizer (264). Pure functions extracted: message-state.ts, session-state.ts.
- plugin.ts cleaned: loadSettings compressed with helper functions (370 → 120 lines). Legacy floatingChatInstances removed. Double-save fixed.
- Performance: useMemo on hook returns (useAgent, useSuggestions, useSessionHistory). sessionRef pattern in useAgentSession. Individual method deps in useChatActions. Workspace event handler refs in ChatPanel.
- Result: 50 → 56 files, ~19,800 lines. Single event channel. All hooks stabilized.