mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user