From 729e69ffe97d0af7d36ae3eff789d5d10d1147db Mon Sep 17 00:00:00 2001 From: zhaojy Date: Wed, 29 Jul 2026 02:02:59 +0800 Subject: [PATCH] fix(vscode): open plan files from review --- apps/vscode/shared/bridge.ts | 2 + apps/vscode/shared/legacy-sdk.ts | 14 ++++++- apps/vscode/src/handlers/file.handler.ts | 17 ++++++++ apps/vscode/src/runtime/tool-display.ts | 3 +- apps/vscode/test/bridge-handler.test.ts | 16 +++++++ apps/vscode/test/event-adapter.test.ts | 38 +++++++++++++++++ apps/vscode/test/workspace-paths.test.ts | 29 +++++++++++++ .../src/components/DisplayBlocks.tsx | 42 ++++++++++++++++++- .../src/components/ToolRenderers.tsx | 2 +- apps/vscode/webview-ui/src/services/bridge.ts | 4 ++ 10 files changed, 163 insertions(+), 4 deletions(-) diff --git a/apps/vscode/shared/bridge.ts b/apps/vscode/shared/bridge.ts index d7f9bd88ae..372d68fa1e 100644 --- a/apps/vscode/shared/bridge.ts +++ b/apps/vscode/shared/bridge.ts @@ -50,6 +50,7 @@ export const Methods = { GetProjectFiles: "getProjectFiles", PickMedia: "pickMedia", OpenFile: "openFile", + OpenPlanFile: "openPlanFile", CheckFileExists: "checkFileExists", CheckFilesExist: "checkFilesExist", OpenFileDiff: "openFileDiff", @@ -146,6 +147,7 @@ function validateParams(method: RpcMethod, params: unknown): boolean { case Methods.GetRegisteredWorkDirs: case Methods.BrowseWorkDir: case Methods.ClearTrackedFiles: + case Methods.OpenPlanFile: case Methods.ShowLogs: case Methods.ReloadWebview: return params === undefined; diff --git a/apps/vscode/shared/legacy-sdk.ts b/apps/vscode/shared/legacy-sdk.ts index 03c5bafbda..ae5e310b57 100644 --- a/apps/vscode/shared/legacy-sdk.ts +++ b/apps/vscode/shared/legacy-sdk.ts @@ -37,13 +37,25 @@ export interface ShellBlock { command: string; } +export interface PlanBlock { + type: 'plan'; + plan: string; + path?: string; +} + export interface UnknownBlock { type: string; data?: Record; [key: string]: unknown; } -export type DisplayBlock = BriefBlock | DiffBlock | TodoBlock | ShellBlock | UnknownBlock; +export type DisplayBlock = + | BriefBlock + | DiffBlock + | TodoBlock + | ShellBlock + | PlanBlock + | UnknownBlock; export interface ToolCall { type: 'function'; diff --git a/apps/vscode/src/handlers/file.handler.ts b/apps/vscode/src/handlers/file.handler.ts index abc49cabf2..863ed0cd00 100644 --- a/apps/vscode/src/handlers/file.handler.ts +++ b/apps/vscode/src/handlers/file.handler.ts @@ -75,6 +75,22 @@ const openFile: Handler = async ({ filePath }, return { ok: true }; }; +const openPlanFile: Handler = async (_, ctx) => { + const runtime = ctx.getSession(); + if (runtime === undefined) return { ok: false }; + const plan = await runtime.session.getPlan(); + if (plan === null) return { ok: false }; + + const uri = vscode.Uri.file(plan.path); + try { + await vscode.workspace.fs.stat(uri); + } catch { + return { ok: false }; + } + await vscode.commands.executeCommand("vscode.open", uri); + return { ok: true }; +}; + const openFileDiff: Handler = async ({ filePath }, ctx) => { const sessionId = ctx.getSessionId(); const resolved = await resolveExistingWorkspaceFile(ctx.requireWorkDirUri(), filePath); @@ -180,6 +196,7 @@ export const fileHandlers: Record> = { [Methods.GetProjectFiles]: getProjectFiles, [Methods.PickMedia]: pickMedia, [Methods.OpenFile]: openFile, + [Methods.OpenPlanFile]: openPlanFile, [Methods.OpenFileDiff]: openFileDiff, [Methods.TrackFiles]: trackFiles, [Methods.ClearTrackedFiles]: clearTrackedFiles, diff --git a/apps/vscode/src/runtime/tool-display.ts b/apps/vscode/src/runtime/tool-display.ts index fe77509774..89996fdf31 100644 --- a/apps/vscode/src/runtime/tool-display.ts +++ b/apps/vscode/src/runtime/tool-display.ts @@ -67,9 +67,10 @@ export function toLegacyDisplay(display: ToolInputDisplay): DisplayBlock[] { case "skill_call": case "task": case "task_stop": - case "plan_review": case "goal_start": case "generic": return [{ type: "brief", text: describeToolDisplay(display) }]; + case "plan_review": + return [{ type: "plan", plan: display.plan, path: display.path }]; } } diff --git a/apps/vscode/test/bridge-handler.test.ts b/apps/vscode/test/bridge-handler.test.ts index 51b203823c..2664a7ecf3 100644 --- a/apps/vscode/test/bridge-handler.test.ts +++ b/apps/vscode/test/bridge-handler.test.ts @@ -170,6 +170,22 @@ describe("Webview RPC boundary (validates requests before host dispatch)", () => expect(showLogs).not.toHaveBeenCalled(); }); + it("does not accept a Webview-supplied path for the plan-only open method", async () => { + const result = await bridge.handle( + { + id: "rpc-plan", + method: Methods.OpenPlanFile, + params: { filePath: "/tmp/untrusted-plan.md" }, + }, + "view-1", + ); + + expect(result).toEqual({ + id: "rpc-plan", + error: "Invalid bridge params for method: openPlanFile", + }); + }); + it("does not execute an object-payload handler when a required field has the wrong type", async () => { const result = await bridge.handle( { id: "rpc-1", method: Methods.AddInputHistory, params: { text: 42 } }, diff --git a/apps/vscode/test/event-adapter.test.ts b/apps/vscode/test/event-adapter.test.ts index e910edfa3e..40ed22378c 100644 --- a/apps/vscode/test/event-adapter.test.ts +++ b/apps/vscode/test/event-adapter.test.ts @@ -262,6 +262,44 @@ describe('event adapter (projects SDK events into the legacy Webview contract)', }); }); + it('preserves a plan review body and path in a dedicated display block', () => { + const started = adaptSdkEvent(createEventAdapterState(), { + type: 'tool.call.started', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + toolCallId: 'plan-1', + name: 'ExitPlanMode', + args: {}, + display: { + kind: 'plan_review', + plan: '# Refactor plan\n\n- Verify the change', + path: '/tmp/kimi-code/plans/refactor.md', + }, + }); + const finished = adaptSdkEvent(started.state, { + type: 'tool.result', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + toolCallId: 'plan-1', + output: 'Plan approved', + }); + + expect(finished.event).toMatchObject({ + type: 'ToolResult', + payload: { + return_value: { + display: [{ + type: 'plan', + plan: '# Refactor plan\n\n- Verify the change', + path: '/tmp/kimi-code/plans/refactor.md', + }], + }, + }, + }); + }); + it('emits snake-case status fields when agent status changes', () => { const result = adaptSdkEvent(createEventAdapterState(), { type: 'agent.status.updated', diff --git a/apps/vscode/test/workspace-paths.test.ts b/apps/vscode/test/workspace-paths.test.ts index 1e64f43381..b2aafc7997 100644 --- a/apps/vscode/test/workspace-paths.test.ts +++ b/apps/vscode/test/workspace-paths.test.ts @@ -280,6 +280,35 @@ describe("Webview workspace paths (selected-directory containment)", () => { expect(vscodeHost.executeCommand).not.toHaveBeenCalled(); }); + it("opens the current session plan outside the workspace through the plan-only RPC", async () => { + const workDir = join(root, "project"); + const planPath = join(root, "kimi-home", ".kimi-code", "plans", "refactor.md"); + await mkdir(workDir); + await mkdir(join(root, "kimi-home", ".kimi-code", "plans"), { recursive: true }); + await writeFile(planPath, "# Refactor plan"); + const getPlan = vi.fn(async () => ({ + id: "plan-1", + content: "# Refactor plan", + path: planPath, + })); + const ctx = { + ...createContext(vscodeHost.Uri.file(workDir)), + getSession: () => ({ session: { getPlan } }) as unknown as SessionRuntime, + } as HandlerContext; + + const genericResult = await fileHandlers[Methods.OpenFile]!({ filePath: planPath }, ctx); + const planResult = await fileHandlers[Methods.OpenPlanFile]!(undefined, ctx); + + expect(genericResult).toEqual({ ok: false }); + expect(planResult).toEqual({ ok: true }); + expect(getPlan).toHaveBeenCalledOnce(); + expect(vscodeHost.executeCommand).toHaveBeenCalledOnce(); + expect(vscodeHost.executeCommand).toHaveBeenCalledWith( + "vscode.open", + expect.objectContaining({ fsPath: planPath }), + ); + }); + it("omits an outside symlink when an SDK Write event requests baseline capture", async () => { const workDir = join(root, "project"); const outsideRoot = await mkdtemp(join(tmpdir(), "kimi-vscode-baseline-outside-")); diff --git a/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx b/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx index f435e782dc..b4d13a684c 100644 --- a/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx +++ b/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx @@ -1,8 +1,18 @@ import { useMemo } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; -import type { DisplayBlock, DiffBlock, TodoBlock, BriefBlock, ShellBlock } from "shared/legacy-sdk"; +import { IconExternalLink } from "@tabler/icons-react"; +import type { + DisplayBlock, + DiffBlock, + TodoBlock, + BriefBlock, + ShellBlock, + PlanBlock, +} from "shared/legacy-sdk"; import { cn } from "@/lib/utils"; +import { bridge } from "@/services"; +import { Markdown } from "./Markdown"; import * as Diff from "diff"; function useIsDark(): boolean { @@ -188,6 +198,34 @@ export function ShellBlockView({ block, maxHeight = "max-h-40" }: ShellBlockProp ); } +interface PlanBlockProps { + block: PlanBlock; + maxHeight?: string; +} + +export function PlanBlockView({ block, maxHeight = "max-h-40" }: PlanBlockProps) { + return ( +
+ {block.path && ( + + )} +
+ +
+
+ ); +} + interface DisplayBlockViewProps { block: DisplayBlock; maxHeight?: string; @@ -201,6 +239,8 @@ export function DisplayBlockView({ block, maxHeight }: DisplayBlockViewProps) { return ; case "brief": return ; + case "plan": + return ; default: return null; } diff --git a/apps/vscode/webview-ui/src/components/ToolRenderers.tsx b/apps/vscode/webview-ui/src/components/ToolRenderers.tsx index 16b0b4eb32..9ac1dec86a 100644 --- a/apps/vscode/webview-ui/src/components/ToolRenderers.tsx +++ b/apps/vscode/webview-ui/src/components/ToolRenderers.tsx @@ -57,7 +57,7 @@ function getRichDisplayBlocks(display?: DisplayBlock[]): DisplayBlock[] { if (!display) { return []; } - return display.filter((b) => b.type === "diff"); + return display.filter((b) => b.type === "diff" || b.type === "plan"); } function CodeBlock({ content, maxLines = 10 }: { content: string; maxLines?: number }) { diff --git a/apps/vscode/webview-ui/src/services/bridge.ts b/apps/vscode/webview-ui/src/services/bridge.ts index 04f90f274b..5d73cca463 100644 --- a/apps/vscode/webview-ui/src/services/bridge.ts +++ b/apps/vscode/webview-ui/src/services/bridge.ts @@ -255,6 +255,10 @@ class Bridge { return this.call<{ ok: boolean }>(Methods.OpenFile, { filePath }); } + openPlanFile() { + return this.call<{ ok: boolean }>(Methods.OpenPlanFile); + } + openFileDiff(filePath: string) { return this.call<{ ok: boolean }>(Methods.OpenFileDiff, { filePath }); }