summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.rules/changelog/2026-03/24/13.md22
-rw-r--r--src/chat-view.ts63
-rw-r--r--src/ollama-client.ts7
-rw-r--r--src/tools.ts132
-rw-r--r--styles.css107
5 files changed, 312 insertions, 19 deletions
diff --git a/.rules/changelog/2026-03/24/13.md b/.rules/changelog/2026-03/24/13.md
new file mode 100644
index 0000000..db16775
--- /dev/null
+++ b/.rules/changelog/2026-03/24/13.md
@@ -0,0 +1,22 @@
+# Changelog — 2026-03-24 #13
+
+## New Tools: Get Current Note & Edit File
+
+### Added
+- **`get_current_note` tool** (`src/tools.ts`): Returns the vault-relative path of the currently active note via `workspace.getActiveFile()`. No approval required.
+- **`edit_file` tool** (`src/tools.ts`): Atomic find-and-replace on vault files using `vault.process()`. Accepts `file_path`, `old_text`, and `new_text`. Requires user approval. Only replaces the first occurrence.
+
+### Changed
+- **System prompt** (`src/ollama-client.ts`): Updated to teach the AI the `get_current_note → read_file → edit_file` workflow and to reference both new tools.
+
+## Markdown Rendering in Assistant Bubbles
+
+### Added
+- **Markdown rendering** (`src/chat-view.ts`): Assistant chat bubbles now render their content as markdown after streaming completes using Obsidian's `MarkdownRenderer.render()`. During streaming, plain text is shown for performance.
+- **`finalizeBubble()` method** (`src/chat-view.ts`): New method that clears a streaming bubble's plain text and replaces it with rendered markdown.
+- **`bubbleContent` Map** (`src/chat-view.ts`): Tracks accumulated raw text per bubble so it can be rendered as markdown upon finalization.
+- **Markdown CSS** (`styles.css`): Scoped typography rules for `.ai-organizer-markdown` inside assistant bubbles — compact margins, scaled-down headings (1.05–1.15em), styled code blocks, tables, blockquotes, lists, and images to fit narrow sidebar bubbles.
+
+### Changed
+- Bubble finalization logic consolidated into `finalizeBubble()` — replaced inline finalization in `onCreateBubble`, post-response, and error handlers.
+- Proper cleanup of `bubbleContent` map in clear chat, close, error, and approval handlers.
diff --git a/src/chat-view.ts b/src/chat-view.ts
index 19df645..fbb6360 100644
--- a/src/chat-view.ts
+++ b/src/chat-view.ts
@@ -1,4 +1,4 @@
-import { ItemView, Notice, WorkspaceLeaf, setIcon } from "obsidian";
+import { ItemView, MarkdownRenderer, Notice, WorkspaceLeaf, setIcon } from "obsidian";
import type AIOrganizer from "./main";
import type { ChatMessage, ToolCallEvent, ApprovalRequestEvent } from "./ollama-client";
import { sendChatMessageStreaming } from "./ollama-client";
@@ -18,6 +18,7 @@ export class ChatView extends ItemView {
private toolsButton: HTMLButtonElement | null = null;
private abortController: AbortController | null = null;
private scrollDebounceTimer: ReturnType<typeof setTimeout> | null = null;
+ private bubbleContent: Map<HTMLDivElement, string> = new Map();
constructor(leaf: WorkspaceLeaf, plugin: AIOrganizer) {
super(leaf);
@@ -98,6 +99,7 @@ export class ChatView extends ItemView {
setIcon(clearBtn, "trash-2");
clearBtn.addEventListener("click", () => {
this.messages = [];
+ this.bubbleContent.clear();
if (this.messageContainer !== null) {
this.messageContainer.empty();
}
@@ -138,6 +140,7 @@ export class ChatView extends ItemView {
}
this.contentEl.empty();
this.messages = [];
+ this.bubbleContent.clear();
this.messageContainer = null;
this.textarea = null;
this.sendButton = null;
@@ -208,6 +211,7 @@ export class ChatView extends ItemView {
// Remove the empty streaming bubble since the approval
// prompt is now the active UI element
if (currentBubble !== null && currentBubble.textContent?.trim() === "") {
+ this.bubbleContent.delete(currentBubble);
currentBubble.remove();
currentBubble = null;
}
@@ -217,11 +221,7 @@ export class ChatView extends ItemView {
const onCreateBubble = (): void => {
// Finalize any previous bubble before creating a new one
if (currentBubble !== null) {
- currentBubble.removeClass("ai-organizer-streaming");
- // Remove empty bubbles from tool-only rounds
- if (currentBubble.textContent?.trim() === "") {
- currentBubble.remove();
- }
+ void this.finalizeBubble(currentBubble);
}
currentBubble = this.createStreamingBubble();
};
@@ -233,6 +233,9 @@ export class ChatView extends ItemView {
if (loadingIcon !== null) {
loadingIcon.remove();
}
+ // Accumulate raw text for later markdown rendering
+ const prev = this.bubbleContent.get(currentBubble) ?? "";
+ this.bubbleContent.set(currentBubble, prev + chunk);
currentBubble.appendText(chunk);
this.debouncedScrollToBottom();
}
@@ -258,16 +261,7 @@ export class ChatView extends ItemView {
// Finalize the last streaming bubble
if (currentBubble !== null) {
- (currentBubble as HTMLDivElement).removeClass("ai-organizer-streaming");
- // Remove loading icon if still present
- const remainingIcon = (currentBubble as HTMLDivElement).querySelector(".ai-organizer-loading-icon");
- if (remainingIcon !== null) {
- remainingIcon.remove();
- }
- // Remove empty assistant bubbles (e.g., tool-only rounds with no content)
- if ((currentBubble as HTMLDivElement).textContent?.trim() === "") {
- (currentBubble as HTMLDivElement).remove();
- }
+ await this.finalizeBubble(currentBubble as HTMLDivElement);
}
this.messages.push({ role: "assistant", content: response });
this.scrollToBottom();
@@ -283,6 +277,7 @@ export class ChatView extends ItemView {
if ((currentBubble as HTMLDivElement).textContent?.trim() === "") {
(currentBubble as HTMLDivElement).remove();
}
+ this.bubbleContent.delete(currentBubble as HTMLDivElement);
}
const errMsg = err instanceof Error ? err.message : "Unknown error.";
@@ -311,6 +306,42 @@ export class ChatView extends ItemView {
return bubble;
}
+ /**
+ * Finalize a streaming bubble: remove streaming state, render markdown,
+ * and clean up the accumulated content tracker.
+ */
+ private async finalizeBubble(bubble: HTMLDivElement): Promise<void> {
+ bubble.removeClass("ai-organizer-streaming");
+
+ // Remove loading icon if still present
+ const loadingIcon = bubble.querySelector(".ai-organizer-loading-icon");
+ if (loadingIcon !== null) {
+ loadingIcon.remove();
+ }
+
+ const rawText = this.bubbleContent.get(bubble) ?? "";
+ this.bubbleContent.delete(bubble);
+
+ // Remove empty bubbles (e.g., tool-only rounds with no content)
+ if (rawText.trim() === "") {
+ bubble.remove();
+ return;
+ }
+
+ // Replace plain text with rendered markdown
+ bubble.empty();
+ bubble.removeClass("ai-organizer-streaming-text");
+ bubble.addClass("ai-organizer-markdown");
+ await MarkdownRenderer.render(
+ this.plugin.app,
+ rawText,
+ bubble,
+ "",
+ this,
+ );
+ this.scrollToBottom();
+ }
+
private appendMessage(role: "user" | "assistant" | "error", content: string): void {
if (this.messageContainer === null) {
return;
diff --git a/src/ollama-client.ts b/src/ollama-client.ts
index be255d3..01651e0 100644
--- a/src/ollama-client.ts
+++ b/src/ollama-client.ts
@@ -78,9 +78,10 @@ interface AgentLoopOptions {
const TOOL_SYSTEM_PROMPT =
"You are a helpful assistant with access to tools for interacting with an Obsidian vault. " +
"When you use the search_files tool, the results contain exact file paths. " +
- "You MUST use these exact paths when calling read_file or referencing files. " +
- "NEVER guess or modify file paths \u2014 always use the paths returned by search_files verbatim. " +
- "Some tools (such as delete_file) require user approval before they execute. " +
+ "You MUST use these exact paths when calling read_file, edit_file, or referencing files. " +
+ "NEVER guess or modify file paths \u2014 always use the paths returned by search_files or get_current_note verbatim. " +
+ "When the user asks you to edit the note they are currently viewing, use get_current_note first to obtain the path, then read_file to see its content, then edit_file to make changes. " +
+ "Some tools (such as delete_file and edit_file) require user approval before they execute. " +
"If the user declines an action, ask them why so you can better assist them.";
/**
diff --git a/src/tools.ts b/src/tools.ts
index ca85091..68937bd 100644
--- a/src/tools.ts
+++ b/src/tools.ts
@@ -115,6 +115,61 @@ async function executeDeleteFile(app: App, args: Record<string, unknown>): Promi
}
/**
+ * Execute the "get_current_note" tool.
+ * Returns the vault-relative path of the currently active note.
+ */
+async function executeGetCurrentNote(app: App, _args: Record<string, unknown>): Promise<string> {
+ const file = app.workspace.getActiveFile();
+ if (file === null) {
+ return "Error: No note is currently open.";
+ }
+ return file.path;
+}
+
+/**
+ * Execute the "edit_file" tool.
+ * Performs a find-and-replace on the file content using vault.process() for atomicity.
+ */
+async function executeEditFile(app: App, args: Record<string, unknown>): Promise<string> {
+ const filePath = typeof args.file_path === "string" ? args.file_path : "";
+ if (filePath === "") {
+ return "Error: file_path parameter is required.";
+ }
+
+ const oldText = typeof args.old_text === "string" ? args.old_text : "";
+ const newText = typeof args.new_text === "string" ? args.new_text : "";
+
+ if (oldText === "") {
+ return "Error: old_text parameter is required and cannot be empty.";
+ }
+
+ const file = app.vault.getAbstractFileByPath(filePath);
+ if (file === null || !(file instanceof TFile)) {
+ return `Error: File not found at path "${filePath}".`;
+ }
+
+ try {
+ let replaced = false;
+ await app.vault.process(file, (data) => {
+ if (!data.includes(oldText)) {
+ return data;
+ }
+ replaced = true;
+ return data.replace(oldText, newText);
+ });
+
+ if (!replaced) {
+ return `Error: The specified old_text was not found in "${filePath}".`;
+ }
+
+ return `Successfully edited "${filePath}".`;
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : "Unknown error";
+ return `Error editing file: ${msg}`;
+ }
+}
+
+/**
* All available tools for the plugin.
*/
export const TOOL_REGISTRY: ToolEntry[] = [
@@ -235,6 +290,83 @@ export const TOOL_REGISTRY: ToolEntry[] = [
},
execute: executeDeleteFile,
},
+ {
+ id: "get_current_note",
+ label: "Get Current Note",
+ description: "Get the file path of the currently open note.",
+ friendlyName: "Get Current Note",
+ requiresApproval: false,
+ summarize: () => "Checking active note",
+ summarizeResult: (result) => {
+ if (result.startsWith("Error")) {
+ return result;
+ }
+ return `"/${result}"`;
+ },
+ definition: {
+ type: "function",
+ function: {
+ name: "get_current_note",
+ description: "Get the vault-relative file path of the note currently open in the editor. Use this to find out which note the user is looking at. Returns an exact path that can be used with read_file or edit_file.",
+ parameters: {
+ type: "object",
+ required: [],
+ properties: {},
+ },
+ },
+ },
+ execute: executeGetCurrentNote,
+ },
+ {
+ id: "edit_file",
+ label: "Edit File",
+ description: "Find and replace text in a vault file (requires approval).",
+ friendlyName: "Edit File",
+ requiresApproval: true,
+ approvalMessage: (args) => {
+ const filePath = typeof args.file_path === "string" ? args.file_path : "unknown";
+ return `Edit "${filePath}"?`;
+ },
+ summarize: (args) => {
+ const filePath = typeof args.file_path === "string" ? args.file_path : "";
+ return `"/${filePath}"`;
+ },
+ summarizeResult: (result) => {
+ if (result.startsWith("Error")) {
+ return result;
+ }
+ if (result.includes("declined")) {
+ return "Declined by user";
+ }
+ return "File edited";
+ },
+ definition: {
+ type: "function",
+ function: {
+ name: "edit_file",
+ description: "Edit a file in the Obsidian vault by finding and replacing text. Provide the exact text to find (old_text) and the replacement (new_text). The old_text must match exactly as it appears in the file, including whitespace and newlines. Only the first occurrence is replaced. The file_path must be an exact path as returned by search_files or get_current_note. This action requires user approval.",
+ parameters: {
+ type: "object",
+ required: ["file_path", "old_text", "new_text"],
+ properties: {
+ file_path: {
+ type: "string",
+ description: "The vault-relative path to the file (e.g. 'folder/note.md').",
+ },
+ old_text: {
+ type: "string",
+ description: "The exact text to find in the file. Must match exactly including whitespace.",
+ },
+ new_text: {
+ type: "string",
+ description: "The text to replace old_text with. Use an empty string to delete the matched text.",
+ },
+ },
+ },
+ },
+ },
+ execute: executeEditFile,
+ },
];
/**
diff --git a/styles.css b/styles.css
index 12a6305..1581aa5 100644
--- a/styles.css
+++ b/styles.css
@@ -356,6 +356,113 @@
color: var(--text-muted);
}
+/* ===== Markdown in Assistant Bubbles ===== */
+
+.ai-organizer-message.assistant.ai-organizer-markdown {
+ white-space: normal;
+}
+
+.ai-organizer-message.assistant.ai-organizer-markdown p {
+ margin: 0 0 0.4em 0;
+}
+
+.ai-organizer-message.assistant.ai-organizer-markdown p:last-child {
+ margin-bottom: 0;
+}
+
+.ai-organizer-message.assistant.ai-organizer-markdown h1,
+.ai-organizer-message.assistant.ai-organizer-markdown h2,
+.ai-organizer-message.assistant.ai-organizer-markdown h3,
+.ai-organizer-message.assistant.ai-organizer-markdown h4,
+.ai-organizer-message.assistant.ai-organizer-markdown h5,
+.ai-organizer-message.assistant.ai-organizer-markdown h6 {
+ margin: 0.4em 0 0.2em 0;
+ font-size: 1em;
+ line-height: 1.3;
+}
+
+.ai-organizer-message.assistant.ai-organizer-markdown h1 {
+ font-size: 1.15em;
+}
+
+.ai-organizer-message.assistant.ai-organizer-markdown h2 {
+ font-size: 1.1em;
+}
+
+.ai-organizer-message.assistant.ai-organizer-markdown h3 {
+ font-size: 1.05em;
+}
+
+.ai-organizer-message.assistant.ai-organizer-markdown ul,
+.ai-organizer-message.assistant.ai-organizer-markdown ol {
+ margin: 0.2em 0;
+ padding-left: 1.4em;
+}
+
+.ai-organizer-message.assistant.ai-organizer-markdown li {
+ margin: 0.1em 0;
+}
+
+.ai-organizer-message.assistant.ai-organizer-markdown pre {
+ margin: 0.3em 0;
+ padding: 6px 8px;
+ border-radius: 4px;
+ background-color: var(--background-secondary);
+ overflow-x: auto;
+ font-size: 0.9em;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+.ai-organizer-message.assistant.ai-organizer-markdown code {
+ padding: 1px 4px;
+ border-radius: 3px;
+ background-color: var(--background-secondary);
+ font-size: 0.9em;
+}
+
+.ai-organizer-message.assistant.ai-organizer-markdown pre code {
+ padding: 0;
+ background-color: transparent;
+ font-size: inherit;
+}
+
+.ai-organizer-message.assistant.ai-organizer-markdown blockquote {
+ margin: 0.3em 0;
+ padding: 2px 8px;
+ border-left: 3px solid var(--interactive-accent);
+ color: var(--text-muted);
+}
+
+.ai-organizer-message.assistant.ai-organizer-markdown table {
+ border-collapse: collapse;
+ margin: 0.3em 0;
+ font-size: 0.9em;
+ width: 100%;
+}
+
+.ai-organizer-message.assistant.ai-organizer-markdown th,
+.ai-organizer-message.assistant.ai-organizer-markdown td {
+ padding: 3px 6px;
+ border: 1px solid var(--background-modifier-border);
+}
+
+.ai-organizer-message.assistant.ai-organizer-markdown th {
+ background-color: var(--background-secondary);
+ font-weight: 600;
+}
+
+.ai-organizer-message.assistant.ai-organizer-markdown hr {
+ margin: 0.4em 0;
+ border: none;
+ border-top: 1px solid var(--background-modifier-border);
+}
+
+.ai-organizer-message.assistant.ai-organizer-markdown img {
+ max-width: 100%;
+ border-radius: 4px;
+}
+
/* ===== Misc ===== */
.ai-organizer-tool-modal-desc {