diff options
| -rw-r--r-- | .rules/changelog/2026-03/24/14.md | 33 | ||||
| -rw-r--r-- | src/chat-view.ts | 71 | ||||
| -rw-r--r-- | src/ollama-client.ts | 16 | ||||
| -rw-r--r-- | src/tools.ts | 36 | ||||
| -rw-r--r-- | styles.css | 11 |
5 files changed, 152 insertions, 15 deletions
diff --git a/.rules/changelog/2026-03/24/14.md b/.rules/changelog/2026-03/24/14.md new file mode 100644 index 0000000..bfa9c2f --- /dev/null +++ b/.rules/changelog/2026-03/24/14.md @@ -0,0 +1,33 @@ +# Improve edit_file tool prompts, empty-file support, and approval details + +## Files Changed + +- `src/ollama-client.ts` +- `src/tools.ts` +- `src/chat-view.ts` +- `styles.css` + +## Changes + +### Improved system prompt for edit_file workflow (`ollama-client.ts`) +- Expanded `TOOL_SYSTEM_PROMPT` with a mandatory numbered workflow: get path, read file, copy exact text, then edit. +- Added explicit rules: never skip reading, never guess file contents, never leave old_text empty on non-empty files. +- Documented the empty-file exception: old_text may be empty only when the file itself is empty. + +### Improved edit_file tool description and parameters (`tools.ts`) +- Updated the `edit_file` function description to instruct the model to call `read_file` first. +- Documented the empty-file special case in both the function description and the `old_text` parameter description. +- Updated `executeEditFile` to allow empty `old_text` when the file is truly empty (writes initial content). +- When `old_text` is empty but the file has content, returns a descriptive error guiding the model to read the file first. +- Improved error messages on match failure to remind the model to read the file first. + +### Added edit details to approval prompt (`chat-view.ts`) +- The `edit_file` approval prompt now includes a collapsible "Review changes" section (expanded by default). +- Shows labeled "Old text" and "New text" blocks so the user can review the full change before approving. +- Empty old_text displays "(empty — new file)" placeholder. + +### Added edit details to tool call summary (`chat-view.ts`) +- The `edit_file` tool call details section now shows structured "File", "Old text", and "New text" labels instead of raw JSON args. + +### Added CSS for tool call labels (`styles.css`) +- Added `.ai-organizer-tool-call-label` styling for the labeled sections in edit_file details. diff --git a/src/chat-view.ts b/src/chat-view.ts index fbb6360..526a6c1 100644 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -387,13 +387,36 @@ export class ChatView extends ItemView { const collapseContent = collapse.createDiv({ cls: "ai-organizer-collapse-content" }); const contentInner = collapseContent.createDiv({ cls: "ai-organizer-collapse-content-inner" }); - const argsStr = JSON.stringify(event.args, null, 2); - contentInner.createEl("pre", { text: argsStr, cls: "ai-organizer-tool-call-args" }); + if (event.toolName === "edit_file") { + // For edit_file, show old_text / new_text in dedicated labeled blocks + const filePath = typeof event.args.file_path === "string" ? event.args.file_path : ""; + const oldText = typeof event.args.old_text === "string" ? event.args.old_text : ""; + const newText = typeof event.args.new_text === "string" ? event.args.new_text : ""; + + if (filePath !== "") { + contentInner.createEl("div", { text: `File: ${filePath}`, cls: "ai-organizer-tool-call-label" }); + } + + contentInner.createEl("div", { text: "Old text:", cls: "ai-organizer-tool-call-label" }); + contentInner.createEl("pre", { + text: oldText === "" ? "(empty — new file)" : oldText, + cls: "ai-organizer-tool-call-args", + }); - const resultPreview = event.result.length > 500 - ? event.result.substring(0, 500) + "..." - : event.result; - contentInner.createEl("pre", { text: resultPreview, cls: "ai-organizer-tool-call-result" }); + contentInner.createEl("div", { text: "New text:", cls: "ai-organizer-tool-call-label" }); + contentInner.createEl("pre", { + text: newText, + cls: "ai-organizer-tool-call-result", + }); + } else { + const argsStr = JSON.stringify(event.args, null, 2); + contentInner.createEl("pre", { text: argsStr, cls: "ai-organizer-tool-call-args" }); + + const resultPreview = event.result.length > 500 + ? event.result.substring(0, 500) + "..." + : event.result; + contentInner.createEl("pre", { text: resultPreview, cls: "ai-organizer-tool-call-result" }); + } } private showApprovalRequest(event: ApprovalRequestEvent): Promise<boolean> { @@ -411,6 +434,42 @@ export class ChatView extends ItemView { container.createDiv({ text: event.message, cls: "ai-organizer-approval-message" }); + // Show details for edit_file so the user can review the change + if (event.toolName === "edit_file") { + const oldText = typeof event.args.old_text === "string" ? event.args.old_text : ""; + const newText = typeof event.args.new_text === "string" ? event.args.new_text : ""; + + const collapse = container.createDiv({ cls: "ai-organizer-collapse ai-organizer-collapse-arrow" }); + const collapseId = `approval-collapse-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const checkbox = collapse.createEl("input", { + type: "checkbox", + attr: { id: collapseId, checked: "" }, + }); + checkbox.addClass("ai-organizer-collapse-toggle"); + checkbox.checked = true; + const titleEl = collapse.createEl("label", { + cls: "ai-organizer-collapse-title", + attr: { for: collapseId }, + text: "Review changes", + }); + void titleEl; + + const collapseContent = collapse.createDiv({ cls: "ai-organizer-collapse-content" }); + const contentInner = collapseContent.createDiv({ cls: "ai-organizer-collapse-content-inner" }); + + contentInner.createEl("div", { text: "Old text:", cls: "ai-organizer-tool-call-label" }); + contentInner.createEl("pre", { + text: oldText === "" ? "(empty \u2014 new file)" : oldText, + cls: "ai-organizer-tool-call-args", + }); + + contentInner.createEl("div", { text: "New text:", cls: "ai-organizer-tool-call-label" }); + contentInner.createEl("pre", { + text: newText, + cls: "ai-organizer-tool-call-result", + }); + } + const buttonRow = container.createDiv({ cls: "ai-organizer-approval-buttons" }); const approveBtn = buttonRow.createEl("button", { diff --git a/src/ollama-client.ts b/src/ollama-client.ts index 01651e0..3bbb35b 100644 --- a/src/ollama-client.ts +++ b/src/ollama-client.ts @@ -79,8 +79,20 @@ 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, 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. " + + "NEVER guess or modify file paths — always use the paths returned by search_files or get_current_note verbatim.\n\n" + + "EDITING FILES — MANDATORY WORKFLOW:\n" + + "The edit_file tool performs a find-and-replace. You provide old_text (the exact text currently in the file) and new_text (what to replace it with). " + + "If old_text does not match the file contents exactly, the edit WILL FAIL.\n" + + "Therefore you MUST follow this sequence every time you edit a file:\n" + + "1. Get the file path (use search_files or get_current_note).\n" + + "2. Call read_file to see the CURRENT content of the file.\n" + + "3. Copy the exact text you want to change from the read_file output and use it as old_text.\n" + + "4. Call edit_file with the correct old_text and your new_text.\n" + + "NEVER skip step 2. NEVER guess what the file contains — always read it first.\n" + + "If the file is empty (read_file returned no content), you may set old_text to an empty string to write initial content.\n" + + "If the file is NOT empty, old_text MUST NOT be empty — copy the exact passage you want to change from the read_file output.\n" + + "old_text must include enough surrounding context (a few lines) to uniquely identify the location in the file. " + + "Preserve the exact whitespace, indentation, and newlines from the read_file output.\n\n" + "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 68937bd..94a136d 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -139,16 +139,30 @@ async function executeEditFile(app: App, args: Record<string, unknown>): Promise 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 { + if (oldText === "") { + // Empty old_text: only allowed when the file is empty (write initial content) + let replaced = false; + await app.vault.process(file, (data) => { + if (data.length !== 0) { + return data; + } + replaced = true; + return newText; + }); + + if (!replaced) { + return `Error: old_text is empty but "${filePath}" is not empty. You must read the file first with read_file and provide the exact text you want to replace as old_text.`; + } + + return `Successfully wrote content to empty file "${filePath}".`; + } + let replaced = false; await app.vault.process(file, (data) => { if (!data.includes(oldText)) { @@ -159,7 +173,7 @@ async function executeEditFile(app: App, args: Record<string, unknown>): Promise }); if (!replaced) { - return `Error: The specified old_text was not found in "${filePath}".`; + return `Error: The specified old_text was not found in "${filePath}". Make sure you read the file first with read_file and copy the exact text.`; } return `Successfully edited "${filePath}".`; @@ -344,7 +358,15 @@ export const TOOL_REGISTRY: ToolEntry[] = [ 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.", + description: "Edit a file in the Obsidian vault by finding and replacing text. " + + "IMPORTANT: You MUST call read_file on the target file BEFORE calling edit_file so you can see its exact current content. " + + "Copy the exact text you want to change from the read_file output and use it as old_text. " + + "old_text must match a passage in the file exactly (including whitespace and newlines). " + + "Only the first occurrence of old_text is replaced with new_text. " + + "SPECIAL CASE: If the file is empty (read_file returned no content), set old_text to an empty string to write initial content. " + + "If old_text is empty but the file is NOT empty, the edit will be rejected. " + + "The file_path must be an exact path from search_files or get_current_note. " + + "This action requires user approval.", parameters: { type: "object", required: ["file_path", "old_text", "new_text"], @@ -355,7 +377,7 @@ export const TOOL_REGISTRY: ToolEntry[] = [ }, old_text: { type: "string", - description: "The exact text to find in the file. Must match exactly including whitespace.", + description: "The exact text to find in the file, copied verbatim from read_file output. Include enough surrounding lines to uniquely identify the location. Preserve all whitespace and newlines exactly. Only set to an empty string when the file itself is empty.", }, new_text: { type: "string", @@ -356,6 +356,17 @@ color: var(--text-muted); } +.ai-organizer-tool-call-label { + font-weight: 600; + color: var(--text-muted); + font-size: 0.85em; + margin: 6px 0 2px 0; +} + +.ai-organizer-tool-call-label:first-child { + margin-top: 0; +} + /* ===== Markdown in Assistant Bubbles ===== */ .ai-organizer-message.assistant.ai-organizer-markdown { |
