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