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 <noreply@anthropic.com>
This commit is contained in:
Zero Liu 2026-07-03 12:46:57 +08:00
parent 8bb1491e53
commit e4b4e08409
No known key found for this signature in database
3 changed files with 257 additions and 17 deletions

View file

@ -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<ToolPermissionCardProps> = ({ 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<EditDiff | null>(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<ToolPermissionCardProps> = ({ request,
</p>
) : null}
{diffContents.length > 0 ? (
<div className="tw-flex tw-flex-col tw-gap-2">
{diffContents.map((d, i) => (
<div
// eslint-disable-next-line @eslint-react/no-array-index-key -- diff list is derived once per render from a snapshot; same path can appear multiple times
key={`diff-${i}-${d.path}`}
className="tw-rounded tw-border tw-border-solid tw-border-border tw-p-2"
>
<p className="tw-mb-1 tw-font-mono tw-text-xs tw-text-muted">{d.path}</p>
<pre className="tw-max-h-48 tw-overflow-auto tw-whitespace-pre-wrap tw-text-xs">
{renderDiff(d.oldText, d.newText)}
</pre>
</div>
))}
{preview && capped && stats ? (
<div
className={cn(
"tw-cursor-pointer tw-rounded tw-border tw-border-solid tw-border-border tw-p-2",
"hover:tw-border-interactive-accent"
)}
role="button"
tabIndex={0}
onClick={openPreview}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openPreview?.();
}
}}
>
<div className="tw-mb-1 tw-flex tw-items-center tw-gap-2">
<span className="tw-flex-1 tw-truncate tw-font-mono tw-text-xs tw-text-muted">
{preview.path}
</span>
<span className="tw-flex tw-shrink-0 tw-items-center tw-gap-1 tw-text-xs tw-tabular-nums">
{stats.added > 0 ? <span className="tw-text-success">+{stats.added}</span> : null}
{stats.removed > 0 ? <span className="tw-text-error">{stats.removed}</span> : null}
</span>
</div>
<pre className="tw-m-0 tw-max-h-48 tw-overflow-auto tw-whitespace-pre-wrap tw-text-xs">
{capped.lines.join("\n")}
{capped.hidden > 0 ? (
<span className="tw-text-muted">{`\n… ${capped.hidden} more lines`}</span>
) : null}
</pre>
</div>
) : inputJson ? (
<details>

View file

@ -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<App["vault"]> = {}, 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();
});
});

View file

@ -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<EditDiff | null> {
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;
}