mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
8bb1491e53
commit
e4b4e08409
3 changed files with 257 additions and 17 deletions
|
|
@ -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>
|
||||
|
|
|
|||
113
src/agentMode/ui/permissionEditPreview.test.ts
Normal file
113
src/agentMode/ui/permissionEditPreview.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
67
src/agentMode/ui/permissionEditPreview.ts
Normal file
67
src/agentMode/ui/permissionEditPreview.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue