refactor(cli): move suggestion flow into kilocode modules

Move suggestion logic into kilocode-specific modules so the shared opencode surface stays small and easier to keep in sync with upstream.
This commit is contained in:
Alex Alecu
2026-04-09 09:33:54 +03:00
parent d219bf9d0a
commit 6996e0e479
22 changed files with 945 additions and 870 deletions
@@ -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
}
@@ -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() {
)}
</Show>
<Show when={permissions().length === 0 && !question()}>
{/* kilocode_change start */}
<Show when={suggestion()} keyed>
{(request) => (
<SuggestPrompt
@@ -1240,13 +1241,14 @@ export function Session() {
/>
)}
</Show>
{/* kilocode_change end */}
</Show>
<Prompt
visible={
!session()?.parentID &&
permissions().length === 0 &&
blockingQuestions().length === 0 &&
blockingSuggestions().length === 0
blockingSuggestions().length === 0 // kilocode_change
}
ref={(r) => {
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
<Question {...toolprops} />
</Match>
<Match when={props.part.tool === "suggest"}>
<Suggest {...toolprops} />
<Suggest {...toolprops} InlineTool={InlineTool} BlockTool={BlockTool} />
</Match>
<Match when={props.part.tool === "skill"}>
<Skill {...toolprops} />
@@ -2309,40 +2311,6 @@ function Question(props: ToolProps<typeof QuestionTool>) {
)
}
function Suggest(props: ToolProps<typeof SuggestTool>) {
const { theme } = useTheme()
const accepted = createMemo(() => props.metadata.accepted)
const dismissed = createMemo(() => props.metadata.dismissed === true)
return (
<Switch>
<Match when={accepted() || dismissed()}>
<BlockTool title="# Suggestion" part={props.part}>
<box gap={1}>
<text fg={theme.textMuted}>{props.input.suggest}</text>
<Show when={accepted()}>
<text fg={theme.text}>Accepted: {accepted()?.label}</text>
</Show>
<Show when={dismissed()}>
<text fg={theme.text}>Dismissed</text>
</Show>
</box>
</BlockTool>
</Match>
<Match when={true}>
<InlineTool
icon="→"
pending="Suggesting next step..."
complete={props.part.state.status === "completed"}
part={props.part}
>
{props.input.suggest ?? "Suggested next step"}
</InlineTool>
</Match>
</Switch>
)
}
function Skill(props: ToolProps<typeof SkillTool>) {
return (
<InlineTool icon="→" pending="Loading skill..." complete={props.input.name} part={props.part}>
@@ -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 (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.secondary}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box paddingLeft={1}>
<text fg={theme.text}>{props.request.text}</text>
</box>
<box>
<For each={options()}>
{(opt, i) => {
const active = () => i() === selected()
const muted = () => i() === props.request.actions.length
return (
<box
onMouseOver={() => setSelected(i())}
onMouseDown={() => setSelected(i())}
onMouseUp={() => choose(i())}
>
<box flexDirection="row">
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
<text fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
{`${i() + 1}.`}
</text>
</box>
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
<text fg={active() ? theme.secondary : muted() ? theme.textMuted : theme.text}>{opt.label}</text>
</box>
</box>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{opt.description}</text>
</box>
</box>
)
}}
</For>
</box>
</box>
<box
flexDirection="row"
flexShrink={0}
gap={2}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
justifyContent="space-between"
>
<box flexDirection="row" gap={2}>
<text fg={theme.text}>
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>choose</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
</text>
</box>
<text fg={theme.textMuted}>{note()}</text>
</box>
</box>
)
}
// kilocode_change - new file
export { SuggestPrompt } from "../../../../../kilocode/suggestion/tui/prompt"
@@ -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"
@@ -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<typeof Action>
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<typeof Info>
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<typeof Request>
export const Accept = z.object({
index: z.number().int().nonnegative().describe("Zero-based action index to accept"),
})
export type Accept = z.infer<typeof Accept>
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<Action> {
const s = await state()
const id = Identifier.ascending("suggestion")
log.info("shown", { id, actions: input.actions.length })
return new Promise<Action>((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<void> {
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<void> {
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))
}
}
@@ -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)
},
),
)
@@ -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<string> {
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<typeof Params, Meta>("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,
}
},
})
@@ -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 (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.secondary}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box paddingLeft={1}>
<text fg={theme.text}>{props.request.text}</text>
</box>
<box>
<For each={options()}>
{(opt, i) => {
const active = () => i() === selected()
const muted = () => i() === props.request.actions.length
return (
<box
onMouseOver={() => setSelected(i())}
onMouseDown={() => setSelected(i())}
onMouseUp={() => choose(i())}
>
<box flexDirection="row">
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
<text fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
{`${i() + 1}.`}
</text>
</box>
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
<text fg={active() ? theme.secondary : muted() ? theme.textMuted : theme.text}>{opt.label}</text>
</box>
</box>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{opt.description}</text>
</box>
</box>
)
}}
</For>
</box>
</box>
<box
flexDirection="row"
flexShrink={0}
gap={2}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
justifyContent="space-between"
>
<box flexDirection="row" gap={2}>
<text fg={theme.text}>
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>choose</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
</text>
</box>
<text fg={theme.textMuted}>{note()}</text>
</box>
</box>
)
}
@@ -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: (
<box gap={1}>
<text fg={theme.textMuted}>{props.input.suggest}</text>
<Show when={accepted()}>
<text fg={theme.text}>Accepted: {accepted()?.label}</text>
</Show>
<Show when={dismissed()}>
<text fg={theme.text}>Dismissed</text>
</Show>
</box>
),
})
}
return props.InlineTool({
icon: "→",
pending: "Suggesting next step...",
complete: props.part.state.status === "completed",
part: props.part,
children: props.input.suggest ?? "Suggested next step",
})
}
@@ -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),
])
}
@@ -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"
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
}
+1 -177
View File
@@ -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<typeof Action>
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<typeof Info>
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<typeof Request>
export const Accept = z.object({
index: z.number().int().nonnegative().describe("Zero-based action index to accept"),
})
export type Accept = z.infer<typeof Accept>
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<Action> {
const s = await state()
const id = Identifier.ascending("suggestion")
log.info("shown", { id, actions: input.actions.length })
return new Promise<Action>((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<void> {
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<void> {
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"
+2 -2
View File
@@ -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,
+1 -103
View File
@@ -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<string> {
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<typeof Params, Meta>("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"
@@ -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() {
@@ -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([])
},
})
})
})
@@ -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<typeof spyOn>
let cmdGet: ReturnType<typeof spyOn>
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)
})
})
@@ -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.
+2 -162
View File
@@ -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<typeof spyOn>
let cmdGet: ReturnType<typeof spyOn>
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.