summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.rules/changelog/2026-03/24/14.md33
-rw-r--r--src/chat-view.ts71
-rw-r--r--src/ollama-client.ts16
-rw-r--r--src/tools.ts36
-rw-r--r--styles.css11
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",
diff --git a/styles.css b/styles.css
index 1581aa5..7ab52d2 100644
--- a/styles.css
+++ b/styles.css
@@ -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 {