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; +}