diff --git a/.changeset/open-plan.md b/.changeset/open-plan.md new file mode 100644 index 000000000000..ac8abfa7341e --- /dev/null +++ b/.changeset/open-plan.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Open saved plan files automatically when an agent requests user review in VS Code. diff --git a/packages/kilo-vscode/tests/unit/open-plan.test.ts b/packages/kilo-vscode/tests/unit/open-plan.test.ts new file mode 100644 index 000000000000..16b8733b8527 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/open-plan.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test" +import type { ExtensionMessage, Part } from "../../webview-ui/src/types/messages" +import { planOpens } from "../../webview-ui/src/utils/open-plan" + +const done = (id = "part-1") => + ({ + type: "tool", + id, + tool: "open_plan", + state: { + status: "completed", + input: {}, + output: "Opened plan", + title: "Opening plan", + metadata: { plan: ".kilo/plans/plan.md", open: true }, + }, + }) satisfies Part + +const update = (part: Part, sessionID = "session-1") => + ({ + type: "partUpdated", + sessionID, + messageID: "message-1", + part, + }) satisfies Extract + +describe("planOpens", () => { + it("returns completed open_plan requests", () => { + expect(planOpens(update(done()))).toEqual([{ id: "part-1", path: ".kilo/plans/plan.md", sessionID: "session-1" }]) + }) + + it("handles batched updates and ignores unrelated parts", () => { + const message = { + type: "partsUpdated", + updates: [ + update(done("part-1")), + update({ ...done("part-2"), tool: "read" }), + update({ ...done("part-3"), state: { ...done("part-3").state, metadata: { open: false } } }), + ], + } satisfies Extract + + expect(planOpens(message)).toEqual([{ id: "part-1", path: ".kilo/plans/plan.md", sessionID: "session-1" }]) + }) + + it("does not open incomplete or unmarked plan parts", () => { + const running = { + ...done("part-running"), + state: { status: "running", input: {} }, + } satisfies Part + const unmarked = { + ...done("part-unmarked"), + state: { ...done("part-unmarked").state, metadata: { plan: ".kilo/plans/plan.md" } }, + } satisfies Part + + expect(planOpens(update(running))).toEqual([]) + expect(planOpens(update(unmarked))).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index fc9f57d7c045..a19bf2f59219 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -18,6 +18,7 @@ import { useWorktreeMode } from "./context/worktree-mode" import { useDiffStyle } from "./context/diff-style" import { dispatchAgentManagerEditPreview } from "./utils/agent-manager-events" import { strongest } from "./utils/session-activity" +import { planOpens } from "./utils/open-plan" import type { PermissionFileDiff } from "./types/messages" // Override the upstream "task" tool renderer with the fully-expanded version @@ -33,6 +34,7 @@ import "./styles/chat.css" type ViewType = "newTask" | "history" | "profile" | "settings" | "subAgentViewer" const VALID_VIEWS = new Set(["newTask", "history", "profile", "settings", "subAgentViewer"]) +const opened = new Set() /** * Bridge our session store to the DataProvider's expected Data shape. @@ -136,6 +138,16 @@ export const DataBridge: Component<{ children: any }> = (props) => { vscode.postMessage({ type: "openFile", filePath, line, column, sessionID }) } + const unsubscribePlans = vscode.onMessage((message) => { + for (const plan of planOpens(message)) { + const id = `${plan.sessionID}:${plan.id}` + if (opened.has(id)) continue + opened.add(id) + queueMicrotask(() => open(plan.path, undefined, undefined, plan.sessionID)) + } + }) + onCleanup(unsubscribePlans) + const openDiff = (diff: PermissionFileDiff) => { if (worktree) { dispatchAgentManagerEditPreview({ diff --git a/packages/kilo-vscode/webview-ui/src/utils/open-plan.ts b/packages/kilo-vscode/webview-ui/src/utils/open-plan.ts new file mode 100644 index 000000000000..62a3fdffb265 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/utils/open-plan.ts @@ -0,0 +1,25 @@ +import type { ExtensionMessage } from "../types/messages" + +type Update = + | Extract + | Extract["updates"][number] + +export type PlanOpen = { + id: string + path: string + sessionID: string +} + +export function planOpens(message: ExtensionMessage): PlanOpen[] { + const updates: Update[] = + message.type === "partUpdated" ? [message] : message.type === "partsUpdated" ? message.updates : [] + + return updates.flatMap((update) => { + const part = update.part + if (part.type !== "tool" || part.tool !== "open_plan" || part.state.status !== "completed") return [] + if (part.state.metadata?.open !== true) return [] + const path = part.state.metadata.plan + if (typeof path !== "string" || !path || !update.sessionID) return [] + return [{ id: part.id, path, sessionID: update.sessionID }] + }) +} diff --git a/packages/opencode/src/kilocode/agent/index.ts b/packages/opencode/src/kilocode/agent/index.ts index b7f8b8f4de3a..81a3bb4f5d56 100644 --- a/packages/opencode/src/kilocode/agent/index.ts +++ b/packages/opencode/src/kilocode/agent/index.ts @@ -316,6 +316,7 @@ function planGuard(worktree: string, mcp: Record + +export const OpenPlanTool = Tool.define( + "open_plan", + Effect.gen(function* () { + const session = yield* Session.Service + + return { + description: + "Open the saved plan in the client document viewer so the user can review it. Call this after finalizing the plan and before plan_exit.", + parameters: Parameters, + execute: (params: Params, ctx: Tool.Context) => + Effect.gen(function* () { + const instance = yield* InstanceState.context + const info = yield* session.get(ctx.sessionID) + const resolved = params.path ? PlanFile.resolve(params.path, instance) : undefined + const messages = yield* session.messages({ sessionID: ctx.sessionID }) + const latest = !params.path ? PlanFile.resolve(PlanFile.latest(messages), instance) : undefined + const target = resolved ?? latest ?? Session.plan(info, instance) + const file = yield* Effect.promise(() => PlanFile.locate(target, messages, info, instance, ctx.agent)) + if (!file) { + const plan = PlanFile.display(target, instance) + const rejected = params.path && !resolved + const hint = rejected + ? `The path "${params.path}" can't be used directly because it is outside the project, or it is a directory. ` + : "" + return yield* Effect.fail( + new Error( + `Plan file not found at ${plan}. ${hint}Write the plan file first, or call open_plan with the exact path of the file you wrote.`, + ), + ) + } + const plan = PlanFile.display(file, instance) + return { + title: "Opening plan", + output: `Opened plan at ${plan} for review.`, + metadata: { plan, open: true }, + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index 860af6610476..d3b9a9bcf760 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -11,6 +11,7 @@ import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "./noteb import { MemoryRecallTool } from "./memory-recall" import { MemorySaveTool } from "./memory-save" import { NotifyUserTool } from "./notify-user" +import { OpenPlanTool } from "./open-plan" import { SendFileTool } from "./send-file" import * as Tool from "../../tool/tool" import { Flag } from "@opencode-ai/core/flag/flag" @@ -85,6 +86,7 @@ export namespace KiloToolRegistry { // context here and injects it into the tool's init Effect. const sessions = yield* KiloSessions.Service const notify = yield* NotifyUserTool.pipe(Effect.provideService(KiloSessions.Service, sessions)) + const openPlan = yield* OpenPlanTool const send = yield* SendFileTool const board = yield* Effect.all({ boardRead: BoardReadTool, boardPost: BoardPostTool }) if (!notebook) @@ -100,6 +102,7 @@ export namespace KiloToolRegistry { image, terminal, notify, + openPlan, send, ...board, } @@ -120,6 +123,7 @@ export namespace KiloToolRegistry { image, terminal, notify, + openPlan, send, ...board, ...tools, @@ -142,6 +146,7 @@ export namespace KiloToolRegistry { image: Tool.Info terminal?: Tool.Info notify: Tool.Info + openPlan?: Tool.Info send: Tool.Info boardRead?: Tool.Info boardPost?: Tool.Info @@ -165,6 +170,7 @@ export namespace KiloToolRegistry { notify: Tool.init(tools.notify), send: Tool.init(tools.send), }) + const openPlan = tools.openPlan ? yield* Tool.init(tools.openPlan) : undefined const terminal = tools.terminal ? yield* Tool.init(tools.terminal) : undefined const board = tools.boardRead && tools.boardPost @@ -180,7 +186,17 @@ export namespace KiloToolRegistry { }) : {} const semantic = yield* semanticTool(deps, loaders) - return { ...base, ...board, terminal, browser, ...notebooks, semantic, notify: base.notify, send: base.send } + return { + ...base, + ...board, + terminal, + browser, + ...notebooks, + semantic, + openPlan, + notify: base.notify, + send: base.send, + } }) } @@ -244,6 +260,7 @@ export namespace KiloToolRegistry { image: Tool.Def terminal?: Tool.Def notify: Tool.Def + openPlan?: Tool.Def send: Tool.Def boardRead?: Tool.Def boardPost?: Tool.Def @@ -285,6 +302,7 @@ export namespace KiloToolRegistry { ? [tools.notebookRead, tools.notebookEdit, tools.notebookExecute] : []), tools.notify, + ...(Flag.KILO_CLIENT === "vscode" && tools.openPlan ? [tools.openPlan] : []), tools.send, ] } diff --git a/packages/opencode/test/kilocode/agent-permission-overrides.test.ts b/packages/opencode/test/kilocode/agent-permission-overrides.test.ts index 10472e6af22f..83ec46e41cb2 100644 --- a/packages/opencode/test/kilocode/agent-permission-overrides.test.ts +++ b/packages/opencode/test/kilocode/agent-permission-overrides.test.ts @@ -132,6 +132,7 @@ for (const [label, config] of [ expect(Permission.evaluate(permission, "src/index.ts", plan!.permission).action).toBe(expected) } expect(Permission.evaluate("plan_exit", "*", plan!.permission).action).toBe(expected) + expect(Permission.evaluate("open_plan", "*", plan!.permission).action).toBe(expected) expect(Permission.disabled(["read", "grep"], ask!.permission)).toEqual(new Set()) }, }) diff --git a/packages/opencode/test/kilocode/chart-tool-gating.test.ts b/packages/opencode/test/kilocode/chart-tool-gating.test.ts index d6cdf402b89c..55b5f30e9aee 100644 --- a/packages/opencode/test/kilocode/chart-tool-gating.test.ts +++ b/packages/opencode/test/kilocode/chart-tool-gating.test.ts @@ -16,6 +16,7 @@ const tools = { chart: stub("chart"), image: stub("image"), notify: stub("notify"), + openPlan: stub("open_plan"), send: stub("send_file"), } @@ -47,3 +48,9 @@ test("browser tool is included only for vscode clients", () => { expect(ids("cli")).not.toContain("browser_open") expect(ids("jetbrains")).not.toContain("browser_open") }) + +test("open plan tool is included only for vscode clients", () => { + expect(ids("vscode")).toContain("open_plan") + expect(ids("cli")).not.toContain("open_plan") + expect(ids("jetbrains")).not.toContain("open_plan") +}) diff --git a/packages/opencode/test/kilocode/tool/open-plan.test.ts b/packages/opencode/test/kilocode/tool/open-plan.test.ts new file mode 100644 index 000000000000..0f8b31b71de7 --- /dev/null +++ b/packages/opencode/test/kilocode/tool/open-plan.test.ts @@ -0,0 +1,68 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import path from "path" +import { Agent } from "../../../src/agent/agent" +import { TestInstance } from "../../fixture/fixture" +import { OpenPlanTool } from "../../../src/kilocode/tool/open-plan" +import { Session } from "../../../src/session/session" +import { MessageID, SessionID } from "../../../src/session/schema" +import { Truncate } from "../../../src/tool/truncate" +import { Tool } from "../../../src/tool/tool" +import { testEffect } from "../../lib/effect" + +const it = testEffect( + LayerNode.compile(LayerNode.group([Agent.node, Session.node, SessionProjector.node, Truncate.node])), +) + +const ctx = (sessionID: SessionID): Tool.Context => ({ + sessionID, + messageID: MessageID.make("msg_open_plan"), + agent: "plan", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +}) + +describe("open_plan", () => { + it.instance( + "returns the saved custom path and asks the client to open it", + Effect.gen(function* () { + const test = yield* TestInstance + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + const file = path.join(test.directory, ".plans", "fix.md") + yield* Effect.promise(() => Bun.write(file, "Do implementation step 1")) + + const info = yield* OpenPlanTool + const tool = yield* Tool.init(info) + const result = yield* tool.execute({ path: ".plans/fix.md" }, ctx(session.id)) + + expect(result.metadata.plan.replaceAll(path.sep, "/")).toBe(".plans/fix.md") + expect(result.metadata.open).toBe(true) + expect(result.output.replaceAll(path.sep, "/")).toContain(".plans/fix.md") + }), + { git: true }, + ) + + it.instance( + "finds the current generated plan when no path is provided", + Effect.gen(function* () { + const test = yield* TestInstance + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "generated" }) + const file = path.join(test.directory, ".kilo", "plans", `${session.time.created}-generated.md`) + yield* Effect.promise(() => Bun.write(file, "Do implementation step 1")) + + const info = yield* OpenPlanTool + const tool = yield* Tool.init(info) + const result = yield* tool.execute({}, ctx(session.id)) + + expect(result.metadata.plan.replaceAll(path.sep, "/")).toBe(`.kilo/plans/${session.time.created}-generated.md`) + expect(result.metadata.open).toBe(true) + }), + { git: true }, + ) +})