Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/open-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---

Open saved plan files automatically when an agent requests user review in VS Code.
58 changes: 58 additions & 0 deletions packages/kilo-vscode/tests/unit/open-plan.test.ts
Original file line number Diff line number Diff line change
@@ -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<ExtensionMessage, { type: "partUpdated" }>

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<ExtensionMessage, { type: "partsUpdated" }>

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([])
})
})
12 changes: 12 additions & 0 deletions packages/kilo-vscode/webview-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,6 +34,7 @@ import "./styles/chat.css"

type ViewType = "newTask" | "history" | "profile" | "settings" | "subAgentViewer"
const VALID_VIEWS = new Set<string>(["newTask", "history", "profile", "settings", "subAgentViewer"])
const opened = new Set<string>()

/**
* Bridge our session store to the DataProvider's expected Data shape.
Expand Down Expand Up @@ -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({
Expand Down
25 changes: 25 additions & 0 deletions packages/kilo-vscode/webview-ui/src/utils/open-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ExtensionMessage } from "../types/messages"

type Update =
| Extract<ExtensionMessage, { type: "partUpdated" }>
| Extract<ExtensionMessage, { type: "partsUpdated" }>["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 }]
})
}
1 change: 1 addition & 0 deletions packages/opencode/src/kilocode/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ function planGuard(worktree: string, mcp: Record<string, "allow" | "ask" | "deny
suggest: "allow",
skill: "allow",
plan_exit: "allow",
open_plan: "allow",
task: {
"*": "allow",
general: "deny",
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/kilocode/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,9 @@ export namespace KiloSessionPrompt {
"Use the chosen plan path as the main plan file. Do not write or edit other files unless the user explicitly asks and your permissions allow it.",
"Project/user instructions about plan location (for example plans/ or .plans/) are authorized when permissions allow them; they do not conflict with this reminder. When finalizing, call plan_exit with the path of the plan file you wrote.",
"In the visible final response, cite the saved plan path as an inline code span so the client can open it as a document. Cite other user-facing files you create the same way instead of pasting the full file into chat.",
...(Flag.KILO_CLIENT === "vscode"
? ["When the plan is ready for user review, call open_plan with the saved path before calling plan_exit."]
: []),
supportsPlanFollowup()
? "When the plan is implementation-ready, write the main plan file and call plan_exit. Do not ask the user to choose between finalizing and refining in chat; the client follow-up after plan_exit asks whether to implement the saved plan or keep refining."
: 'Before creating or updating the plan file, or calling plan_exit, ask the user to choose exactly one of: "Finalize and save the plan" or "Continue refining". If the user chooses to finalize, write the main plan file, then call plan_exit.',
Expand Down
56 changes: 56 additions & 0 deletions packages/opencode/src/kilocode/tool/open-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Effect, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { PlanFile } from "@/kilocode/plan-file"
import { Session } from "@/session/session"
import * as Tool from "@/tool/tool"

export const Parameters = Schema.Struct({
path: Schema.optional(
Schema.String.annotate({
description: "Optional workspace-local path to the plan file. Omit this to open the current plan.",
}),
),
})

type Params = Schema.Schema.Type<typeof Parameters>

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),
}
}),
)
20 changes: 19 additions & 1 deletion packages/opencode/src/kilocode/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -100,6 +102,7 @@ export namespace KiloToolRegistry {
image,
terminal,
notify,
openPlan,
send,
...board,
}
Expand All @@ -120,6 +123,7 @@ export namespace KiloToolRegistry {
image,
terminal,
notify,
openPlan,
send,
...board,
...tools,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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,
}
})
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
},
})
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/test/kilocode/chart-tool-gating.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const tools = {
chart: stub("chart"),
image: stub("image"),
notify: stub("notify"),
openPlan: stub("open_plan"),
send: stub("send_file"),
}

Expand Down Expand Up @@ -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")
})
68 changes: 68 additions & 0 deletions packages/opencode/test/kilocode/tool/open-plan.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
)
})
Loading