diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index be682a6eb4d..97a68c3fd94 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -27,6 +27,7 @@ import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
+import { handleSuggestionEvent } from "@/kilocode/suggestion/tui/sync" // kilocode_change
import { Log } from "@/util/log"
import type { Path } from "@kilocode/sdk"
import type { Workspace } from "@kilocode/sdk/v2"
@@ -49,9 +50,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
question: {
[sessionID: string]: QuestionRequest[]
}
+ // kilocode_change start
suggestion: {
[sessionID: string]: SuggestionRequest[]
}
+ // kilocode_change end
config: Config
session: Session[]
session_status: {
@@ -92,7 +95,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
agent: [],
permission: {},
question: {},
- suggestion: {},
+ suggestion: {}, // kilocode_change
command: [],
provider: [],
provider_default: {},
@@ -235,40 +238,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
case "suggestion.accepted":
- case "suggestion.dismissed": {
- const requests = store.suggestion[event.properties.sessionID]
- if (!requests) break
- const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
- if (!match.found) break
- setStore(
- "suggestion",
- event.properties.sessionID,
- produce((draft) => {
- draft.splice(match.index, 1)
- }),
- )
- break
- }
-
+ case "suggestion.dismissed":
case "suggestion.shown": {
- const request = event.properties
- const requests = store.suggestion[request.sessionID]
- if (!requests) {
- setStore("suggestion", request.sessionID, [request])
- break
- }
- const match = Binary.search(requests, request.id, (r) => r.id)
- if (match.found) {
- setStore("suggestion", request.sessionID, match.index, reconcile(request))
- break
- }
- setStore(
- "suggestion",
- request.sessionID,
- produce((draft) => {
- draft.splice(match.index, 0, request)
- }),
- )
+ // kilocode_change start
+ handleSuggestionEvent(event, store, setStore)
+ // kilocode_change end
break
}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 46ab4394252..ed3348913ac 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -45,7 +45,6 @@ import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
-import type { SuggestTool } from "@/tool/suggest"
import type { SkillTool } from "@/tool/skill"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
@@ -79,7 +78,8 @@ import { Filesystem } from "@/util/filesystem"
import { Global } from "@/global"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
-import { SuggestPrompt } from "./suggest"
+import { Suggest } from "@/kilocode/suggestion/tui/render" // kilocode_change
+import { SuggestPrompt } from "@/kilocode/suggestion/tui/prompt" // kilocode_change
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
@@ -150,9 +150,9 @@ export function Session() {
const blockingQuestions = createMemo(() => questions().filter((q) => q.blocking !== false)) // kilocode_change
const nonBlockingQuestions = createMemo(() => questions().filter((q) => q.blocking === false)) // kilocode_change
const question = createMemo(() => blockingQuestions()[0] ?? nonBlockingQuestions()[0]) // kilocode_change
- const blockingSuggestions = createMemo(() => suggestions().filter((s) => s.blocking !== false))
- const nonBlockingSuggestions = createMemo(() => suggestions().filter((s) => s.blocking === false))
- const suggestion = createMemo(() => blockingSuggestions()[0] ?? nonBlockingSuggestions()[0])
+ const blockingSuggestions = createMemo(() => suggestions().filter((s) => s.blocking !== false)) // kilocode_change
+ const nonBlockingSuggestions = createMemo(() => suggestions().filter((s) => s.blocking === false)) // kilocode_change
+ const suggestion = createMemo(() => blockingSuggestions()[0] ?? nonBlockingSuggestions()[0]) // kilocode_change
const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
@@ -1231,6 +1231,7 @@ export function Session() {
)}
+ {/* kilocode_change start */}
{(request) => (
)}
+ {/* kilocode_change end */}
{
prompt = r
@@ -1257,7 +1259,7 @@ export function Session() {
}
}}
disabled={
- permissions().length > 0 || blockingQuestions().length > 0 || blockingSuggestions().length > 0
+ permissions().length > 0 || blockingQuestions().length > 0 || blockingSuggestions().length > 0 // kilocode_change
}
onSubmit={() => {
toBottom()
@@ -1654,7 +1656,7 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
-
+
@@ -2309,40 +2311,6 @@ function Question(props: ToolProps) {
)
}
-function Suggest(props: ToolProps) {
- const { theme } = useTheme()
- const accepted = createMemo(() => props.metadata.accepted)
- const dismissed = createMemo(() => props.metadata.dismissed === true)
-
- return (
-
-
-
-
- {props.input.suggest}
-
- Accepted: {accepted()?.label}
-
-
- Dismissed
-
-
-
-
-
-
- {props.input.suggest ?? "Suggested next step"}
-
-
-
- )
-}
-
function Skill(props: ToolProps) {
return (
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/suggest.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/suggest.tsx
index 9a5d7b43087..2e01ca68b7e 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/suggest.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/suggest.tsx
@@ -1,171 +1,2 @@
-import { createMemo, createSignal, For } from "solid-js"
-import { useKeyboard } from "@opentui/solid"
-import type { SuggestionRequest } from "@kilocode/sdk/v2"
-import { SplitBorder } from "../../component/border"
-import { useKeybind } from "../../context/keybind"
-import { useSDK } from "../../context/sdk"
-import { tint, useTheme } from "../../context/theme"
-import { useDialog } from "../../ui/dialog"
-
-const dismiss = {
- label: "Dismiss",
- description: "Dismiss this suggestion and continue",
-}
-
-export function SuggestPrompt(props: {
- request: SuggestionRequest
- nonBlocking?: boolean
- inputFocused?: () => boolean
-}) {
- const sdk = useSDK()
- const { theme } = useTheme()
- const keybind = useKeybind()
- const dialog = useDialog()
-
- const options = createMemo(() => [...props.request.actions, dismiss])
- const [selected, setSelected] = createSignal(0)
- const [busy, setBusy] = createSignal(false)
-
- function accept(index: number) {
- if (busy()) return
- setBusy(true)
- sdk.client.suggestion
- .accept({
- requestID: props.request.id,
- index,
- })
- .catch(() => {
- setBusy(false)
- })
- }
-
- function reject() {
- if (busy()) return
- setBusy(true)
- sdk.client.suggestion
- .dismiss({
- requestID: props.request.id,
- })
- .catch(() => {
- setBusy(false)
- })
- }
-
- function choose(index: number) {
- if (index >= props.request.actions.length) {
- reject()
- return
- }
- accept(index)
- }
-
- useKeyboard((evt) => {
- if (dialog.stack.length > 0) return
- if (props.nonBlocking && props.inputFocused?.()) return
-
- const total = options().length
- const max = Math.min(total, 9)
- const digit = Number(evt.name)
-
- if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
- evt.preventDefault()
- const index = digit - 1
- setSelected(index)
- choose(index)
- return
- }
-
- if (evt.name === "up" || evt.name === "k") {
- evt.preventDefault()
- setSelected((selected() - 1 + total) % total)
- return
- }
-
- if (evt.name === "down" || evt.name === "j") {
- evt.preventDefault()
- setSelected((selected() + 1) % total)
- return
- }
-
- if (evt.name === "return") {
- evt.preventDefault()
- choose(selected())
- return
- }
-
- if (evt.name === "escape" || keybind.match("app_exit", evt)) {
- evt.preventDefault()
- reject()
- }
- })
-
- const note = createMemo(() => (busy() ? "Waiting..." : undefined))
-
- return (
-
-
-
- {props.request.text}
-
-
-
-
- {(opt, i) => {
- const active = () => i() === selected()
- const muted = () => i() === props.request.actions.length
- return (
- setSelected(i())}
- onMouseDown={() => setSelected(i())}
- onMouseUp={() => choose(i())}
- >
-
-
-
- {`${i() + 1}.`}
-
-
-
- {opt.label}
-
-
-
-
- {opt.description}
-
-
- )
- }}
-
-
-
-
-
-
- {"↑↓"} select
-
-
- enter choose
-
-
- esc dismiss
-
-
- {note()}
-
-
- )
-}
+// kilocode_change - new file
+export { SuggestPrompt } from "../../../../../kilocode/suggestion/tui/prompt"
diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts
index 53837294dfe..1a55bed0544 100644
--- a/packages/opencode/src/kilo-sessions/remote-sender.ts
+++ b/packages/opencode/src/kilo-sessions/remote-sender.ts
@@ -5,7 +5,7 @@ import { Instance } from "@/project/instance"
import { Session } from "@/session"
import { SessionPrompt } from "@/session/prompt"
import { Question } from "@/question"
-import { Suggestion } from "@/suggestion"
+import { Suggestion } from "@/kilocode/suggestion" // kilocode_change
import { PermissionNext } from "@/permission/next"
import { Log } from "@/util/log"
import z from "zod"
diff --git a/packages/opencode/src/kilocode/suggestion/index.ts b/packages/opencode/src/kilocode/suggestion/index.ts
new file mode 100644
index 00000000000..4ba990bedf3
--- /dev/null
+++ b/packages/opencode/src/kilocode/suggestion/index.ts
@@ -0,0 +1,177 @@
+import { Bus } from "../../bus"
+import { BusEvent } from "../../bus/bus-event"
+import { Identifier } from "../../id/id"
+import { Instance } from "../../project/instance"
+import { Log } from "../../util/log"
+import z from "zod"
+
+export namespace Suggestion {
+ const log = Log.create({ service: "suggestion" })
+
+ export const Action = z
+ .object({
+ label: z.string().describe("Button or option label (1-5 words)"),
+ description: z.string().optional().describe("Brief explanation of what this action does"),
+ prompt: z.string().describe("Synthetic user prompt to inject when this action is accepted"),
+ })
+ .meta({
+ ref: "SuggestionAction",
+ })
+ export type Action = z.infer
+
+ export const Info = z
+ .object({
+ text: z.string().describe("Suggestion text shown to the user"),
+ actions: z.array(Action).min(1).max(2).describe("Available actions the user can take"),
+ })
+ .meta({
+ ref: "SuggestionInfo",
+ })
+ export type Info = z.infer
+
+ export const Request = z
+ .object({
+ id: Identifier.schema("suggestion"),
+ sessionID: Identifier.schema("session"),
+ text: z.string().describe("Suggestion text shown to the user"),
+ actions: z.array(Action).min(1).max(2).describe("Available actions the user can take"),
+ blocking: z.boolean().optional().describe("Whether this suggestion blocks prompt input (default: true)"),
+ tool: z
+ .object({
+ messageID: z.string(),
+ callID: z.string(),
+ })
+ .optional(),
+ })
+ .meta({
+ ref: "SuggestionRequest",
+ })
+ export type Request = z.infer
+
+ export const Accept = z.object({
+ index: z.number().int().nonnegative().describe("Zero-based action index to accept"),
+ })
+ export type Accept = z.infer
+
+ export const Event = {
+ Shown: BusEvent.define("suggestion.shown", Request),
+ Accepted: BusEvent.define(
+ "suggestion.accepted",
+ z.object({
+ sessionID: z.string(),
+ requestID: z.string(),
+ index: z.number().int().nonnegative(),
+ action: Action,
+ }),
+ ),
+ Dismissed: BusEvent.define(
+ "suggestion.dismissed",
+ z.object({
+ sessionID: z.string(),
+ requestID: z.string(),
+ }),
+ ),
+ }
+
+ const state = Instance.state(async () => {
+ const pending: Record<
+ string,
+ {
+ info: Request
+ resolve: (action: Action) => void
+ reject: (error: any) => void
+ }
+ > = {}
+
+ return {
+ pending,
+ }
+ })
+
+ export async function show(input: {
+ sessionID: string
+ text: string
+ actions: Action[]
+ blocking?: boolean
+ tool?: { messageID: string; callID: string }
+ }): Promise {
+ const s = await state()
+ const id = Identifier.ascending("suggestion")
+
+ log.info("shown", { id, actions: input.actions.length })
+
+ return new Promise((resolve, reject) => {
+ const info: Request = {
+ id,
+ sessionID: input.sessionID,
+ text: input.text,
+ actions: input.actions,
+ blocking: input.blocking,
+ tool: input.tool,
+ }
+ s.pending[id] = {
+ info,
+ resolve,
+ reject,
+ }
+ Bus.publish(Event.Shown, info)
+ })
+ }
+
+ export async function accept(input: { requestID: string; index: number }): Promise {
+ const s = await state()
+ const existing = s.pending[input.requestID]
+ if (!existing) {
+ log.warn("accept for unknown request", { requestID: input.requestID })
+ return
+ }
+
+ const action = existing.info.actions[input.index]
+ if (!action) {
+ log.warn("accept for invalid action index", { requestID: input.requestID, index: input.index })
+ return
+ }
+
+ delete s.pending[input.requestID]
+
+ log.info("accepted", { requestID: input.requestID, index: input.index, label: action.label })
+
+ Bus.publish(Event.Accepted, {
+ sessionID: existing.info.sessionID,
+ requestID: existing.info.id,
+ index: input.index,
+ action,
+ })
+
+ existing.resolve(action)
+ }
+
+ export async function dismiss(requestID: string): Promise {
+ const s = await state()
+ const existing = s.pending[requestID]
+ if (!existing) {
+ log.warn("dismiss for unknown request", { requestID })
+ return
+ }
+ delete s.pending[requestID]
+
+ log.info("dismissed", { requestID })
+
+ Bus.publish(Event.Dismissed, {
+ sessionID: existing.info.sessionID,
+ requestID: existing.info.id,
+ })
+
+ existing.reject(new DismissedError())
+ }
+
+ export class DismissedError extends Error {
+ constructor() {
+ super("The user dismissed this suggestion")
+ }
+ }
+
+ export async function list() {
+ return state().then((state) => Object.values(state.pending).map((item) => item.info))
+ }
+}
diff --git a/packages/opencode/src/kilocode/suggestion/routes.ts b/packages/opencode/src/kilocode/suggestion/routes.ts
new file mode 100644
index 00000000000..359f8274545
--- /dev/null
+++ b/packages/opencode/src/kilocode/suggestion/routes.ts
@@ -0,0 +1,97 @@
+import { errors } from "../../server/error"
+import { lazy } from "../../util/lazy"
+import { Hono } from "hono"
+import { describeRoute, resolver, validator } from "hono-openapi"
+import z from "zod"
+import { Suggestion } from "./index"
+
+export const SuggestionRoutes = lazy(() =>
+ new Hono()
+ .get(
+ "/",
+ describeRoute({
+ summary: "List pending suggestions",
+ description: "Get all pending suggestion requests across all sessions.",
+ operationId: "suggestion.list",
+ responses: {
+ 200: {
+ description: "List of pending suggestions",
+ content: {
+ "application/json": {
+ schema: resolver(Suggestion.Request.array()),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ const suggestions = await Suggestion.list()
+ return c.json(suggestions)
+ },
+ )
+ .post(
+ "/:requestID/accept",
+ describeRoute({
+ summary: "Accept suggestion request",
+ description: "Accept a suggestion request from the AI assistant.",
+ operationId: "suggestion.accept",
+ responses: {
+ 200: {
+ description: "Suggestion accepted successfully",
+ content: {
+ "application/json": {
+ schema: resolver(z.boolean()),
+ },
+ },
+ },
+ ...errors(400, 404),
+ },
+ }),
+ validator(
+ "param",
+ z.object({
+ requestID: z.string(),
+ }),
+ ),
+ validator("json", Suggestion.Accept),
+ async (c) => {
+ const params = c.req.valid("param")
+ const json = c.req.valid("json")
+ await Suggestion.accept({
+ requestID: params.requestID,
+ index: json.index,
+ })
+ return c.json(true)
+ },
+ )
+ .post(
+ "/:requestID/dismiss",
+ describeRoute({
+ summary: "Dismiss suggestion request",
+ description: "Dismiss a suggestion request from the AI assistant.",
+ operationId: "suggestion.dismiss",
+ responses: {
+ 200: {
+ description: "Suggestion dismissed successfully",
+ content: {
+ "application/json": {
+ schema: resolver(z.boolean()),
+ },
+ },
+ },
+ ...errors(400, 404),
+ },
+ }),
+ validator(
+ "param",
+ z.object({
+ requestID: z.string(),
+ }),
+ ),
+ async (c) => {
+ const params = c.req.valid("param")
+ await Suggestion.dismiss(params.requestID)
+ return c.json(true)
+ },
+ ),
+)
diff --git a/packages/opencode/src/kilocode/suggestion/tool.ts b/packages/opencode/src/kilocode/suggestion/tool.ts
new file mode 100644
index 00000000000..e92e34e5647
--- /dev/null
+++ b/packages/opencode/src/kilocode/suggestion/tool.ts
@@ -0,0 +1,103 @@
+import { Command } from "../../command"
+import { Flag } from "../../flag/flag"
+import { Log } from "../../util/log"
+import z from "zod"
+import DESCRIPTION from "./tool.txt"
+import { Tool } from "../../tool/tool"
+import { Suggestion } from "./index"
+
+const log = Log.create({ service: "tool.suggest" })
+
+const Params = z.object({
+ suggest: z.string().describe("Short suggestion text shown to the user"),
+ actions: z.array(Suggestion.Action).min(1).max(2).describe("Available actions the user can take"),
+})
+
+type Meta = {
+ accepted?: Suggestion.Action
+ dismissed: boolean
+}
+
+/**
+ * If prompt starts with `/`, treat it as a slash-command reference.
+ * Resolve the command template and return its content so the LLM can
+ * act on it in the current turn — without injecting a synthetic user
+ * message or trying to dispatch a command on the same session (which
+ * would deadlock).
+ */
+async function resolve(prompt: string): Promise {
+ if (!prompt.startsWith("/")) return prompt
+
+ const name = prompt.slice(1).split(/\s/, 1)[0]
+ if (!name) return prompt
+
+ const cmd = await Command.get(name)
+ if (!cmd) {
+ log.warn("unknown command in suggestion action", { name })
+ return prompt
+ }
+
+ try {
+ const template = await cmd.template
+ log.info("resolved command template", { name, length: template.length })
+ return template
+ } catch (err) {
+ log.warn("failed to resolve command template", { name, err })
+ return prompt
+ }
+}
+
+export const SuggestTool = Tool.define("suggest", {
+ description: DESCRIPTION,
+ parameters: Params,
+ async execute(params, ctx) {
+ const promise = Suggestion.show({
+ sessionID: ctx.sessionID,
+ text: params.suggest,
+ actions: params.actions,
+ blocking: Flag.KILO_CLIENT !== "vscode",
+ tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
+ })
+
+ const listener = () =>
+ Suggestion.list().then((items: Suggestion.Request[]) => {
+ const match = items.find((item: Suggestion.Request) => item.sessionID === ctx.sessionID)
+ if (match) return Suggestion.dismiss(match.id)
+ })
+ ctx.abort.addEventListener("abort", listener, { once: true })
+
+ const action = await promise
+ .catch((error) => {
+ if (error instanceof Suggestion.DismissedError) return undefined
+ throw error
+ })
+ .finally(() => {
+ ctx.abort.removeEventListener("abort", listener)
+ })
+
+ if (!action) {
+ const metadata: Meta = {
+ accepted: undefined,
+ dismissed: true,
+ }
+ return {
+ title: "Suggestion dismissed",
+ output: "User dismissed the suggestion.",
+ metadata,
+ }
+ }
+
+ const resolved = await resolve(action.prompt)
+
+ const metadata: Meta = {
+ accepted: action,
+ dismissed: false,
+ }
+
+ return {
+ title: `User accepted: ${action.label}`,
+ output: `User accepted the suggestion "${action.label}". Carry out the following request now:\n\n${resolved}`,
+ metadata,
+ }
+ },
+})
diff --git a/packages/opencode/src/tool/suggest.txt b/packages/opencode/src/kilocode/suggestion/tool.txt
similarity index 100%
rename from packages/opencode/src/tool/suggest.txt
rename to packages/opencode/src/kilocode/suggestion/tool.txt
diff --git a/packages/opencode/src/kilocode/suggestion/tui/prompt.tsx b/packages/opencode/src/kilocode/suggestion/tui/prompt.tsx
new file mode 100644
index 00000000000..91614f3fbf4
--- /dev/null
+++ b/packages/opencode/src/kilocode/suggestion/tui/prompt.tsx
@@ -0,0 +1,173 @@
+/** @jsxImportSource @opentui/solid */
+
+import { useKeyboard } from "@opentui/solid"
+import type { SuggestionRequest } from "@kilocode/sdk/v2"
+import { createMemo, createSignal, For } from "solid-js"
+import { SplitBorder } from "../../../cli/cmd/tui/component/border"
+import { useKeybind } from "../../../cli/cmd/tui/context/keybind"
+import { useSDK } from "../../../cli/cmd/tui/context/sdk"
+import { tint, useTheme } from "../../../cli/cmd/tui/context/theme"
+import { useDialog } from "../../../cli/cmd/tui/ui/dialog"
+
+const dismiss = {
+ label: "Dismiss",
+ description: "Dismiss this suggestion and continue",
+}
+
+export function SuggestPrompt(props: {
+ request: SuggestionRequest
+ nonBlocking?: boolean
+ inputFocused?: () => boolean
+}) {
+ const sdk = useSDK()
+ const { theme } = useTheme()
+ const keybind = useKeybind()
+ const dialog = useDialog()
+
+ const options = createMemo(() => [...props.request.actions, dismiss])
+ const [selected, setSelected] = createSignal(0)
+ const [busy, setBusy] = createSignal(false)
+
+ function accept(index: number) {
+ if (busy()) return
+ setBusy(true)
+ sdk.client.suggestion
+ .accept({
+ requestID: props.request.id,
+ index,
+ })
+ .catch(() => {
+ setBusy(false)
+ })
+ }
+
+ function reject() {
+ if (busy()) return
+ setBusy(true)
+ sdk.client.suggestion
+ .dismiss({
+ requestID: props.request.id,
+ })
+ .catch(() => {
+ setBusy(false)
+ })
+ }
+
+ function choose(index: number) {
+ if (index >= props.request.actions.length) {
+ reject()
+ return
+ }
+ accept(index)
+ }
+
+ useKeyboard((evt) => {
+ if (dialog.stack.length > 0) return
+ if (props.nonBlocking && props.inputFocused?.()) return
+
+ const total = options().length
+ const max = Math.min(total, 9)
+ const digit = Number(evt.name)
+
+ if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
+ evt.preventDefault()
+ const index = digit - 1
+ setSelected(index)
+ choose(index)
+ return
+ }
+
+ if (evt.name === "up" || evt.name === "k") {
+ evt.preventDefault()
+ setSelected((selected() - 1 + total) % total)
+ return
+ }
+
+ if (evt.name === "down" || evt.name === "j") {
+ evt.preventDefault()
+ setSelected((selected() + 1) % total)
+ return
+ }
+
+ if (evt.name === "return") {
+ evt.preventDefault()
+ choose(selected())
+ return
+ }
+
+ if (evt.name === "escape" || keybind.match("app_exit", evt)) {
+ evt.preventDefault()
+ reject()
+ }
+ })
+
+ const note = createMemo(() => (busy() ? "Waiting..." : undefined))
+
+ return (
+
+
+
+ {props.request.text}
+
+
+
+
+ {(opt, i) => {
+ const active = () => i() === selected()
+ const muted = () => i() === props.request.actions.length
+ return (
+ setSelected(i())}
+ onMouseDown={() => setSelected(i())}
+ onMouseUp={() => choose(i())}
+ >
+
+
+
+ {`${i() + 1}.`}
+
+
+
+ {opt.label}
+
+
+
+
+ {opt.description}
+
+
+ )
+ }}
+
+
+
+
+
+
+ {"↑↓"} select
+
+
+ enter choose
+
+
+ esc dismiss
+
+
+ {note()}
+
+
+ )
+}
diff --git a/packages/opencode/src/kilocode/suggestion/tui/render.tsx b/packages/opencode/src/kilocode/suggestion/tui/render.tsx
new file mode 100644
index 00000000000..638794f15d1
--- /dev/null
+++ b/packages/opencode/src/kilocode/suggestion/tui/render.tsx
@@ -0,0 +1,64 @@
+/** @jsxImportSource @opentui/solid */
+
+import { createMemo, Show, type JSX } from "solid-js"
+import { useTheme } from "../../../cli/cmd/tui/context/theme"
+import type { ToolPart as MessageToolPart } from "@kilocode/sdk/v2"
+
+type InlineProps = {
+ icon: string
+ complete: unknown
+ pending: string
+ part: MessageToolPart
+ children: JSX.Element
+}
+
+type BlockProps = {
+ title: string
+ part?: MessageToolPart
+ children: JSX.Element
+}
+
+export function Suggest(props: {
+ input: {
+ suggest?: string
+ }
+ metadata: {
+ accepted?: {
+ label: string
+ }
+ dismissed?: boolean
+ }
+ part: MessageToolPart
+ InlineTool: (props: InlineProps) => JSX.Element
+ BlockTool: (props: BlockProps) => JSX.Element
+}) {
+ const { theme } = useTheme()
+ const accepted = createMemo(() => props.metadata.accepted)
+ const dismissed = createMemo(() => props.metadata.dismissed === true)
+
+ if (accepted() || dismissed()) {
+ return props.BlockTool({
+ title: "# Suggestion",
+ part: props.part,
+ children: (
+
+ {props.input.suggest}
+
+ Accepted: {accepted()?.label}
+
+
+ Dismissed
+
+
+ ),
+ })
+ }
+
+ return props.InlineTool({
+ icon: "→",
+ pending: "Suggesting next step...",
+ complete: props.part.state.status === "completed",
+ part: props.part,
+ children: props.input.suggest ?? "Suggested next step",
+ })
+}
diff --git a/packages/opencode/src/kilocode/suggestion/tui/sync.ts b/packages/opencode/src/kilocode/suggestion/tui/sync.ts
new file mode 100644
index 00000000000..48a0e93d1f5
--- /dev/null
+++ b/packages/opencode/src/kilocode/suggestion/tui/sync.ts
@@ -0,0 +1,58 @@
+import { Binary } from "@opencode-ai/util/binary"
+import type { SuggestionRequest } from "@kilocode/sdk/v2"
+
+type RemovedEvent = {
+ type: "suggestion.accepted" | "suggestion.dismissed"
+ properties: {
+ sessionID: string
+ requestID: string
+ }
+}
+
+type ShownEvent = {
+ type: "suggestion.shown"
+ properties: SuggestionRequest
+}
+
+type Event = RemovedEvent | ShownEvent
+
+type Store = {
+ suggestion: {
+ [sessionID: string]: SuggestionRequest[]
+ }
+}
+
+type SetStore = {
+ (key: "suggestion", sessionID: string, value: SuggestionRequest[]): void
+}
+
+export function handleSuggestionEvent(event: Event, store: Store, setStore: SetStore) {
+ if (event.type !== "suggestion.shown") {
+ const info = event.properties
+ const requests = store.suggestion[info.sessionID]
+ if (!requests) return
+ const match = Binary.search(requests, info.requestID, (r) => r.id)
+ if (!match.found) return
+ setStore("suggestion", info.sessionID, requests.toSpliced(match.index, 1))
+ return
+ }
+
+ const request = event.properties
+ const requests = store.suggestion[request.sessionID]
+ if (!requests) {
+ setStore("suggestion", request.sessionID, [request])
+ return
+ }
+ const match = Binary.search(requests, request.id, (r) => r.id)
+ if (match.found) {
+ const next = [...requests]
+ next[match.index] = request
+ setStore("suggestion", request.sessionID, next)
+ return
+ }
+ setStore("suggestion", request.sessionID, [
+ ...requests.slice(0, match.index),
+ request,
+ ...requests.slice(match.index),
+ ])
+}
diff --git a/packages/opencode/src/server/routes/suggestion.ts b/packages/opencode/src/server/routes/suggestion.ts
index 630d8714ac1..e3f4d44e464 100644
--- a/packages/opencode/src/server/routes/suggestion.ts
+++ b/packages/opencode/src/server/routes/suggestion.ts
@@ -1,98 +1,2 @@
// kilocode_change - new file
-import { Suggestion } from "@/suggestion"
-import { errors } from "@/server/error"
-import { lazy } from "@/util/lazy"
-import { Hono } from "hono"
-import { describeRoute, resolver, validator } from "hono-openapi"
-import z from "zod"
-
-export const SuggestionRoutes = lazy(() =>
- new Hono()
- .get(
- "/",
- describeRoute({
- summary: "List pending suggestions",
- description: "Get all pending suggestion requests across all sessions.",
- operationId: "suggestion.list",
- responses: {
- 200: {
- description: "List of pending suggestions",
- content: {
- "application/json": {
- schema: resolver(Suggestion.Request.array()),
- },
- },
- },
- },
- }),
- async (c) => {
- const suggestions = await Suggestion.list()
- return c.json(suggestions)
- },
- )
- .post(
- "/:requestID/accept",
- describeRoute({
- summary: "Accept suggestion request",
- description: "Accept a suggestion request from the AI assistant.",
- operationId: "suggestion.accept",
- responses: {
- 200: {
- description: "Suggestion accepted successfully",
- content: {
- "application/json": {
- schema: resolver(z.boolean()),
- },
- },
- },
- ...errors(400, 404),
- },
- }),
- validator(
- "param",
- z.object({
- requestID: z.string(),
- }),
- ),
- validator("json", Suggestion.Accept),
- async (c) => {
- const params = c.req.valid("param")
- const json = c.req.valid("json")
- await Suggestion.accept({
- requestID: params.requestID,
- index: json.index,
- })
- return c.json(true)
- },
- )
- .post(
- "/:requestID/dismiss",
- describeRoute({
- summary: "Dismiss suggestion request",
- description: "Dismiss a suggestion request from the AI assistant.",
- operationId: "suggestion.dismiss",
- responses: {
- 200: {
- description: "Suggestion dismissed successfully",
- content: {
- "application/json": {
- schema: resolver(z.boolean()),
- },
- },
- },
- ...errors(400, 404),
- },
- }),
- validator(
- "param",
- z.object({
- requestID: z.string(),
- }),
- ),
- async (c) => {
- const params = c.req.valid("param")
- await Suggestion.dismiss(params.requestID)
- return c.json(true)
- },
- ),
-)
+export { SuggestionRoutes } from "../../kilocode/suggestion/routes"
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index e37323f1488..82d4beaceb6 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -50,7 +50,7 @@ import { EnhancePromptRoutes } from "./routes/enhance-prompt" // kilocode_change
import { KilocodeRoutes } from "./routes/kilocode" // kilocode_change
import { Filesystem } from "@/util/filesystem"
import { QuestionRoutes } from "./routes/question"
-import { SuggestionRoutes } from "./routes/suggestion"
+import { SuggestionRoutes } from "../kilocode/suggestion/routes" // kilocode_change
import { PermissionRoutes } from "./routes/permission"
import { RemoteRoutes } from "./routes/remote" // kilocode_change
import { GlobalRoutes } from "./routes/global"
@@ -279,7 +279,7 @@ export namespace Server {
.route("/session", SessionRoutes())
.route("/permission", PermissionRoutes())
.route("/question", QuestionRoutes())
- .route("/suggestion", SuggestionRoutes())
+ .route("/suggestion", SuggestionRoutes()) // kilocode_change
.route("/provider", ProviderRoutes())
.route("/telemetry", TelemetryRoutes()) // kilocode_change
.route("/remote", RemoteRoutes()) // kilocode_change
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index eb15e435496..90a43e41c61 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -15,7 +15,7 @@ import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission/next"
import { Question } from "@/question"
-import { Suggestion } from "@/suggestion"
+import { Suggestion } from "@/kilocode/suggestion" // kilocode_change
import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change
import { Flag } from "@/flag/flag" // kilocode_change
@@ -252,7 +252,7 @@ export namespace SessionProcessor {
if (
value.error instanceof PermissionNext.RejectedError ||
value.error instanceof Question.RejectedError ||
- value.error instanceof Suggestion.DismissedError
+ value.error instanceof Suggestion.DismissedError // kilocode_change
) {
blocked = shouldBreak
}
diff --git a/packages/opencode/src/suggestion/index.ts b/packages/opencode/src/suggestion/index.ts
index aa4c6b58cfb..04c2bcbf868 100644
--- a/packages/opencode/src/suggestion/index.ts
+++ b/packages/opencode/src/suggestion/index.ts
@@ -1,178 +1,2 @@
// kilocode_change - new file
-import { Bus } from "@/bus"
-import { BusEvent } from "@/bus/bus-event"
-import { Identifier } from "@/id/id"
-import { Instance } from "@/project/instance"
-import { Log } from "@/util/log"
-import z from "zod"
-
-export namespace Suggestion {
- const log = Log.create({ service: "suggestion" })
-
- export const Action = z
- .object({
- label: z.string().describe("Button or option label (1-5 words)"),
- description: z.string().optional().describe("Brief explanation of what this action does"),
- prompt: z.string().describe("Synthetic user prompt to inject when this action is accepted"),
- })
- .meta({
- ref: "SuggestionAction",
- })
- export type Action = z.infer
-
- export const Info = z
- .object({
- text: z.string().describe("Suggestion text shown to the user"),
- actions: z.array(Action).min(1).max(2).describe("Available actions the user can take"),
- })
- .meta({
- ref: "SuggestionInfo",
- })
- export type Info = z.infer
-
- export const Request = z
- .object({
- id: Identifier.schema("suggestion"),
- sessionID: Identifier.schema("session"),
- text: z.string().describe("Suggestion text shown to the user"),
- actions: z.array(Action).min(1).max(2).describe("Available actions the user can take"),
- blocking: z.boolean().optional().describe("Whether this suggestion blocks prompt input (default: true)"),
- tool: z
- .object({
- messageID: z.string(),
- callID: z.string(),
- })
- .optional(),
- })
- .meta({
- ref: "SuggestionRequest",
- })
- export type Request = z.infer
-
- export const Accept = z.object({
- index: z.number().int().nonnegative().describe("Zero-based action index to accept"),
- })
- export type Accept = z.infer
-
- export const Event = {
- Shown: BusEvent.define("suggestion.shown", Request),
- Accepted: BusEvent.define(
- "suggestion.accepted",
- z.object({
- sessionID: z.string(),
- requestID: z.string(),
- index: z.number().int().nonnegative(),
- action: Action,
- }),
- ),
- Dismissed: BusEvent.define(
- "suggestion.dismissed",
- z.object({
- sessionID: z.string(),
- requestID: z.string(),
- }),
- ),
- }
-
- const state = Instance.state(async () => {
- const pending: Record<
- string,
- {
- info: Request
- resolve: (action: Action) => void
- reject: (error: any) => void
- }
- > = {}
-
- return {
- pending,
- }
- })
-
- export async function show(input: {
- sessionID: string
- text: string
- actions: Action[]
- blocking?: boolean
- tool?: { messageID: string; callID: string }
- }): Promise {
- const s = await state()
- const id = Identifier.ascending("suggestion")
-
- log.info("shown", { id, actions: input.actions.length })
-
- return new Promise((resolve, reject) => {
- const info: Request = {
- id,
- sessionID: input.sessionID,
- text: input.text,
- actions: input.actions,
- blocking: input.blocking,
- tool: input.tool,
- }
- s.pending[id] = {
- info,
- resolve,
- reject,
- }
- Bus.publish(Event.Shown, info)
- })
- }
-
- export async function accept(input: { requestID: string; index: number }): Promise {
- const s = await state()
- const existing = s.pending[input.requestID]
- if (!existing) {
- log.warn("accept for unknown request", { requestID: input.requestID })
- return
- }
-
- const action = existing.info.actions[input.index]
- if (!action) {
- log.warn("accept for invalid action index", { requestID: input.requestID, index: input.index })
- return
- }
-
- delete s.pending[input.requestID]
-
- log.info("accepted", { requestID: input.requestID, index: input.index, label: action.label })
-
- Bus.publish(Event.Accepted, {
- sessionID: existing.info.sessionID,
- requestID: existing.info.id,
- index: input.index,
- action,
- })
-
- existing.resolve(action)
- }
-
- export async function dismiss(requestID: string): Promise {
- const s = await state()
- const existing = s.pending[requestID]
- if (!existing) {
- log.warn("dismiss for unknown request", { requestID })
- return
- }
- delete s.pending[requestID]
-
- log.info("dismissed", { requestID })
-
- Bus.publish(Event.Dismissed, {
- sessionID: existing.info.sessionID,
- requestID: existing.info.id,
- })
-
- existing.reject(new DismissedError())
- }
-
- export class DismissedError extends Error {
- constructor() {
- super("The user dismissed this suggestion")
- }
- }
-
- export async function list() {
- return state().then((x) => Object.values(x.pending).map((x) => x.info))
- }
-}
+export { Suggestion } from "../kilocode/suggestion/index"
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index ce842048e22..bddbc88c4b9 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -1,6 +1,6 @@
import { PlanExitTool } from "./plan"
import { QuestionTool } from "./question"
-import { SuggestTool } from "./suggest"
+import { SuggestTool } from "../kilocode/suggestion/tool" // kilocode_change
import { BashTool } from "./bash"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
@@ -106,7 +106,7 @@ export namespace ToolRegistry {
return [
InvalidTool,
...(["app", "cli", "desktop", "vscode"].includes(Flag.KILO_CLIENT) && interactive ? [QuestionTool] : []), // kilocode_change
- ...(suggest ? [SuggestTool] : []),
+ ...(suggest ? [SuggestTool] : []), // kilocode_change
BashTool,
ReadTool,
GlobTool,
diff --git a/packages/opencode/src/tool/suggest.ts b/packages/opencode/src/tool/suggest.ts
index 9edf9e0d36e..76debcc98ca 100644
--- a/packages/opencode/src/tool/suggest.ts
+++ b/packages/opencode/src/tool/suggest.ts
@@ -1,104 +1,2 @@
// kilocode_change - new file
-import { Command } from "@/command"
-import { Flag } from "@/flag/flag"
-import { Log } from "@/util/log"
-import { Suggestion } from "@/suggestion"
-import z from "zod"
-import DESCRIPTION from "./suggest.txt"
-import { Tool } from "./tool"
-
-const log = Log.create({ service: "tool.suggest" })
-
-const Params = z.object({
- suggest: z.string().describe("Short suggestion text shown to the user"),
- actions: z.array(Suggestion.Action).min(1).max(2).describe("Available actions the user can take"),
-})
-
-type Meta = {
- accepted?: Suggestion.Action
- dismissed: boolean
-}
-
-/**
- * If prompt starts with `/`, treat it as a slash-command reference.
- * Resolve the command template and return its content so the LLM can
- * act on it in the current turn — without injecting a synthetic user
- * message or trying to dispatch a command on the same session (which
- * would deadlock).
- */
-async function resolve(prompt: string): Promise {
- if (!prompt.startsWith("/")) return prompt
-
- const name = prompt.slice(1).split(/\s/, 1)[0]
- if (!name) return prompt
-
- const cmd = await Command.get(name)
- if (!cmd) {
- log.warn("unknown command in suggestion action", { name })
- return prompt
- }
-
- try {
- const template = await cmd.template
- log.info("resolved command template", { name, length: template.length })
- return template
- } catch (err) {
- log.warn("failed to resolve command template", { name, err })
- return prompt
- }
-}
-
-export const SuggestTool = Tool.define("suggest", {
- description: DESCRIPTION,
- parameters: Params,
- async execute(params, ctx) {
- const promise = Suggestion.show({
- sessionID: ctx.sessionID,
- text: params.suggest,
- actions: params.actions,
- blocking: Flag.KILO_CLIENT !== "vscode",
- tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
- })
-
- const listener = () =>
- Suggestion.list().then((items: Suggestion.Request[]) => {
- const match = items.find((item: Suggestion.Request) => item.sessionID === ctx.sessionID)
- if (match) return Suggestion.dismiss(match.id)
- })
- ctx.abort.addEventListener("abort", listener, { once: true })
-
- const action = await promise
- .catch((error) => {
- if (error instanceof Suggestion.DismissedError) return undefined
- throw error
- })
- .finally(() => {
- ctx.abort.removeEventListener("abort", listener)
- })
-
- if (!action) {
- const metadata: Meta = {
- accepted: undefined,
- dismissed: true,
- }
- return {
- title: "Suggestion dismissed",
- output: "User dismissed the suggestion.",
- metadata,
- }
- }
-
- const resolved = await resolve(action.prompt)
-
- const metadata: Meta = {
- accepted: action,
- dismissed: false,
- }
-
- return {
- title: `User accepted: ${action.label}`,
- output: `User accepted the suggestion "${action.label}". Carry out the following request now:\n\n${resolved}`,
- metadata,
- }
- },
-})
+export { SuggestTool } from "../kilocode/suggestion/tool"
diff --git a/packages/opencode/test/kilo-sessions/remote-sender.test.ts b/packages/opencode/test/kilo-sessions/remote-sender.test.ts
index 195bc2efcd9..c434442101e 100644
--- a/packages/opencode/test/kilo-sessions/remote-sender.test.ts
+++ b/packages/opencode/test/kilo-sessions/remote-sender.test.ts
@@ -9,7 +9,7 @@ import type { RemoteProtocol } from "../../src/kilo-sessions/remote-protocol"
import { SessionPrompt } from "../../src/session/prompt"
import { Question } from "../../src/question"
import { PermissionNext } from "../../src/permission/next"
-import { Suggestion } from "../../src/suggestion"
+import { Suggestion } from "../../src/kilocode/suggestion"
// kilocode_change end
function fakeConn() {
diff --git a/packages/opencode/test/kilocode/suggestion/suggestion.test.ts b/packages/opencode/test/kilocode/suggestion/suggestion.test.ts
new file mode 100644
index 00000000000..39e4d02ec92
--- /dev/null
+++ b/packages/opencode/test/kilocode/suggestion/suggestion.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, test } from "bun:test"
+import { Instance } from "../../../src/project/instance"
+import { Suggestion } from "../../../src/kilocode/suggestion"
+import { tmpdir } from "../../fixture/fixture"
+
+describe("suggestion", () => {
+ test("show adds pending request with blocking flag", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const pending = Suggestion.show({
+ sessionID: "ses_test",
+ text: "Run review?",
+ blocking: false,
+ actions: [{ label: "Start", description: "Run it", prompt: "/local-review-uncommitted" }],
+ })
+
+ const list = await Suggestion.list()
+ expect(list).toHaveLength(1)
+ expect(list[0]?.blocking).toBe(false)
+ expect(list[0]?.text).toBe("Run review?")
+
+ await Suggestion.dismiss(list[0]!.id)
+ await expect(pending).rejects.toBeInstanceOf(Suggestion.DismissedError)
+ },
+ })
+ })
+
+ test("accept resolves selected action and removes pending request", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const ask = Suggestion.show({
+ sessionID: "ses_test",
+ text: "Next step?",
+ actions: [
+ { label: "Review", description: "Start review", prompt: "/local-review-uncommitted" },
+ { label: "Test", description: "Run tests", prompt: "Run the relevant tests now." },
+ ],
+ })
+
+ const list = await Suggestion.list()
+ await Suggestion.accept({ requestID: list[0]!.id, index: 1 })
+
+ await expect(ask).resolves.toEqual({
+ label: "Test",
+ description: "Run tests",
+ prompt: "Run the relevant tests now.",
+ })
+ await expect(Suggestion.list()).resolves.toEqual([])
+ },
+ })
+ })
+
+ test("dismiss rejects pending request and removes it", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const ask = Suggestion.show({
+ sessionID: "ses_test",
+ text: "Review changes?",
+ actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
+ })
+
+ const list = await Suggestion.list()
+ await Suggestion.dismiss(list[0]!.id)
+
+ await expect(ask).rejects.toBeInstanceOf(Suggestion.DismissedError)
+ await expect(Suggestion.list()).resolves.toEqual([])
+ },
+ })
+ })
+})
diff --git a/packages/opencode/test/kilocode/suggestion/tool.test.ts b/packages/opencode/test/kilocode/suggestion/tool.test.ts
new file mode 100644
index 00000000000..74311ca5c7a
--- /dev/null
+++ b/packages/opencode/test/kilocode/suggestion/tool.test.ts
@@ -0,0 +1,162 @@
+import { afterEach, beforeEach, describe, expect, test, spyOn } from "bun:test"
+import { Command } from "../../../src/command"
+import { Suggestion } from "../../../src/kilocode/suggestion"
+import { SuggestTool } from "../../../src/kilocode/suggestion/tool"
+
+const ctx = {
+ sessionID: "ses_test",
+ messageID: "msg_assistant",
+ callID: "call_suggest",
+ agent: "code",
+ abort: AbortSignal.any([]),
+ messages: [
+ {
+ info: {
+ id: "msg_user",
+ role: "user",
+ sessionID: "ses_test",
+ time: { created: 1 },
+ agent: "code",
+ model: { providerID: "openai", modelID: "gpt-4" },
+ },
+ parts: [],
+ },
+ ],
+ metadata: () => {},
+ ask: async () => {},
+}
+
+describe("tool.suggest", () => {
+ let show: ReturnType
+ let cmdGet: ReturnType
+
+ beforeEach(() => {
+ show = spyOn(Suggestion, "show")
+ cmdGet = spyOn(Command, "get")
+ })
+
+ afterEach(() => {
+ show.mockRestore()
+ cmdGet.mockRestore()
+ })
+
+ test("returns dismissal result when suggestion is dismissed", async () => {
+ const tool = await SuggestTool.init()
+ show.mockRejectedValueOnce(new Suggestion.DismissedError())
+
+ const result = await tool.execute(
+ {
+ suggest: "Run review?",
+ actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
+ },
+ ctx as any,
+ )
+
+ expect(result.title).toBe("Suggestion dismissed")
+ expect(result.output).toBe("User dismissed the suggestion.")
+ expect(result.metadata.dismissed).toBe(true)
+ })
+
+ test("resolves command template for slash-command action prompt", async () => {
+ const tool = await SuggestTool.init()
+ show.mockResolvedValueOnce({
+ label: "Start review",
+ description: "Run a local review now",
+ prompt: "/local-review-uncommitted",
+ })
+ cmdGet.mockResolvedValueOnce({
+ name: "local-review-uncommitted",
+ description: "local review (uncommitted changes)",
+ template: Promise.resolve("Review these uncommitted changes:\n\n## Files Changed\n..."),
+ hints: [],
+ })
+
+ const result = await tool.execute(
+ {
+ suggest: "Run review?",
+ actions: [{ label: "Start review", prompt: "/local-review-uncommitted" }],
+ },
+ ctx as any,
+ )
+
+ expect(result.title).toBe("User accepted: Start review")
+ expect(result.output).toContain("Review these uncommitted changes:")
+ expect(result.output).toContain("Carry out the following request now")
+ expect(result.metadata.dismissed).toBe(false)
+ expect(result.metadata.accepted).toEqual({
+ label: "Start review",
+ description: "Run a local review now",
+ prompt: "/local-review-uncommitted",
+ })
+ expect(cmdGet).toHaveBeenCalledWith("local-review-uncommitted")
+ })
+
+ test("returns plain-text prompt directly for non-command actions", async () => {
+ const tool = await SuggestTool.init()
+ show.mockResolvedValueOnce({
+ label: "Run tests",
+ prompt: "Run the test suite and fix any failures",
+ })
+
+ const result = await tool.execute(
+ {
+ suggest: "Tests might need running",
+ actions: [{ label: "Run tests", prompt: "Run the test suite and fix any failures" }],
+ },
+ ctx as any,
+ )
+
+ expect(result.title).toBe("User accepted: Run tests")
+ expect(result.output).toContain("Run the test suite and fix any failures")
+ expect(result.output).toContain("Carry out the following request now")
+ expect(result.metadata.dismissed).toBe(false)
+ expect(cmdGet).not.toHaveBeenCalled()
+ })
+
+ test("falls back to raw prompt when command is not found", async () => {
+ const tool = await SuggestTool.init()
+ show.mockResolvedValueOnce({
+ label: "Unknown cmd",
+ prompt: "/nonexistent-command",
+ })
+ cmdGet.mockResolvedValueOnce(undefined)
+
+ const result = await tool.execute(
+ {
+ suggest: "Try this?",
+ actions: [{ label: "Unknown cmd", prompt: "/nonexistent-command" }],
+ },
+ ctx as any,
+ )
+
+ expect(result.title).toBe("User accepted: Unknown cmd")
+ expect(result.output).toContain("/nonexistent-command")
+ expect(result.metadata.dismissed).toBe(false)
+ })
+
+ test("falls back to raw prompt when template resolution fails", async () => {
+ const tool = await SuggestTool.init()
+ show.mockResolvedValueOnce({
+ label: "Start review",
+ prompt: "/local-review-uncommitted",
+ })
+ cmdGet.mockResolvedValueOnce({
+ name: "local-review-uncommitted",
+ description: "local review (uncommitted changes)",
+ template: Promise.reject(new Error("git not found")),
+ hints: [],
+ })
+
+ const result = await tool.execute(
+ {
+ suggest: "Run review?",
+ actions: [{ label: "Start review", prompt: "/local-review-uncommitted" }],
+ },
+ ctx as any,
+ )
+
+ expect(result.title).toBe("User accepted: Start review")
+ expect(result.output).toContain("/local-review-uncommitted")
+ expect(result.metadata.dismissed).toBe(false)
+ })
+})
diff --git a/packages/opencode/test/suggestion/suggestion.test.ts b/packages/opencode/test/suggestion/suggestion.test.ts
index cbef9b12e03..10b911a24ff 100644
--- a/packages/opencode/test/suggestion/suggestion.test.ts
+++ b/packages/opencode/test/suggestion/suggestion.test.ts
@@ -1,76 +1,2 @@
-import { describe, expect, test } from "bun:test"
-import { Instance } from "../../src/project/instance"
-import { Suggestion } from "../../src/suggestion"
-import { tmpdir } from "../fixture/fixture"
-
-describe("suggestion", () => {
- test("show adds pending request with blocking flag", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const pending = Suggestion.show({
- sessionID: "ses_test",
- text: "Run review?",
- blocking: false,
- actions: [{ label: "Start", description: "Run it", prompt: "/local-review-uncommitted" }],
- })
-
- const list = await Suggestion.list()
- expect(list).toHaveLength(1)
- expect(list[0]?.blocking).toBe(false)
- expect(list[0]?.text).toBe("Run review?")
-
- await Suggestion.dismiss(list[0]!.id)
- await expect(pending).rejects.toBeInstanceOf(Suggestion.DismissedError)
- },
- })
- })
-
- test("accept resolves selected action and removes pending request", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const ask = Suggestion.show({
- sessionID: "ses_test",
- text: "Next step?",
- actions: [
- { label: "Review", description: "Start review", prompt: "/local-review-uncommitted" },
- { label: "Test", description: "Run tests", prompt: "Run the relevant tests now." },
- ],
- })
-
- const list = await Suggestion.list()
- await Suggestion.accept({ requestID: list[0]!.id, index: 1 })
-
- await expect(ask).resolves.toEqual({
- label: "Test",
- description: "Run tests",
- prompt: "Run the relevant tests now.",
- })
- await expect(Suggestion.list()).resolves.toEqual([])
- },
- })
- })
-
- test("dismiss rejects pending request and removes it", async () => {
- await using tmp = await tmpdir({ git: true })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const ask = Suggestion.show({
- sessionID: "ses_test",
- text: "Review changes?",
- actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
- })
-
- const list = await Suggestion.list()
- await Suggestion.dismiss(list[0]!.id)
-
- await expect(ask).rejects.toBeInstanceOf(Suggestion.DismissedError)
- await expect(Suggestion.list()).resolves.toEqual([])
- },
- })
- })
-})
+// kilocode_change - new file
+// Moved to test/kilocode/suggestion/suggestion.test.ts.
diff --git a/packages/opencode/test/tool/suggest.test.ts b/packages/opencode/test/tool/suggest.test.ts
index d0db0e27fb6..448fbb3e940 100644
--- a/packages/opencode/test/tool/suggest.test.ts
+++ b/packages/opencode/test/tool/suggest.test.ts
@@ -1,162 +1,2 @@
-import { afterEach, beforeEach, describe, expect, test, spyOn } from "bun:test"
-import { Command } from "../../src/command"
-import { Suggestion } from "../../src/suggestion"
-import { SuggestTool } from "../../src/tool/suggest"
-
-const ctx = {
- sessionID: "ses_test",
- messageID: "msg_assistant",
- callID: "call_suggest",
- agent: "code",
- abort: AbortSignal.any([]),
- messages: [
- {
- info: {
- id: "msg_user",
- role: "user",
- sessionID: "ses_test",
- time: { created: 1 },
- agent: "code",
- model: { providerID: "openai", modelID: "gpt-4" },
- },
- parts: [],
- },
- ],
- metadata: () => {},
- ask: async () => {},
-}
-
-describe("tool.suggest", () => {
- let show: ReturnType
- let cmdGet: ReturnType
-
- beforeEach(() => {
- show = spyOn(Suggestion, "show")
- cmdGet = spyOn(Command, "get")
- })
-
- afterEach(() => {
- show.mockRestore()
- cmdGet.mockRestore()
- })
-
- test("returns dismissal result when suggestion is dismissed", async () => {
- const tool = await SuggestTool.init()
- show.mockRejectedValueOnce(new Suggestion.DismissedError())
-
- const result = await tool.execute(
- {
- suggest: "Run review?",
- actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
- },
- ctx as any,
- )
-
- expect(result.title).toBe("Suggestion dismissed")
- expect(result.output).toBe("User dismissed the suggestion.")
- expect(result.metadata.dismissed).toBe(true)
- })
-
- test("resolves command template for slash-command action prompt", async () => {
- const tool = await SuggestTool.init()
- show.mockResolvedValueOnce({
- label: "Start review",
- description: "Run a local review now",
- prompt: "/local-review-uncommitted",
- })
- cmdGet.mockResolvedValueOnce({
- name: "local-review-uncommitted",
- description: "local review (uncommitted changes)",
- template: Promise.resolve("Review these uncommitted changes:\n\n## Files Changed\n..."),
- hints: [],
- })
-
- const result = await tool.execute(
- {
- suggest: "Run review?",
- actions: [{ label: "Start review", prompt: "/local-review-uncommitted" }],
- },
- ctx as any,
- )
-
- expect(result.title).toBe("User accepted: Start review")
- expect(result.output).toContain("Review these uncommitted changes:")
- expect(result.output).toContain("Carry out the following request now")
- expect(result.metadata.dismissed).toBe(false)
- expect(result.metadata.accepted).toEqual({
- label: "Start review",
- description: "Run a local review now",
- prompt: "/local-review-uncommitted",
- })
- expect(cmdGet).toHaveBeenCalledWith("local-review-uncommitted")
- })
-
- test("returns plain-text prompt directly for non-command actions", async () => {
- const tool = await SuggestTool.init()
- show.mockResolvedValueOnce({
- label: "Run tests",
- prompt: "Run the test suite and fix any failures",
- })
-
- const result = await tool.execute(
- {
- suggest: "Tests might need running",
- actions: [{ label: "Run tests", prompt: "Run the test suite and fix any failures" }],
- },
- ctx as any,
- )
-
- expect(result.title).toBe("User accepted: Run tests")
- expect(result.output).toContain("Run the test suite and fix any failures")
- expect(result.output).toContain("Carry out the following request now")
- expect(result.metadata.dismissed).toBe(false)
- expect(cmdGet).not.toHaveBeenCalled()
- })
-
- test("falls back to raw prompt when command is not found", async () => {
- const tool = await SuggestTool.init()
- show.mockResolvedValueOnce({
- label: "Unknown cmd",
- prompt: "/nonexistent-command",
- })
- cmdGet.mockResolvedValueOnce(undefined)
-
- const result = await tool.execute(
- {
- suggest: "Try this?",
- actions: [{ label: "Unknown cmd", prompt: "/nonexistent-command" }],
- },
- ctx as any,
- )
-
- expect(result.title).toBe("User accepted: Unknown cmd")
- expect(result.output).toContain("/nonexistent-command")
- expect(result.metadata.dismissed).toBe(false)
- })
-
- test("falls back to raw prompt when template resolution fails", async () => {
- const tool = await SuggestTool.init()
- show.mockResolvedValueOnce({
- label: "Start review",
- prompt: "/local-review-uncommitted",
- })
- cmdGet.mockResolvedValueOnce({
- name: "local-review-uncommitted",
- description: "local review (uncommitted changes)",
- template: Promise.reject(new Error("git not found")),
- hints: [],
- })
-
- const result = await tool.execute(
- {
- suggest: "Run review?",
- actions: [{ label: "Start review", prompt: "/local-review-uncommitted" }],
- },
- ctx as any,
- )
-
- expect(result.title).toBe("User accepted: Start review")
- expect(result.output).toContain("/local-review-uncommitted")
- expect(result.metadata.dismissed).toBe(false)
- })
-})
+// kilocode_change - new file
+// Moved to test/kilocode/suggestion/tool.test.ts.