From d12076ef74c4c7622fb5f7ef782fd66469c6acf2 Mon Sep 17 00:00:00 2001 From: hajimiHenry Date: Sun, 12 Apr 2026 23:53:27 +0800 Subject: [PATCH 01/95] Fix math formula rendering in chat messages --- src/services/message-sender.ts | 43 +++-- src/ui/shared/MarkdownRenderer.tsx | 248 ++++++++++++++++++++++++++++- 2 files changed, 280 insertions(+), 11 deletions(-) diff --git a/src/services/message-sender.ts b/src/services/message-sender.ts index 6c994da..44ebbcb 100644 --- a/src/services/message-sender.ts +++ b/src/services/message-sender.ts @@ -144,6 +144,12 @@ export interface SendPromptResult { const DEFAULT_MAX_NOTE_LENGTH = 10000; // Default maximum characters per note const DEFAULT_MAX_SELECTION_LENGTH = 10000; // Default maximum characters for selection +const MATH_FORMATTING_INSTRUCTION = [ + "When your response includes mathematical expressions, always format them using standard LaTeX math delimiters that render correctly in Obsidian Markdown.", + "Use $...$ for inline math and $$...$$ for display math.", + "Do not output bare LaTeX such as A^{-1}, \\begin{pmatrix}...\\end{pmatrix}, or standalone [ ... ] without math delimiters.", + "Do not wrap mathematical expressions in code fences or inline backticks unless the user explicitly asks for source code.", +].join(" "); // ============================================================================ // Shared Helper Functions @@ -252,6 +258,22 @@ function buildAutoMentionPrefix( return `@[[${activeNote.name}]]\n`; } +function buildAgentMessageText( + message: string, + autoMentionPrefix: string, + contextBlocks?: string[], +): string { + const userMessage = autoMentionPrefix + message; + + return [ + ...(contextBlocks && contextBlocks.length > 0 + ? [contextBlocks.join("\n")] + : []), + MATH_FORMATTING_INSTRUCTION, + ...(userMessage ? [userMessage] : []), + ].join("\n\n"); +} + /** * Build display content array (message + images + resource links). */ @@ -397,15 +419,19 @@ async function preparePromptWithEmbeddedContext( input.activeNote, input.isAutoMentionDisabled, ); + const agentMessageText = buildAgentMessageText( + input.message, + autoMentionPrefix, + ); const agentContent: PromptContent[] = [ ...resourceBlocks, ...autoMentionBlocks, - ...(input.message || autoMentionPrefix + ...(agentMessageText ? [ { type: "text" as const, - text: autoMentionPrefix + input.message, + text: agentMessageText, }, ] : []), @@ -477,14 +503,11 @@ async function preparePromptWithTextContext( input.isAutoMentionDisabled, ); - // Build agent message text (context blocks + auto-mention prefix + original message) - const agentMessageText = - contextBlocks.length > 0 - ? contextBlocks.join("\n") + - "\n\n" + - autoMentionPrefix + - input.message - : autoMentionPrefix + input.message; + const agentMessageText = buildAgentMessageText( + input.message, + autoMentionPrefix, + contextBlocks, + ); const agentContent: PromptContent[] = [ ...(agentMessageText diff --git a/src/ui/shared/MarkdownRenderer.tsx b/src/ui/shared/MarkdownRenderer.tsx index dd7b6ae..548b410 100644 --- a/src/ui/shared/MarkdownRenderer.tsx +++ b/src/ui/shared/MarkdownRenderer.tsx @@ -15,6 +15,250 @@ interface MarkdownRendererProps { plugin: AgentClientPlugin; } +const FENCED_CODE_BLOCK_REGEX = /(```[\s\S]*?```)/g; +const INLINE_CODE_REGEX = /(`[^`\n]+`)/g; +const DISPLAY_MATH_BLOCK_REGEX = /(\$\$[\s\S]*?\$\$)/g; +const PROTECTED_MATH_REGEX = /(\$\$[\s\S]*?\$\$|\$[^$\n]+\$)/g; + +function pushDisplayMathBlock(target: string[], lines: string[]): void { + if (target.length > 0 && target[target.length - 1] !== "") { + target.push(""); + } + + target.push("$$", ...lines, "$$", ""); +} + +function looksLikeMathContent(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + + return [ + /\\begin\{[a-z*]+\}/i, + /\\end\{[a-z*]+\}/i, + /\\[a-zA-Z]+/, + /\^/, + /_/, + /&/, + /\\\\/, + /[=<>+\-*/]/, + ].some((pattern) => pattern.test(trimmed)); +} + +function stripOuterMathDelimiters(text: string): string { + const trimmed = text.trim(); + const doubleMatch = trimmed.match(/^\$\$\s*([\s\S]*?)\s*\$\$$/); + if (doubleMatch && looksLikeMathContent(doubleMatch[1])) { + return doubleMatch[1].trim(); + } + + const singleMatch = trimmed.match(/^\$\s*([\s\S]*?)\s*\$$/); + if (singleMatch && looksLikeMathContent(singleMatch[1])) { + return singleMatch[1].trim(); + } + + return trimmed; +} + +function isMathLikeInlineFragment(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + if (/[一-龥]{2,}/.test(trimmed)) return false; + if (!looksLikeMathContent(trimmed)) return false; + if (/^(https?:\/\/|www\.)/i.test(trimmed)) return false; + + return /^[A-Za-z0-9\\{}()[\]^_=+\-*/.,\s]+$/.test(trimmed); +} + +function isProbablyMathLine(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed) return false; + if ( + trimmed === "$$" || + trimmed.startsWith("```") || + trimmed.startsWith(">") || + trimmed.startsWith("#") || + trimmed.startsWith("|") || + trimmed.includes("`") + ) { + return false; + } + + const candidate = trimmed + .replace(/^[-*+]\s+/, "") + .replace(/^\d+\.\s+/, "") + .trim(); + if (candidate.includes("$")) return false; + const unwrappedCandidate = stripOuterMathDelimiters(candidate); + + if (!unwrappedCandidate) return false; + if (/[一-龥]{2,}/.test(unwrappedCandidate)) return false; + if (/[::;,。!?]$/.test(unwrappedCandidate)) return false; + if (/^(https?:\/\/|www\.)/i.test(unwrappedCandidate)) return false; + if ( + !/^[A-Za-z0-9\\{}()[\]^_=+\-*/.,\s&]+$/.test(unwrappedCandidate) + ) { + return false; + } + + if (/\\begin\{[a-z*]+\}/i.test(unwrappedCandidate)) return true; + if (/\\end\{[a-z*]+\}/i.test(unwrappedCandidate)) return true; + if ( + /[=<>]/.test(unwrappedCandidate) && + !/[,:;]/.test(unwrappedCandidate.replace(/\s+/g, "")) + ) { + return true; + } + + return isMathLikeInlineFragment(unwrappedCandidate); +} + +function normalizeStandaloneBracketMath(text: string): string { + const lines = text.split(/\r?\n/); + const normalized: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const current = lines[i].trim(); + if (current !== "[" && current !== "\\[") { + normalized.push(lines[i]); + continue; + } + + let closingIndex = -1; + for (let j = i + 1; j < lines.length; j++) { + const candidate = lines[j].trim(); + if (candidate === "]" || candidate === "\\]") { + closingIndex = j; + break; + } + } + + if (closingIndex === -1) { + normalized.push(lines[i]); + continue; + } + + const innerLines = lines.slice(i + 1, closingIndex); + if (!looksLikeMathContent(innerLines.join("\n"))) { + normalized.push(lines[i]); + continue; + } + + normalized.push("$$", ...innerLines, "$$"); + i = closingIndex; + } + + return normalized.join("\n"); +} + +function normalizeMathDisplayLines(text: string): string { + const lines = text.split(/\r?\n/); + const normalized: string[] = []; + let block: string[] = []; + + const flushBlock = () => { + if (block.length === 0) return; + + const trimmedBlock = block.map((line) => + stripOuterMathDelimiters(line), + ); + const containsEnvironment = trimmedBlock.some((line) => + /\\begin\{[a-z*]+\}|\\end\{[a-z*]+\}/i.test(line), + ); + + if (containsEnvironment) { + pushDisplayMathBlock(normalized, trimmedBlock); + } else { + for (const line of trimmedBlock) { + pushDisplayMathBlock(normalized, [line]); + } + } + + block = []; + }; + + for (const line of lines) { + if (isProbablyMathLine(line)) { + block.push(line); + continue; + } + + flushBlock(); + normalized.push(line); + } + + flushBlock(); + return normalized.join("\n"); +} + +function normalizeInlineMathFragments(text: string): string { + const codeSegments = text.split(INLINE_CODE_REGEX); + + return codeSegments + .map((segment, index) => { + // Preserve inline code verbatim. + if (index % 2 === 1) return segment; + + return segment + .split(PROTECTED_MATH_REGEX) + .map((part, partIndex) => { + if (partIndex % 2 === 1) return part; + + return part.replace( + /(^|[^\w$\\])((?:\\[A-Za-z]+(?:\{[^}\n]*\})*|[A-Za-z][A-Za-z0-9]*)(?:[A-Za-z0-9\\{}()[\]^_=+\-*/.,\s]|\\[A-Za-z]+(?:\{[^}\n]*\})*)*)/g, + (match, prefix: string, expression: string) => { + if (!isMathLikeInlineFragment(expression)) { + return match; + } + + return `${prefix}$${expression}$`; + }, + ); + }) + .join(""); + }) + .join(""); +} + +function normalizeMathMarkdown(text: string): string { + const segments = text.split(FENCED_CODE_BLOCK_REGEX); + + return segments + .map((segment, index) => { + // Preserve fenced code blocks verbatim. + if (index % 2 === 1) return segment; + + const normalizedDisplayMath = segment.replace( + /\\\[\s*([\s\S]*?)\s*\\\]/g, + (_match, inner: string) => `$$\n${inner.trim()}\n$$`, + ); + + const normalizedInlineMath = normalizedDisplayMath.replace( + /\\\((.+?)\\\)/g, + (_match, inner: string) => `$${inner}$`, + ); + + const normalizedBracketMath = + normalizeStandaloneBracketMath(normalizedInlineMath); + const normalizedDisplayLines = normalizedBracketMath + .split(DISPLAY_MATH_BLOCK_REGEX) + .map((part, partIndex) => { + if (partIndex % 2 === 1) return part; + return normalizeMathDisplayLines(part); + }) + .join(""); + + return normalizedDisplayLines + .split(DISPLAY_MATH_BLOCK_REGEX) + .map((part, partIndex) => { + // Preserve explicit display math blocks verbatim. + if (partIndex % 2 === 1) return part; + return normalizeInlineMathFragments(part); + }) + .join(""); + }) + .join(""); +} + export function MarkdownRenderer({ text, plugin }: MarkdownRendererProps) { const containerRef = useRef(null); @@ -28,10 +272,12 @@ export function MarkdownRenderer({ text, plugin }: MarkdownRendererProps) { const component = new Component(); component.load(); + const normalizedText = normalizeMathMarkdown(text); + // Render markdown void ObsidianMarkdownRenderer.render( plugin.app, - text, + normalizedText, el, "", component, From cb92f4b6ce91e8c023a6b164da9bb3db0709afeb Mon Sep 17 00:00:00 2001 From: betamod Date: Mon, 13 Apr 2026 18:00:21 -0400 Subject: [PATCH 02/95] Harden secret storage and debug logging --- manifest.json | 2 +- src/acp/acp-client.ts | 29 +++++++-- src/acp/acp-handler.ts | 14 +++- src/acp/permission-handler.ts | 13 +++- src/acp/terminal-handler.ts | 10 ++- src/plugin.ts | 118 ++++++++++++++++++++++++++++++++-- src/types/agent.ts | 6 ++ src/ui/SettingsTab.ts | 26 +++++--- 8 files changed, 191 insertions(+), 27 deletions(-) diff --git a/manifest.json b/manifest.json index 424cd51..2f05860 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "id": "agent-client", "name": "Agent Client", "version": "0.10.0", - "minAppVersion": "0.15.0", + "minAppVersion": "1.11.4", "description": "Chat with AI agents via the Agent Client Protocol directly from your vault.", "author": "RAIT-09", "authorUrl": "https://github.com/RAIT-09", diff --git a/src/acp/acp-client.ts b/src/acp/acp-client.ts index a09d8e9..8c049d0 100644 --- a/src/acp/acp-client.ts +++ b/src/acp/acp-client.ts @@ -110,7 +110,7 @@ export class AcpClient { async initialize(config: AgentConfig): Promise { this.logger.log( "[AcpClient] Starting initialization with config:", - config, + this.getSafeConfigForLog(config), ); this.logger.log( `[AcpClient] Current state - process: ${!!this.agentProcess}, PID: ${this.agentProcess?.pid}`, @@ -204,7 +204,7 @@ export class AcpClient { this.logger.log( "[AcpClient] Prepared spawn command:", spawnCommand, - spawnArgs, + `(${spawnArgs.length} args)`, ); // Spawn the agent process @@ -299,9 +299,12 @@ export class AcpClient { agentProcess.stderr?.setEncoding("utf8"); agentProcess.stderr?.on("data", (data) => { - this.logger.log(`[AcpClient] ${agentLabel} stderr:`, data); + const stderrChunk = String(data); + this.logger.log( + `[AcpClient] ${agentLabel} stderr chunk (${stderrChunk.length} chars)`, + ); // Keep a rolling window of recent stderr for error diagnostics - this.recentStderr += data; + this.recentStderr += stderrChunk; if (this.recentStderr.length > 8192) { this.recentStderr = this.recentStderr.slice(-4096); } @@ -384,6 +387,24 @@ export class AcpClient { } } + private getSafeConfigForLog(config: AgentConfig): { + id: string; + displayName: string; + command: string; + args: string[]; + envKeys: string[]; + workingDirectory: string; + } { + return { + id: config.id, + displayName: config.displayName, + command: config.command, + args: [...config.args], + envKeys: Object.keys(config.env || {}), + workingDirectory: config.workingDirectory, + }; + } + /** * Create a new chat session with the agent. */ diff --git a/src/acp/acp-handler.ts b/src/acp/acp-handler.ts index 5084589..c38d739 100644 --- a/src/acp/acp-handler.ts +++ b/src/acp/acp-handler.ts @@ -68,7 +68,10 @@ export class AcpHandler { const update = params.update; const sessionId = params.sessionId; this.promptSessionUpdateCount++; - this.logger.log("[AcpHandler] sessionUpdate:", { sessionId, update }); + this.logger.log("[AcpHandler] sessionUpdate:", { + sessionId, + type: update.sessionUpdate, + }); switch (update.sessionUpdate) { case "agent_message_chunk": @@ -174,7 +177,7 @@ export class AcpHandler { ): Promise { this.logger.log( `[AcpHandler] Extension notification received: ${method}`, - params, + Object.keys(params), ); } @@ -199,7 +202,12 @@ export class AcpHandler { ): Promise { this.logger.log( "[AcpHandler] createTerminal called with params:", - params, + { + command: params.command, + argCount: params.args?.length ?? 0, + hasEnv: (params.env?.length ?? 0) > 0, + cwd: params.cwd || this.getWorkingDirectory(), + }, ); const terminalId = this.terminalManager.createTerminal({ diff --git a/src/acp/permission-handler.ts b/src/acp/permission-handler.ts index 4403320..680ec51 100644 --- a/src/acp/permission-handler.ts +++ b/src/acp/permission-handler.ts @@ -78,7 +78,12 @@ export class PermissionManager { ): Promise { this.logger.log( "[PermissionManager] Permission request received:", - params, + { + sessionId: params.sessionId, + optionCount: params.options.length, + toolCallId: params.toolCall?.toolCallId, + title: params.toolCall?.title, + }, ); // If auto-allow is enabled, automatically approve the first allow option @@ -94,7 +99,11 @@ export class PermissionManager { this.logger.log( "[PermissionManager] Auto-allowing permission request:", - allowOption, + { + optionId: allowOption.optionId, + kind: allowOption.kind, + name: allowOption.name, + }, ); return Promise.resolve({ diff --git a/src/acp/terminal-handler.ts b/src/acp/terminal-handler.ts index 780bb03..ca73b6d 100644 --- a/src/acp/terminal-handler.ts +++ b/src/acp/terminal-handler.ts @@ -93,7 +93,7 @@ export class TerminalManager { this.logger.log(`[Terminal ${terminalId}] Creating terminal:`, { command, - args, + argCount: args.length, cwd: params.cwd, }); @@ -135,13 +135,17 @@ export class TerminalManager { // Capture stdout and stderr childProcess.stdout?.on("data", (data: Buffer) => { const output = data.toString(); - this.logger.log(`[Terminal ${terminalId}] stdout:`, output); + this.logger.log( + `[Terminal ${terminalId}] stdout chunk (${Buffer.byteLength(output, "utf8")} bytes)`, + ); this.appendOutput(terminal, output); }); childProcess.stderr?.on("data", (data: Buffer) => { const output = data.toString(); - this.logger.log(`[Terminal ${terminalId}] stderr:`, output); + this.logger.log( + `[Terminal ${terminalId}] stderr chunk (${Buffer.byteLength(output, "utf8")} bytes)`, + ); this.appendOutput(terminal, output); }); diff --git a/src/plugin.ts b/src/plugin.ts index 90da26b..33962d0 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -185,6 +185,14 @@ const DEFAULT_SETTINGS: AgentClientPluginSettings = { floatingButtonPosition: null, }; +const AGENT_API_KEY_SECRET_IDS = { + claude: "agent-client-claude-api-key", + codex: "agent-client-codex-api-key", + gemini: "agent-client-gemini-api-key", +} as const; + +type BuiltInAgentKey = keyof typeof AGENT_API_KEY_SECRET_IDS; + export default class AgentClientPlugin extends Plugin { settings: AgentClientPluginSettings; settingsService!: SettingsService; @@ -830,6 +838,7 @@ export default class AgentClientPlugin extends Plugin { async loadSettings() { const raw = ((await this.loadData()) ?? {}) as Record; const D = DEFAULT_SETTINGS; + let migratedSecrets = false; // Extract agent sub-objects const rc = obj(raw.claude) ?? {}; @@ -865,7 +874,17 @@ export default class AgentClientPlugin extends Plugin { claude: { id: D.claude.id, // Fixed — never from raw displayName: str(rc.displayName, D.claude.displayName), - apiKey: str(rc.apiKey, D.claude.apiKey), + apiKeySecretId: str( + rc.apiKeySecretId, + AGENT_API_KEY_SECRET_IDS.claude, + ), + apiKey: this.loadAgentApiKey( + str(rc.apiKeySecretId, AGENT_API_KEY_SECRET_IDS.claude), + str(rc.apiKey, D.claude.apiKey), + () => { + migratedSecrets = true; + }, + ), // Migration: claude.command ← claudeCodeAcpCommandPath (old name) command: str(rc.command, "") || @@ -877,7 +896,17 @@ export default class AgentClientPlugin extends Plugin { codex: { id: D.codex.id, displayName: str(rk.displayName, D.codex.displayName), - apiKey: str(rk.apiKey, D.codex.apiKey), + apiKeySecretId: str( + rk.apiKeySecretId, + AGENT_API_KEY_SECRET_IDS.codex, + ), + apiKey: this.loadAgentApiKey( + str(rk.apiKeySecretId, AGENT_API_KEY_SECRET_IDS.codex), + str(rk.apiKey, D.codex.apiKey), + () => { + migratedSecrets = true; + }, + ), command: str(rk.command, "") || D.codex.command, args: sanitizeArgs(rk.args), env: normalizeEnvVars(rk.env), @@ -885,7 +914,17 @@ export default class AgentClientPlugin extends Plugin { gemini: { id: D.gemini.id, displayName: str(rg.displayName, D.gemini.displayName), - apiKey: str(rg.apiKey, D.gemini.apiKey), + apiKeySecretId: str( + rg.apiKeySecretId, + AGENT_API_KEY_SECRET_IDS.gemini, + ), + apiKey: this.loadAgentApiKey( + str(rg.apiKeySecretId, AGENT_API_KEY_SECRET_IDS.gemini), + str(rg.apiKey, D.gemini.apiKey), + () => { + migratedSecrets = true; + }, + ), // Migration: gemini.command ← geminiCommandPath (old name) command: str(rg.command, "") || @@ -1017,16 +1056,87 @@ export default class AgentClientPlugin extends Plugin { }; this.ensureDefaultAgentId(); + + if (migratedSecrets) { + await this.saveSettings(); + } } async saveSettings() { - await this.saveData(this.settings); + await this.saveData(this.getPersistedSettings()); } async saveSettingsAndNotify(nextSettings: AgentClientPluginSettings) { await this.settingsService.updateSettings(nextSettings); } + private loadAgentApiKey( + secretId: string, + legacyApiKey: string, + onMigrate: () => void, + ): string { + const storedSecret = this.app.secretStorage.getSecret(secretId); + if (storedSecret !== null) { + return storedSecret; + } + + if (legacyApiKey.trim().length > 0) { + this.app.secretStorage.setSecret(secretId, legacyApiKey); + onMigrate(); + return legacyApiKey; + } + + return ""; + } + + private getPersistedSettings(): AgentClientPluginSettings { + return { + ...this.settings, + claude: { + ...this.settings.claude, + apiKey: "", + apiKeySecretId: + this.settings.claude.apiKeySecretId ?? + AGENT_API_KEY_SECRET_IDS.claude, + }, + codex: { + ...this.settings.codex, + apiKey: "", + apiKeySecretId: + this.settings.codex.apiKeySecretId ?? + AGENT_API_KEY_SECRET_IDS.codex, + }, + gemini: { + ...this.settings.gemini, + apiKey: "", + apiKeySecretId: + this.settings.gemini.apiKeySecretId ?? + AGENT_API_KEY_SECRET_IDS.gemini, + }, + }; + } + + async updateAgentApiKey( + agent: BuiltInAgentKey, + apiKey: string, + ): Promise { + const secretId = AGENT_API_KEY_SECRET_IDS[agent]; + this.app.secretStorage.setSecret(secretId, apiKey); + + if (agent === "claude") { + this.settings.claude.apiKey = apiKey; + this.settings.claude.apiKeySecretId = secretId; + } else if (agent === "codex") { + this.settings.codex.apiKey = apiKey; + this.settings.codex.apiKeySecretId = secretId; + } else { + this.settings.gemini.apiKey = apiKey; + this.settings.gemini.apiKeySecretId = secretId; + } + + await this.saveSettings(); + } + /** * Fetch the latest stable release version from GitHub. */ diff --git a/src/types/agent.ts b/src/types/agent.ts index 818b6f4..b716feb 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -61,6 +61,8 @@ export interface BaseAgentSettings { export interface GeminiAgentSettings extends BaseAgentSettings { /** Gemini API key (GEMINI_API_KEY) */ apiKey: string; + /** Secret storage ID containing the Gemini API key */ + apiKeySecretId?: string; } /** @@ -71,6 +73,8 @@ export interface GeminiAgentSettings extends BaseAgentSettings { export interface ClaudeAgentSettings extends BaseAgentSettings { /** Anthropic API key for Claude (ANTHROPIC_API_KEY) */ apiKey: string; + /** Secret storage ID containing the Anthropic API key */ + apiKeySecretId?: string; } /** @@ -81,6 +85,8 @@ export interface ClaudeAgentSettings extends BaseAgentSettings { export interface CodexAgentSettings extends BaseAgentSettings { /** OpenAI API key for Codex (OPENAI_API_KEY) */ apiKey: string; + /** Secret storage ID containing the OpenAI API key */ + apiKeySecretId?: string; } /** diff --git a/src/ui/SettingsTab.ts b/src/ui/SettingsTab.ts index bf47820..c2d1f67 100644 --- a/src/ui/SettingsTab.ts +++ b/src/ui/SettingsTab.ts @@ -813,14 +813,16 @@ export class AgentClientSettingTab extends PluginSettingTab { new Setting(sectionEl) .setName("API key") .setDesc( - "Gemini API key. Required if not logging in with a Google account. (Stored as plain text)", + "Gemini API key. Required if not logging in with a Google account. Stored in Obsidian secret storage.", ) .addText((text) => { text.setPlaceholder("Enter your Gemini API key") .setValue(gemini.apiKey) .onChange(async (value) => { - this.plugin.settings.gemini.apiKey = value.trim(); - await this.plugin.saveSettings(); + await this.plugin.updateAgentApiKey( + "gemini", + value.trim(), + ); }); text.inputEl.type = "password"; }); @@ -863,7 +865,7 @@ export class AgentClientSettingTab extends PluginSettingTab { new Setting(sectionEl) .setName("Environment variables") .setDesc( - "Enter KEY=VALUE pairs, one per line. Required to authenticate with Vertex AI. GEMINI_API_KEY is derived from the field above.(Stored as plain text)", + "Enter KEY=VALUE pairs, one per line. Required to authenticate with Vertex AI. GEMINI_API_KEY is derived from the field above.", ) .addTextArea((text) => { text.setPlaceholder("GOOGLE_CLOUD_PROJECT=...") @@ -886,14 +888,16 @@ export class AgentClientSettingTab extends PluginSettingTab { new Setting(sectionEl) .setName("API key") .setDesc( - "Anthropic API key. Required if not logging in with an Anthropic account. (Stored as plain text)", + "Anthropic API key. Required if not logging in with an Anthropic account. Stored in Obsidian secret storage.", ) .addText((text) => { text.setPlaceholder("Enter your Anthropic API key") .setValue(claude.apiKey) .onChange(async (value) => { - this.plugin.settings.claude.apiKey = value.trim(); - await this.plugin.saveSettings(); + await this.plugin.updateAgentApiKey( + "claude", + value.trim(), + ); }); text.inputEl.type = "password"; }); @@ -963,14 +967,16 @@ export class AgentClientSettingTab extends PluginSettingTab { new Setting(sectionEl) .setName("API key") .setDesc( - "OpenAI API key. Required if not logging in with an OpenAI account. (Stored as plain text)", + "OpenAI API key. Required if not logging in with an OpenAI account. Stored in Obsidian secret storage.", ) .addText((text) => { text.setPlaceholder("Enter your OpenAI API key") .setValue(codex.apiKey) .onChange(async (value) => { - this.plugin.settings.codex.apiKey = value.trim(); - await this.plugin.saveSettings(); + await this.plugin.updateAgentApiKey( + "codex", + value.trim(), + ); }); text.inputEl.type = "password"; }); From 7b47e1cc6e2134e3ee3e052f17301932a516ad40 Mon Sep 17 00:00:00 2001 From: hajimiHenry Date: Tue, 14 Apr 2026 17:10:18 +0800 Subject: [PATCH 03/95] Remove math normalization from markdown renderer --- src/ui/shared/MarkdownRenderer.tsx | 248 +---------------------------- 1 file changed, 1 insertion(+), 247 deletions(-) diff --git a/src/ui/shared/MarkdownRenderer.tsx b/src/ui/shared/MarkdownRenderer.tsx index 548b410..dd7b6ae 100644 --- a/src/ui/shared/MarkdownRenderer.tsx +++ b/src/ui/shared/MarkdownRenderer.tsx @@ -15,250 +15,6 @@ interface MarkdownRendererProps { plugin: AgentClientPlugin; } -const FENCED_CODE_BLOCK_REGEX = /(```[\s\S]*?```)/g; -const INLINE_CODE_REGEX = /(`[^`\n]+`)/g; -const DISPLAY_MATH_BLOCK_REGEX = /(\$\$[\s\S]*?\$\$)/g; -const PROTECTED_MATH_REGEX = /(\$\$[\s\S]*?\$\$|\$[^$\n]+\$)/g; - -function pushDisplayMathBlock(target: string[], lines: string[]): void { - if (target.length > 0 && target[target.length - 1] !== "") { - target.push(""); - } - - target.push("$$", ...lines, "$$", ""); -} - -function looksLikeMathContent(text: string): boolean { - const trimmed = text.trim(); - if (!trimmed) return false; - - return [ - /\\begin\{[a-z*]+\}/i, - /\\end\{[a-z*]+\}/i, - /\\[a-zA-Z]+/, - /\^/, - /_/, - /&/, - /\\\\/, - /[=<>+\-*/]/, - ].some((pattern) => pattern.test(trimmed)); -} - -function stripOuterMathDelimiters(text: string): string { - const trimmed = text.trim(); - const doubleMatch = trimmed.match(/^\$\$\s*([\s\S]*?)\s*\$\$$/); - if (doubleMatch && looksLikeMathContent(doubleMatch[1])) { - return doubleMatch[1].trim(); - } - - const singleMatch = trimmed.match(/^\$\s*([\s\S]*?)\s*\$$/); - if (singleMatch && looksLikeMathContent(singleMatch[1])) { - return singleMatch[1].trim(); - } - - return trimmed; -} - -function isMathLikeInlineFragment(text: string): boolean { - const trimmed = text.trim(); - if (!trimmed) return false; - if (/[一-龥]{2,}/.test(trimmed)) return false; - if (!looksLikeMathContent(trimmed)) return false; - if (/^(https?:\/\/|www\.)/i.test(trimmed)) return false; - - return /^[A-Za-z0-9\\{}()[\]^_=+\-*/.,\s]+$/.test(trimmed); -} - -function isProbablyMathLine(line: string): boolean { - const trimmed = line.trim(); - if (!trimmed) return false; - if ( - trimmed === "$$" || - trimmed.startsWith("```") || - trimmed.startsWith(">") || - trimmed.startsWith("#") || - trimmed.startsWith("|") || - trimmed.includes("`") - ) { - return false; - } - - const candidate = trimmed - .replace(/^[-*+]\s+/, "") - .replace(/^\d+\.\s+/, "") - .trim(); - if (candidate.includes("$")) return false; - const unwrappedCandidate = stripOuterMathDelimiters(candidate); - - if (!unwrappedCandidate) return false; - if (/[一-龥]{2,}/.test(unwrappedCandidate)) return false; - if (/[::;,。!?]$/.test(unwrappedCandidate)) return false; - if (/^(https?:\/\/|www\.)/i.test(unwrappedCandidate)) return false; - if ( - !/^[A-Za-z0-9\\{}()[\]^_=+\-*/.,\s&]+$/.test(unwrappedCandidate) - ) { - return false; - } - - if (/\\begin\{[a-z*]+\}/i.test(unwrappedCandidate)) return true; - if (/\\end\{[a-z*]+\}/i.test(unwrappedCandidate)) return true; - if ( - /[=<>]/.test(unwrappedCandidate) && - !/[,:;]/.test(unwrappedCandidate.replace(/\s+/g, "")) - ) { - return true; - } - - return isMathLikeInlineFragment(unwrappedCandidate); -} - -function normalizeStandaloneBracketMath(text: string): string { - const lines = text.split(/\r?\n/); - const normalized: string[] = []; - - for (let i = 0; i < lines.length; i++) { - const current = lines[i].trim(); - if (current !== "[" && current !== "\\[") { - normalized.push(lines[i]); - continue; - } - - let closingIndex = -1; - for (let j = i + 1; j < lines.length; j++) { - const candidate = lines[j].trim(); - if (candidate === "]" || candidate === "\\]") { - closingIndex = j; - break; - } - } - - if (closingIndex === -1) { - normalized.push(lines[i]); - continue; - } - - const innerLines = lines.slice(i + 1, closingIndex); - if (!looksLikeMathContent(innerLines.join("\n"))) { - normalized.push(lines[i]); - continue; - } - - normalized.push("$$", ...innerLines, "$$"); - i = closingIndex; - } - - return normalized.join("\n"); -} - -function normalizeMathDisplayLines(text: string): string { - const lines = text.split(/\r?\n/); - const normalized: string[] = []; - let block: string[] = []; - - const flushBlock = () => { - if (block.length === 0) return; - - const trimmedBlock = block.map((line) => - stripOuterMathDelimiters(line), - ); - const containsEnvironment = trimmedBlock.some((line) => - /\\begin\{[a-z*]+\}|\\end\{[a-z*]+\}/i.test(line), - ); - - if (containsEnvironment) { - pushDisplayMathBlock(normalized, trimmedBlock); - } else { - for (const line of trimmedBlock) { - pushDisplayMathBlock(normalized, [line]); - } - } - - block = []; - }; - - for (const line of lines) { - if (isProbablyMathLine(line)) { - block.push(line); - continue; - } - - flushBlock(); - normalized.push(line); - } - - flushBlock(); - return normalized.join("\n"); -} - -function normalizeInlineMathFragments(text: string): string { - const codeSegments = text.split(INLINE_CODE_REGEX); - - return codeSegments - .map((segment, index) => { - // Preserve inline code verbatim. - if (index % 2 === 1) return segment; - - return segment - .split(PROTECTED_MATH_REGEX) - .map((part, partIndex) => { - if (partIndex % 2 === 1) return part; - - return part.replace( - /(^|[^\w$\\])((?:\\[A-Za-z]+(?:\{[^}\n]*\})*|[A-Za-z][A-Za-z0-9]*)(?:[A-Za-z0-9\\{}()[\]^_=+\-*/.,\s]|\\[A-Za-z]+(?:\{[^}\n]*\})*)*)/g, - (match, prefix: string, expression: string) => { - if (!isMathLikeInlineFragment(expression)) { - return match; - } - - return `${prefix}$${expression}$`; - }, - ); - }) - .join(""); - }) - .join(""); -} - -function normalizeMathMarkdown(text: string): string { - const segments = text.split(FENCED_CODE_BLOCK_REGEX); - - return segments - .map((segment, index) => { - // Preserve fenced code blocks verbatim. - if (index % 2 === 1) return segment; - - const normalizedDisplayMath = segment.replace( - /\\\[\s*([\s\S]*?)\s*\\\]/g, - (_match, inner: string) => `$$\n${inner.trim()}\n$$`, - ); - - const normalizedInlineMath = normalizedDisplayMath.replace( - /\\\((.+?)\\\)/g, - (_match, inner: string) => `$${inner}$`, - ); - - const normalizedBracketMath = - normalizeStandaloneBracketMath(normalizedInlineMath); - const normalizedDisplayLines = normalizedBracketMath - .split(DISPLAY_MATH_BLOCK_REGEX) - .map((part, partIndex) => { - if (partIndex % 2 === 1) return part; - return normalizeMathDisplayLines(part); - }) - .join(""); - - return normalizedDisplayLines - .split(DISPLAY_MATH_BLOCK_REGEX) - .map((part, partIndex) => { - // Preserve explicit display math blocks verbatim. - if (partIndex % 2 === 1) return part; - return normalizeInlineMathFragments(part); - }) - .join(""); - }) - .join(""); -} - export function MarkdownRenderer({ text, plugin }: MarkdownRendererProps) { const containerRef = useRef(null); @@ -272,12 +28,10 @@ export function MarkdownRenderer({ text, plugin }: MarkdownRendererProps) { const component = new Component(); component.load(); - const normalizedText = normalizeMathMarkdown(text); - // Render markdown void ObsidianMarkdownRenderer.render( plugin.app, - normalizedText, + text, el, "", component, From 8c5b001ddb1491195d0a2b054c5c0742aff0074e Mon Sep 17 00:00:00 2001 From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:56:14 +0900 Subject: [PATCH 04/95] feat: add configurable system prompt injection for Obsidian Markdown formatting --- src/hooks/useAgentMessages.ts | 6 ++++ src/hooks/useChatActions.ts | 1 + src/plugin.ts | 18 +++++++++++ src/services/message-sender.ts | 59 ++++++++++++++++++++++++++-------- src/ui/SettingsTab.ts | 37 +++++++++++++++++++++ 5 files changed, 108 insertions(+), 13 deletions(-) diff --git a/src/hooks/useAgentMessages.ts b/src/hooks/useAgentMessages.ts index e0235ae..95df3ff 100644 --- a/src/hooks/useAgentMessages.ts +++ b/src/hooks/useAgentMessages.ts @@ -48,6 +48,8 @@ export interface SendMessageOptions { images?: ImagePromptContent[]; /** Attached file references (resource links) */ resourceLinks?: ResourceLinkPromptContent[]; + /** Whether this is the first message in the session */ + isFirstMessage?: boolean; } export interface UseAgentMessagesReturn { @@ -247,6 +249,10 @@ export function useAgentMessages( maxNoteLength: settings.displaySettings.maxNoteLength, maxSelectionLength: settings.displaySettings.maxSelectionLength, + isFirstMessage: options.isFirstMessage, + promptInjection: settings.promptInjection.enabled + ? { latex: settings.promptInjection.latex } + : undefined, }, vaultAccess, vaultAccess, // IMentionService (same object) diff --git a/src/hooks/useChatActions.ts b/src/hooks/useChatActions.ts index 92a05f5..5a40c01 100644 --- a/src/hooks/useChatActions.ts +++ b/src/hooks/useChatActions.ts @@ -185,6 +185,7 @@ export function useChatActions( images: images.length > 0 ? images : undefined, resourceLinks: resourceLinks.length > 0 ? resourceLinks : undefined, + isFirstMessage, }); // Save session metadata locally on first message diff --git a/src/plugin.ts b/src/plugin.ts index 90da26b..398e440 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -77,6 +77,13 @@ export interface AgentClientPluginSettings { autoMentionActiveNote: boolean; /** Show OS system notifications on response completion and permission requests */ enableSystemNotifications: boolean; + /** Prompt injection settings for Obsidian-flavored Markdown guidance */ + promptInjection: { + /** Master toggle for prompt injection */ + enabled: boolean; + /** Inject LaTeX math formatting instructions ($...$ and $$...$$) */ + latex: boolean; + }; debugMode: boolean; nodePath: string; exportSettings: { @@ -150,6 +157,10 @@ const DEFAULT_SETTINGS: AgentClientPluginSettings = { autoAllowPermissions: false, autoMentionActiveNote: true, enableSystemNotifications: true, + promptInjection: { + enabled: true, + latex: true, + }, debugMode: false, nodePath: "", exportSettings: { @@ -911,6 +922,13 @@ export default class AgentClientPlugin extends Plugin { raw.enableSystemNotifications, D.enableSystemNotifications, ), + promptInjection: (() => { + const rp = obj(raw.promptInjection) ?? {}; + return { + enabled: bool(rp.enabled, D.promptInjection.enabled), + latex: bool(rp.latex, D.promptInjection.latex), + }; + })(), debugMode: bool(raw.debugMode, D.debugMode), nodePath: str(raw.nodePath, D.nodePath), exportSettings: { diff --git a/src/services/message-sender.ts b/src/services/message-sender.ts index 44ebbcb..6e30919 100644 --- a/src/services/message-sender.ts +++ b/src/services/message-sender.ts @@ -75,6 +75,14 @@ export interface PreparePromptInput { /** Maximum characters for selection (default: 10000) */ maxSelectionLength?: number; + + /** Whether this is the first message in the session */ + isFirstMessage?: boolean; + + /** Prompt injection settings (undefined = disabled) */ + promptInjection?: { + latex?: boolean; + }; } /** @@ -144,12 +152,8 @@ export interface SendPromptResult { const DEFAULT_MAX_NOTE_LENGTH = 10000; // Default maximum characters per note const DEFAULT_MAX_SELECTION_LENGTH = 10000; // Default maximum characters for selection -const MATH_FORMATTING_INSTRUCTION = [ - "When your response includes mathematical expressions, always format them using standard LaTeX math delimiters that render correctly in Obsidian Markdown.", - "Use $...$ for inline math and $$...$$ for display math.", - "Do not output bare LaTeX such as A^{-1}, \\begin{pmatrix}...\\end{pmatrix}, or standalone [ ... ] without math delimiters.", - "Do not wrap mathematical expressions in code fences or inline backticks unless the user explicitly asks for source code.", -].join(" "); +const LATEX_MATH_INSTRUCTION = + "This client uses Obsidian Flavored Markdown. For math, use $...$ for inline and $$...$$ for display (not \\(...\\) or \\[...\\])."; // ============================================================================ // Shared Helper Functions @@ -258,6 +262,24 @@ function buildAutoMentionPrefix( return `@[[${activeNote.name}]]\n`; } +/** + * Build system prompt instruction strings for Obsidian-flavored Markdown. + * Returns an array of instruction strings to inject. + * Empty array if not first message or no instructions enabled. + */ +function buildSystemInstructions(input: PreparePromptInput): string[] { + if (!input.isFirstMessage) return []; + if (!input.promptInjection) return []; + + const instructions: string[] = []; + + if (input.promptInjection.latex) { + instructions.push(LATEX_MATH_INSTRUCTION); + } + + return instructions; +} + function buildAgentMessageText( message: string, autoMentionPrefix: string, @@ -269,7 +291,6 @@ function buildAgentMessageText( ...(contextBlocks && contextBlocks.length > 0 ? [contextBlocks.join("\n")] : []), - MATH_FORMATTING_INSTRUCTION, ...(userMessage ? [userMessage] : []), ].join("\n\n"); } @@ -419,19 +440,23 @@ async function preparePromptWithEmbeddedContext( input.activeNote, input.isAutoMentionDisabled, ); - const agentMessageText = buildAgentMessageText( - input.message, - autoMentionPrefix, - ); + + // Build system prompt instructions (first message only) + const systemInstructions = buildSystemInstructions(input); + const systemBlocks: PromptContent[] = systemInstructions.map((text) => ({ + type: "text" as const, + text, + })); const agentContent: PromptContent[] = [ + ...systemBlocks, ...resourceBlocks, ...autoMentionBlocks, - ...(agentMessageText + ...(input.message || autoMentionPrefix ? [ { type: "text" as const, - text: agentMessageText, + text: autoMentionPrefix + input.message, }, ] : []), @@ -498,6 +523,14 @@ async function preparePromptWithTextContext( contextBlocks.push(autoMentionContextBlock); } + // Build system prompt instructions (first message only) + const systemInstructions = buildSystemInstructions(input); + for (const instruction of systemInstructions) { + contextBlocks.push( + `\n${instruction}\n`, + ); + } + const autoMentionPrefix = buildAutoMentionPrefix( input.activeNote, input.isAutoMentionDisabled, diff --git a/src/ui/SettingsTab.ts b/src/ui/SettingsTab.ts index bf47820..cdc474f 100644 --- a/src/ui/SettingsTab.ts +++ b/src/ui/SettingsTab.ts @@ -445,6 +445,43 @@ export class AgentClientSettingTab extends PluginSettingTab { }), ); + // ───────────────────────────────────────────────────────────────────── + // Prompt injection + // ───────────��───────────────────────────────────────────────────────── + + new Setting(containerEl).setName("Prompt injection").setHeading(); + + new Setting(containerEl) + .setName("Inject Obsidian Markdown instructions") + .setDesc( + "Include formatting guidance in the first message of each session so agents produce Obsidian-compatible Markdown.", + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.promptInjection.enabled) + .onChange(async (value) => { + this.plugin.settings.promptInjection.enabled = value; + await this.plugin.saveSettings(); + this.display(); + }), + ); + + if (this.plugin.settings.promptInjection.enabled) { + new Setting(containerEl) + .setName("LaTeX math formatting") + .setDesc( + "Instruct agents to use $...$ and $$...$$ delimiters for math expressions.", + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.promptInjection.latex) + .onChange(async (value) => { + this.plugin.settings.promptInjection.latex = value; + await this.plugin.saveSettings(); + }), + ); + } + // ───────────────────────────────────────────────────────────────────── // Windows WSL Settings (Windows only) // ───────────────────────────────────────────────────────────────────── From a67c3c927c84f8d6f394c9ae1dd3b964c7c3970a Mon Sep 17 00:00:00 2001 From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com> Date: Wed, 15 Apr 2026 23:12:17 +0900 Subject: [PATCH 05/95] feat: add wikilink formatting instruction to prompt injection --- src/hooks/useAgentMessages.ts | 5 ++++- src/plugin.ts | 4 ++++ src/services/message-sender.ts | 7 +++++++ src/ui/SettingsTab.ts | 17 +++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/hooks/useAgentMessages.ts b/src/hooks/useAgentMessages.ts index 95df3ff..9df08eb 100644 --- a/src/hooks/useAgentMessages.ts +++ b/src/hooks/useAgentMessages.ts @@ -251,7 +251,10 @@ export function useAgentMessages( settings.displaySettings.maxSelectionLength, isFirstMessage: options.isFirstMessage, promptInjection: settings.promptInjection.enabled - ? { latex: settings.promptInjection.latex } + ? { + latex: settings.promptInjection.latex, + wikiLinks: settings.promptInjection.wikiLinks, + } : undefined, }, vaultAccess, diff --git a/src/plugin.ts b/src/plugin.ts index 398e440..40ff3a5 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -83,6 +83,8 @@ export interface AgentClientPluginSettings { enabled: boolean; /** Inject LaTeX math formatting instructions ($...$ and $$...$$) */ latex: boolean; + /** Instruct agents to use [[Note Name]] wikilink syntax */ + wikiLinks: boolean; }; debugMode: boolean; nodePath: string; @@ -160,6 +162,7 @@ const DEFAULT_SETTINGS: AgentClientPluginSettings = { promptInjection: { enabled: true, latex: true, + wikiLinks: true, }, debugMode: false, nodePath: "", @@ -927,6 +930,7 @@ export default class AgentClientPlugin extends Plugin { return { enabled: bool(rp.enabled, D.promptInjection.enabled), latex: bool(rp.latex, D.promptInjection.latex), + wikiLinks: bool(rp.wikiLinks, D.promptInjection.wikiLinks), }; })(), debugMode: bool(raw.debugMode, D.debugMode), diff --git a/src/services/message-sender.ts b/src/services/message-sender.ts index 6e30919..dce9633 100644 --- a/src/services/message-sender.ts +++ b/src/services/message-sender.ts @@ -82,6 +82,7 @@ export interface PreparePromptInput { /** Prompt injection settings (undefined = disabled) */ promptInjection?: { latex?: boolean; + wikiLinks?: boolean; }; } @@ -154,6 +155,8 @@ const DEFAULT_MAX_NOTE_LENGTH = 10000; // Default maximum characters per note const DEFAULT_MAX_SELECTION_LENGTH = 10000; // Default maximum characters for selection const LATEX_MATH_INSTRUCTION = "This client uses Obsidian Flavored Markdown. For math, use $...$ for inline and $$...$$ for display (not \\(...\\) or \\[...\\])."; +const WIKI_LINK_INSTRUCTION = + "When referencing notes in this vault, use [[Note Name]] wikilink syntax so they become clickable links."; // ============================================================================ // Shared Helper Functions @@ -273,6 +276,10 @@ function buildSystemInstructions(input: PreparePromptInput): string[] { const instructions: string[] = []; + if (input.promptInjection.wikiLinks) { + instructions.push(WIKI_LINK_INSTRUCTION); + } + if (input.promptInjection.latex) { instructions.push(LATEX_MATH_INSTRUCTION); } diff --git a/src/ui/SettingsTab.ts b/src/ui/SettingsTab.ts index cdc474f..db18e0f 100644 --- a/src/ui/SettingsTab.ts +++ b/src/ui/SettingsTab.ts @@ -467,6 +467,23 @@ export class AgentClientSettingTab extends PluginSettingTab { ); if (this.plugin.settings.promptInjection.enabled) { + new Setting(containerEl) + .setName("Wikilink formatting") + .setDesc( + "Instruct agents to use [[Note Name]] wikilink syntax when referencing notes.", + ) + .addToggle((toggle) => + toggle + .setValue( + this.plugin.settings.promptInjection.wikiLinks, + ) + .onChange(async (value) => { + this.plugin.settings.promptInjection.wikiLinks = + value; + await this.plugin.saveSettings(); + }), + ); + new Setting(containerEl) .setName("LaTeX math formatting") .setDesc( From 70c97c07b5719d7b29b92451adc2b92d8e98891b Mon Sep 17 00:00:00 2001 From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:19:45 +0900 Subject: [PATCH 06/95] feat: add Markdown table spacing instruction to prompt injection --- src/hooks/useAgentMessages.ts | 1 + src/plugin.ts | 4 ++++ src/services/message-sender.ts | 7 +++++++ src/ui/SettingsTab.ts | 14 ++++++++++++++ 4 files changed, 26 insertions(+) diff --git a/src/hooks/useAgentMessages.ts b/src/hooks/useAgentMessages.ts index 9df08eb..b92e6f2 100644 --- a/src/hooks/useAgentMessages.ts +++ b/src/hooks/useAgentMessages.ts @@ -254,6 +254,7 @@ export function useAgentMessages( ? { latex: settings.promptInjection.latex, wikiLinks: settings.promptInjection.wikiLinks, + tables: settings.promptInjection.tables, } : undefined, }, diff --git a/src/plugin.ts b/src/plugin.ts index 40ff3a5..597c7f8 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -85,6 +85,8 @@ export interface AgentClientPluginSettings { latex: boolean; /** Instruct agents to use [[Note Name]] wikilink syntax */ wikiLinks: boolean; + /** Instruct agents to leave a blank line before Markdown tables */ + tables: boolean; }; debugMode: boolean; nodePath: string; @@ -163,6 +165,7 @@ const DEFAULT_SETTINGS: AgentClientPluginSettings = { enabled: true, latex: true, wikiLinks: true, + tables: true, }, debugMode: false, nodePath: "", @@ -931,6 +934,7 @@ export default class AgentClientPlugin extends Plugin { enabled: bool(rp.enabled, D.promptInjection.enabled), latex: bool(rp.latex, D.promptInjection.latex), wikiLinks: bool(rp.wikiLinks, D.promptInjection.wikiLinks), + tables: bool(rp.tables, D.promptInjection.tables), }; })(), debugMode: bool(raw.debugMode, D.debugMode), diff --git a/src/services/message-sender.ts b/src/services/message-sender.ts index dce9633..9842c29 100644 --- a/src/services/message-sender.ts +++ b/src/services/message-sender.ts @@ -83,6 +83,7 @@ export interface PreparePromptInput { promptInjection?: { latex?: boolean; wikiLinks?: boolean; + tables?: boolean; }; } @@ -157,6 +158,8 @@ const LATEX_MATH_INSTRUCTION = "This client uses Obsidian Flavored Markdown. For math, use $...$ for inline and $$...$$ for display (not \\(...\\) or \\[...\\])."; const WIKI_LINK_INSTRUCTION = "When referencing notes in this vault, use [[Note Name]] wikilink syntax so they become clickable links."; +const TABLE_INSTRUCTION = + "Always leave a blank line before Markdown tables; without it Obsidian renders them as plain text."; // ============================================================================ // Shared Helper Functions @@ -280,6 +283,10 @@ function buildSystemInstructions(input: PreparePromptInput): string[] { instructions.push(WIKI_LINK_INSTRUCTION); } + if (input.promptInjection.tables) { + instructions.push(TABLE_INSTRUCTION); + } + if (input.promptInjection.latex) { instructions.push(LATEX_MATH_INSTRUCTION); } diff --git a/src/ui/SettingsTab.ts b/src/ui/SettingsTab.ts index db18e0f..768ea3a 100644 --- a/src/ui/SettingsTab.ts +++ b/src/ui/SettingsTab.ts @@ -484,6 +484,20 @@ export class AgentClientSettingTab extends PluginSettingTab { }), ); + new Setting(containerEl) + .setName("Markdown table spacing") + .setDesc( + "Instruct agents to leave a blank line before Markdown tables so Obsidian renders them correctly.", + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.promptInjection.tables) + .onChange(async (value) => { + this.plugin.settings.promptInjection.tables = value; + await this.plugin.saveSettings(); + }), + ); + new Setting(containerEl) .setName("LaTeX math formatting") .setDesc( From 26bf9442dbf502136c3d360229ec15a897486d51 Mon Sep 17 00:00:00 2001 From: Alice Date: Sun, 26 Apr 2026 21:11:48 +0800 Subject: [PATCH 07/95] fix: prevent stale state after Stop from swallowing next reply (#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add generation counter and sendPromise tracking in useAgentMessages to prevent race conditions when sendMessage() is called while a previous send is still completing after cancel/stop. Three-layer defense: 1. sendPromiseRef — new send waits for previous to settle before starting 2. generationRef — stale callbacks discard their results if a newer send has started 3. clearPendingUpdates() — flushes RAF batch queue and resets isSending after cancel, called from handleStopGeneration Steps to reproduce the bug: 1. Ask for a long reply 2. Click Stop while it is streaming 3. Send any message — no visible reply appears 4. Send another message — the missing reply suddenly appears Co-Authored-By: Claude Opus 4.7 --- src/hooks/useAgent.ts | 4 ++ src/hooks/useAgentMessages.ts | 109 ++++++++++++++++++++++++---------- src/hooks/useChatActions.ts | 8 +-- 3 files changed, 84 insertions(+), 37 deletions(-) diff --git a/src/hooks/useAgent.ts b/src/hooks/useAgent.ts index f59a418..850ee94 100644 --- a/src/hooks/useAgent.ts +++ b/src/hooks/useAgent.ts @@ -91,6 +91,8 @@ export interface UseAgentReturn { setMessagesFromLocal: (localMessages: ChatMessage[]) => void; clearError: () => void; setIgnoreUpdates: (ignore: boolean) => void; + /** Discard any pending RAF updates and reset streaming state (call after stop/cancel). */ + clearPendingUpdates: () => void; // Permission activePermission: ActivePermission | null; @@ -204,6 +206,7 @@ export function useAgent( setMessagesFromLocal: agentMessages.setMessagesFromLocal, clearError: agentMessages.clearError, setIgnoreUpdates: agentMessages.setIgnoreUpdates, + clearPendingUpdates: agentMessages.clearPendingUpdates, // Permission activePermission: agentMessages.activePermission, @@ -234,6 +237,7 @@ export function useAgent( agentMessages.setInitialMessages, agentMessages.setMessagesFromLocal, agentMessages.clearError, + agentMessages.clearPendingUpdates, agentMessages.setIgnoreUpdates, agentMessages.activePermission, agentMessages.hasActivePermission, diff --git a/src/hooks/useAgentMessages.ts b/src/hooks/useAgentMessages.ts index e0235ae..9b9bccd 100644 --- a/src/hooks/useAgentMessages.ts +++ b/src/hooks/useAgentMessages.ts @@ -72,6 +72,8 @@ export interface UseAgentMessagesReturn { setMessagesFromLocal: (localMessages: ChatMessage[]) => void; clearError: () => void; setIgnoreUpdates: (ignore: boolean) => void; + /** Discard any pending RAF updates and reset streaming state (call after stop/cancel). */ + clearPendingUpdates: () => void; // Permission activePermission: ActivePermission | null; @@ -109,6 +111,16 @@ export function useAgentMessages( // Ignore updates flag (used during session/load to skip history replay) const ignoreUpdatesRef = useRef(false); + // Generation counter to prevent stale async callbacks from overwriting + // state after cancel/stop followed by a new send. Each sendMessage() + // increments this; completion handlers only update state if the + // generation hasn't changed (fixes Issue #200). + const generationRef = useRef(0); + + // Track the current send promise so a new sendMessage() can wait for + // the previous one to settle before starting (avoids interleaved sends). + const sendPromiseRef = useRef | null>(null); + // ============================================================ // Streaming Update Batching // ============================================================ @@ -168,6 +180,13 @@ export function useAgentMessages( ignoreUpdatesRef.current = ignore; }, []); + /** Discard any pending RAF updates and reset the streaming flag. */ + const clearPendingUpdates = useCallback((): void => { + pendingUpdatesRef.current = []; + flushScheduledRef.current = false; + setIsSending(false); + }, []); + const clearMessages = useCallback((): void => { setMessages([]); toolCallIndexRef.current.clear(); @@ -231,6 +250,14 @@ export function useAgentMessages( return; } + // Wait for any in-flight send to settle (e.g. after cancel/stop) + // before starting a new one to avoid interleaved state updates. + if (sendPromiseRef.current) { + try { await sendPromiseRef.current; } catch { /* ignore */ } + } + + const currentSessionId = session.sessionId as string; + const generation = ++generationRef.current; const settings = settingsAccess.getSnapshot(); const prepared = await preparePrompt( @@ -300,41 +327,56 @@ export function useAgentMessages( setIsSending(true); setLastUserMessage(content); - try { - const result = await sendPreparedPrompt( - { - sessionId: session.sessionId, - agentContent: prepared.agentContent, - displayContent: prepared.displayContent, - authMethods: session.authMethods, - }, - agentClient, - ); - - if (result.success) { - setIsSending(false); - setLastUserMessage(null); - } else { - setIsSending(false); - setErrorInfo( - result.error - ? { - title: result.error.title, - message: result.error.message, - suggestion: result.error.suggestion, - } - : { - title: "Send Message Failed", - message: "Failed to send message", - }, + const sendPromise = (async () => { + try { + const result = await sendPreparedPrompt( + { + sessionId: currentSessionId, + agentContent: prepared.agentContent, + displayContent: prepared.displayContent, + authMethods: session.authMethods, + }, + agentClient, ); + + // Discard results if a newer send has started + if (generationRef.current !== generation) return; + + if (result.success) { + setIsSending(false); + setLastUserMessage(null); + } else { + setIsSending(false); + setErrorInfo( + result.error + ? { + title: result.error.title, + message: result.error.message, + suggestion: result.error.suggestion, + } + : { + title: "Send Message Failed", + message: "Failed to send message", + }, + ); + } + } catch (error) { + if (generationRef.current !== generation) return; + setIsSending(false); + setErrorInfo({ + title: "Send Message Failed", + message: `Failed to send message: ${error instanceof Error ? error.message : String(error)}`, + }); } - } catch (error) { - setIsSending(false); - setErrorInfo({ - title: "Send Message Failed", - message: `Failed to send message: ${error instanceof Error ? error.message : String(error)}`, - }); + })(); + + sendPromiseRef.current = sendPromise; + try { + await sendPromise; + } catch { + // Error already handled inside sendPromise + } finally { + sendPromiseRef.current = null; } }, [ @@ -416,6 +458,7 @@ export function useAgentMessages( setMessagesFromLocal, clearError, setIgnoreUpdates, + clearPendingUpdates, activePermission, hasActivePermission, approvePermission, diff --git a/src/hooks/useChatActions.ts b/src/hooks/useChatActions.ts index 16de1c8..16cba42 100644 --- a/src/hooks/useChatActions.ts +++ b/src/hooks/useChatActions.ts @@ -109,15 +109,13 @@ export function useChatActions( try { const exporter = new ChatExporter(plugin); - const openFile = - plugin.settings.exportSettings.openFileAfterExport; const filePath = await exporter.exportToMarkdown( triggerMessages, triggerSession.agentDisplayName, triggerSession.agentId, triggerSession.sessionId, triggerSession.createdAt, - openFile, + false, ); if (filePath) { const context = @@ -219,10 +217,12 @@ export function useChatActions( logger.log("Cancelling current operation..."); const lastMessage = agent.lastUserMessage; await agent.cancelOperation(); + // Discard stale streaming state so the next send starts clean (Issue #200) + agent.clearPendingUpdates(); if (lastMessage) { setRestoredMessage(lastMessage); } - }, [logger, agent.cancelOperation, agent.lastUserMessage]); + }, [logger, agent.cancelOperation, agent.clearPendingUpdates, agent.lastUserMessage]); const handleNewChat = useCallback( async (requestedAgentId?: string) => { From 073f27342f911bef207e0ac09e6862178805b6d7 Mon Sep 17 00:00:00 2001 From: Vinod Panicker Date: Mon, 27 Apr 2026 19:45:34 -0700 Subject: [PATCH 08/95] fix: guard registerView against hot-reload race condition Rapid plugin toggling or npm-run-dev hot-reload can race onunload/onload, causing 'Attempting to register an existing view type' when the new instance's onload fires before the old instance fully unloads. detachLeavesOfType forces stale leaves to clean up before re-registering. --- src/plugin.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/plugin.ts b/src/plugin.ts index 597c7f8..9d87f98 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -224,6 +224,10 @@ export default class AgentClientPlugin extends Plugin { // Initialize settings store this.settingsService = createSettingsService(this.settings, this); + // Detach stale leaves from a previous plugin instance to prevent + // "Attempting to register an existing view type" when Obsidian's + // hot-reload races onunload/onload (e.g. rapid toggle or npm run dev). + this.app.workspace.detachLeavesOfType(VIEW_TYPE_CHAT); this.registerView(VIEW_TYPE_CHAT, (leaf) => new ChatView(leaf, this)); const ribbonIconEl = this.addRibbonIcon( From a404d001b587358a9a7e423e43600304c197571c Mon Sep 17 00:00:00 2001 From: Vinod Panicker Date: Mon, 27 Apr 2026 20:09:47 -0700 Subject: [PATCH 09/95] fix: stabilize attachment remove button with stable refs (I27) The remove button and file icon in AttachmentStrip used inline callback refs with setIcon(), creating new function references every render. React treated each as unmount/remount, replacing the SVG child on every cycle. Clicks were swallowed when the SVG was replaced between mousedown and mouseup. Extract RemoveButton and FileIcon components with stable useRef + useEffect so setIcon runs once on mount. Same pattern as the fixes for the scroll-to-bottom button and context note toggle. --- src/ui/InputArea.tsx | 1 + src/ui/shared/AttachmentStrip.tsx | 55 ++++++++++++++++++++++--------- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/ui/InputArea.tsx b/src/ui/InputArea.tsx index 549de4a..3b991be 100644 --- a/src/ui/InputArea.tsx +++ b/src/ui/InputArea.tsx @@ -358,6 +358,7 @@ export function InputArea({ const removeFile = useCallback( (id: string) => { onAttachedFilesChange(attachedFiles.filter((f) => f.id !== id)); + textareaRef.current?.focus(); }, [attachedFiles, onAttachedFilesChange], ); diff --git a/src/ui/shared/AttachmentStrip.tsx b/src/ui/shared/AttachmentStrip.tsx index 9ccecb0..50e6aeb 100644 --- a/src/ui/shared/AttachmentStrip.tsx +++ b/src/ui/shared/AttachmentStrip.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useRef, useEffect } from "react"; import { setIcon } from "obsidian"; import type { AttachedFile } from "../../types/chat"; @@ -7,6 +8,43 @@ interface AttachmentStripProps { onRemove: (id: string) => void; } +/** Remove button with a stable ref so setIcon runs once on mount. */ +function RemoveButton({ + fileId, + onRemove, +}: { + fileId: string; + onRemove: (id: string) => void; +}) { + const ref = useRef(null); + useEffect(() => { + if (ref.current) setIcon(ref.current, "x"); + }, []); + return ( +