From e3476be3bb70db976e061cfb238d118fec4ea425 Mon Sep 17 00:00:00 2001 From: Johnny Amancio Date: Mon, 27 Apr 2026 12:18:36 +0200 Subject: [PATCH] feat: Add diff view for edit tool --- .../kilo-ui/src/components/message-part.tsx | 31 ++++++++++++++----- .../tests/unit/databridge-shape.test.ts | 19 ++++++++++-- .../tests/unit/kilo-ui-contract.test.ts | 22 +++++++++++++ packages/kilo-vscode/webview-ui/src/App.tsx | 5 +++ packages/ui/src/context/data.tsx | 16 ++++++++-- 5 files changed, 81 insertions(+), 12 deletions(-) diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index cb65a7718d..5cc9ce2687 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -2022,11 +2022,28 @@ ToolRegistry.register({ const filename = () => getFilename(props.input.filePath ?? "") const pending = () => busy(props.status) const reveal = useToolReveal(pending, () => props.reveal !== false) + const before = () => props.metadata?.filediff?.before ?? props.input.oldString ?? "" + const after = () => props.metadata?.filediff?.after ?? props.input.newString ?? "" + const canOpenDiff = () => !!data.openDiff && !!path() && (before() !== "" || after() !== "") + const canOpenFile = () => !!data.openFile && !!path() const handleFileClick = (e: MouseEvent) => { - if (!data.openFile || !props.input.filePath) return e.stopPropagation() - data.openFile(props.input.filePath) + + if (canOpenDiff()) { + data.openDiff!({ + file: path(), + before: before(), + after: after(), + additions: props.metadata?.filediff?.additions ?? 0, + deletions: props.metadata?.filediff?.deletions ?? 0, + }) + return + } + + if (canOpenFile()) { + data.openFile!(path()) + } } return ( @@ -2049,7 +2066,7 @@ ToolRegistry.register({ path={props.input.filePath?.includes("/") ? getDirectory(props.input.filePath!) : undefined} changes={props.metadata.filediff} animate={reveal()} - onClick={data.openFile && props.input.filePath ? handleFileClick : undefined} + onClick={canOpenDiff() || canOpenFile() ? handleFileClick : undefined} /> )} @@ -2072,12 +2089,12 @@ ToolRegistry.register({ component={fileComponent} mode="diff" before={{ - name: props.metadata?.filediff?.file || props.input.filePath, - contents: props.metadata?.filediff?.before || props.input.oldString, + name: path(), + contents: before(), }} after={{ - name: props.metadata?.filediff?.file || props.input.filePath, - contents: props.metadata?.filediff?.after || props.input.newString, + name: path(), + contents: after(), }} /> diff --git a/packages/kilo-vscode/tests/unit/databridge-shape.test.ts b/packages/kilo-vscode/tests/unit/databridge-shape.test.ts index 353edcfd51..ef2e16365c 100644 --- a/packages/kilo-vscode/tests/unit/databridge-shape.test.ts +++ b/packages/kilo-vscode/tests/unit/databridge-shape.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect } from "bun:test" import { readFileSync } from "node:fs" import { join } from "node:path" +const APP_FILE = join(__dirname, "..", "..", "webview-ui", "src", "App.tsx") +const src = readFileSync(APP_FILE, "utf8") + /** * Static guard against the perf regression fixed in this PR. * @@ -28,9 +31,6 @@ import { join } from "node:path" * `tests/webview-reactivity/databridge-reactivity.test.ts`. */ describe("DataBridge shape (perf regression guard)", () => { - const path = join(__dirname, "..", "..", "webview-ui", "src", "App.tsx") - const src = readFileSync(path, "utf8") - it("DataBridge exists in App.tsx", () => { expect(src).toMatch(/export const DataBridge/) }) @@ -59,3 +59,16 @@ describe("DataBridge shape (perf regression guard)", () => { expect(block).toMatch(/get\s+part\s*\(\s*\)\s*\{/) }) }) + +describe("DataBridge openDiff wiring (regression guard)", () => { + const openDiffBlock = () => { + const match = src.match(/const\s+openDiff\s*=\s*\(diff:\s*\{[\s\S]*?\n\s*\}\n\n\s*const\s+openUrl/) + expect(match).toBeTruthy() + return match![0] + } + + it("wires openDiff to the openDiffVirtual webview message", () => { + expect(openDiffBlock()).toMatch(/postMessage\(\{\s*type:\s*["']openDiffVirtual["']\s*,\s*diff\s*\}\)/) + expect(src).toContain("onOpenDiff={openDiff}") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts index 280b9b022f..2db786d39d 100644 --- a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts @@ -21,6 +21,7 @@ const MONOREPO_ROOT = path.resolve(import.meta.dir, "../../../..") const KILO_UI_DIR = path.join(MONOREPO_ROOT, "packages/kilo-ui") const DATA_CONTEXT_FILE = path.join(MONOREPO_ROOT, "packages/ui/src/context/data.tsx") const MESSAGE_PART_FILE = path.join(MONOREPO_ROOT, "packages/ui/src/components/message-part.tsx") +const KILO_MESSAGE_PART_FILE = path.join(MONOREPO_ROOT, "packages/kilo-ui/src/components/message-part.tsx") function check(code: string): { ok: boolean; output: string } { const result = Bun.spawnSync(["bun", "--conditions=browser", "-e", code], { @@ -118,6 +119,27 @@ describe("DataProvider contract (runtime)", () => { expect(src).toContain("OpenFileFn") expect(src).toMatch(/openFile:\s*props\.onOpenFile/) }) + + it("DataProvider accepts onOpenDiff prop and exports OpenDiffFn (source)", () => { + // onOpenDiff and OpenDiffFn are `kilocode_change` additions — TypeScript types + // erased at runtime, so we verify via source analysis + const src = fs.readFileSync(DATA_CONTEXT_FILE, "utf-8") + expect(src).toContain("onOpenDiff") + expect(src).toContain("OpenDiffFn") + expect(src).toMatch(/openDiff:\s*props\.onOpenDiff/) + }) +}) + +describe("Edit tool diff-first click contract (source)", () => { + const src = fs.readFileSync(KILO_MESSAGE_PART_FILE, "utf-8") + + const editBlockMatch = src.match(/ToolRegistry\.register\(\{\s*name:\s*"edit"[\s\S]*?(?=ToolRegistry\.register\(|$)/) + const editBlock = editBlockMatch?.[0] ?? "" + + it("edit tool derives before/after content from filediff or input", () => { + expect(editBlock).toMatch(/filediff\?\.before\s*\?\?.*oldString/) + expect(editBlock).toMatch(/filediff\?\.after\s*\?\?.*newString/) + }) }) describe("BasicTool export contract (runtime)", () => { diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index f8169319f4..c7ce0828a2 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -132,6 +132,10 @@ export const DataBridge: Component<{ children: any }> = (props) => { vscode.postMessage({ type: "openFile", filePath, line, column }) } + const openDiff = (diff: { file: string; before: string; after: string; additions: number; deletions: number }) => { + vscode.postMessage({ type: "openDiffVirtual", diff }) + } + const openUrl = (url: string) => { vscode.postMessage({ type: "openExternal", url }) } @@ -151,6 +155,7 @@ export const DataBridge: Component<{ children: any }> = (props) => { onQuestionReply={reply} onQuestionReject={reject} onOpenFile={open} + onOpenDiff={openDiff} onOpenUrl={openUrl} > {props.children} diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 4d3cfa263f..2c13631038 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -30,9 +30,19 @@ export type NavigateToSessionFn = (sessionID: string) => void export type SessionHrefFn = (sessionID: string) => string -export type OpenFileFn = (filePath: string, line?: number, column?: number) => void // kilocode_change +// kilocode_change start +export type OpenFileFn = (filePath: string, line?: number, column?: number) => void -export type OpenUrlFn = (url: string) => void // kilocode_change +export type OpenDiffFn = (diff: { + file: string + before: string + after: string + additions: number + deletions: number +}) => void + +export type OpenUrlFn = (url: string) => void +// kilocode_change end export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", @@ -42,6 +52,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn onOpenFile?: OpenFileFn // kilocode_change + onOpenDiff?: OpenDiffFn // kilocode_change onOpenUrl?: OpenUrlFn // kilocode_change }) => { return { @@ -54,6 +65,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref, openFile: props.onOpenFile, // kilocode_change + openDiff: props.onOpenDiff, // kilocode_change openUrl: props.onOpenUrl, // kilocode_change } },