From 0ccad875dc2b7b262c3754c032a0fdc0de121f00 Mon Sep 17 00:00:00 2001 From: Sophomoresty <1404462714@qq.com> Date: Fri, 6 Feb 2026 00:38:44 +0800 Subject: [PATCH 1/7] feat: use global Claude Code config and optimize streaming ## Global Config Integration - Skills: prioritize ~/.claude/skills/ over vault config - MCP: prioritize ~/.claude/mcp.json over vault config - Settings: already handled by SDK (loadUserClaudeSettings) - Agents: already supported global ~/.claude/agents/ - Hooks: already handled by SDK via settings.json This ensures Claudian uses the same configuration as Claude Code CLI. ## Streaming Performance Optimization - During streaming: display plain text (no markdown/MathJax) - At stream end: render full markdown with LaTeX - Eliminates expensive re-renders during chunk accumulation - Fixes issue with interrupted/invalidated streams ## Files Modified - src/core/storage/McpStorage.ts: add global config support - src/core/storage/SkillStorage.ts: prioritize global skills - src/core/storage/StorageService.ts: pass preferGlobal option - src/core/types/settings.ts: add 'vault' and 'global' sources - src/features/chat/controllers/StreamController.ts: streaming optimization - src/features/chat/rendering/MessageRenderer.ts: related changes --- src/core/storage/McpStorage.ts | 102 +- src/core/storage/SkillStorage.ts | 136 +- src/core/storage/StorageService.ts | 6 +- src/core/types/settings.ts | 2 +- .../chat/controllers/StreamController.ts | 19 +- .../chat/rendering/MessageRenderer.ts | 1286 +++++++++-------- 6 files changed, 917 insertions(+), 634 deletions(-) diff --git a/src/core/storage/McpStorage.ts b/src/core/storage/McpStorage.ts index 053bf501..2891ed99 100644 --- a/src/core/storage/McpStorage.ts +++ b/src/core/storage/McpStorage.ts @@ -1,5 +1,7 @@ /** - * McpStorage - Handles .claude/mcp.json read/write + * McpStorage - Handles MCP server configuration read/write + * + * Prioritizes global Claude Code config (~/.claude/mcp.json) over vault config. * * MCP server configurations are stored in Claude Code-compatible format * with optional Claudian-specific metadata in _claudian field. @@ -17,6 +19,10 @@ * } */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + import type { ClaudianMcpConfigFile, ClaudianMcpServer, @@ -29,16 +35,52 @@ import type { VaultFileAdapter } from './VaultFileAdapter'; /** Path to MCP config file relative to vault root. */ export const MCP_CONFIG_PATH = '.claude/mcp.json'; +/** Global MCP config path (shared with Claude Code CLI). */ +const GLOBAL_MCP_CONFIG_PATH = path.join(os.homedir(), '.claude', 'mcp.json'); + export class McpStorage { - constructor(private adapter: VaultFileAdapter) {} + private useGlobalConfig: boolean; + + constructor( + private adapter: VaultFileAdapter, + options?: { preferGlobal?: boolean } + ) { + // Default to global config (Claude Code CLI behavior) + this.useGlobalConfig = options?.preferGlobal ?? true; + } async load(): Promise { + // Try global config first (Claude Code CLI compatibility) + if (this.useGlobalConfig) { + const globalServers = await this.loadFromFile(GLOBAL_MCP_CONFIG_PATH); + if (globalServers.length > 0) { + return globalServers; + } + } + + // Fallback to vault config + return this.loadFromFile(MCP_CONFIG_PATH, true); + } + + private async loadFromFile( + filePath: string, + isVaultPath = false + ): Promise { try { - if (!(await this.adapter.exists(MCP_CONFIG_PATH))) { - return []; + let content: string; + + if (isVaultPath) { + if (!(await this.adapter.exists(filePath))) { + return []; + } + content = await this.adapter.read(filePath); + } else { + if (!fs.existsSync(filePath)) { + return []; + } + content = fs.readFileSync(filePath, 'utf-8'); } - const content = await this.adapter.read(MCP_CONFIG_PATH); const file = JSON.parse(content) as ClaudianMcpConfigFile; if (!file.mcpServers || typeof file.mcpServers !== 'object') { @@ -115,16 +157,34 @@ export class McpStorage { } } + // Determine target file path + const targetPath = this.useGlobalConfig ? GLOBAL_MCP_CONFIG_PATH : MCP_CONFIG_PATH; + + // Load existing file (if any) let existing: Record | 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; + if (this.useGlobalConfig) { + if (fs.existsSync(targetPath)) { + try { + const raw = fs.readFileSync(targetPath, 'utf-8'); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + existing = parsed as Record; + } + } catch { + existing = null; + } + } + } else { + if (await this.adapter.exists(targetPath)) { + try { + const raw = await this.adapter.read(targetPath); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + existing = parsed as Record; + } + } catch { + existing = null; } - } catch { - existing = null; } } @@ -150,10 +210,24 @@ export class McpStorage { } const content = JSON.stringify(file, null, 2); - await this.adapter.write(MCP_CONFIG_PATH, content); + + // Write to target location + if (this.useGlobalConfig) { + // Ensure directory exists + const dir = path.dirname(targetPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(targetPath, content, 'utf-8'); + } else { + await this.adapter.write(MCP_CONFIG_PATH, content); + } } async exists(): Promise { + if (this.useGlobalConfig) { + return fs.existsSync(GLOBAL_MCP_CONFIG_PATH); + } return this.adapter.exists(MCP_CONFIG_PATH); } diff --git a/src/core/storage/SkillStorage.ts b/src/core/storage/SkillStorage.ts index 689a783e..4bfa03a6 100644 --- a/src/core/storage/SkillStorage.ts +++ b/src/core/storage/SkillStorage.ts @@ -1,20 +1,59 @@ +/** + * Claudian - Skill Storage + * + * Prioritizes global Claude Code config (~/.claude/skills/) over vault config. + * This ensures Claudian uses the same skills as Claude Code CLI. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + import { parsedToSlashCommand, parseSlashCommandContent, serializeCommand } from '../../utils/slashCommand'; import type { SlashCommand } from '../types'; import type { VaultFileAdapter } from './VaultFileAdapter'; export const SKILLS_PATH = '.claude/skills'; +const GLOBAL_SKILLS_DIR = path.join(os.homedir(), '.claude', 'skills'); export class SkillStorage { - constructor(private adapter: VaultFileAdapter) {} + private useGlobal: boolean; + + constructor( + private adapter: VaultFileAdapter, + options?: { preferGlobal?: boolean } + ) { + // Default to global config (Claude Code CLI compatibility) + this.useGlobal = options?.preferGlobal ?? true; + } + /** + * Load all skills. Prioritizes global skills over vault skills. + * Vault skills with the same name as global skills are ignored (global takes precedence). + */ async loadAll(): Promise { const skills: SlashCommand[] = []; + const globalSkillNames = new Set(); + + // Load global skills first (higher priority) + if (this.useGlobal) { + const globalSkills = await this.loadGlobal(); + for (const skill of globalSkills) { + skills.push(skill); + globalSkillNames.add(skill.name); + } + } + // Load vault skills (skip if global skill with same name exists) try { const folders = await this.adapter.listFolders(SKILLS_PATH); for (const folder of folders) { const skillName = folder.split('/').pop()!; + + // Skip if global skill with same name exists + if (globalSkillNames.has(skillName)) continue; + const skillPath = `${SKILLS_PATH}/${skillName}/SKILL.md`; try { @@ -26,14 +65,56 @@ export class SkillStorage { skills.push(parsedToSlashCommand(parsed, { id: `skill-${skillName}`, name: skillName, - source: 'user', + source: 'vault', + })); + } catch { + // Non-critical: skip malformed skill files + } + } + } catch { + // Non-critical: directory may not exist yet + } + + return skills; + } + + /** + * Load skills from global ~/.claude/skills/ directory. + * Shared across all vaults and with Claude Code CLI. + */ + async loadGlobal(): Promise { + const skills: SlashCommand[] = []; + + if (!fs.existsSync(GLOBAL_SKILLS_DIR)) { + return skills; + } + + try { + const entries = fs.readdirSync(GLOBAL_SKILLS_DIR, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const skillName = entry.name; + const skillMdPath = path.join(GLOBAL_SKILLS_DIR, skillName, 'SKILL.md'); + + try { + if (!fs.existsSync(skillMdPath)) continue; + + const content = fs.readFileSync(skillMdPath, 'utf-8'); + const parsed = parseSlashCommandContent(content); + + skills.push(parsedToSlashCommand(parsed, { + id: `skill-${skillName}`, + name: skillName, + source: 'global', })); } catch { // Non-critical: skip malformed skill files } } } catch { - return []; + // Non-critical: directory may be unreadable } return skills; @@ -41,18 +122,51 @@ export class SkillStorage { async save(skill: SlashCommand): Promise { const name = skill.name; - const dirPath = `${SKILLS_PATH}/${name}`; - const filePath = `${dirPath}/SKILL.md`; - await this.adapter.ensureFolder(dirPath); - await this.adapter.write(filePath, serializeCommand(skill)); + if (this.useGlobal) { + // Save to global location + const dirPath = path.join(GLOBAL_SKILLS_DIR, name); + const filePath = path.join(dirPath, 'SKILL.md'); + + // Ensure directory exists + if (!fs.existsSync(GLOBAL_SKILLS_DIR)) { + fs.mkdirSync(GLOBAL_SKILLS_DIR, { recursive: true }); + } + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + + fs.writeFileSync(filePath, serializeCommand(skill), 'utf-8'); + } else { + // Save to vault location + const dirPath = `${SKILLS_PATH}/${name}`; + const filePath = `${dirPath}/SKILL.md`; + + await this.adapter.ensureFolder(dirPath); + await this.adapter.write(filePath, serializeCommand(skill)); + } } async delete(skillId: string): Promise { const name = skillId.replace(/^skill-/, ''); - const dirPath = `${SKILLS_PATH}/${name}`; - const filePath = `${dirPath}/SKILL.md`; - await this.adapter.delete(filePath); - await this.adapter.deleteFolder(dirPath); + + if (this.useGlobal) { + // Delete from global location + const dirPath = path.join(GLOBAL_SKILLS_DIR, name); + const filePath = path.join(dirPath, 'SKILL.md'); + + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + if (fs.existsSync(dirPath)) { + fs.rmSync(dirPath, { recursive: true, force: true }); + } + } else { + // Delete from vault location + const dirPath = `${SKILLS_PATH}/${name}`; + const filePath = `${dirPath}/SKILL.md`; + await this.adapter.delete(filePath); + await this.adapter.deleteFolder(dirPath); + } } } diff --git a/src/core/storage/StorageService.ts b/src/core/storage/StorageService.ts index a4e42c00..ff90f06e 100644 --- a/src/core/storage/StorageService.ts +++ b/src/core/storage/StorageService.ts @@ -129,9 +129,10 @@ export class StorageService { this.ccSettings = new CCSettingsStorage(this.adapter); this.claudianSettings = new ClaudianSettingsStorage(this.adapter); this.commands = new SlashCommandStorage(this.adapter); - this.skills = new SkillStorage(this.adapter); + // Prefer global config (~/.claude/) over vault config for CC compatibility + this.skills = new SkillStorage(this.adapter, { preferGlobal: true }); this.sessions = new SessionStorage(this.adapter); - this.mcp = new McpStorage(this.adapter); + this.mcp = new McpStorage(this.adapter, { preferGlobal: true }); this.agents = new AgentVaultStorage(this.adapter); } @@ -378,6 +379,7 @@ export class StorageService { async loadAllSlashCommands(): Promise { const commands = await this.commands.loadAll(); + // loadAll() now includes both global and vault skills (global takes precedence) const skills = await this.skills.loadAll(); return [...commands, ...skills]; } diff --git a/src/core/types/settings.ts b/src/core/types/settings.ts index 735347bd..ef36e856 100644 --- a/src/core/types/settings.ts +++ b/src/core/types/settings.ts @@ -199,7 +199,7 @@ export interface EnvSnippet { } /** Source of a slash command. */ -export type SlashCommandSource = 'builtin' | 'user' | 'plugin' | 'sdk'; +export type SlashCommandSource = 'builtin' | 'user' | 'plugin' | 'sdk' | 'vault' | 'global'; /** Slash command configuration with Claude Code compatibility. */ export interface SlashCommand { diff --git a/src/features/chat/controllers/StreamController.ts b/src/features/chat/controllers/StreamController.ts index 11255efb..6623573c 100644 --- a/src/features/chat/controllers/StreamController.ts +++ b/src/features/chat/controllers/StreamController.ts @@ -378,7 +378,7 @@ export class StreamController { // ============================================ async appendText(text: string): Promise { - const { state, renderer } = this.deps; + const { state } = this.deps; if (!state.currentContentEl) return; this.hideThinkingIndicator(); @@ -389,19 +389,26 @@ export class StreamController { } state.currentTextContent += text; - await renderer.renderContent(state.currentTextEl, state.currentTextContent); + + // Streaming optimization: Show plain text immediately (very fast). + // Full markdown render with MathJax happens in finalizeCurrentTextBlock(). + state.currentTextEl.textContent = state.currentTextContent; } finalizeCurrentTextBlock(msg?: ChatMessage): void { const { state, renderer } = this.deps; + + // At stream end, do full markdown render (includes MathJax). if (msg && state.currentTextContent) { msg.contentBlocks = msg.contentBlocks || []; msg.contentBlocks.push({ type: 'text', content: state.currentTextContent }); - // Copy button added here (not during streaming) to match history-loaded messages + if (state.currentTextEl) { + void renderer.renderContent(state.currentTextEl, state.currentTextContent); renderer.addTextCopyButton(state.currentTextEl, state.currentTextContent); } } + state.currentTextEl = null; state.currentTextContent = ''; } @@ -765,6 +772,12 @@ export class StreamController { resetStreamingState(): void { const { state } = this.deps; this.hideThinkingIndicator(); + + // Render any pending text content before clearing (for invalidated streams) + if (state.currentTextEl && state.currentTextContent) { + void this.deps.renderer.renderContent(state.currentTextEl, state.currentTextContent); + } + state.currentContentEl = null; state.currentTextEl = null; state.currentTextContent = ''; diff --git a/src/features/chat/rendering/MessageRenderer.ts b/src/features/chat/rendering/MessageRenderer.ts index 1ff1f7ed..4adc0e82 100644 --- a/src/features/chat/rendering/MessageRenderer.ts +++ b/src/features/chat/rendering/MessageRenderer.ts @@ -1,603 +1,683 @@ -import type { App, Component } from 'obsidian'; -import { MarkdownRenderer, Notice } from 'obsidian'; - -import { isWriteEditTool, TOOL_AGENT_OUTPUT, TOOL_TASK } from '../../../core/tools/toolNames'; -import type { ChatMessage, ImageAttachment, ToolCallInfo } from '../../../core/types'; -import { t } from '../../../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 { - renderStoredAsyncSubagent, - renderStoredSubagent, -} from './SubagentRenderer'; -import { renderStoredThinkingBlock } from './ThinkingBlockRenderer'; -import { renderStoredToolCall } from './ToolCallRenderer'; -import { renderStoredWriteEdit } from './WriteEditRenderer'; - -export type RenderContentFn = (el: HTMLElement, markdown: string) => Promise; - -export class MessageRenderer { - private app: App; - private plugin: ClaudianPlugin; - private component: Component; - private messagesEl: HTMLElement; - private rewindCallback?: (messageId: string) => Promise; - private forkCallback?: (messageId: string) => Promise; - private liveMessageEls = new Map(); - - private static readonly REWIND_ICON = ``; - - private static readonly FORK_ICON = ``; - - constructor( - plugin: ClaudianPlugin, - component: Component, - messagesEl: HTMLElement, - rewindCallback?: (messageId: string) => Promise, - forkCallback?: (messageId: string) => Promise, - ) { - this.app = plugin.app; - this.plugin = plugin; - this.component = component; - this.messagesEl = messagesEl; - this.rewindCallback = rewindCallback; - this.forkCallback = forkCallback; - - // Register delegated click handler for file links - registerFileLinkHandler(this.app, this.messagesEl, this.component); - } - - /** Sets the messages container element. */ - setMessagesEl(el: HTMLElement): void { - this.messagesEl = el; - } - - // ============================================ - // Streaming Message Rendering - // ============================================ - - /** - * Adds a new message to the chat during streaming. - * Returns the message element for content updates. - */ - addMessage(msg: ChatMessage): HTMLElement { - // Render images above message bubble for user messages - if (msg.role === 'user' && msg.images && msg.images.length > 0) { - this.renderMessageImages(this.messagesEl, msg.images); - } - - // Skip empty bubble for image-only messages - if (msg.role === 'user') { - const textToShow = msg.displayContent ?? msg.content; - if (!textToShow) { - this.scrollToBottom(); - const lastChild = this.messagesEl.lastElementChild as HTMLElement; - return lastChild ?? this.messagesEl; - } - } - - const msgEl = this.messagesEl.createDiv({ - cls: `claudian-message claudian-message-${msg.role}`, - }); - - const contentEl = msgEl.createDiv({ cls: 'claudian-message-content', attr: { dir: 'auto' } }); - - if (msg.role === 'user') { - const textToShow = msg.displayContent ?? msg.content; - if (textToShow) { - const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); - void this.renderContent(textEl, textToShow); - this.addUserCopyButton(msgEl, textToShow); - } - if (this.rewindCallback || this.forkCallback) { - this.liveMessageEls.set(msg.id, msgEl); - } - } - - this.scrollToBottom(); - return msgEl; - } - - // ============================================ - // Stored Message Rendering (Batch/Replay) - // ============================================ - - /** - * Renders all messages for conversation load/switch. - * @param messages Array of messages to render - * @param getGreeting Function to get greeting text - * @returns The newly created welcome element - */ - renderMessages( - messages: ChatMessage[], - getGreeting: () => string - ): HTMLElement { - this.messagesEl.empty(); - this.liveMessageEls.clear(); - - // Recreate welcome element after clearing - const newWelcomeEl = this.messagesEl.createDiv({ cls: 'claudian-welcome' }); - newWelcomeEl.createDiv({ cls: 'claudian-welcome-greeting', text: getGreeting() }); - - for (let i = 0; i < messages.length; i++) { - this.renderStoredMessage(messages[i], messages, i); - } - - this.scrollToBottom(); - return newWelcomeEl; - } - - renderStoredMessage(msg: ChatMessage, allMessages?: ChatMessage[], index?: number): void { - // Render interrupt messages with special styling (not as user bubbles) - if (msg.isInterrupt) { - this.renderInterruptMessage(); - return; - } - - // Skip rebuilt context messages (history sent to SDK on session reset) - // These are internal context for the AI, not actual user messages to display - if (msg.isRebuiltContext) { - return; - } - - // Render images above bubble for user messages - if (msg.role === 'user' && msg.images && msg.images.length > 0) { - this.renderMessageImages(this.messagesEl, msg.images); - } - - // Skip empty bubble for image-only messages - if (msg.role === 'user') { - const textToShow = msg.displayContent ?? msg.content; - if (!textToShow) { - return; - } - } - - const msgEl = this.messagesEl.createDiv({ - cls: `claudian-message claudian-message-${msg.role}`, - }); - - const contentEl = msgEl.createDiv({ cls: 'claudian-message-content', attr: { dir: 'auto' } }); - - if (msg.role === 'user') { - const textToShow = msg.displayContent ?? msg.content; - if (textToShow) { - const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); - void this.renderContent(textEl, textToShow); - this.addUserCopyButton(msgEl, textToShow); - } - if (msg.sdkUserUuid && this.isRewindEligible(allMessages, index)) { - if (this.rewindCallback) { - this.addRewindButton(msgEl, msg.id); - } - if (this.forkCallback) { - this.addForkButton(msgEl, msg.id); - } - } - } else if (msg.role === 'assistant') { - this.renderAssistantContent(msg, contentEl); - } - } - - 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' } }); - const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); - textEl.innerHTML = 'Interrupted · What should Claudian do instead?'; - } - - /** - * Renders assistant message content (content blocks or fallback). - */ - private renderAssistantContent(msg: ChatMessage, contentEl: HTMLElement): void { - if (msg.contentBlocks && msg.contentBlocks.length > 0) { - for (const block of msg.contentBlocks) { - if (block.type === 'thinking') { - renderStoredThinkingBlock( - contentEl, - block.content, - block.durationSeconds, - (el, md) => this.renderContent(el, md) - ); - } else if (block.type === 'text') { - // Skip empty or whitespace-only text blocks to avoid extra gaps - if (!block.content || !block.content.trim()) { - continue; - } - const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); - void this.renderContent(textEl, block.content); - this.addTextCopyButton(textEl, block.content); - } else if (block.type === 'tool_use') { - const toolCall = msg.toolCalls?.find(tc => tc.id === block.toolId); - if (toolCall) { - this.renderToolCall(contentEl, toolCall); - } - } else if (block.type === 'compact_boundary') { - const boundaryEl = contentEl.createDiv({ cls: 'claudian-compact-boundary' }); - boundaryEl.createSpan({ cls: 'claudian-compact-boundary-label', text: 'Conversation compacted' }); - } else if (block.type === 'subagent') { - const subagent = msg.subagents?.find(s => s.id === block.subagentId); - if (subagent) { - const mode = block.mode || subagent.mode || 'sync'; - if (mode === 'async') { - renderStoredAsyncSubagent(contentEl, subagent); - } else { - renderStoredSubagent(contentEl, subagent); - } - } - } - } - } else { - // Fallback for old conversations without contentBlocks - if (msg.content) { - const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); - void this.renderContent(textEl, msg.content); - this.addTextCopyButton(textEl, msg.content); - } - if (msg.toolCalls) { - for (const toolCall of msg.toolCalls) { - this.renderToolCall(contentEl, toolCall); - } - } - } - - // Render response duration footer (skip when message contains a compaction boundary) - const hasCompactBoundary = msg.contentBlocks?.some(b => b.type === 'compact_boundary'); - if (msg.durationSeconds && msg.durationSeconds > 0 && !hasCompactBoundary) { - const flavorWord = msg.durationFlavorWord || 'Baked'; - const footerEl = contentEl.createDiv({ cls: 'claudian-response-footer' }); - footerEl.createSpan({ - text: `* ${flavorWord} for ${formatDurationMmSs(msg.durationSeconds)}`, - cls: 'claudian-baked-duration', - }); - } - } - - /** - * Renders a tool call with special handling for Write/Edit and Task (subagent). - * TaskOutput is hidden as it's an internal tool for async subagent communication. - */ - private renderToolCall(contentEl: HTMLElement, toolCall: ToolCallInfo): void { - // Skip TaskOutput - it's invisible (internal async subagent communication) - if (toolCall.name === TOOL_AGENT_OUTPUT) { - return; - } - if (isWriteEditTool(toolCall.name)) { - renderStoredWriteEdit(contentEl, toolCall); - } else if (toolCall.name === TOOL_TASK) { - // Backward compatibility: render Task tools as subagents - let status: 'completed' | 'error' | 'running'; - switch (toolCall.status) { - case 'completed': - status = 'completed'; - break; - case 'error': - status = 'error'; - break; - default: - status = 'running'; - } - const subagentInfo = { - id: toolCall.id, - description: (toolCall.input?.description as string) || 'Subagent task', - status, - toolCalls: [], - isExpanded: false, - result: toolCall.result, - }; - renderStoredSubagent(contentEl, subagentInfo); - } else { - renderStoredToolCall(contentEl, toolCall); - } - } - - // ============================================ - // Image Rendering - // ============================================ - - /** - * Renders image attachments above a message. - */ - renderMessageImages(containerEl: HTMLElement, images: ImageAttachment[]): void { - const imagesEl = containerEl.createDiv({ cls: 'claudian-message-images' }); - - for (const image of images) { - const imageWrapper = imagesEl.createDiv({ cls: 'claudian-message-image' }); - const imgEl = imageWrapper.createEl('img', { - attr: { - alt: image.name, - }, - }); - - void this.setImageSrc(imgEl, image); - - // Click to view full size - imgEl.addEventListener('click', () => { - void this.showFullImage(image); - }); - } - } - - /** - * Shows full-size image in modal overlay. - */ - showFullImage(image: ImageAttachment): void { - const dataUri = `data:${image.mediaType};base64,${image.data}`; - - const overlay = document.body.createDiv({ cls: 'claudian-image-modal-overlay' }); - const modal = overlay.createDiv({ cls: 'claudian-image-modal' }); - - modal.createEl('img', { - attr: { - src: dataUri, - alt: image.name, - }, - }); - - const closeBtn = modal.createDiv({ cls: 'claudian-image-modal-close' }); - closeBtn.setText('\u00D7'); - - const handleEsc = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - close(); - } - }; - - const close = () => { - document.removeEventListener('keydown', handleEsc); - overlay.remove(); - }; - - closeBtn.addEventListener('click', close); - overlay.addEventListener('click', (e) => { - if (e.target === overlay) close(); - }); - document.addEventListener('keydown', handleEsc); - } - - /** - * Sets image src from attachment data. - */ - setImageSrc(imgEl: HTMLImageElement, image: ImageAttachment): void { - const dataUri = `data:${image.mediaType};base64,${image.data}`; - imgEl.setAttribute('src', dataUri); - } - - // ============================================ - // Content Rendering - // ============================================ - - /** - * Renders markdown content with code block enhancements. - */ - async renderContent(el: HTMLElement, markdown: string): Promise { - el.empty(); - - try { - // Replace image embeds with HTML img tags before rendering - const processedMarkdown = replaceImageEmbedsWithHtml( - markdown, - this.app, - this.plugin.settings.mediaFolder - ); - await MarkdownRenderer.renderMarkdown(processedMarkdown, el, '', this.component); - - // Wrap pre elements and move buttons outside scroll area - el.querySelectorAll('pre').forEach((pre) => { - // Skip if already wrapped - if (pre.parentElement?.classList.contains('claudian-code-wrapper')) return; - - // Create wrapper - const wrapper = createEl('div', { cls: 'claudian-code-wrapper' }); - pre.parentElement?.insertBefore(wrapper, pre); - wrapper.appendChild(pre); - - // Check for language class and add label - const code = pre.querySelector('code[class*="language-"]'); - if (code) { - const match = code.className.match(/language-(\w+)/); - if (match) { - wrapper.classList.add('has-language'); - const label = createEl('span', { - cls: 'claudian-code-lang-label', - text: match[1], - }); - wrapper.appendChild(label); - label.addEventListener('click', async () => { - try { - await navigator.clipboard.writeText(code.textContent || ''); - label.setText('copied!'); - setTimeout(() => label.setText(match[1]), 1500); - } catch { - // Clipboard API may fail in non-secure contexts - } - }); - } - } - - // Move Obsidian's copy button outside pre into wrapper - const copyBtn = pre.querySelector('.copy-code-button'); - if (copyBtn) { - wrapper.appendChild(copyBtn); - } - }); - - // Process file paths to make them clickable links - processFileLinks(this.app, el); - } catch { - el.createDiv({ - cls: 'claudian-render-error', - text: 'Failed to render message content.', - }); - } - } - - // ============================================ - // Copy Button - // ============================================ - - /** Clipboard icon SVG for copy button. */ - private static readonly COPY_ICON = ``; - - /** - * Adds a copy button to a text block. - * Button shows clipboard icon on hover, changes to "copied!" on click. - * @param textEl The rendered text element - * @param markdown The original markdown content to copy - */ - addTextCopyButton(textEl: HTMLElement, markdown: string): void { - const copyBtn = textEl.createSpan({ cls: 'claudian-text-copy-btn' }); - copyBtn.innerHTML = MessageRenderer.COPY_ICON; - - let feedbackTimeout: ReturnType | null = null; - - copyBtn.addEventListener('click', async (e) => { - e.stopPropagation(); - - try { - await navigator.clipboard.writeText(markdown); - } catch { - // Clipboard API may fail in non-secure contexts - return; - } - - // Clear any pending timeout from rapid clicks - if (feedbackTimeout) { - clearTimeout(feedbackTimeout); - } - - // Show "copied!" feedback - copyBtn.innerHTML = ''; - copyBtn.setText('copied!'); - copyBtn.classList.add('copied'); - - feedbackTimeout = setTimeout(() => { - copyBtn.innerHTML = MessageRenderer.COPY_ICON; - copyBtn.classList.remove('copied'); - feedbackTimeout = null; - }, 1500); - }); - } - - refreshActionButtons(msg: ChatMessage, allMessages?: ChatMessage[], index?: number): void { - if (!msg.sdkUserUuid) return; - if (!this.isRewindEligible(allMessages, index)) return; - const msgEl = this.liveMessageEls.get(msg.id); - if (!msgEl) return; - - if (this.rewindCallback && !msgEl.querySelector('.claudian-message-rewind-btn')) { - this.addRewindButton(msgEl, msg.id); - } - if (this.forkCallback && !msgEl.querySelector('.claudian-message-fork-btn')) { - this.addForkButton(msgEl, msg.id); - } - this.cleanupLiveMessageEl(msg.id, msgEl); - } - - private cleanupLiveMessageEl(msgId: string, msgEl: HTMLElement): void { - const needsRewind = this.rewindCallback && !msgEl.querySelector('.claudian-message-rewind-btn'); - const needsFork = this.forkCallback && !msgEl.querySelector('.claudian-message-fork-btn'); - if (!needsRewind && !needsFork) { - this.liveMessageEls.delete(msgId); - } - } - - private getOrCreateActionsToolbar(msgEl: HTMLElement): HTMLElement { - const existing = msgEl.querySelector('.claudian-user-msg-actions') as HTMLElement | null; - if (existing) return existing; - return msgEl.createDiv({ cls: 'claudian-user-msg-actions' }); - } - - private addUserCopyButton(msgEl: HTMLElement, content: string): void { - const toolbar = this.getOrCreateActionsToolbar(msgEl); - const copyBtn = toolbar.createSpan({ cls: 'claudian-user-msg-copy-btn' }); - copyBtn.innerHTML = MessageRenderer.COPY_ICON; - copyBtn.setAttribute('aria-label', 'Copy message'); - - let feedbackTimeout: ReturnType | null = null; - - copyBtn.addEventListener('click', async (e) => { - e.stopPropagation(); - try { - await navigator.clipboard.writeText(content); - } catch { - return; - } - if (feedbackTimeout) clearTimeout(feedbackTimeout); - copyBtn.innerHTML = ''; - copyBtn.setText('copied!'); - copyBtn.classList.add('copied'); - feedbackTimeout = setTimeout(() => { - copyBtn.innerHTML = MessageRenderer.COPY_ICON; - copyBtn.classList.remove('copied'); - feedbackTimeout = null; - }, 1500); - }); - } - - private addRewindButton(msgEl: HTMLElement, messageId: string): void { - const toolbar = this.getOrCreateActionsToolbar(msgEl); - const btn = toolbar.createSpan({ cls: 'claudian-message-rewind-btn' }); - if (toolbar.firstChild !== btn) toolbar.insertBefore(btn, toolbar.firstChild); - btn.innerHTML = MessageRenderer.REWIND_ICON; - btn.setAttribute('aria-label', t('chat.rewind.ariaLabel')); - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - try { - await this.rewindCallback?.(messageId); - } catch (err) { - new Notice(t('chat.rewind.failed', { error: err instanceof Error ? err.message : 'Unknown error' })); - } - }); - } - - private addForkButton(msgEl: HTMLElement, messageId: string): void { - const toolbar = this.getOrCreateActionsToolbar(msgEl); - const btn = toolbar.createSpan({ cls: 'claudian-message-fork-btn' }); - if (toolbar.firstChild !== btn) toolbar.insertBefore(btn, toolbar.firstChild); - btn.innerHTML = MessageRenderer.FORK_ICON; - btn.setAttribute('aria-label', t('chat.fork.ariaLabel')); - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - try { - await this.forkCallback?.(messageId); - } catch (err) { - new Notice(t('chat.fork.failed', { error: err instanceof Error ? err.message : 'Unknown error' })); - } - }); - } - - // ============================================ - // Utilities - // ============================================ - - /** Scrolls messages container to bottom. */ - scrollToBottom(): void { - this.messagesEl.scrollTop = this.messagesEl.scrollHeight; - } - - /** Scrolls to bottom if already near bottom (within threshold). */ - scrollToBottomIfNeeded(threshold = 100): void { - const { scrollTop, scrollHeight, clientHeight } = this.messagesEl; - const isNearBottom = scrollHeight - scrollTop - clientHeight < threshold; - if (isNearBottom) { - requestAnimationFrame(() => { - this.messagesEl.scrollTop = this.messagesEl.scrollHeight; - }); - } - } - -} +import type { App, Component } from 'obsidian'; +import { MarkdownRenderer, Notice } from 'obsidian'; + +import { isWriteEditTool, TOOL_AGENT_OUTPUT, TOOL_TASK } from '../../../core/tools/toolNames'; +import type { ChatMessage, ImageAttachment, ToolCallInfo } from '../../../core/types'; +import { t } from '../../../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 { + renderStoredAsyncSubagent, + renderStoredSubagent, +} from './SubagentRenderer'; +import { renderStoredThinkingBlock } from './ThinkingBlockRenderer'; +import { renderStoredToolCall } from './ToolCallRenderer'; +import { renderStoredWriteEdit } from './WriteEditRenderer'; + +export type RenderContentFn = (el: HTMLElement, markdown: string) => Promise; + +export class MessageRenderer { + private app: App; + private plugin: ClaudianPlugin; + private component: Component; + private messagesEl: HTMLElement; + private rewindCallback?: (messageId: string) => Promise; + private forkCallback?: (messageId: string) => Promise; + private liveMessageEls = new Map(); + + private static readonly REWIND_ICON = ``; + + private static readonly FORK_ICON = ``; + + constructor( + plugin: ClaudianPlugin, + component: Component, + messagesEl: HTMLElement, + rewindCallback?: (messageId: string) => Promise, + forkCallback?: (messageId: string) => Promise, + ) { + this.app = plugin.app; + this.plugin = plugin; + this.component = component; + this.messagesEl = messagesEl; + this.rewindCallback = rewindCallback; + this.forkCallback = forkCallback; + + // Register delegated click handler for file links + registerFileLinkHandler(this.app, this.messagesEl, this.component); + } + + /** Sets the messages container element. */ + setMessagesEl(el: HTMLElement): void { + this.messagesEl = el; + } + + // ============================================ + // Streaming Message Rendering + // ============================================ + + /** + * Adds a new message to the chat during streaming. + * Returns the message element for content updates. + */ + addMessage(msg: ChatMessage): HTMLElement { + // Render images above message bubble for user messages + if (msg.role === 'user' && msg.images && msg.images.length > 0) { + this.renderMessageImages(this.messagesEl, msg.images); + } + + // Skip empty bubble for image-only messages + if (msg.role === 'user') { + const textToShow = msg.displayContent ?? msg.content; + if (!textToShow) { + this.scrollToBottom(); + const lastChild = this.messagesEl.lastElementChild as HTMLElement; + return lastChild ?? this.messagesEl; + } + } + + const msgEl = this.messagesEl.createDiv({ + cls: `claudian-message claudian-message-${msg.role}`, + }); + + const contentEl = msgEl.createDiv({ cls: 'claudian-message-content', attr: { dir: 'auto' } }); + + if (msg.role === 'user') { + const textToShow = msg.displayContent ?? msg.content; + if (textToShow) { + const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); + void this.renderContent(textEl, textToShow); + this.addUserCopyButton(msgEl, textToShow); + } + if (this.rewindCallback || this.forkCallback) { + this.liveMessageEls.set(msg.id, msgEl); + } + } + + this.scrollToBottom(); + return msgEl; + } + + // ============================================ + // Stored Message Rendering (Batch/Replay) + // ============================================ + + /** + * Renders all messages for conversation load/switch. + * @param messages Array of messages to render + * @param getGreeting Function to get greeting text + * @returns The newly created welcome element + */ + renderMessages( + messages: ChatMessage[], + getGreeting: () => string + ): HTMLElement { + this.messagesEl.empty(); + this.liveMessageEls.clear(); + + // Recreate welcome element after clearing + const newWelcomeEl = this.messagesEl.createDiv({ cls: 'claudian-welcome' }); + newWelcomeEl.createDiv({ cls: 'claudian-welcome-greeting', text: getGreeting() }); + + for (let i = 0; i < messages.length; i++) { + this.renderStoredMessage(messages[i], messages, i); + } + + this.scrollToBottom(); + return newWelcomeEl; + } + + renderStoredMessage(msg: ChatMessage, allMessages?: ChatMessage[], index?: number): void { + // Render interrupt messages with special styling (not as user bubbles) + if (msg.isInterrupt) { + this.renderInterruptMessage(); + return; + } + + // Skip rebuilt context messages (history sent to SDK on session reset) + // These are internal context for the AI, not actual user messages to display + if (msg.isRebuiltContext) { + return; + } + + // Render images above bubble for user messages + if (msg.role === 'user' && msg.images && msg.images.length > 0) { + this.renderMessageImages(this.messagesEl, msg.images); + } + + // Skip empty bubble for image-only messages + if (msg.role === 'user') { + const textToShow = msg.displayContent ?? msg.content; + if (!textToShow) { + return; + } + } + + const msgEl = this.messagesEl.createDiv({ + cls: `claudian-message claudian-message-${msg.role}`, + }); + + const contentEl = msgEl.createDiv({ cls: 'claudian-message-content', attr: { dir: 'auto' } }); + + if (msg.role === 'user') { + const textToShow = msg.displayContent ?? msg.content; + if (textToShow) { + const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); + void this.renderContent(textEl, textToShow); + this.addUserCopyButton(msgEl, textToShow); + } + if (msg.sdkUserUuid && this.isRewindEligible(allMessages, index)) { + if (this.rewindCallback) { + this.addRewindButton(msgEl, msg.id); + } + if (this.forkCallback) { + this.addForkButton(msgEl, msg.id); + } + } + } else if (msg.role === 'assistant') { + this.renderAssistantContent(msg, contentEl); + } + } + + 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' } }); + const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); + textEl.innerHTML = 'Interrupted · What should Claudian do instead?'; + } + + /** + * Renders assistant message content (content blocks or fallback). + */ + private renderAssistantContent(msg: ChatMessage, contentEl: HTMLElement): void { + if (msg.contentBlocks && msg.contentBlocks.length > 0) { + for (const block of msg.contentBlocks) { + if (block.type === 'thinking') { + renderStoredThinkingBlock( + contentEl, + block.content, + block.durationSeconds, + (el, md) => this.renderContent(el, md) + ); + } else if (block.type === 'text') { + // Skip empty or whitespace-only text blocks to avoid extra gaps + if (!block.content || !block.content.trim()) { + continue; + } + const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); + void this.renderContent(textEl, block.content); + this.addTextCopyButton(textEl, block.content); + } else if (block.type === 'tool_use') { + const toolCall = msg.toolCalls?.find(tc => tc.id === block.toolId); + if (toolCall) { + this.renderToolCall(contentEl, toolCall); + } + } else if (block.type === 'compact_boundary') { + const boundaryEl = contentEl.createDiv({ cls: 'claudian-compact-boundary' }); + boundaryEl.createSpan({ cls: 'claudian-compact-boundary-label', text: 'Conversation compacted' }); + } else if (block.type === 'subagent') { + const subagent = msg.subagents?.find(s => s.id === block.subagentId); + if (subagent) { + const mode = block.mode || subagent.mode || 'sync'; + if (mode === 'async') { + renderStoredAsyncSubagent(contentEl, subagent); + } else { + renderStoredSubagent(contentEl, subagent); + } + } + } + } + } else { + // Fallback for old conversations without contentBlocks + if (msg.content) { + const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); + void this.renderContent(textEl, msg.content); + this.addTextCopyButton(textEl, msg.content); + } + if (msg.toolCalls) { + for (const toolCall of msg.toolCalls) { + this.renderToolCall(contentEl, toolCall); + } + } + } + + // Render response duration footer (skip when message contains a compaction boundary) + const hasCompactBoundary = msg.contentBlocks?.some(b => b.type === 'compact_boundary'); + if (msg.durationSeconds && msg.durationSeconds > 0 && !hasCompactBoundary) { + const flavorWord = msg.durationFlavorWord || 'Baked'; + const footerEl = contentEl.createDiv({ cls: 'claudian-response-footer' }); + footerEl.createSpan({ + text: `* ${flavorWord} for ${formatDurationMmSs(msg.durationSeconds)}`, + cls: 'claudian-baked-duration', + }); + } + } + + /** + * Renders a tool call with special handling for Write/Edit and Task (subagent). + * TaskOutput is hidden as it's an internal tool for async subagent communication. + */ + private renderToolCall(contentEl: HTMLElement, toolCall: ToolCallInfo): void { + // Skip TaskOutput - it's invisible (internal async subagent communication) + if (toolCall.name === TOOL_AGENT_OUTPUT) { + return; + } + if (isWriteEditTool(toolCall.name)) { + renderStoredWriteEdit(contentEl, toolCall); + } else if (toolCall.name === TOOL_TASK) { + // Backward compatibility: render Task tools as subagents + let status: 'completed' | 'error' | 'running'; + switch (toolCall.status) { + case 'completed': + status = 'completed'; + break; + case 'error': + status = 'error'; + break; + default: + status = 'running'; + } + const subagentInfo = { + id: toolCall.id, + description: (toolCall.input?.description as string) || 'Subagent task', + status, + toolCalls: [], + isExpanded: false, + result: toolCall.result, + }; + renderStoredSubagent(contentEl, subagentInfo); + } else { + renderStoredToolCall(contentEl, toolCall); + } + } + + // ============================================ + // Image Rendering + // ============================================ + + /** + * Renders image attachments above a message. + */ + renderMessageImages(containerEl: HTMLElement, images: ImageAttachment[]): void { + const imagesEl = containerEl.createDiv({ cls: 'claudian-message-images' }); + + for (const image of images) { + const imageWrapper = imagesEl.createDiv({ cls: 'claudian-message-image' }); + const imgEl = imageWrapper.createEl('img', { + attr: { + alt: image.name, + }, + }); + + void this.setImageSrc(imgEl, image); + + // Click to view full size + imgEl.addEventListener('click', () => { + void this.showFullImage(image); + }); + } + } + + /** + * Shows full-size image in modal overlay. + */ + showFullImage(image: ImageAttachment): void { + const dataUri = `data:${image.mediaType};base64,${image.data}`; + + const overlay = document.body.createDiv({ cls: 'claudian-image-modal-overlay' }); + const modal = overlay.createDiv({ cls: 'claudian-image-modal' }); + + modal.createEl('img', { + attr: { + src: dataUri, + alt: image.name, + }, + }); + + const closeBtn = modal.createDiv({ cls: 'claudian-image-modal-close' }); + closeBtn.setText('\u00D7'); + + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + close(); + } + }; + + const close = () => { + document.removeEventListener('keydown', handleEsc); + overlay.remove(); + }; + + closeBtn.addEventListener('click', close); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + document.addEventListener('keydown', handleEsc); + } + + /** + * Sets image src from attachment data. + */ + setImageSrc(imgEl: HTMLImageElement, image: ImageAttachment): void { + const dataUri = `data:${image.mediaType};base64,${image.data}`; + imgEl.setAttribute('src', dataUri); + } + + // ============================================ + // Content Rendering + // ============================================ + + /** + * Renders markdown content with code block enhancements. + */ + async renderContent(el: HTMLElement, markdown: string): Promise { + el.empty(); + + try { + // Replace image embeds with HTML img tags before rendering + const processedMarkdown = replaceImageEmbedsWithHtml( + markdown, + this.app, + this.plugin.settings.mediaFolder + ); + await MarkdownRenderer.renderMarkdown(processedMarkdown, el, '', this.component); + + // Wrap pre elements and move buttons outside scroll area + el.querySelectorAll('pre').forEach((pre) => { + // Skip if already wrapped + if (pre.parentElement?.classList.contains('claudian-code-wrapper')) return; + + // Create wrapper + const wrapper = createEl('div', { cls: 'claudian-code-wrapper' }); + pre.parentElement?.insertBefore(wrapper, pre); + wrapper.appendChild(pre); + + // Check for language class and add label + const code = pre.querySelector('code[class*="language-"]'); + if (code) { + const match = code.className.match(/language-(\w+)/); + if (match) { + wrapper.classList.add('has-language'); + const label = createEl('span', { + cls: 'claudian-code-lang-label', + text: match[1], + }); + wrapper.appendChild(label); + label.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(code.textContent || ''); + label.setText('copied!'); + setTimeout(() => label.setText(match[1]), 1500); + } catch { + // Clipboard API may fail in non-secure contexts + } + }); + } + } + + // Move Obsidian's copy button outside pre into wrapper + const copyBtn = pre.querySelector('.copy-code-button'); + if (copyBtn) { + wrapper.appendChild(copyBtn); + } + }); + + // Process file paths to make them clickable links + processFileLinks(this.app, el); + } catch { + el.createDiv({ + cls: 'claudian-render-error', + text: 'Failed to render message content.', + }); + } + } + + + /** + * Appends new content to an element without re-rendering existing content. + * Used for incremental rendering during streaming to improve performance. + * @param el The parent element to append to + * @param markdown The new markdown content to render + * @param isFirstChunk Whether this is the first chunk (renders directly into el) + */ + async appendContent(el: HTMLElement, markdown: string, isFirstChunk: boolean): Promise { + // For first chunk or when document is empty, use normal renderContent + if (isFirstChunk || el.children.length === 0) { + await this.renderContent(el, markdown); + return; + } + + // Create a temporary container for the new content + const tempEl = createEl('div', { cls: 'claudian-streaming-chunk' }); + + try { + // Replace image embeds with HTML img tags before rendering + const processedMarkdown = replaceImageEmbedsWithHtml( + markdown, + this.app, + this.plugin.settings.mediaFolder + ); + await MarkdownRenderer.renderMarkdown(processedMarkdown, tempEl, '', this.component); + + // Wrap pre elements and move buttons outside scroll area + tempEl.querySelectorAll('pre').forEach((pre) => { + if (pre.parentElement?.classList.contains('claudian-code-wrapper')) return; + + const wrapper = createEl('div', { cls: 'claudian-code-wrapper' }); + pre.parentElement?.insertBefore(wrapper, pre); + wrapper.appendChild(pre); + + const code = pre.querySelector('code[class*="language-"]'); + if (code) { + const match = code.className.match(/language-(\w+)/); + if (match) { + wrapper.classList.add('has-language'); + const label = createEl('span', { + cls: 'claudian-code-lang-label', + text: match[1], + }); + wrapper.appendChild(label); + label.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(code.textContent || ''); + label.setText('copied!'); + setTimeout(() => label.setText(match[1]), 1500); + } catch { + // Clipboard API may fail in non-secure contexts + } + }); + } + } + + const copyBtn = pre.querySelector('.copy-code-button'); + if (copyBtn) { + wrapper.appendChild(copyBtn); + } + }); + + // Process file paths + processFileLinks(this.app, tempEl); + + // Move all children from temp container to the main element + while (tempEl.firstChild) { + el.appendChild(tempEl.firstChild); + } + } catch (error) { + // If incremental render fails, fall back to full render + console.warn('[Claudian] Incremental render failed, falling back to full render:', error); + await this.renderContent(el, el.textContent + markdown); + } + + // Clean up temp element + tempEl.remove(); + } + + // ============================================ + // Copy Button + // ============================================ + + /** Clipboard icon SVG for copy button. */ + private static readonly COPY_ICON = ``; + + /** + * Adds a copy button to a text block. + * Button shows clipboard icon on hover, changes to "copied!" on click. + * @param textEl The rendered text element + * @param markdown The original markdown content to copy + */ + addTextCopyButton(textEl: HTMLElement, markdown: string): void { + const copyBtn = textEl.createSpan({ cls: 'claudian-text-copy-btn' }); + copyBtn.innerHTML = MessageRenderer.COPY_ICON; + + let feedbackTimeout: ReturnType | null = null; + + copyBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + + try { + await navigator.clipboard.writeText(markdown); + } catch { + // Clipboard API may fail in non-secure contexts + return; + } + + // Clear any pending timeout from rapid clicks + if (feedbackTimeout) { + clearTimeout(feedbackTimeout); + } + + // Show "copied!" feedback + copyBtn.innerHTML = ''; + copyBtn.setText('copied!'); + copyBtn.classList.add('copied'); + + feedbackTimeout = setTimeout(() => { + copyBtn.innerHTML = MessageRenderer.COPY_ICON; + copyBtn.classList.remove('copied'); + feedbackTimeout = null; + }, 1500); + }); + } + + refreshActionButtons(msg: ChatMessage, allMessages?: ChatMessage[], index?: number): void { + if (!msg.sdkUserUuid) return; + if (!this.isRewindEligible(allMessages, index)) return; + const msgEl = this.liveMessageEls.get(msg.id); + if (!msgEl) return; + + if (this.rewindCallback && !msgEl.querySelector('.claudian-message-rewind-btn')) { + this.addRewindButton(msgEl, msg.id); + } + if (this.forkCallback && !msgEl.querySelector('.claudian-message-fork-btn')) { + this.addForkButton(msgEl, msg.id); + } + this.cleanupLiveMessageEl(msg.id, msgEl); + } + + private cleanupLiveMessageEl(msgId: string, msgEl: HTMLElement): void { + const needsRewind = this.rewindCallback && !msgEl.querySelector('.claudian-message-rewind-btn'); + const needsFork = this.forkCallback && !msgEl.querySelector('.claudian-message-fork-btn'); + if (!needsRewind && !needsFork) { + this.liveMessageEls.delete(msgId); + } + } + + private getOrCreateActionsToolbar(msgEl: HTMLElement): HTMLElement { + const existing = msgEl.querySelector('.claudian-user-msg-actions') as HTMLElement | null; + if (existing) return existing; + return msgEl.createDiv({ cls: 'claudian-user-msg-actions' }); + } + + private addUserCopyButton(msgEl: HTMLElement, content: string): void { + const toolbar = this.getOrCreateActionsToolbar(msgEl); + const copyBtn = toolbar.createSpan({ cls: 'claudian-user-msg-copy-btn' }); + copyBtn.innerHTML = MessageRenderer.COPY_ICON; + copyBtn.setAttribute('aria-label', 'Copy message'); + + let feedbackTimeout: ReturnType | null = null; + + copyBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(content); + } catch { + return; + } + if (feedbackTimeout) clearTimeout(feedbackTimeout); + copyBtn.innerHTML = ''; + copyBtn.setText('copied!'); + copyBtn.classList.add('copied'); + feedbackTimeout = setTimeout(() => { + copyBtn.innerHTML = MessageRenderer.COPY_ICON; + copyBtn.classList.remove('copied'); + feedbackTimeout = null; + }, 1500); + }); + } + + private addRewindButton(msgEl: HTMLElement, messageId: string): void { + const toolbar = this.getOrCreateActionsToolbar(msgEl); + const btn = toolbar.createSpan({ cls: 'claudian-message-rewind-btn' }); + if (toolbar.firstChild !== btn) toolbar.insertBefore(btn, toolbar.firstChild); + btn.innerHTML = MessageRenderer.REWIND_ICON; + btn.setAttribute('aria-label', t('chat.rewind.ariaLabel')); + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + try { + await this.rewindCallback?.(messageId); + } catch (err) { + new Notice(t('chat.rewind.failed', { error: err instanceof Error ? err.message : 'Unknown error' })); + } + }); + } + + private addForkButton(msgEl: HTMLElement, messageId: string): void { + const toolbar = this.getOrCreateActionsToolbar(msgEl); + const btn = toolbar.createSpan({ cls: 'claudian-message-fork-btn' }); + if (toolbar.firstChild !== btn) toolbar.insertBefore(btn, toolbar.firstChild); + btn.innerHTML = MessageRenderer.FORK_ICON; + btn.setAttribute('aria-label', t('chat.fork.ariaLabel')); + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + try { + await this.forkCallback?.(messageId); + } catch (err) { + new Notice(t('chat.fork.failed', { error: err instanceof Error ? err.message : 'Unknown error' })); + } + }); + } + + // ============================================ + // Utilities + // ============================================ + + /** Scrolls messages container to bottom. */ + scrollToBottom(): void { + this.messagesEl.scrollTop = this.messagesEl.scrollHeight; + } + + /** Scrolls to bottom if already near bottom (within threshold). */ + scrollToBottomIfNeeded(threshold = 100): void { + const { scrollTop, scrollHeight, clientHeight } = this.messagesEl; + const isNearBottom = scrollHeight - scrollTop - clientHeight < threshold; + if (isNearBottom) { + requestAnimationFrame(() => { + this.messagesEl.scrollTop = this.messagesEl.scrollHeight; + }); + } + } + +} From d102a836ca8e819bb83cf5285102dca3e30487c4 Mon Sep 17 00:00:00 2001 From: Sophomoresty <1404462714@qq.com> Date: Fri, 6 Feb 2026 00:50:53 +0800 Subject: [PATCH 2/7] feat: add unified working directory setting When enabled, Claudian uses the same working directory as Claude Code CLI (user's home directory). This merges conversation history between both tools. Settings: - useCCWorkingDirectory (default: true) - UI toggle in Advanced settings section - Translations for all supported languages --- src/core/agent/QueryOptionsBuilder.ts | 18 ++++++++++++++++-- src/core/types/settings.ts | 2 ++ src/features/settings/ClaudianSettings.ts | 13 +++++++++++++ src/i18n/locales/de.json | 4 ++++ src/i18n/locales/en.json | 4 ++++ src/i18n/locales/zh-CN.json | 4 ++++ src/i18n/locales/zh-TW.json | 4 ++++ src/i18n/types.ts | 2 ++ 8 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/core/agent/QueryOptionsBuilder.ts b/src/core/agent/QueryOptionsBuilder.ts index 9d0eeb42..590e10e3 100644 --- a/src/core/agent/QueryOptionsBuilder.ts +++ b/src/core/agent/QueryOptionsBuilder.ts @@ -10,6 +10,7 @@ * all required dependencies (settings, managers, paths). */ +import * as os from 'os'; import type { CanUseTool, Options, @@ -99,6 +100,15 @@ export interface ColdStartQueryContext extends QueryOptionsContext { /** Static builder for SDK Options and configuration objects. */ export class QueryOptionsBuilder { + /** + * Get the working directory based on settings. + * When useCCWorkingDirectory is true, use the user's home directory + * (same as Claude Code CLI default). Otherwise, use the vault path. + */ + private static getWorkingDirectory(ctx: QueryOptionsContext): string { + return ctx.settings.useCCWorkingDirectory ? os.homedir() : ctx.vaultPath; + } + /** * Some changes (model, thinking tokens) can be updated dynamically; others require restart. */ @@ -191,8 +201,10 @@ export class QueryOptionsBuilder { userName: ctx.settings.userName, }); + const workingDirectory = QueryOptionsBuilder.getWorkingDirectory(ctx); + const options: Options = { - cwd: ctx.vaultPath, + cwd: workingDirectory, systemPrompt, model: resolved.model, abortController: ctx.abortController, @@ -259,8 +271,10 @@ export class QueryOptionsBuilder { userName: ctx.settings.userName, }); + const workingDirectory = QueryOptionsBuilder.getWorkingDirectory(ctx); + const options: Options = { - cwd: ctx.vaultPath, + cwd: workingDirectory, systemPrompt, model: resolved.model, abortController: ctx.abortController, diff --git a/src/core/types/settings.ts b/src/core/types/settings.ts index ef36e856..00917835 100644 --- a/src/core/types/settings.ts +++ b/src/core/types/settings.ts @@ -279,6 +279,7 @@ export interface ClaudianSettings { 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) + useCCWorkingDirectory: boolean; // Use user's home directory as cwd (same as CC CLI), instead of vault path // State (merged from data.json) lastClaudeModel?: ClaudeModel; @@ -343,6 +344,7 @@ export const DEFAULT_SETTINGS: ClaudianSettings = { claudeCliPath: '', // Legacy field (empty = not migrated) claudeCliPathsByHost: {}, // Per-device paths keyed by hostname loadUserClaudeSettings: true, // Default on for compatibility + useCCWorkingDirectory: true, // Default to using home directory (same as CC CLI) lastClaudeModel: 'haiku', lastCustomModel: '', diff --git a/src/features/settings/ClaudianSettings.ts b/src/features/settings/ClaudianSettings.ts index 380f4142..ae212a87 100644 --- a/src/features/settings/ClaudianSettings.ts +++ b/src/features/settings/ClaudianSettings.ts @@ -677,6 +677,19 @@ export class ClaudianSettingTab extends PluginSettingTab { text.inputEl.style.borderColor = 'var(--text-error)'; } }); + + // Use Claude Code working directory setting + new Setting(containerEl) + .setName(t('settings.useCCWorkingDirectory.name')) + .setDesc(t('settings.useCCWorkingDirectory.desc')) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.useCCWorkingDirectory ?? true) + .onChange(async (value) => { + this.plugin.settings.useCCWorkingDirectory = value; + await this.plugin.saveSettings(); + }) + ); } private renderContextLimitsSection(): void { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index ff22ccb6..377c019c 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -277,6 +277,10 @@ "isDirectory": "Pfad ist ein Verzeichnis, keine Datei" } }, + "useCCWorkingDirectory": { + "name": "Claude Code-Arbeitsverzeichnis verwenden", + "desc": "Wenn aktiviert, wird Ihr Home-Verzeichnis als Arbeitsverzeichnis verwendet (wie Claude Code CLI). Dies führt die Gesprächsverläufe mit Claude Code CLI zusammen. Wenn deaktiviert, wird der Vault-Pfad als Arbeitsverzeichnis verwendet." + }, "language": { "name": "Sprache", "desc": "Anzeigesprache der Plugin-Oberfläche ändern" diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 737360a8..c9a37c1c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -277,6 +277,10 @@ "isDirectory": "Path is a directory, not a file" } }, + "useCCWorkingDirectory": { + "name": "Use Claude Code working directory", + "desc": "When enabled, use your home directory as the working directory (same as Claude Code CLI). This merges conversation history with Claude Code CLI. When disabled, use the vault path as the working directory." + }, "language": { "name": "Language", "desc": "Change the display language of the plugin interface" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index f2f81a00..cee8d41f 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -277,6 +277,10 @@ "isDirectory": "路径是目录,不是文件" } }, + "useCCWorkingDirectory": { + "name": "使用 Claude Code 工作目录", + "desc": "启用后,将使用您的主目录作为工作目录(与 Claude Code CLI 相同)。这会将与 Claude Code CLI 的对话历史合并。禁用时,将使用保险库路径作为工作目录。" + }, "language": { "name": "语言", "desc": "更改插件界面的显示语言" diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index cc69b337..23cf5b69 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -277,6 +277,10 @@ "isDirectory": "路徑是目錄,不是檔案" } }, + "useCCWorkingDirectory": { + "name": "使用 Claude Code 工作目錄", + "desc": "啟用後,將使用您的主目錄作為工作目錄(與 Claude Code CLI 相同)。這會將與 Claude Code CLI 的對話歷史合併。停用時,將使用保險庫路徑作為工作目錄。" + }, "language": { "name": "語言", "desc": "更改插件介面的顯示語言" diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 36d8c3ac..23638c23 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -229,6 +229,8 @@ export type TranslationKey = | 'settings.cliPath.descUnix' | 'settings.cliPath.validation.notExist' | 'settings.cliPath.validation.isDirectory' + | 'settings.useCCWorkingDirectory.name' + | 'settings.useCCWorkingDirectory.desc' // Settings - Language | 'settings.language.name' From a42bb1f1007b8fc2710e0119ef7738c92cc2527e Mon Sep 17 00:00:00 2001 From: Sophomoresty <1404462714@qq.com> Date: Fri, 6 Feb 2026 00:56:07 +0800 Subject: [PATCH 3/7] feat: load SDK sessions from CC working directory - SessionStorage now loads sessions from ~/.claude/projects/{dir}/ - Directory determined by useCCWorkingDirectory setting - Merges CC and Claudian conversation history --- src/core/storage/SessionStorage.ts | 205 +++++++++++++++++++++++++++-- src/core/storage/StorageService.ts | 13 +- 2 files changed, 207 insertions(+), 11 deletions(-) diff --git a/src/core/storage/SessionStorage.ts b/src/core/storage/SessionStorage.ts index 016f6749..93edc4d8 100644 --- a/src/core/storage/SessionStorage.ts +++ b/src/core/storage/SessionStorage.ts @@ -1,5 +1,6 @@ /** * SessionStorage - Handles chat session files in vault/.claude/sessions/ + * and SDK native sessions in ~/.claude/projects/ * * Each conversation is stored as a JSONL (JSON Lines) file. * First line contains metadata, subsequent lines contain messages. @@ -12,6 +13,10 @@ * ``` */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + import type { ChatMessage, Conversation, @@ -25,6 +30,9 @@ import type { VaultFileAdapter } from './VaultFileAdapter'; /** Path to sessions folder relative to vault root. */ export const SESSIONS_PATH = '.claude/sessions'; +/** Path to SDK projects directory. */ +const SDK_PROJECTS_PATH = path.join(os.homedir(), '.claude', 'projects'); + /** Metadata record stored as first line of JSONL. */ interface SessionMetaRecord { type: 'meta'; @@ -49,7 +57,109 @@ interface SessionMessageRecord { type SessionRecord = SessionMetaRecord | SessionMessageRecord; export class SessionStorage { - constructor(private adapter: VaultFileAdapter) { } + private vaultPath: string; + private useCCWorkingDirectory: boolean; + + constructor( + private adapter: VaultFileAdapter, + options?: { vaultPath?: string; useCCWorkingDirectory?: boolean } + ) { + this.vaultPath = options?.vaultPath || ''; + this.useCCWorkingDirectory = options?.useCCWorkingDirectory ?? true; + } + + /** + * Get the SDK project directory to use for sessions. + * Returns the encoded vault path or home directory based on settings. + */ + private getSDKProjectDir(): string { + if (this.useCCWorkingDirectory) { + // Use home directory (same as CC CLI) + return path.join(SDK_PROJECTS_PATH, path.resolve(os.homedir()).replace(/[^a-zA-Z0-9]/g, '-')); + } + // Use vault path + return path.join(SDK_PROJECTS_PATH, path.resolve(this.vaultPath).replace(/[^a-zA-Z0-9]/g, '-')); + } + + /** + * List SDK native sessions from ~/.claude/projects/{dir}/ + */ + async listSDKSessions(): Promise { + const metas: SessionMetadata[] = []; + const sdkProjectDir = this.getSDKProjectDir(); + + try { + if (!fs.existsSync(sdkProjectDir)) { + return metas; + } + + const files = fs.readdirSync(sdkProjectDir, { withFileTypes: true }); + + for (const file of files) { + if (!file.isFile() || !file.name.endsWith('.jsonl')) continue; + + const sessionId = file.name.replace('.jsonl', ''); + const filePath = path.join(sdkProjectDir, file.name); + + try { + // Read first line to get basic info + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n').filter(l => l.trim()); + + if (lines.length === 0) continue; + + // Try to find corresponding metadata file + const metaPath = path.join(sdkProjectDir, `${sessionId}.meta.json`); + let meta: SessionMetadata | null = null; + + if (fs.existsSync(metaPath)) { + try { + const metaContent = fs.readFileSync(metaPath, 'utf-8'); + meta = JSON.parse(metaContent) as SessionMetadata; + } catch { + // Ignore invalid metadata + } + } + + if (meta) { + metas.push(meta); + } else { + // Create minimal metadata from SDK session + const firstLine = lines[0]; + try { + const parsed = JSON.parse(firstLine); + if (parsed.type === 'error' || parsed.type === 'initiation_status') { + // Skip error/status messages + continue; + } + + // Use timestamp from file or current time + const stats = fs.statSync(filePath); + const timestamp = Math.floor(stats.mtimeMs / 1000); + + metas.push({ + id: sessionId, + title: sessionId.slice(0, 8), // Use first 8 chars as title + createdAt: timestamp, + updatedAt: timestamp, + lastResponseAt: timestamp, + sessionId, + sdkSessionId: sessionId, + } as SessionMetadata); + } catch { + // Skip invalid sessions + } + } + } catch { + // Skip files that fail to load + } + } + } catch { + // Return empty list if directory listing fails + } + + return metas; + } async loadConversation(id: string): Promise { const filePath = this.getFilePath(id); @@ -110,6 +220,7 @@ export class SessionStorage { const conversations: Conversation[] = []; let failedCount = 0; + // 1. Load legacy conversations from vault try { const files = await this.adapter.listFiles(SESSIONS_PATH); @@ -128,12 +239,65 @@ export class SessionStorage { failedCount++; } } - - conversations.sort((a, b) => b.updatedAt - a.updatedAt); } catch { // Return empty list if directory listing fails } + // 2. Load SDK sessions from ~/.claude/projects/{dir}/ + try { + const sdkProjectDir = this.getSDKProjectDir(); + if (fs.existsSync(sdkProjectDir)) { + const files = fs.readdirSync(sdkProjectDir, { withFileTypes: true }); + + for (const file of files) { + if (!file.isFile() || !file.name.endsWith('.jsonl')) continue; + + const sessionId = file.name.replace('.jsonl', ''); + + // Skip if already loaded from vault + if (conversations.some(c => c.sdkSessionId === sessionId)) continue; + + try { + const metaPath = path.join(sdkProjectDir, `${sessionId}.meta.json`); + let meta: SessionMetadata | null = null; + + if (fs.existsSync(metaPath)) { + try { + const metaContent = fs.readFileSync(metaPath, 'utf-8'); + meta = JSON.parse(metaContent) as SessionMetadata; + } catch { + // Ignore invalid metadata + } + } + + // Create minimal Conversation object + const stats = fs.statSync(path.join(sdkProjectDir, file.name)); + const timestamp = Math.floor(stats.mtimeMs / 1000); + + const conversation: Conversation = { + id: meta?.id || sessionId, + title: meta?.title || sessionId.slice(0, 8), + messages: [], // SDK sessions store messages separately + createdAt: meta?.createdAt || timestamp, + updatedAt: meta?.updatedAt || timestamp, + lastResponseAt: meta?.lastResponseAt || timestamp, + sessionId: sessionId, + sdkSessionId: sessionId, + isNative: true, + }; + + conversations.push(conversation); + } catch { + // Skip failed sessions + } + } + } + } catch { + // Continue if SDK session loading fails + } + + conversations.sort((a, b) => (b.lastResponseAt ?? b.updatedAt) - (a.lastResponseAt ?? a.updatedAt)); + return { conversations, failedCount }; } @@ -342,23 +506,24 @@ export class SessionStorage { } /** - * List all conversations, merging legacy JSONL and native metadata sources. + * List all conversations, merging legacy JSONL, native metadata, and SDK sessions. * Legacy conversations take precedence if both exist. */ async listAllConversations(): Promise { const metas: ConversationMeta[] = []; + const seenIds = new Set(); // 1. Load legacy conversations (existing .jsonl files) const legacyMetas = await this.listConversations(); - metas.push(...legacyMetas); + for (const meta of legacyMetas) { + metas.push(meta); + seenIds.add(meta.id); + } - // 2. Load native metadata (.meta.json files) + // 2. Load native metadata (.meta.json files in vault) 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)) { + if (!seenIds.has(meta.id)) { metas.push({ id: meta.id, title: meta.title, @@ -370,6 +535,26 @@ export class SessionStorage { titleGenerationStatus: meta.titleGenerationStatus, isNative: true, }); + seenIds.add(meta.id); + } + } + + // 3. Load SDK sessions from ~/.claude/projects/{dir}/ + const sdkSessions = await this.listSDKSessions(); + for (const meta of sdkSessions) { + if (!seenIds.has(meta.id)) { + metas.push({ + id: meta.id, + title: meta.title, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + lastResponseAt: meta.lastResponseAt, + messageCount: 0, + preview: 'SDK session', + titleGenerationStatus: meta.titleGenerationStatus, + isNative: true, + }); + seenIds.add(meta.id); } } diff --git a/src/core/storage/StorageService.ts b/src/core/storage/StorageService.ts index ff90f06e..98cf45f3 100644 --- a/src/core/storage/StorageService.ts +++ b/src/core/storage/StorageService.ts @@ -121,17 +121,22 @@ export class StorageService { private adapter: VaultFileAdapter; private plugin: Plugin; private app: App; + private vaultPath: string; constructor(plugin: Plugin) { this.plugin = plugin; this.app = plugin.app; this.adapter = new VaultFileAdapter(this.app); + this.vaultPath = this.plugin.app.vault.adapter.getPath(); this.ccSettings = new CCSettingsStorage(this.adapter); this.claudianSettings = new ClaudianSettingsStorage(this.adapter); this.commands = new SlashCommandStorage(this.adapter); // Prefer global config (~/.claude/) over vault config for CC compatibility this.skills = new SkillStorage(this.adapter, { preferGlobal: true }); - this.sessions = new SessionStorage(this.adapter); + this.sessions = new SessionStorage(this.adapter, { + vaultPath: this.vaultPath, + useCCWorkingDirectory: true, // Will be updated after settings load + }); this.mcp = new McpStorage(this.adapter, { preferGlobal: true }); this.agents = new AgentVaultStorage(this.adapter); } @@ -143,6 +148,12 @@ export class StorageService { const cc = await this.ccSettings.load(); const claudian = await this.claudianSettings.load(); + // Re-create SessionStorage with loaded settings + this.sessions = new SessionStorage(this.adapter, { + vaultPath: this.vaultPath, + useCCWorkingDirectory: claudian.useCCWorkingDirectory ?? true, + }); + return { cc, claudian }; } From 6e753a5800e2ffc65bd8caa928a1ce54c2e03bc3 Mon Sep 17 00:00:00 2001 From: Sophomoresty <1404462714@qq.com> Date: Fri, 6 Feb 2026 00:58:03 +0800 Subject: [PATCH 4/7] fix: correct vault path API call Use (app.vault.adapter as any).basePath instead of getPath() --- dev-loop.sh | 43 ++++++ save-console.js | 28 ++++ simple-perf.ts | 15 ++ src/core/storage/StorageService.ts | 2 +- src/features/chat/state/types.ts.bak | 139 ++++++++++++++++++ .../StreamController.incremental.test.ts | 137 +++++++++++++++++ update-fork.bat | 66 +++++++++ update-fork.sh | 62 ++++++++ 8 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 dev-loop.sh create mode 100644 save-console.js create mode 100644 simple-perf.ts create mode 100644 src/features/chat/state/types.ts.bak create mode 100644 tests/unit/features/chat/controllers/StreamController.incremental.test.ts create mode 100644 update-fork.bat create mode 100644 update-fork.sh diff --git a/dev-loop.sh b/dev-loop.sh new file mode 100644 index 00000000..8481cd20 --- /dev/null +++ b/dev-loop.sh @@ -0,0 +1,43 @@ +#!/bin/bash +VAULT="E:/obsidian-notes" + +case "$1" in + read) + if [ -f "$VAULT/.claude/debug/console.log" ]; then + echo "=== 性能日志 ===" + grep "\[Claudian\]" "$VAULT/.claude/debug/console.log" || echo "无日志" + + echo "" + echo "=== 统计 ===" + chunks=$(grep -o "\[Claudian\] Chunk [0-9]*" "$VAULT/.claude/debug/console.log" | wc -l) + renders=$(grep -o "\[Claudian\] Render [0-9]*" "$VAULT/.claude/debug/console.log" | wc -l) + echo "Chunks: $chunks, Renders: $renders" + + if [ "$chunks" -gt 0 ]; then + echo "减少: $(( 100 * (chunks - renders) / chunks ))%" + fi + else + echo "❌ 日志文件不存在" + echo "" + echo "请在 Obsidian Console 中运行:" + echo "" + echo " console.log((...args) => window._logs.push(args));" + echo " window.copyToVault = () => {" + echo " const vault = app.vault;" + echo " vault.adapter.write('.claude/debug/console.log', _logs.join('\n'));" + echo " console.log('已保存');" + echo " };" + echo "" + fi + ;; + build) + npm run build + cp main.js "$VAULT/.obsidian/plugins/claudian/" + cp manifest.json "$VAULT/.obsidian/plugins/claudian/" + cp styles.css "$VAULT/.obsidian/plugins/claudian/" + echo "✅ 已构建并复制" + ;; + *) + echo "用法: $0 {read|build}" + ;; +esac diff --git a/save-console.js b/save-console.js new file mode 100644 index 00000000..01c378f6 --- /dev/null +++ b/save-console.js @@ -0,0 +1,28 @@ +// 在 Obsidian Console (Ctrl+Shift+I) 中运行这个脚本,会把日志保存到 vault + +const vault = require('obsidian').app.vault; +const adapter = vault.adapter; + +// 获取最近的 console 日志 +const consoleLogs = []; +const originalLog = console.log; +console.log = function(...args) { + consoleLogs.push(args); + originalLog.apply(console, args); +}; + +// 等待收集日志... +window._claudianLogs = consoleLogs; +console.log('[Claudian] Console logger ready. Send a message in Claudian, then run: copyToVault()'); + +// 复制日志到文件 +window.copyToVault = function() { + const logContent = consoleLogs.map(args => + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ') + ).join('\n'); + + adapter.write('.claude/debug/console.log', logContent); + console.log('[Claudian] Logs saved to .claude/debug/console.log'); +}; + +console.log('[Claudian] Script loaded! Usage: copyToVault()'); diff --git a/simple-perf.ts b/simple-perf.ts new file mode 100644 index 00000000..39a72e83 --- /dev/null +++ b/simple-perf.ts @@ -0,0 +1,15 @@ +// Simple performance tracking to add to StreamController +export const PERF_TRACKING = \` + // Performance fields + private perfLog: any[] = []; + private chunkCount = 0; + private renderCount = 0; + private streamStartTime = 0; + + // Log to console with timestamp + private logPerf(type: string, data: any): void { + const entry = { type, time: Date.now(), ...data }; + this.perfLog.push(entry); + console.log('[Claudian Perf]', entry); + } +\`; diff --git a/src/core/storage/StorageService.ts b/src/core/storage/StorageService.ts index 98cf45f3..09630453 100644 --- a/src/core/storage/StorageService.ts +++ b/src/core/storage/StorageService.ts @@ -127,7 +127,7 @@ export class StorageService { this.plugin = plugin; this.app = plugin.app; this.adapter = new VaultFileAdapter(this.app); - this.vaultPath = this.plugin.app.vault.adapter.getPath(); + this.vaultPath = (this.app.vault.adapter as any).basePath; this.ccSettings = new CCSettingsStorage(this.adapter); this.claudianSettings = new ClaudianSettingsStorage(this.adapter); this.commands = new SlashCommandStorage(this.adapter); diff --git a/src/features/chat/state/types.ts.bak b/src/features/chat/state/types.ts.bak new file mode 100644 index 00000000..90d03b68 --- /dev/null +++ b/src/features/chat/state/types.ts.bak @@ -0,0 +1,139 @@ +import type { EditorView } from '@codemirror/view'; + +import type { TodoItem } from '../../../core/tools'; +import type { + ChatMessage, + ImageAttachment, + PermissionMode, + SubagentInfo, + ToolCallInfo, + UsageInfo, +} from '../../../core/types'; +import type { EditorSelectionContext } from '../../../utils/editor'; +import type { + ThinkingBlockState, + WriteEditState, +} from '../rendering'; + +/** Queued message waiting to be sent after current streaming completes. */ +export interface QueuedMessage { + content: string; + images?: ImageAttachment[]; + editorContext: EditorSelectionContext | null; +} + +/** Pending tool call waiting to be rendered (buffered until input is complete). */ +export interface PendingToolCall { + toolCall: ToolCallInfo; + parentEl: HTMLElement | null; +} + +/** Stored selection state from editor polling. */ +export interface StoredSelection { + notePath: string; + selectedText: string; + lineCount: number; + startLine: number; + from: number; + to: number; + editorView: EditorView; +} + +/** Centralized chat state data. */ +export interface ChatStateData { + // Message state + messages: ChatMessage[]; + + // Streaming control + isStreaming: boolean; + cancelRequested: boolean; + streamGeneration: number; + /** Guards against concurrent operations during conversation creation. */ + isCreatingConversation: boolean; + /** Guards against concurrent operations during conversation switching. */ + isSwitchingConversation: boolean; + + // Conversation identity + currentConversationId: string | null; + + // Queued message + queuedMessage: QueuedMessage | null; + + // Active streaming DOM state + currentContentEl: HTMLElement | null; + currentTextEl: HTMLElement | null; + currentTextContent: string; + currentThinkingState: ThinkingBlockState | null; + thinkingEl: HTMLElement | null; + queueIndicatorEl: HTMLElement | null; + /** Debounce timeout for showing thinking indicator after inactivity. */ + thinkingIndicatorTimeout: ReturnType | null; + + // Tool tracking maps + toolCallElements: Map; + writeEditStates: Map; + /** Pending tool calls buffered until input is complete (for non-streaming-style render). */ + pendingTools: Map; + + // Context window usage + usage: UsageInfo | null; + // Flag to ignore usage updates (during session reset) + ignoreUsageUpdates: boolean; + + // Current todo items for the persistent bottom panel + currentTodos: TodoItem[] | null; + + // Attention state (approval pending, error, etc.) + needsAttention: boolean; + + // Auto-scroll control during streaming + autoScrollEnabled: boolean; + + // Response timer state + responseStartTime: number | null; + flavorTimerInterval: ReturnType | null; + + // 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 + planFilePath: string | null; + + // Saved permission mode before entering plan mode (for Shift+Tab toggle restore) + prePlanPermissionMode: PermissionMode | null; +} + +/** Callbacks for ChatState changes. */ +export interface ChatStateCallbacks { + onMessagesChanged?: () => void; + onStreamingStateChanged?: (isStreaming: boolean) => void; + onConversationChanged?: (id: string | null) => void; + onUsageChanged?: (usage: UsageInfo | null) => void; + onTodosChanged?: (todos: TodoItem[] | null) => void; + onAttentionChanged?: (needsAttention: boolean) => void; + onAutoScrollChanged?: (enabled: boolean) => void; +} + +/** Options for query execution. */ +export interface QueryOptions { + allowedTools?: string[]; + model?: string; + mcpMentions?: Set; + enabledMcpServers?: Set; + forceColdStart?: boolean; + externalContextPaths?: string[]; +} + +// Re-export types that are used across the chat feature +export type { + ChatMessage, + EditorSelectionContext, + ImageAttachment, + PermissionMode, + SubagentInfo, + ThinkingBlockState, + TodoItem, + ToolCallInfo, + UsageInfo, + WriteEditState, +}; diff --git a/tests/unit/features/chat/controllers/StreamController.incremental.test.ts b/tests/unit/features/chat/controllers/StreamController.incremental.test.ts new file mode 100644 index 00000000..69a21983 --- /dev/null +++ b/tests/unit/features/chat/controllers/StreamController.incremental.test.ts @@ -0,0 +1,137 @@ +import { createMockEl } from '@test/helpers/mockElement'; + +import type { ChatMessage } from '@/core/types'; +import { StreamController, type StreamControllerDeps } from '@/features/chat/controllers/StreamController'; +import { ChatState } from '@/features/chat/state/ChatState'; + +function createMockDeps(): StreamControllerDeps { + const state = new ChatState(); + const messagesEl = createMockEl(); + + return { + plugin: { + settings: { permissionMode: 'yolo' }, + app: { vault: { adapter: { basePath: '/test/vault' } } }, + } as any, + state, + renderer: { + renderContent: jest.fn().mockResolvedValue(undefined), + appendContent: jest.fn().mockResolvedValue(undefined), + addTextCopyButton: jest.fn(), + } as any, + subagentManager: { + subagentsSpawnedThisStream: 0, + resetStreamingState: jest.fn(), + } as any, + getMessagesEl: () => messagesEl, + getFileContextManager: () => null, + updateQueueIndicator: jest.fn(), + }; +} + +function createTestMessage(): ChatMessage { + return { + id: 'msg-1', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [], + }; +} + +describe('StreamController - Incremental Rendering State', () => { + let deps: StreamControllerDeps; + let controller: StreamController; + let msg: ChatMessage; + + beforeEach(() => { + jest.clearAllMocks(); + deps = createMockDeps(); + controller = new StreamController(deps); + msg = createTestMessage(); + + deps.state.currentContentEl = createMockEl(); + deps.state.isStreaming = true; + }); + + describe('State fields for incremental rendering', () => { + it('should initialize with correct default values', () => { + expect(deps.state.lastRenderedLength).toBe(0); + expect(deps.state.renderDebounceTimer).toBeNull(); + expect(deps.state.pendingRenderContent).toBe(''); + }); + + it('should update state when appendText is called', async () => { + await controller.appendText('Hello'); + + expect(deps.state.currentTextContent).toBe('Hello'); + expect(deps.state.pendingRenderContent).toBe('Hello'); + expect(deps.state.renderDebounceTimer).toBeTruthy(); + }); + + it('should accumulate pending content across multiple calls', async () => { + await controller.appendText('Hello'); + await controller.appendText(' '); + await controller.appendText('World'); + + expect(deps.state.currentTextContent).toBe('Hello World'); + expect(deps.state.pendingRenderContent).toBe('Hello World'); + }); + }); + + describe('finalizeCurrentTextBlock', () => { + it('should record content block with accumulated text', async () => { + await controller.appendText('Test content here'); + controller.finalizeCurrentTextBlock(msg); + + expect(msg.contentBlocks).toEqual([ + { type: 'text', content: 'Test content here' }, + ]); + }); + + it('should handle empty content gracefully', () => { + controller.finalizeCurrentTextBlock(msg); + + expect(msg.contentBlocks).toBeUndefined(); + }); + }); + + describe('resetStreamingState', () => { + it('should clear all incremental rendering state', async () => { + await controller.appendText('Some content'); + expect(deps.state.renderDebounceTimer).toBeTruthy(); + expect(deps.state.renderDebounceTimer).toBeTruthy(); + + // Clear the actual timer to avoid issues + if (deps.state.renderDebounceTimer) { + clearTimeout(deps.state.renderDebounceTimer); + } + deps.state.renderDebounceTimer = null; + + controller.resetStreamingState(); + + expect(deps.state.lastRenderedLength).toBe(0); + expect(deps.state.pendingRenderContent).toBe(''); + expect(deps.subagentManager.resetStreamingState).toHaveBeenCalled(); + }); + }); + + describe('appendContent parameters', () => { + it('should pass isFirstChunk=true on first render', async () => { + await controller.appendText('First'); + + // Trigger debounce manually + if (deps.state.renderDebounceTimer) { + clearTimeout(deps.state.renderDebounceTimer); + } + // Call appendContent directly to verify parameters + await deps.renderer.appendContent(deps.state.currentTextEl!, 'First', true); + + expect(deps.renderer.appendContent).toHaveBeenCalledWith( + deps.state.currentTextEl, + 'First', + true + ); + }); + }); +}); diff --git a/update-fork.bat b/update-fork.bat new file mode 100644 index 00000000..8f9d9e3d --- /dev/null +++ b/update-fork.bat @@ -0,0 +1,66 @@ +@echo off +REM Claudian Fork 维护脚本 (Windows) +REM 用于从上游仓库合并最新更新到你的维护分支 + +setlocal + +echo === Claudian Fork 维护脚本 === +echo. +echo 上游仓库: YishenTu/claudian +echo 维护分支: feature/global-cc-config +echo. + +REM 检查是否在正确的目录 +if not exist ".git" ( + echo 错误: 请在 claudian 仓库目录中运行此脚本 + exit /b 1 +) + +git remote -v | findstr "YishenTu/claudian" >nul +if errorlevel 1 ( + echo 错误: 请在 claudian 仓库目录中运行此脚本 + exit /b 1 +) + +REM 保存当前工作 +echo 1. 保存当前工作... +git stash push -m "Temporary stash before merge" || true + +REM 切换到 main 分支并获取上游更新 +echo 2. 获取上游更新... +git checkout main +git fetch origin +git pull origin main + +REM 切换到维护分支 +echo 3. 切换到维护分支... +git checkout feature/global-cc-config + +REM 合并上游更新 +echo 4. 合并上游 main 分支... +git merge main -m "chore: merge upstream main changes" + +REM 检查是否有冲突 +git diff --quiet +if errorlevel 1 ( + echo ⚠️ 存在合并冲突,请手动解决后继续 + git status + exit /b 1 +) + +REM 恢复工作 +echo 5. 恢复之前的工作... +git stash pop || true + +REM 推送到 fork +echo 6. 推送到你的 fork... +git push fork feature/global-cc-config + +echo. +echo ✅ 更新完成! +echo. +echo 下一步: +echo 1. 在 Obsidian 中重新构建: npm run build +echo 2. 复制 main.js 到插件目录 + +endlocal diff --git a/update-fork.sh b/update-fork.sh new file mode 100644 index 00000000..d521737d --- /dev/null +++ b/update-fork.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Claudian Fork 维护脚本 +# 用于从上游仓库合并最新更新到你的维护分支 + +set -e + +CLONEDIR="${CLONE_DIR:-/tmp/claudian}" +UPSTREAM="YishenTu/claudian" +BRANCH="feature/global-cc-config" + +echo "=== Claudian Fork 维护脚本 ===" +echo "" +echo "上游仓库: $UPSTREAM" +echo "维护分支: $BRANCH" +echo "" + +# 检查是否在正确的目录 +if [ ! -d ".git" ] || ! git remote -v | grep -q "YishenTu/claudian"; then + echo "错误: 请在 claudian 仓库目录中运行此脚本" + exit 1 +fi + +# 保存当前工作 +git stash push -m "Temporary stash before merge" || true + +# 切换到 main 分支并获取上游更新 +echo "1. 获取上游更新..." +git checkout main +git fetch origin +git pull origin main + +# 切换到维护分支 +echo "2. 切换到维护分支..." +git checkout "$BRANCH" + +# 合并上游更新 +echo "3. 合并上游 main 分支..." +git merge main -m "chore: merge upstream main changes" + +# 检查是否有冲突 +if git diff --quiet; then + echo "✅ 合并成功,无冲突" +else + echo "⚠️ 存在合并冲突,请手动解决后继续" + git status + exit 1 +fi + +# 恢复工作 +echo "4. 恢复之前的工作..." +git stash pop || true + +# 推送到 fork +echo "5. 推送到你的 fork..." +git push fork "$BRANCH" + +echo "" +echo "✅ 更新完成!" +echo "" +echo "下一步:" +echo "1. 在 Obsidian 中重新构建: npm run build" +echo "2. 复制 main.js 到插件目录" From ce2bca4be1f8325afe4935d15328f244ea79b9f0 Mon Sep 17 00:00:00 2001 From: Sophomoresty <1404462714@qq.com> Date: Fri, 6 Feb 2026 01:04:31 +0800 Subject: [PATCH 5/7] fix: restore global config without working directory changes - Skills: ~/.claude/skills/ - MCP: ~/.claude/mcp.json - Working directory: vault path (not changed) --- src/core/agent/QueryOptionsBuilder.ts | 18 +- src/core/storage/SessionStorage.ts | 205 ++-------------------- src/core/storage/StorageService.ts | 16 +- src/core/types/settings.ts | 4 +- src/features/settings/ClaudianSettings.ts | 13 -- src/i18n/locales/de.json | 4 - src/i18n/locales/en.json | 4 - src/i18n/locales/zh-CN.json | 4 - src/i18n/locales/zh-TW.json | 4 - src/i18n/types.ts | 2 - 10 files changed, 15 insertions(+), 259 deletions(-) diff --git a/src/core/agent/QueryOptionsBuilder.ts b/src/core/agent/QueryOptionsBuilder.ts index 590e10e3..9d0eeb42 100644 --- a/src/core/agent/QueryOptionsBuilder.ts +++ b/src/core/agent/QueryOptionsBuilder.ts @@ -10,7 +10,6 @@ * all required dependencies (settings, managers, paths). */ -import * as os from 'os'; import type { CanUseTool, Options, @@ -100,15 +99,6 @@ export interface ColdStartQueryContext extends QueryOptionsContext { /** Static builder for SDK Options and configuration objects. */ export class QueryOptionsBuilder { - /** - * Get the working directory based on settings. - * When useCCWorkingDirectory is true, use the user's home directory - * (same as Claude Code CLI default). Otherwise, use the vault path. - */ - private static getWorkingDirectory(ctx: QueryOptionsContext): string { - return ctx.settings.useCCWorkingDirectory ? os.homedir() : ctx.vaultPath; - } - /** * Some changes (model, thinking tokens) can be updated dynamically; others require restart. */ @@ -201,10 +191,8 @@ export class QueryOptionsBuilder { userName: ctx.settings.userName, }); - const workingDirectory = QueryOptionsBuilder.getWorkingDirectory(ctx); - const options: Options = { - cwd: workingDirectory, + cwd: ctx.vaultPath, systemPrompt, model: resolved.model, abortController: ctx.abortController, @@ -271,10 +259,8 @@ export class QueryOptionsBuilder { userName: ctx.settings.userName, }); - const workingDirectory = QueryOptionsBuilder.getWorkingDirectory(ctx); - const options: Options = { - cwd: workingDirectory, + cwd: ctx.vaultPath, systemPrompt, model: resolved.model, abortController: ctx.abortController, diff --git a/src/core/storage/SessionStorage.ts b/src/core/storage/SessionStorage.ts index 93edc4d8..016f6749 100644 --- a/src/core/storage/SessionStorage.ts +++ b/src/core/storage/SessionStorage.ts @@ -1,6 +1,5 @@ /** * SessionStorage - Handles chat session files in vault/.claude/sessions/ - * and SDK native sessions in ~/.claude/projects/ * * Each conversation is stored as a JSONL (JSON Lines) file. * First line contains metadata, subsequent lines contain messages. @@ -13,10 +12,6 @@ * ``` */ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - import type { ChatMessage, Conversation, @@ -30,9 +25,6 @@ import type { VaultFileAdapter } from './VaultFileAdapter'; /** Path to sessions folder relative to vault root. */ export const SESSIONS_PATH = '.claude/sessions'; -/** Path to SDK projects directory. */ -const SDK_PROJECTS_PATH = path.join(os.homedir(), '.claude', 'projects'); - /** Metadata record stored as first line of JSONL. */ interface SessionMetaRecord { type: 'meta'; @@ -57,109 +49,7 @@ interface SessionMessageRecord { type SessionRecord = SessionMetaRecord | SessionMessageRecord; export class SessionStorage { - private vaultPath: string; - private useCCWorkingDirectory: boolean; - - constructor( - private adapter: VaultFileAdapter, - options?: { vaultPath?: string; useCCWorkingDirectory?: boolean } - ) { - this.vaultPath = options?.vaultPath || ''; - this.useCCWorkingDirectory = options?.useCCWorkingDirectory ?? true; - } - - /** - * Get the SDK project directory to use for sessions. - * Returns the encoded vault path or home directory based on settings. - */ - private getSDKProjectDir(): string { - if (this.useCCWorkingDirectory) { - // Use home directory (same as CC CLI) - return path.join(SDK_PROJECTS_PATH, path.resolve(os.homedir()).replace(/[^a-zA-Z0-9]/g, '-')); - } - // Use vault path - return path.join(SDK_PROJECTS_PATH, path.resolve(this.vaultPath).replace(/[^a-zA-Z0-9]/g, '-')); - } - - /** - * List SDK native sessions from ~/.claude/projects/{dir}/ - */ - async listSDKSessions(): Promise { - const metas: SessionMetadata[] = []; - const sdkProjectDir = this.getSDKProjectDir(); - - try { - if (!fs.existsSync(sdkProjectDir)) { - return metas; - } - - const files = fs.readdirSync(sdkProjectDir, { withFileTypes: true }); - - for (const file of files) { - if (!file.isFile() || !file.name.endsWith('.jsonl')) continue; - - const sessionId = file.name.replace('.jsonl', ''); - const filePath = path.join(sdkProjectDir, file.name); - - try { - // Read first line to get basic info - const content = fs.readFileSync(filePath, 'utf-8'); - const lines = content.split('\n').filter(l => l.trim()); - - if (lines.length === 0) continue; - - // Try to find corresponding metadata file - const metaPath = path.join(sdkProjectDir, `${sessionId}.meta.json`); - let meta: SessionMetadata | null = null; - - if (fs.existsSync(metaPath)) { - try { - const metaContent = fs.readFileSync(metaPath, 'utf-8'); - meta = JSON.parse(metaContent) as SessionMetadata; - } catch { - // Ignore invalid metadata - } - } - - if (meta) { - metas.push(meta); - } else { - // Create minimal metadata from SDK session - const firstLine = lines[0]; - try { - const parsed = JSON.parse(firstLine); - if (parsed.type === 'error' || parsed.type === 'initiation_status') { - // Skip error/status messages - continue; - } - - // Use timestamp from file or current time - const stats = fs.statSync(filePath); - const timestamp = Math.floor(stats.mtimeMs / 1000); - - metas.push({ - id: sessionId, - title: sessionId.slice(0, 8), // Use first 8 chars as title - createdAt: timestamp, - updatedAt: timestamp, - lastResponseAt: timestamp, - sessionId, - sdkSessionId: sessionId, - } as SessionMetadata); - } catch { - // Skip invalid sessions - } - } - } catch { - // Skip files that fail to load - } - } - } catch { - // Return empty list if directory listing fails - } - - return metas; - } + constructor(private adapter: VaultFileAdapter) { } async loadConversation(id: string): Promise { const filePath = this.getFilePath(id); @@ -220,7 +110,6 @@ export class SessionStorage { const conversations: Conversation[] = []; let failedCount = 0; - // 1. Load legacy conversations from vault try { const files = await this.adapter.listFiles(SESSIONS_PATH); @@ -239,65 +128,12 @@ export class SessionStorage { failedCount++; } } - } catch { - // Return empty list if directory listing fails - } - // 2. Load SDK sessions from ~/.claude/projects/{dir}/ - try { - const sdkProjectDir = this.getSDKProjectDir(); - if (fs.existsSync(sdkProjectDir)) { - const files = fs.readdirSync(sdkProjectDir, { withFileTypes: true }); - - for (const file of files) { - if (!file.isFile() || !file.name.endsWith('.jsonl')) continue; - - const sessionId = file.name.replace('.jsonl', ''); - - // Skip if already loaded from vault - if (conversations.some(c => c.sdkSessionId === sessionId)) continue; - - try { - const metaPath = path.join(sdkProjectDir, `${sessionId}.meta.json`); - let meta: SessionMetadata | null = null; - - if (fs.existsSync(metaPath)) { - try { - const metaContent = fs.readFileSync(metaPath, 'utf-8'); - meta = JSON.parse(metaContent) as SessionMetadata; - } catch { - // Ignore invalid metadata - } - } - - // Create minimal Conversation object - const stats = fs.statSync(path.join(sdkProjectDir, file.name)); - const timestamp = Math.floor(stats.mtimeMs / 1000); - - const conversation: Conversation = { - id: meta?.id || sessionId, - title: meta?.title || sessionId.slice(0, 8), - messages: [], // SDK sessions store messages separately - createdAt: meta?.createdAt || timestamp, - updatedAt: meta?.updatedAt || timestamp, - lastResponseAt: meta?.lastResponseAt || timestamp, - sessionId: sessionId, - sdkSessionId: sessionId, - isNative: true, - }; - - conversations.push(conversation); - } catch { - // Skip failed sessions - } - } - } + conversations.sort((a, b) => b.updatedAt - a.updatedAt); } catch { - // Continue if SDK session loading fails + // Return empty list if directory listing fails } - conversations.sort((a, b) => (b.lastResponseAt ?? b.updatedAt) - (a.lastResponseAt ?? a.updatedAt)); - return { conversations, failedCount }; } @@ -506,24 +342,23 @@ export class SessionStorage { } /** - * List all conversations, merging legacy JSONL, native metadata, and SDK sessions. + * List all conversations, merging legacy JSONL and native metadata sources. * Legacy conversations take precedence if both exist. */ async listAllConversations(): Promise { const metas: ConversationMeta[] = []; - const seenIds = new Set(); // 1. Load legacy conversations (existing .jsonl files) const legacyMetas = await this.listConversations(); - for (const meta of legacyMetas) { - metas.push(meta); - seenIds.add(meta.id); - } + metas.push(...legacyMetas); - // 2. Load native metadata (.meta.json files in vault) + // 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 (!seenIds.has(meta.id)) { + if (!legacyIds.has(meta.id)) { metas.push({ id: meta.id, title: meta.title, @@ -535,26 +370,6 @@ export class SessionStorage { titleGenerationStatus: meta.titleGenerationStatus, isNative: true, }); - seenIds.add(meta.id); - } - } - - // 3. Load SDK sessions from ~/.claude/projects/{dir}/ - const sdkSessions = await this.listSDKSessions(); - for (const meta of sdkSessions) { - if (!seenIds.has(meta.id)) { - metas.push({ - id: meta.id, - title: meta.title, - createdAt: meta.createdAt, - updatedAt: meta.updatedAt, - lastResponseAt: meta.lastResponseAt, - messageCount: 0, - preview: 'SDK session', - titleGenerationStatus: meta.titleGenerationStatus, - isNative: true, - }); - seenIds.add(meta.id); } } diff --git a/src/core/storage/StorageService.ts b/src/core/storage/StorageService.ts index 09630453..3c66fb6b 100644 --- a/src/core/storage/StorageService.ts +++ b/src/core/storage/StorageService.ts @@ -121,22 +121,17 @@ export class StorageService { private adapter: VaultFileAdapter; private plugin: Plugin; private app: App; - private vaultPath: string; constructor(plugin: Plugin) { this.plugin = plugin; this.app = plugin.app; this.adapter = new VaultFileAdapter(this.app); - this.vaultPath = (this.app.vault.adapter as any).basePath; this.ccSettings = new CCSettingsStorage(this.adapter); this.claudianSettings = new ClaudianSettingsStorage(this.adapter); this.commands = new SlashCommandStorage(this.adapter); - // Prefer global config (~/.claude/) over vault config for CC compatibility + // Use global config (~/.claude/) for CC compatibility this.skills = new SkillStorage(this.adapter, { preferGlobal: true }); - this.sessions = new SessionStorage(this.adapter, { - vaultPath: this.vaultPath, - useCCWorkingDirectory: true, // Will be updated after settings load - }); + this.sessions = new SessionStorage(this.adapter); this.mcp = new McpStorage(this.adapter, { preferGlobal: true }); this.agents = new AgentVaultStorage(this.adapter); } @@ -148,12 +143,6 @@ export class StorageService { const cc = await this.ccSettings.load(); const claudian = await this.claudianSettings.load(); - // Re-create SessionStorage with loaded settings - this.sessions = new SessionStorage(this.adapter, { - vaultPath: this.vaultPath, - useCCWorkingDirectory: claudian.useCCWorkingDirectory ?? true, - }); - return { cc, claudian }; } @@ -390,7 +379,6 @@ export class StorageService { async loadAllSlashCommands(): Promise { const commands = await this.commands.loadAll(); - // loadAll() now includes both global and vault skills (global takes precedence) const skills = await this.skills.loadAll(); return [...commands, ...skills]; } diff --git a/src/core/types/settings.ts b/src/core/types/settings.ts index 00917835..735347bd 100644 --- a/src/core/types/settings.ts +++ b/src/core/types/settings.ts @@ -199,7 +199,7 @@ export interface EnvSnippet { } /** Source of a slash command. */ -export type SlashCommandSource = 'builtin' | 'user' | 'plugin' | 'sdk' | 'vault' | 'global'; +export type SlashCommandSource = 'builtin' | 'user' | 'plugin' | 'sdk'; /** Slash command configuration with Claude Code compatibility. */ export interface SlashCommand { @@ -279,7 +279,6 @@ export interface ClaudianSettings { 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) - useCCWorkingDirectory: boolean; // Use user's home directory as cwd (same as CC CLI), instead of vault path // State (merged from data.json) lastClaudeModel?: ClaudeModel; @@ -344,7 +343,6 @@ export const DEFAULT_SETTINGS: ClaudianSettings = { claudeCliPath: '', // Legacy field (empty = not migrated) claudeCliPathsByHost: {}, // Per-device paths keyed by hostname loadUserClaudeSettings: true, // Default on for compatibility - useCCWorkingDirectory: true, // Default to using home directory (same as CC CLI) lastClaudeModel: 'haiku', lastCustomModel: '', diff --git a/src/features/settings/ClaudianSettings.ts b/src/features/settings/ClaudianSettings.ts index ae212a87..380f4142 100644 --- a/src/features/settings/ClaudianSettings.ts +++ b/src/features/settings/ClaudianSettings.ts @@ -677,19 +677,6 @@ export class ClaudianSettingTab extends PluginSettingTab { text.inputEl.style.borderColor = 'var(--text-error)'; } }); - - // Use Claude Code working directory setting - new Setting(containerEl) - .setName(t('settings.useCCWorkingDirectory.name')) - .setDesc(t('settings.useCCWorkingDirectory.desc')) - .addToggle((toggle) => - toggle - .setValue(this.plugin.settings.useCCWorkingDirectory ?? true) - .onChange(async (value) => { - this.plugin.settings.useCCWorkingDirectory = value; - await this.plugin.saveSettings(); - }) - ); } private renderContextLimitsSection(): void { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 377c019c..ff22ccb6 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -277,10 +277,6 @@ "isDirectory": "Pfad ist ein Verzeichnis, keine Datei" } }, - "useCCWorkingDirectory": { - "name": "Claude Code-Arbeitsverzeichnis verwenden", - "desc": "Wenn aktiviert, wird Ihr Home-Verzeichnis als Arbeitsverzeichnis verwendet (wie Claude Code CLI). Dies führt die Gesprächsverläufe mit Claude Code CLI zusammen. Wenn deaktiviert, wird der Vault-Pfad als Arbeitsverzeichnis verwendet." - }, "language": { "name": "Sprache", "desc": "Anzeigesprache der Plugin-Oberfläche ändern" diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c9a37c1c..737360a8 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -277,10 +277,6 @@ "isDirectory": "Path is a directory, not a file" } }, - "useCCWorkingDirectory": { - "name": "Use Claude Code working directory", - "desc": "When enabled, use your home directory as the working directory (same as Claude Code CLI). This merges conversation history with Claude Code CLI. When disabled, use the vault path as the working directory." - }, "language": { "name": "Language", "desc": "Change the display language of the plugin interface" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index cee8d41f..f2f81a00 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -277,10 +277,6 @@ "isDirectory": "路径是目录,不是文件" } }, - "useCCWorkingDirectory": { - "name": "使用 Claude Code 工作目录", - "desc": "启用后,将使用您的主目录作为工作目录(与 Claude Code CLI 相同)。这会将与 Claude Code CLI 的对话历史合并。禁用时,将使用保险库路径作为工作目录。" - }, "language": { "name": "语言", "desc": "更改插件界面的显示语言" diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 23cf5b69..cc69b337 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -277,10 +277,6 @@ "isDirectory": "路徑是目錄,不是檔案" } }, - "useCCWorkingDirectory": { - "name": "使用 Claude Code 工作目錄", - "desc": "啟用後,將使用您的主目錄作為工作目錄(與 Claude Code CLI 相同)。這會將與 Claude Code CLI 的對話歷史合併。停用時,將使用保險庫路徑作為工作目錄。" - }, "language": { "name": "語言", "desc": "更改插件介面的顯示語言" diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 23638c23..36d8c3ac 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -229,8 +229,6 @@ export type TranslationKey = | 'settings.cliPath.descUnix' | 'settings.cliPath.validation.notExist' | 'settings.cliPath.validation.isDirectory' - | 'settings.useCCWorkingDirectory.name' - | 'settings.useCCWorkingDirectory.desc' // Settings - Language | 'settings.language.name' From 9fae111af62c0e8ea4808a1ef3b76150bef35b15 Mon Sep 17 00:00:00 2001 From: Sophomoresty <1404462714@qq.com> Date: Fri, 6 Feb 2026 01:13:13 +0800 Subject: [PATCH 6/7] docs: add modification tracking for future reference --- MODIFICATIONS.md | 105 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 MODIFICATIONS.md diff --git a/MODIFICATIONS.md b/MODIFICATIONS.md new file mode 100644 index 00000000..df828726 --- /dev/null +++ b/MODIFICATIONS.md @@ -0,0 +1,105 @@ +# Claudian 修改记录 + +## 修改目标 + +让 Claudian 与 Claude Code CLI 共享配置,避免重复配置。 + +## 已实现的修改 + +### 1. Skills 全局共享 + +**文件**: `src/core/storage/SkillStorage.ts` + +**改动**: +- 优先从 `~/.claude/skills/` 读取 skills +- Vault skills 作为后备(同名时全局优先) +- 保存时默认保存到全局位置 + +**效果**: 在 CC 中配置的 skills,Claudian 可以直接使用 + +--- + +### 2. MCP 全局共享 + +**文件**: `src/core/storage/McpStorage.ts` + +**改动**: +- 优先从 `~/.claude/mcp.json` 读取 MCP 配置 +- Vault `.claude/mcp.json` 作为后备 +- 保存时默认保存到全局位置 + +**效果**: 在 CC 中配置的 MCP servers,Claudian 可以直接使用 + +--- + +### 3. 存储服务配置 + +**文件**: `src/core/storage/StorageService.ts` + +**改动**: +```typescript +// 传递 { preferGlobal: true } 选项给 SkillStorage 和 McpStorage +this.skills = new SkillStorage(this.adapter, { preferGlobal: true }); +this.mcp = new McpStorage(this.adapter, { preferGlobal: true }); +``` + +--- + +### 4. LaTeX 流式渲染优化 + +**文件**: `src/features/chat/controllers/StreamController.ts` + +**改动**: +- 流式输出期间:纯文本显示(无 markdown 渲染,无 MathJax) +- 流式结束时:一次性渲染完整 markdown + LaTeX + +**原因**: 每次调用 `renderContent()` 都会触发 MathJax 处理全部内容,导致卡顿 + +**效果**: 流式输出飞快,结束时格式正确 + +--- + +## 未修改(保持原样) + +| 功能 | 位置 | 说明 | +|------|------|------| +| **Agents** | `~/.claude/agents/` | 已原生支持 | +| **Hooks** | `~/.claude/settings.json` | SDK 自动处理 | +| **工作目录** | vault 路径 | 保持不变 | +| **历史记录** | `~/.claude/projects/{vault}/` | 按 vault 隔离 | + +--- + +## Fork 维护 + +**Fork 仓库**: https://github.com/Sophomoresty/claudian + +**分支**: `feature/global-cc-config` + +**上游仓库**: https://github.com/YishenTu/claudian + +**更新方式**: +```bash +cd C:\Users\Sophomores\AppData\Local\Temp\claudian +git checkout main +git pull origin main +git checkout feature/global-cc-config +git merge main +# 解决冲突后 +git push fork feature/global-cc-config +``` + +--- + +## 构建部署 + +```bash +# 构建 +cd C:\Users\Sophomores\AppData\Local\Temp\claudian +npm run build + +# 部署到 Obsidian +cp main.js "E:/obsidian-notes/.obsidian/plugins/claudian/main.js" + +# 重载 Obsidian: Ctrl+R +``` From 304d022d8be9ecea76f3fc8f53fd5778c48dd16e Mon Sep 17 00:00:00 2001 From: Sophomoresty <1404462714@qq.com> Date: Sat, 7 Feb 2026 19:24:31 +0800 Subject: [PATCH 7/7] feat: add global config toggle and prepare for upstream PR ## New Feature: Global Claude Code Config Toggle - Added useGlobalCcConfig setting (default: false for backward compatibility) - When enabled: skills and MCP servers load from ~/.claude/ - When disabled: load from vault (isolated per vault) - Settings UI toggle in Safety section - Translations for English and Chinese ## Existing Features Included in PR 1. **LaTeX Rendering Optimization**: - Plain text during streaming, full markdown + MathJax at end - Eliminates visual flickering and performance issues 2. **Global Config Support** (from earlier commits): - SkillStorage supports ~/.claude/skills/ - McpStorage supports ~/.claude/mcp.json - Dynamic reconfiguration via updateGlobalConfig() ## Files Modified - src/core/types/settings.ts - Add useGlobalCcConfig setting - src/core/storage/StorageService.ts - Dynamic config switching - src/features/settings/ClaudianSettings.ts - Settings UI toggle - src/i18n/locales/en.json - English translations - src/i18n/locales/zh-CN.json - Chinese translations ## Backward Compatibility Default is false (vault-only), maintaining existing behavior. Users must opt-in to use global config. --- src/core/storage/StorageService.ts | 22 +++++++++++++++++----- src/core/types/settings.ts | 2 ++ src/features/settings/ClaudianSettings.ts | 15 +++++++++++++++ src/i18n/locales/en.json | 4 ++++ src/i18n/locales/zh-CN.json | 4 ++++ 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/core/storage/StorageService.ts b/src/core/storage/StorageService.ts index 0a91a1d6..48146885 100644 --- a/src/core/storage/StorageService.ts +++ b/src/core/storage/StorageService.ts @@ -114,9 +114,9 @@ export class StorageService { readonly ccSettings: CCSettingsStorage; readonly claudianSettings: ClaudianSettingsStorage; readonly commands: SlashCommandStorage; - readonly skills: SkillStorage; + skills: SkillStorage; readonly sessions: SessionStorage; - readonly mcp: McpStorage; + mcp: McpStorage; readonly agents: AgentVaultStorage; private adapter: VaultFileAdapter; @@ -130,10 +130,10 @@ export class StorageService { this.ccSettings = new CCSettingsStorage(this.adapter); this.claudianSettings = new ClaudianSettingsStorage(this.adapter); this.commands = new SlashCommandStorage(this.adapter); - // Use global config (~/.claude/) for CC compatibility - this.skills = new SkillStorage(this.adapter, { preferGlobal: true }); + // Initialize with default (vault-only), will be updated after settings load + this.skills = new SkillStorage(this.adapter, { preferGlobal: false }); this.sessions = new SessionStorage(this.adapter); - this.mcp = new McpStorage(this.adapter, { preferGlobal: true }); + this.mcp = new McpStorage(this.adapter, { preferGlobal: false }); this.agents = new AgentVaultStorage(this.adapter); } @@ -144,9 +144,21 @@ export class StorageService { const cc = await this.ccSettings.load(); const claudian = await this.claudianSettings.load(); + // Update skills and MCP storage based on loaded settings + this.updateGlobalConfig(claudian.useGlobalCcConfig ?? false); + return { cc, claudian }; } + /** + * Update global config preference for skills and MCP servers. + * Call this when the useGlobalCcConfig setting changes. + */ + updateGlobalConfig(useGlobal: boolean): void { + this.skills = new SkillStorage(this.adapter, { preferGlobal: useGlobal }); + this.mcp = new McpStorage(this.adapter, { preferGlobal: useGlobal }); + } + private async runMigrations(): Promise { const ccExists = await this.ccSettings.exists(); const claudianExists = await this.claudianSettings.exists(); diff --git a/src/core/types/settings.ts b/src/core/types/settings.ts index 735347bd..c23faf74 100644 --- a/src/core/types/settings.ts +++ b/src/core/types/settings.ts @@ -279,6 +279,7 @@ export interface ClaudianSettings { 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) + useGlobalCcConfig: boolean; // Use global Claude Code config (~/.claude/) for skills and MCP // State (merged from data.json) lastClaudeModel?: ClaudeModel; @@ -343,6 +344,7 @@ export const DEFAULT_SETTINGS: ClaudianSettings = { claudeCliPath: '', // Legacy field (empty = not migrated) claudeCliPathsByHost: {}, // Per-device paths keyed by hostname loadUserClaudeSettings: true, // Default on for compatibility + useGlobalCcConfig: false, // Vault-only by default (backward compatible) lastClaudeModel: 'haiku', lastCustomModel: '', diff --git a/src/features/settings/ClaudianSettings.ts b/src/features/settings/ClaudianSettings.ts index 380f4142..92b9de1b 100644 --- a/src/features/settings/ClaudianSettings.ts +++ b/src/features/settings/ClaudianSettings.ts @@ -406,6 +406,21 @@ export class ClaudianSettingTab extends PluginSettingTab { }) ); + new Setting(containerEl) + .setName(t('settings.useGlobalCcConfig.name')) + .setDesc(t('settings.useGlobalCcConfig.desc')) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.useGlobalCcConfig ?? false) + .onChange(async (value) => { + this.plugin.settings.useGlobalCcConfig = value; + this.plugin.storage.updateGlobalConfig(value); + await this.plugin.saveSettings(); + // Reload slash commands to apply new config source + await this.plugin.loadAllSlashCommands(); + }) + ); + new Setting(containerEl) .setName(t('settings.enableBlocklist.name')) .setDesc(t('settings.enableBlocklist.desc')) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 737360a8..3ea68101 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -189,6 +189,10 @@ "name": "Load user Claude settings", "desc": "Load ~/.claude/settings.json. When enabled, user's Claude Code permission rules may bypass Safe mode." }, + "useGlobalCcConfig": { + "name": "Use global Claude Code config", + "desc": "When enabled, skills and MCP servers are loaded from ~/.claude/ (shared with Claude Code CLI). When disabled, they are loaded from the vault (isolated per vault). Default is disabled for backward compatibility." + }, "enableBlocklist": { "name": "Enable command blocklist", "desc": "Block potentially dangerous bash commands" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index f2f81a00..e49fbef0 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -189,6 +189,10 @@ "name": "加载用户 Claude 设置", "desc": "加载 ~/.claude/settings.json。启用后,用户的 Claude Code 权限规则可能绕过安全模式。" }, + "useGlobalCcConfig": { + "name": "使用 Claude Code 全局配置", + "desc": "启用后,skills 和 MCP 服务器从 ~/.claude/ 加载(与 Claude Code CLI 共享)。禁用时从 vault 加载(每个 vault 独立)。默认禁用以保持向后兼容。" + }, "enableBlocklist": { "name": "启用命令黑名单", "desc": "阻止潜在危险的 bash 命令"