fix(vscode): show apply_patch diffs before approval (#9691)

* fix(vscode): show apply patch permission diffs

* fix(vscode): avoid caching permission diff patches

* chore: update kilo-vscode visual regression baselines

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Marius
2026-04-29 15:49:06 +02:00
committed by GitHub
parent 8d91d99b01
commit ae726cdc45
10 changed files with 256 additions and 21 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show apply_patch diffs in the permission prompt before approval.
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7aa0924c6679cbff6e816dd86371a2d483afb7f0840824503ef4ba325c25d6c5
size 26282
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:36d371115e898752d566502cae5815c9a9719be64d9b5bd9f6612c0ed8a1d114
size 15546
oid sha256:f28a551185ec9d7607d02295803b155e0f7bac378fcefdf0996c6ca51c0afad6
size 21892
@@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test"
import { permissionDiffs } from "../../webview-ui/src/components/chat/permission-diff-utils"
import type { PermissionRequest } from "../../webview-ui/src/types/messages"
function perm(args: PermissionRequest["args"]): PermissionRequest {
return {
id: "perm",
sessionID: "ses",
toolName: "edit",
patterns: ["*"],
always: ["*"],
args,
}
}
describe("permissionDiffs", () => {
test("uses filediff metadata for edit and write permissions", () => {
const diffs = permissionDiffs(
perm({ filediff: { file: "src/app.ts", patch: "patch", additions: 1, deletions: 0 } }),
)
expect(diffs).toEqual([{ file: "src/app.ts", patch: "patch", additions: 1, deletions: 0 }])
})
test("uses apply_patch files metadata", () => {
const diffs = permissionDiffs(
perm({
files: [
{ relativePath: "src/a.ts", type: "update", patch: "a", additions: 1, deletions: 1 },
{ relativePath: "src/b.ts", type: "add", patch: "b", additions: 2, deletions: 0 },
],
}),
)
expect(diffs).toEqual([
{ file: "src/a.ts", patch: "a", additions: 1, deletions: 1 },
{ file: "src/b.ts", patch: "b", additions: 2, deletions: 0 },
])
})
test("falls back to raw diff metadata", () => {
const diffs = permissionDiffs(perm({ filepath: "src/a.ts", diff: "Index: src/a.ts" }))
expect(diffs).toEqual([{ file: "src/a.ts", patch: "Index: src/a.ts", additions: 0, deletions: 0 }])
})
test("returns no diffs for command-only permissions", () => {
expect(permissionDiffs(perm({ command: "git status" }))).toEqual([])
})
})
@@ -1,9 +1,9 @@
import { type Component, createMemo } from "solid-js"
import { Show, type Component, createMemo } from "solid-js"
import { Diff } from "@kilocode/kilo-ui/diff"
import { DiffChanges } from "@kilocode/kilo-ui/diff-changes"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { normalize, text } from "@kilocode/kilo-ui/session-diff"
import { parsePatch } from "diff"
import type { PermissionFileDiff } from "../../types/messages"
import { useVSCode } from "../../context/vscode"
@@ -11,6 +11,29 @@ interface PermissionDiffProps {
filediff: PermissionFileDiff
}
function patchText(patch: string) {
const parsed = parsePatch(patch)[0]
if (!parsed) return { before: "", after: "" }
const before: string[] = []
const after: string[] = []
for (const hunk of parsed.hunks) {
for (const line of hunk.lines) {
if (line.startsWith("-")) {
before.push(line.slice(1))
continue
}
if (line.startsWith("+")) {
after.push(line.slice(1))
continue
}
before.push(line.slice(1))
after.push(line.slice(1))
}
}
return { before: before.join("\n"), after: after.join("\n") }
}
export const PermissionDiff: Component<PermissionDiffProps> = (props) => {
const vscode = useVSCode()
const filename = createMemo(() => {
@@ -27,13 +50,15 @@ export const PermissionDiff: Component<PermissionDiffProps> = (props) => {
const resolved = createMemo(() => {
const fd = props.filediff
if (fd.before !== undefined || fd.after !== undefined) return { before: fd.before ?? "", after: fd.after ?? "" }
if (fd.patch) {
const view = normalize(fd)
return { before: text(view, "deletions"), after: text(view, "additions") }
}
if (fd.patch) return patchText(fd.patch)
return { before: "", after: "" }
})
const empty = createMemo(() => {
const diff = resolved()
return diff.before === "" && diff.after === ""
})
const openInTab = () => {
const { before, after } = resolved()
vscode.postMessage({
@@ -77,11 +102,16 @@ export const PermissionDiff: Component<PermissionDiffProps> = (props) => {
</div>
</div>
<div data-slot="permission-diff-content">
<Diff
before={{ name: props.filediff.file, contents: resolved().before }}
after={{ name: props.filediff.file, contents: resolved().after }}
diffStyle="unified"
/>
<Show
when={!empty()}
fallback={<div data-slot="permission-diff-empty">Diff preview unavailable for this file.</div>}
>
<Diff
before={{ name: props.filediff.file, contents: resolved().before }}
after={{ name: props.filediff.file, contents: resolved().after }}
diffStyle="unified"
/>
</Show>
</div>
</div>
)
@@ -20,6 +20,7 @@ import { useConfig } from "../../context/config"
import { describePatterns, resolveLabel, savedRuleStates, type RuleDecision } from "./permission-dock-utils"
import { PermissionCommand } from "./PermissionCommand"
import { PermissionDiff } from "./PermissionDiff"
import { permissionDiffs } from "./permission-diff-utils"
import type { PermissionRequest } from "../../types/messages"
let rulesExpandedPreference = false
@@ -47,12 +48,7 @@ export const PermissionDock: Component<{
command() ? null : describePatterns(props.request.toolName, props.request.patterns, language.t),
)
const filediff = () => {
if (props.request.toolName !== "edit" && props.request.toolName !== "write") return null
const fd = props.request.args?.filediff
if (!fd || typeof fd !== "object") return null
return fd as NonNullable<PermissionRequest["args"]["filediff"]>
}
const diffs = createMemo(() => permissionDiffs(props.request))
// Pre-populate toggle states from existing config rules so previously
// approved/denied patterns show their saved state immediately.
@@ -247,7 +243,11 @@ export const PermissionDock: Component<{
)
})()}
<Show when={filediff()}>{(fd) => <PermissionDiff filediff={fd()} />}</Show>
<Show when={diffs().length > 0}>
<div data-slot="permission-diffs" data-count={diffs().length}>
<For each={diffs()}>{(diff) => <PermissionDiff filediff={diff} />}</For>
</div>
</Show>
<div data-slot="permission-actions">
<Button
@@ -0,0 +1,62 @@
import type { PermissionFileDiff, PermissionRequest } from "../../types/messages"
type File = {
filePath?: unknown
relativePath?: unknown
type?: unknown
patch?: unknown
additions?: unknown
deletions?: unknown
}
function num(value: unknown) {
return typeof value === "number" ? value : 0
}
function text(value: unknown) {
return typeof value === "string" ? value : undefined
}
function clean(diff: unknown): PermissionFileDiff | undefined {
if (!diff || typeof diff !== "object") return
const item = diff as Record<string, unknown>
const file = text(item.file)
if (!file) return
return {
file,
...(text(item.patch) !== undefined ? { patch: text(item.patch) } : {}),
...(text(item.before) !== undefined ? { before: text(item.before) } : {}),
...(text(item.after) !== undefined ? { after: text(item.after) } : {}),
additions: num(item.additions),
deletions: num(item.deletions),
}
}
function file(item: File): PermissionFileDiff | undefined {
const name = text(item.relativePath) ?? text(item.filePath)
if (!name) return
return {
file: name,
...(text(item.patch) !== undefined ? { patch: text(item.patch) } : {}),
additions: num(item.additions),
deletions: num(item.deletions),
}
}
export function permissionDiffs(request: PermissionRequest): PermissionFileDiff[] {
const direct = clean(request.args?.filediff)
if (direct) return [direct]
const files = request.args?.files
if (Array.isArray(files)) {
return files.flatMap((item) => {
const diff = file(item as File)
return diff ? [diff] : []
})
}
const patch = text(request.args?.diff)
if (!patch) return []
const name = text(request.args?.filepath) ?? "patch"
return [{ file: name, patch, additions: 0, deletions: 0 }]
}
@@ -677,10 +677,48 @@ const editPermission: PermissionRequest = {
toolName: "edit",
patterns: ["src/components/App.tsx", "src/utils/helpers.ts"],
always: ["*"],
args: {},
args: {
filediff: {
file: "src/components/App.tsx",
patch:
'===================================================================\n--- src/components/App.tsx\n+++ src/components/App.tsx\n@@ -1,3 +1,4 @@\n import { Button } from "@kilocode/kilo-ui/button"\n+import { Card } from "@kilocode/kilo-ui/card"\n \n export function App() {\n',
additions: 1,
deletions: 0,
},
},
tool: { messageID: ASST_MSG_ID, callID: "call-edit-001" },
}
const applyPatchPermission: PermissionRequest = {
id: "perm-patch-001",
sessionID: SESSION_ID,
toolName: "edit",
patterns: ["src/components/App.tsx", "src/utils/helpers.ts"],
always: ["*"],
args: {
filepath: "src/components/App.tsx, src/utils/helpers.ts",
files: [
{
relativePath: "src/components/App.tsx",
type: "update",
patch:
'===================================================================\n--- src/components/App.tsx\n+++ src/components/App.tsx\n@@ -1,3 +1,4 @@\n import { Button } from "@kilocode/kilo-ui/button"\n+import { Card } from "@kilocode/kilo-ui/card"\n \n export function App() {\n',
additions: 1,
deletions: 0,
},
{
relativePath: "src/utils/helpers.ts",
type: "update",
patch:
"===================================================================\n--- src/utils/helpers.ts\n+++ src/utils/helpers.ts\n@@ -1,3 +1,3 @@\n export function label(value: string) {\n- return value\n+ return value.trim()\n }\n",
additions: 1,
deletions: 1,
},
],
},
tool: { messageID: ASST_MSG_ID, callID: "call-patch-001" },
}
export const PermissionDockEdit: Story = {
name: "Permission Dock — edit",
render: () => {
@@ -701,6 +739,26 @@ export const PermissionDockEdit: Story = {
},
}
export const PermissionDockApplyPatch: Story = {
name: "Permission Dock - apply patch",
render: () => {
const perms = [applyPatchPermission]
const session = {
...mockSessionValue({ id: SESSION_ID, status: "busy", permissions: perms }),
messages: () => [{ id: "msg-001" }] as any[],
}
return (
<StoryProviders permissions={perms} sessionID={SESSION_ID} status="busy" noPadding>
<SessionContext.Provider value={session as any}>
<div style={{ width: "100%", height: "420px", display: "flex", "flex-direction": "column" }}>
<ChatView />
</div>
</SessionContext.Provider>
</StoryProviders>
)
},
}
// ---------------------------------------------------------------------------
// 13. Permission dock — websearch tool
// ---------------------------------------------------------------------------
@@ -110,6 +110,15 @@
word-break: break-all;
}
[data-slot="permission-diffs"] {
display: flex;
flex-direction: column;
gap: 8px;
margin: 8px 0;
max-height: 420px;
overflow: auto;
}
[data-slot="permission-diff"] {
margin: 8px 0;
border: 1px solid var(--border-weak-base);
@@ -205,6 +214,13 @@
overflow: auto;
}
[data-slot="permission-diff-empty"] {
padding: 12px;
font-size: 12px;
color: var(--text-weak, var(--vscode-descriptionForeground));
background: var(--vscode-editor-background, #1e1e1e);
}
[data-slot="permission-rules"] {
margin: 0;
max-height: 160px;
@@ -22,6 +22,16 @@ export interface PermissionFileDiff {
deletions: number
}
export interface PermissionPatchFile {
filePath?: string
relativePath?: string
type?: "add" | "update" | "delete" | "move"
patch?: string
additions?: number
deletions?: number
movePath?: string
}
export interface PermissionRequest {
id: string
sessionID: string
@@ -33,6 +43,7 @@ export interface PermissionRequest {
diff?: string
filepath?: string
filediff?: PermissionFileDiff
files?: PermissionPatchFile[]
}
message?: string
tool?: { messageID: string; callID: string }