From e4b4e084093fc2640037e98ec4a0e6870a130038 Mon Sep 17 00:00:00 2001
From: Zero Liu
Date: Fri, 3 Jul 2026 12:46:57 +0800
Subject: [PATCH] feat(agent-mode): edit preview + open in the permission card
Phase 5: the permission card synthesizes an edit preview from the pending tool
input (Edit old/new strings; a Write reads the current file for its before),
shows the +N/-M chip + a capped preview, and opens the same read-only
AgentDiffView on click. Paths are normalized vault-relative so the preview pane
reuses the tab the completed action opens.
Co-Authored-By: Claude Opus 4.8
---
src/agentMode/ui/ToolPermissionCard.tsx | 94 ++++++++++++---
.../ui/permissionEditPreview.test.ts | 113 ++++++++++++++++++
src/agentMode/ui/permissionEditPreview.ts | 67 +++++++++++
3 files changed, 257 insertions(+), 17 deletions(-)
create mode 100644 src/agentMode/ui/permissionEditPreview.test.ts
create mode 100644 src/agentMode/ui/permissionEditPreview.ts
diff --git a/src/agentMode/ui/ToolPermissionCard.tsx b/src/agentMode/ui/ToolPermissionCard.tsx
index 4563ae25..9cfb88fe 100644
--- a/src/agentMode/ui/ToolPermissionCard.tsx
+++ b/src/agentMode/ui/ToolPermissionCard.tsx
@@ -1,13 +1,39 @@
import { Button } from "@/components/ui/button";
-import { extractDiffContents, formatAgentInput, renderDiff } from "@/agentMode/ui/diffRender";
+import { formatAgentInput, renderDiff } from "@/agentMode/ui/diffRender";
import type {
PermissionOption,
PermissionOptionKind,
PermissionPrompt,
} from "@/agentMode/session/types";
import { PERMISSION_OPTION_KINDS } from "@/agentMode/session/types";
+import { diffStats, type EditDiff } from "@/agentMode/ui/editDiff";
+import { synthesizePermissionEditDiff } from "@/agentMode/ui/permissionEditPreview";
+import { openAgentDiffView } from "@/agentMode/ui/AgentDiffView";
+import { cn } from "@/lib/utils";
+import { useApp } from "@/context";
import { ShieldQuestion } from "lucide-react";
-import React, { useMemo, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
+
+/**
+ * Cap the synthesized preview so a full-file Write doesn't flood the card: the
+ * unified `renderDiff` output is sliced to the first `PREVIEW_LINE_CAP` lines,
+ * with a muted "… N more lines" footer when truncated. The full diff is one
+ * click away via the diff pane.
+ */
+const PREVIEW_LINE_CAP = 12;
+
+interface CappedPreview {
+ lines: string[];
+ hidden: number;
+}
+
+function cappedDiffPreview(diff: EditDiff): CappedPreview {
+ const all = renderDiff(diff.oldText, diff.newText).split("\n");
+ return {
+ lines: all.slice(0, PREVIEW_LINE_CAP),
+ hidden: Math.max(0, all.length - PREVIEW_LINE_CAP),
+ };
+}
interface ToolPermissionCardProps {
request: PermissionPrompt;
@@ -28,13 +54,30 @@ interface ToolPermissionCardProps {
* `mapDecisionToSdk` — this component just forwards the chosen `optionId`.
*/
export const ToolPermissionCard: React.FC = ({ request, onResolve }) => {
+ const app = useApp();
const { toolCall, options } = request;
const [busy, setBusy] = useState(false);
const orderedOptions = useMemo(() => sortOptions(options), [options]);
- const diffContents = useMemo(() => extractDiffContents(toolCall.content), [toolCall.content]);
const inputJson = useMemo(() => formatAgentInput(toolCall.rawInput), [toolCall.rawInput]);
const title = toolCall.title ?? "Tool call";
+ // Synthesized from the tool INPUT (the edit hasn't run, so there's no SDK
+ // result). Async because a Write reads the current file as its "before".
+ const [preview, setPreview] = useState(null);
+ useEffect(() => {
+ let cancelled = false;
+ void synthesizePermissionEditDiff(app, toolCall).then((d) => {
+ if (!cancelled) setPreview(d);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [app, toolCall]);
+
+ const stats = preview ? diffStats(preview) : null;
+ const capped = preview ? cappedDiffPreview(preview) : null;
+ const openPreview = preview ? () => openAgentDiffView(app, preview) : undefined;
+
const choose = (optionId: string) => {
if (busy) return;
setBusy(true);
@@ -58,20 +101,37 @@ export const ToolPermissionCard: React.FC = ({ request,
) : null}
- {diffContents.length > 0 ? (
-
- {diffContents.map((d, i) => (
-
-
{d.path}
-
- {renderDiff(d.oldText, d.newText)}
-
-
- ))}
+ {preview && capped && stats ? (
+
{
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ openPreview?.();
+ }
+ }}
+ >
+
+
+ {preview.path}
+
+
+ {stats.added > 0 ? +{stats.added} : null}
+ {stats.removed > 0 ? −{stats.removed} : null}
+
+
+
+ {capped.lines.join("\n")}
+ {capped.hidden > 0 ? (
+ {`\n… ${capped.hidden} more lines`}
+ ) : null}
+
) : inputJson ? (
diff --git a/src/agentMode/ui/permissionEditPreview.test.ts b/src/agentMode/ui/permissionEditPreview.test.ts
new file mode 100644
index 00000000..a617d575
--- /dev/null
+++ b/src/agentMode/ui/permissionEditPreview.test.ts
@@ -0,0 +1,113 @@
+import { App, FileSystemAdapter, TFile } from "obsidian";
+import { synthesizePermissionEditDiff } from "@/agentMode/ui/permissionEditPreview";
+import { __resetVaultBaseCache } from "@/utils/vaultPath";
+import type { ToolCallContent } from "@/agentMode/session/types";
+
+// A minimal fake `app`: only the vault methods the synthesizer touches. Paths
+// are already vault-relative in these cases (no `adapter`), so `getVaultBase`
+// returns null and `toVaultRelative` passes the path through. Pass `basePath`
+// to attach a `FileSystemAdapter` and exercise absolute→relative normalization.
+function fakeApp(vault: Partial = {}, basePath?: string): App {
+ return {
+ vault: {
+ getAbstractFileByPath: () => null,
+ read: async () => "",
+ ...(basePath !== undefined
+ ? { adapter: new (FileSystemAdapter as unknown as new (b: string) => unknown)(basePath) }
+ : {}),
+ ...vault,
+ },
+ } as unknown as App;
+}
+
+beforeEach(() => {
+ __resetVaultBaseCache();
+});
+
+describe("synthesizePermissionEditDiff", () => {
+ it("uses old_string/new_string for an Edit without reading the vault", async () => {
+ const read = jest.fn();
+ const app = fakeApp({ read });
+ const diff = await synthesizePermissionEditDiff(app, {
+ rawInput: { file_path: "notes/a.md", old_string: "before", new_string: "after" },
+ });
+ expect(diff).toEqual({ path: "notes/a.md", oldText: "before", newText: "after" });
+ expect(read).not.toHaveBeenCalled();
+ });
+
+ it("reads the existing file as the before for a Write", async () => {
+ // The obsidian mock's `TFile` constructor takes a path and yields a real
+ // instance, so the production `file instanceof TFile` check passes. The
+ // public obsidian type declares no constructor args, hence the cast.
+ const file = new (TFile as unknown as new (path: string) => TFile)("notes/b.md");
+ const app = fakeApp({
+ getAbstractFileByPath: jest.fn(() => file),
+ read: jest.fn(async () => "current contents"),
+ });
+ const diff = await synthesizePermissionEditDiff(app, {
+ rawInput: { file_path: "notes/b.md", content: "brand new body" },
+ });
+ expect(diff).toEqual({
+ path: "notes/b.md",
+ oldText: "current contents",
+ newText: "brand new body",
+ });
+ expect(app.vault.read).toHaveBeenCalledWith(file);
+ });
+
+ it("treats a missing file as a create (empty before) for a Write", async () => {
+ const app = fakeApp({ getAbstractFileByPath: jest.fn(() => null) });
+ const diff = await synthesizePermissionEditDiff(app, {
+ rawInput: { file_path: "notes/new.md", content: "fresh" },
+ });
+ expect(diff).toEqual({ path: "notes/new.md", oldText: "", newText: "fresh" });
+ });
+
+ it("returns an ACP content diff directly when present", async () => {
+ const content: ToolCallContent[] = [
+ { type: "diff", path: "notes/c.md", oldText: "was", newText: "now" },
+ ];
+ const app = fakeApp();
+ const diff = await synthesizePermissionEditDiff(app, {
+ rawInput: { file_path: "ignored.md", content: "ignored" },
+ content,
+ });
+ expect(diff).toEqual({ path: "notes/c.md", oldText: "was", newText: "now" });
+ });
+
+ it("normalizes an absolute ACP diff path to vault-relative", async () => {
+ // ACP diff paths arrive raw (often absolute); the chip label and the
+ // diff-pane leaf reuse both key off `path`, so it must match the
+ // vault-relative form the post-execution ActionCard uses.
+ const content: ToolCallContent[] = [
+ { type: "diff", path: "/vault/notes/c.md", oldText: "was", newText: "now" },
+ ];
+ const diff = await synthesizePermissionEditDiff(fakeApp({}, "/vault"), {
+ rawInput: { file_path: "/vault/notes/c.md", content: "ignored" },
+ content,
+ });
+ expect(diff).toEqual({ path: "notes/c.md", oldText: "was", newText: "now" });
+ });
+
+ it("normalizes a null ACP diff oldText to an empty string", async () => {
+ const content: ToolCallContent[] = [
+ { type: "diff", path: "notes/d.md", oldText: null, newText: "created" },
+ ];
+ const diff = await synthesizePermissionEditDiff(fakeApp(), { content });
+ expect(diff).toEqual({ path: "notes/d.md", oldText: "", newText: "created" });
+ });
+
+ it("returns null for a non-edit tool (e.g. a Bash command)", async () => {
+ const diff = await synthesizePermissionEditDiff(fakeApp(), {
+ rawInput: { command: "ls -la" },
+ });
+ expect(diff).toBeNull();
+ });
+
+ it("returns null when an edit-shaped path is missing", async () => {
+ const diff = await synthesizePermissionEditDiff(fakeApp(), {
+ rawInput: { old_string: "a", new_string: "b" },
+ });
+ expect(diff).toBeNull();
+ });
+});
diff --git a/src/agentMode/ui/permissionEditPreview.ts b/src/agentMode/ui/permissionEditPreview.ts
new file mode 100644
index 00000000..a4610d1a
--- /dev/null
+++ b/src/agentMode/ui/permissionEditPreview.ts
@@ -0,0 +1,67 @@
+import { App, TFile } from "obsidian";
+import type { EditDiff } from "@/agentMode/ui/editDiff";
+import { rawEditPath } from "@/agentMode/ui/editDiff";
+import { extractDiffContents } from "@/agentMode/ui/diffRender";
+import type { ToolCallContent } from "@/agentMode/session/types";
+import { getVaultBase, toVaultRelative } from "@/utils/vaultPath";
+
+interface EditRawInput {
+ old_string?: unknown;
+ new_string?: unknown;
+ content?: unknown;
+}
+
+/**
+ * Synthesize a before/after `EditDiff` for a permission card, which fires
+ * BEFORE the edit executes — so there is no SDK result yet and we can't reuse
+ * `deriveEditDiff` (that reads a `ToolCallPart.output`; here we only have a
+ * `ToolCallSnapshot`'s `rawInput` / `content`). Priority:
+ * (a) an ACP `{ type: "diff" }` already carried in `content` at permission
+ * time — the agent computed old/new for us;
+ * (b) synthesize from `rawInput`: Edit (`old_string`/`new_string`, the changed
+ * hunk is the preview) or Write (`content`, whose "before" we read from
+ * the current file — empty for a new file).
+ * Returns null when no path resolves or neither source is a usable edit, so the
+ * card falls back to its raw-input JSON view for non-edit tools.
+ */
+export async function synthesizePermissionEditDiff(
+ app: App,
+ toolCall: { rawInput?: unknown; content?: ToolCallContent[] | null }
+): Promise {
+ const vaultBase = getVaultBase(app);
+
+ const diffs = extractDiffContents(toolCall.content);
+ if (diffs.length > 0) {
+ const first = diffs[0];
+ // Normalize like `deriveEditDiff`: ACP diff paths arrive raw (often
+ // absolute), and the vault-relative form drives both the chip label and
+ // the diff-pane leaf reuse (matched on `path`), so the pre-execution
+ // preview and the post-execution ActionCard address the same tab.
+ return {
+ path: toVaultRelative(first.path, vaultBase),
+ oldText: first.oldText ?? "",
+ newText: first.newText,
+ };
+ }
+
+ const rawInput = toolCall.rawInput;
+ const rawPath = rawEditPath(rawInput);
+ if (rawPath === null) return null;
+
+ const path = toVaultRelative(rawPath, vaultBase);
+ const input = rawInput as EditRawInput;
+
+ if (typeof input.old_string === "string" && typeof input.new_string === "string") {
+ return { path, oldText: input.old_string, newText: input.new_string };
+ }
+
+ if (typeof input.content === "string") {
+ // Write: read the current file as the "before" so the preview shows the
+ // real replacement, not an all-additions dump. A missing file is a create.
+ const file = app.vault.getAbstractFileByPath(path);
+ const oldText = file instanceof TFile ? await app.vault.read(file) : "";
+ return { path, oldText, newText: input.content };
+ }
+
+ return null;
+}