Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/vscode/shared/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const Methods = {
GetProjectFiles: "getProjectFiles",
PickMedia: "pickMedia",
OpenFile: "openFile",
OpenPlanFile: "openPlanFile",
CheckFileExists: "checkFileExists",
CheckFilesExist: "checkFilesExist",
OpenFileDiff: "openFileDiff",
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 13 additions & 1 deletion apps/vscode/shared/legacy-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
[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';
Expand Down
17 changes: 17 additions & 0 deletions apps/vscode/src/handlers/file.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,22 @@ const openFile: Handler<FilePathParams, { ok: boolean }> = async ({ filePath },
return { ok: true };
};

const openPlanFile: Handler<void, { ok: boolean }> = 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<FilePathParams, { ok: boolean }> = async ({ filePath }, ctx) => {
const sessionId = ctx.getSessionId();
const resolved = await resolveExistingWorkspaceFile(ctx.requireWorkDirUri(), filePath);
Expand Down Expand Up @@ -180,6 +196,7 @@ export const fileHandlers: Record<string, Handler<any, any>> = {
[Methods.GetProjectFiles]: getProjectFiles,
[Methods.PickMedia]: pickMedia,
[Methods.OpenFile]: openFile,
[Methods.OpenPlanFile]: openPlanFile,
[Methods.OpenFileDiff]: openFileDiff,
[Methods.TrackFiles]: trackFiles,
[Methods.ClearTrackedFiles]: clearTrackedFiles,
Expand Down
3 changes: 2 additions & 1 deletion apps/vscode/src/runtime/tool-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }];
}
}
16 changes: 16 additions & 0 deletions apps/vscode/test/bridge-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down
38 changes: 38 additions & 0 deletions apps/vscode/test/event-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
29 changes: 29 additions & 0 deletions apps/vscode/test/workspace-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down
42 changes: 41 additions & 1 deletion apps/vscode/webview-ui/src/components/DisplayBlocks.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 (
<div className="text-xs border border-border rounded-md overflow-hidden">
{block.path && (
<button
type="button"
onClick={() => {
void bridge.openPlanFile();
}}
className="flex items-center gap-1.5 w-full px-2 py-1 bg-muted/50 border-b border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
title="Open plan file"
>
<span className="font-mono break-all text-left flex-1">{block.path}</span>
<IconExternalLink className="size-3.5 shrink-0" />
</button>
)}
<div className={cn("px-2 py-1.5 overflow-auto", maxHeight)}>
<Markdown content={block.plan} className="text-xs leading-relaxed" />
</div>
</div>
);
}

interface DisplayBlockViewProps {
block: DisplayBlock;
maxHeight?: string;
Expand All @@ -201,6 +239,8 @@ export function DisplayBlockView({ block, maxHeight }: DisplayBlockViewProps) {
return <TodoBlockView block={block as TodoBlock} />;
case "brief":
return <BriefBlockView block={block as BriefBlock} />;
case "plan":
return <PlanBlockView block={block as PlanBlock} maxHeight={maxHeight} />;
default:
return null;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/vscode/webview-ui/src/components/ToolRenderers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
4 changes: 4 additions & 0 deletions apps/vscode/webview-ui/src/services/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down