From 2f3408acd266db9e7dbd9c0358b7887450cb9cb6 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 16 Apr 2026 09:22:18 +0300 Subject: [PATCH 01/32] feat: restore suggest code review tool Re-applies #6404 and #8988 (reverted in #8994). --- packages/kilo-vscode/src/KiloProvider.ts | 3 + .../kilo-vscode/src/kilo-provider-utils.ts | 37 ++- .../src/kilo-provider/handlers/question.ts | 1 + .../src/kilo-provider/handlers/suggestion.ts | 110 +++++++++ .../cli-backend/connection-service.ts | 12 + .../services/cli-backend/connection-utils.ts | 11 + .../tests/unit/connection-utils.test.ts | 24 ++ .../tests/unit/kilo-provider-utils.test.ts | 40 ++++ .../tests/unit/suggestion-recovery.test.ts | 89 ++++++++ .../suggest-bar-review-chromium-linux.png | 3 + .../src/components/chat/AssistantMessage.tsx | 59 +++-- .../src/components/chat/ChatView.tsx | 15 +- .../src/components/chat/MessageList.tsx | 6 +- .../src/components/chat/PromptInput.tsx | 4 +- .../src/components/chat/SuggestBar.tsx | 65 ++++++ .../components/shared/WorkingIndicator.tsx | 3 +- .../webview-ui/src/context/session.tsx | 136 +++++++++++ .../webview-ui/src/stories/StoryProviders.tsx | 19 +- .../webview-ui/src/stories/chat.stories.tsx | 22 +- .../webview-ui/src/styles/chat.css | 1 + .../webview-ui/src/styles/suggest-bar.css | 49 ++++ .../webview-ui/src/types/messages.ts | 52 +++++ packages/opencode/src/agent/agent.ts | 2 + .../opencode/src/cli/cmd/tui/context/sync.tsx | 19 +- .../src/cli/cmd/tui/routes/session/index.tsx | 71 +++++- .../cli/cmd/tui/routes/session/question.tsx | 10 +- .../cli/cmd/tui/routes/session/suggest.tsx | 2 + packages/opencode/src/command/index.ts | 6 + packages/opencode/src/id/id.ts | 1 + .../src/kilo-sessions/remote-sender.ts | 55 ++++- packages/opencode/src/kilocode/agent/index.ts | 3 + .../opencode/src/kilocode/plan-followup.ts | 1 + .../opencode/src/kilocode/review/review.ts | 34 ++- .../opencode/src/kilocode/server/instance.ts | 2 + packages/opencode/src/kilocode/soul.txt | 13 ++ .../opencode/src/kilocode/suggestion/index.ts | 194 ++++++++++++++++ .../src/kilocode/suggestion/routes.ts | 99 ++++++++ .../opencode/src/kilocode/suggestion/tool.ts | 108 +++++++++ .../opencode/src/kilocode/suggestion/tool.txt | 17 ++ .../src/kilocode/suggestion/tui/prompt.tsx | 173 ++++++++++++++ .../src/kilocode/suggestion/tui/render.tsx | 64 ++++++ .../src/kilocode/suggestion/tui/sync.ts | 58 +++++ .../opencode/src/kilocode/tool/registry.ts | 5 + packages/opencode/src/question/index.ts | 5 + .../opencode/src/server/routes/suggestion.ts | 2 + packages/opencode/src/session/processor.ts | 12 +- packages/opencode/src/session/prompt.ts | 6 + packages/opencode/src/suggestion/index.ts | 2 + packages/opencode/src/tool/registry.ts | 3 + packages/opencode/src/tool/suggest.ts | 2 + .../kilocode/sessions/remote-sender.test.ts | 98 ++++++++ .../kilocode/suggestion/suggestion.test.ts | 76 +++++++ .../test/kilocode/suggestion/tool.test.ts | 162 +++++++++++++ .../opencode/test/question/question.test.ts | 28 +++ .../test/suggestion/suggestion.test.ts | 2 + packages/opencode/test/tool/registry.test.ts | 31 +++ packages/opencode/test/tool/suggest.test.ts | 2 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 113 ++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 158 +++++++++++++ packages/sdk/openapi.json | 213 ++++++++++++++++++ 60 files changed, 2563 insertions(+), 50 deletions(-) create mode 100644 packages/kilo-vscode/src/kilo-provider/handlers/suggestion.ts create mode 100644 packages/kilo-vscode/tests/unit/suggestion-recovery.test.ts create mode 100644 packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/suggest-bar-review-chromium-linux.png create mode 100644 packages/kilo-vscode/webview-ui/src/components/chat/SuggestBar.tsx create mode 100644 packages/kilo-vscode/webview-ui/src/styles/suggest-bar.css create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/suggest.tsx 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 create mode 100644 packages/opencode/src/kilocode/suggestion/tool.txt 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/src/server/routes/suggestion.ts create mode 100644 packages/opencode/src/suggestion/index.ts create mode 100644 packages/opencode/src/tool/suggest.ts create mode 100644 packages/opencode/test/kilocode/suggestion/suggestion.test.ts create mode 100644 packages/opencode/test/kilocode/suggestion/tool.test.ts create mode 100644 packages/opencode/test/suggestion/suggestion.test.ts create mode 100644 packages/opencode/test/tool/suggest.test.ts diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 63d56523321..7e4ed60bf3d 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -85,6 +85,7 @@ import { handleQuestionReject, fetchAndSendPendingQuestions, } from "./kilo-provider/handlers/question" +import { fetchAndSendPendingSuggestions, routeSuggestionWebviewMessage } from "./kilo-provider/handlers/suggestion" import { buildActionContext, @@ -506,6 +507,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await Promise.all([ fetchAndSendPendingPermissions(this.permissionCtx), fetchAndSendPendingQuestions(this.questionCtx), + fetchAndSendPendingSuggestions(this.questionCtx), ]) } } @@ -559,6 +561,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + await routeSuggestionWebviewMessage(this.questionCtx, message) switch (message.type) { case "webviewReady": console.log("[Kilo New] KiloProvider: ✅ webviewReady received") diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index 1f9bf18fd3f..2bc1b2fe085 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -307,8 +307,24 @@ export type WebviewMessage = } } | { type: "todoUpdated"; sessionID: string; items: unknown[] } - | { type: "questionRequest"; question: { id: string; sessionID: string; questions: unknown[]; tool?: unknown } } + | { + type: "questionRequest" + question: { id: string; sessionID: string; questions: unknown[]; blocking?: boolean; tool?: unknown } + } | { type: "questionResolved"; requestID: string } + | { + type: "suggestionRequest" + suggestion: { + id: string + sessionID: string + text: string + actions: unknown[] + blocking?: boolean + tool?: unknown + } + } + | { type: "suggestionResolved"; requestID: string } + | { type: "suggestionError"; requestID: string } | { type: "permissionResolved"; permissionID: string } | { type: "permissionError"; permissionID: string } | { type: "sessionCreated"; session: ReturnType; draftID?: string } @@ -412,6 +428,7 @@ export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | un id: event.properties.id, sessionID: event.properties.sessionID, questions: event.properties.questions, + blocking: event.properties.blocking, tool: event.properties.tool, }, } @@ -421,6 +438,24 @@ export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | un type: "questionResolved", requestID: event.properties.requestID, } + case "suggestion.shown": + return { + type: "suggestionRequest", + suggestion: { + id: event.properties.id, + sessionID: event.properties.sessionID, + text: event.properties.text, + actions: event.properties.actions, + blocking: event.properties.blocking, + tool: event.properties.tool, + }, + } + case "suggestion.accepted": + case "suggestion.dismissed": + return { + type: "suggestionResolved", + requestID: event.properties.requestID, + } case "session.error": { return { type: "sessionError", diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/question.ts b/packages/kilo-vscode/src/kilo-provider/handlers/question.ts index 90e82bad7fc..69d37960006 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/question.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/question.ts @@ -41,6 +41,7 @@ export async function fetchAndSendPendingQuestions(ctx: QuestionContext): Promis id: q.id, sessionID: q.sessionID, questions: q.questions, + blocking: q.blocking, tool: q.tool, }, }) diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/suggestion.ts b/packages/kilo-vscode/src/kilo-provider/handlers/suggestion.ts new file mode 100644 index 00000000000..f265144085c --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/handlers/suggestion.ts @@ -0,0 +1,110 @@ +/** + * Suggestion handlers — extracted from KiloProvider. + * + * Manages suggestion accept and dismiss flows plus recovery after SSE reconnects. + * No vscode dependency. + */ + +import type { KiloClient, SuggestionRequest } from "@kilocode/sdk/v2/client" +import { recoveryDirs } from "./permission-handler" + +export type RecoverableSuggestion = SuggestionRequest + +export interface SuggestionContext { + readonly client: KiloClient | null + readonly currentSessionId: string | undefined + readonly trackedSessionIds: Set + readonly sessionDirectories: ReadonlyMap + postMessage(msg: unknown): void + getWorkspaceDirectory(sessionId?: string): string +} + +export function recoverableSuggestions(items: RecoverableSuggestion[], tracked: Set, seen: Set) { + return items.filter((item) => { + if (seen.has(item.id)) return false + seen.add(item.id) + return tracked.has(item.sessionID) + }) +} + +/** + * Route suggestion-related webview messages. + * Extracted from the main message handler to stay within the complexity limit. + */ +export async function routeSuggestionWebviewMessage( + ctx: SuggestionContext, + message: { type: string; requestID?: string; sessionID?: string; index?: number }, +): Promise { + switch (message.type) { + case "suggestionAccept": + await handleSuggestionAccept(ctx, message.requestID!, message.index!, message.sessionID) + break + case "suggestionDismiss": + await handleSuggestionDismiss(ctx, message.requestID!, message.sessionID) + break + } +} + +export async function handleSuggestionAccept( + ctx: SuggestionContext, + requestID: string, + index: number, + sessionID?: string, +): Promise { + if (!ctx.client) { + ctx.postMessage({ type: "suggestionError", requestID }) + return + } + + try { + await ctx.client.suggestion.accept( + { requestID, index, directory: ctx.getWorkspaceDirectory(sessionID ?? ctx.currentSessionId) }, + { throwOnError: true }, + ) + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to accept suggestion:", error) + ctx.postMessage({ type: "suggestionError", requestID }) + } +} + +export async function handleSuggestionDismiss( + ctx: SuggestionContext, + requestID: string, + sessionID?: string, +): Promise { + if (!ctx.client) { + ctx.postMessage({ type: "suggestionError", requestID }) + return + } + + try { + await ctx.client.suggestion.dismiss( + { requestID, directory: ctx.getWorkspaceDirectory(sessionID ?? ctx.currentSessionId) }, + { throwOnError: true }, + ) + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to dismiss suggestion:", error) + ctx.postMessage({ type: "suggestionError", requestID }) + } +} + +export async function fetchAndSendPendingSuggestions(ctx: SuggestionContext): Promise { + if (!ctx.client) return + try { + const dirs = recoveryDirs(ctx.getWorkspaceDirectory(), ctx.sessionDirectories) + + const seen = new Set() + for (const dir of dirs) { + const { data } = await ctx.client.suggestion.list({ directory: dir }) + if (!data) continue + for (const suggestion of recoverableSuggestions(data, ctx.trackedSessionIds, seen)) { + ctx.postMessage({ + type: "suggestionRequest", + suggestion, + }) + } + } + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to fetch pending suggestions:", error) + } +} diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index e68a5e2953c..0a237f5c29d 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -405,6 +405,7 @@ export class KiloConnectionService { if (error) throw new Error(`Failed to reject question ${q.id}: ${String(error)}`) } } + await drainSuggestions(this.client, dir) await drainNetworkWaits(this.client, dir) } for (const listener of this.clearPendingPromptsListeners) { @@ -633,3 +634,14 @@ export class KiloConnectionService { this.startHealthPoll(config.baseUrl, config.password) } } + +async function drainSuggestions(client: KiloClient, directory: string): Promise { + const { data, error: err } = await client.suggestion.list({ directory }) + if (err) throw new Error(`Failed to list suggestions for ${directory}: ${String(err)}`) + if (data) { + for (const s of data) { + const { error } = await client.suggestion.dismiss({ requestID: s.id, directory }) + if (error) throw new Error(`Failed to dismiss suggestion ${s.id}: ${String(error)}`) + } + } +} diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts index 25eb285ecd3..cd91d811192 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts @@ -41,6 +41,17 @@ export function resolveEventSessionId( case "question.replied": case "question.rejected": return event.properties.sessionID + default: + return resolveSuggestionSessionId(event) + } +} + +function resolveSuggestionSessionId(event: Event): string | undefined { + switch (event.type) { + case "suggestion.shown": + case "suggestion.accepted": + case "suggestion.dismissed": + return event.properties.sessionID default: // session.network.* events are not yet in the SDK Event type union // (pending SDK regeneration). Handle them via string comparison. diff --git a/packages/kilo-vscode/tests/unit/connection-utils.test.ts b/packages/kilo-vscode/tests/unit/connection-utils.test.ts index 0715975f3d8..c916af225a9 100644 --- a/packages/kilo-vscode/tests/unit/connection-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/connection-utils.test.ts @@ -149,6 +149,30 @@ describe("resolveEventSessionId", () => { expect(resolveEventSessionId(e, noLookup)).toBe("s11") }) + it("returns sessionID from suggestion.shown", () => { + const e = event({ + type: "suggestion.shown", + properties: { id: "sug_1", sessionID: "s12", text: "Review?", actions: [] }, + }) + expect(resolveEventSessionId(e, noLookup)).toBe("s12") + }) + + it("returns sessionID from suggestion.accepted", () => { + const e = event({ + type: "suggestion.accepted", + properties: { sessionID: "s13", requestID: "sug_1", index: 0, action: { label: "Start", prompt: "x" } }, + }) + expect(resolveEventSessionId(e, noLookup)).toBe("s13") + }) + + it("returns sessionID from suggestion.dismissed", () => { + const e = event({ + type: "suggestion.dismissed", + properties: { sessionID: "s14", requestID: "sug_2" }, + }) + expect(resolveEventSessionId(e, noLookup)).toBe("s14") + }) + it("returns undefined for unknown event types (global events)", () => { const e = event({ type: "server.connected", properties: {} }) expect(resolveEventSessionId(e, noLookup)).toBeUndefined() diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index 5d9f38aae33..ebd96663c76 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -26,6 +26,9 @@ import type { EventQuestionAsked, EventQuestionReplied, EventQuestionRejected, + EventSuggestionShown, + EventSuggestionAccepted, + EventSuggestionDismissed, EventSessionCreated, EventSessionUpdated, EventServerConnected, @@ -444,6 +447,43 @@ describe("mapSSEEventToWebviewMessage", () => { } }) + it("maps suggestion.shown to suggestionRequest", () => { + const event: EventSuggestionShown = { + type: "suggestion.shown", + properties: { + id: "sug-1", + sessionID: "sess-1", + text: "Review changes?", + actions: [{ label: "Start", prompt: "/local-review-uncommitted" }], + }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("suggestionRequest") + }) + + it("maps suggestion.accepted to suggestionResolved", () => { + const event: EventSuggestionAccepted = { + type: "suggestion.accepted", + properties: { + sessionID: "sess-1", + requestID: "sug-1", + index: 0, + action: { label: "Start", prompt: "/local-review-uncommitted" }, + }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("suggestionResolved") + }) + + it("maps suggestion.dismissed to suggestionResolved", () => { + const event: EventSuggestionDismissed = { + type: "suggestion.dismissed", + properties: { sessionID: "sess-1", requestID: "sug-2" }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("suggestionResolved") + }) + it("maps session.created to sessionCreated with ISO dates", () => { const event: EventSessionCreated = { type: "session.created", diff --git a/packages/kilo-vscode/tests/unit/suggestion-recovery.test.ts b/packages/kilo-vscode/tests/unit/suggestion-recovery.test.ts new file mode 100644 index 00000000000..81f535afa1d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/suggestion-recovery.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "bun:test" +import { + fetchAndSendPendingSuggestions, + recoverableSuggestions, + type RecoverableSuggestion, + type SuggestionContext, +} from "../../src/kilo-provider/handlers/suggestion" + +function pending(id: string, sessionID: string): RecoverableSuggestion { + return { + id, + sessionID, + text: "Review changes?", + actions: [{ label: "Start", prompt: "/local-review-uncommitted" }], + } +} + +type Items = Record + +function suggestionClient(itemsPerDir: Items, queries: string[]) { + return { + suggestion: { + list: async (args?: { directory?: string }) => { + const dir = args?.directory ?? "" + queries.push(dir) + return { data: itemsPerDir[dir] ?? [] } + }, + accept: async () => ({ data: true }), + dismiss: async () => ({ data: true }), + }, + } +} + +function ctx(opts: { tracked: string[]; dirs?: Map; itemsPerDir?: Items }) { + const messages: unknown[] = [] + const queries: string[] = [] + const sdk = suggestionClient(opts.itemsPerDir ?? {}, queries) as unknown as SuggestionContext["client"] + + const fake: SuggestionContext = { + client: sdk, + currentSessionId: undefined, + trackedSessionIds: new Set(opts.tracked), + sessionDirectories: opts.dirs ?? new Map(), + postMessage: (msg) => messages.push(msg), + getWorkspaceDirectory: () => "/workspace", + } + + return { fake, messages, queries } +} + +describe("recoverableSuggestions", () => { + it("filters out untracked suggestions and deduplicates by id", () => { + const seen = new Set() + const list = [pending("s1", "tracked"), pending("s1", "tracked"), pending("s2", "other")] + expect(recoverableSuggestions(list, new Set(["tracked"]), seen)).toEqual([pending("s1", "tracked")]) + }) +}) + +describe("fetchAndSendPendingSuggestions", () => { + it("forwards suggestions from tracked sessions", async () => { + const dirs = new Map([["s1", "/wt"]]) + const { fake, messages, queries } = ctx({ + tracked: ["s1"], + dirs, + itemsPerDir: { "/wt": [pending("sug-1", "s1")] }, + }) + + await fetchAndSendPendingSuggestions(fake) + + expect(queries).toContain("/workspace") + expect(queries).toContain("/wt") + expect(messages).toEqual([{ type: "suggestionRequest", suggestion: pending("sug-1", "s1") }]) + }) + + it("does nothing when client is null", async () => { + const messages: unknown[] = [] + const fake: SuggestionContext = { + client: null, + currentSessionId: undefined, + trackedSessionIds: new Set(["s1"]), + sessionDirectories: new Map(), + postMessage: (msg) => messages.push(msg), + getWorkspaceDirectory: () => "/workspace", + } + + await fetchAndSendPendingSuggestions(fake) + expect(messages).toHaveLength(0) + }) +}) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/suggest-bar-review-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/suggest-bar-review-chromium-linux.png new file mode 100644 index 00000000000..27e9b8983a1 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/suggest-bar-review-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eee5f5d61cf2ac9ad0b987a8b220d76c503be0d11447766e6669da4684698c82 +size 6130 diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 02035c8cd1e..b4541bde844 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -19,6 +19,7 @@ import type { import { useData } from "@kilocode/kilo-ui/context/data" import { useSession } from "../../context/session" import { QuestionDock } from "./QuestionDock" +import { SuggestBar } from "./SuggestBar" // Tools that the upstream message-part renderer suppresses (returns null for). // We render these ourselves via ToolRegistry when they complete, @@ -41,6 +42,22 @@ function isRenderable(part: SDKPart): boolean { return !!PART_MAPPING[part.type] } +/** + * Match a tool part to an active request (question or suggestion) by tool name + * and callID/messageID. Returns the matched request or undefined. + */ +function matchToolRequest( + part: SDKPart, + name: string, + requests: T[], +): T | undefined { + if (part.type !== "tool") return undefined + const tp = part as unknown as ToolPart + if (tp.tool !== name) return undefined + if (tp.state?.status !== "pending" && tp.state?.status !== "running") return undefined + return requests.find((r) => r.tool?.callID === tp.callID && r.tool?.messageID === tp.messageID) +} + interface AssistantMessageProps { message: SDKAssistantMessage showAssistantCopyPartID?: string | null @@ -87,36 +104,40 @@ export const AssistantMessage: Component = (props) => { part.type === "tool" && UPSTREAM_SUPPRESSED_TOOLS.has((part as SDKPart & { tool: string }).tool) // Active question tool parts render the interactive QuestionDock inline - const activeQuestion = createMemo(() => { - if (part.type !== "tool") return undefined - const tp = part as unknown as ToolPart - if (tp.tool !== "question") return undefined - if (tp.state?.status !== "pending" && tp.state?.status !== "running") return undefined - return session.questions().find((q) => q.tool?.callID === tp.callID && q.tool?.messageID === tp.messageID) - }) + const activeQuestion = createMemo(() => matchToolRequest(part, "question", session.questions())) + + // Active suggestion tool parts render the interactive SuggestBar inline + const activeSuggestion = createMemo(() => matchToolRequest(part, "suggest", session.suggestions())) return ( - +
} - /> + > + + } > - + {(req) => } } > diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index eee0d6a844a..1f769cae8f7 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -55,13 +55,16 @@ export const ChatView: Component = (props) => { // not once per accessor call (questionRequest, permissionRequest, blocked all read these). const familyPermissions = createMemo(() => session.scopedPermissions(id())) const familyQuestions = createMemo(() => session.scopedQuestions(id())) - + const familySuggestions = createMemo(() => session.scopedSuggestions(id())) // Non-tool questions (standalone, not from the question tool) render inline in // the message list since they don't have an associated tool part in the conversation. // Tool-linked questions render inline at their tool part position via AssistantMessage. const standaloneQuestions = createMemo(() => familyQuestions().filter((q) => !q.tool)) + const standaloneSuggestions = createMemo(() => familySuggestions().filter((s) => !s.tool)) const permissionRequest = () => familyPermissions().find((p) => p.sessionID === id()) ?? familyPermissions()[0] - const blocked = () => familyPermissions().length > 0 || familyQuestions().length > 0 + const blocked = () => familyPermissions().length > 0 || familyQuestions().some((q) => q.blocking !== false) + // Session is busy only because a suggestion tool call is pending — prompt should behave as idle + const suggesting = () => !blocked() && familySuggestions().length > 0 const dock = () => !props.readonly || !!permissionRequest() // When a bottom-dock permission disappears while the session is busy, @@ -130,6 +133,7 @@ export const ChatView: Component = (props) => { onSelectSession={props.onSelectSession} onShowHistory={props.onShowHistory} questions={standaloneQuestions} + suggestions={standaloneSuggestions} readonly={props.readonly} />
@@ -215,7 +219,12 @@ export const ChatView: Component = (props) => {
- +
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index 8828ca0020f..a057a1fab47 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -23,8 +23,9 @@ import { AccountSwitcher } from "../shared/AccountSwitcher" import { KiloNotifications } from "./KiloNotifications" import { WorkingIndicator } from "../shared/WorkingIndicator" import { QuestionDock } from "./QuestionDock" +import { SuggestBar } from "./SuggestBar" import { activeUserMessageID as getActiveUserMessageID } from "../../context/session-queue" -import type { QuestionRequest } from "../../types/messages" +import type { QuestionRequest, SuggestionRequest } from "../../types/messages" const KiloLogo = (): JSX.Element => { const iconsBaseUri = (window as { ICONS_BASE_URI?: string }).ICONS_BASE_URI || "" @@ -44,6 +45,8 @@ interface MessageListProps { onShowHistory?: () => void /** Non-tool question requests to render inline at the bottom of the message list */ questions?: () => QuestionRequest[] + /** Non-tool suggestion requests to render inline at the bottom of the message list */ + suggestions?: () => SuggestionRequest[] /** When true (subagent viewer), replace the welcome screen with an initializing indicator */ readonly?: boolean } @@ -173,6 +176,7 @@ export const MessageList: Component = (props) => { {(req) => } + {(req) => } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index b5a2ff32182..8fe805e6547 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -50,6 +50,8 @@ function mergeReviewComments(current: ReviewComment[], incoming: ReviewComment[] interface PromptInputProps { blocked?: () => boolean + /** When true, session is busy only because a suggestion is pending — treat as idle for input */ + suggesting?: () => boolean boxId?: string pendingSessionID?: string } @@ -268,7 +270,7 @@ export const PromptInput: Component = (props) => { window.addEventListener("compactSession", onCompact) onCleanup(() => window.removeEventListener("compactSession", onCompact)) - const isBusy = () => session.status() !== "idle" + const isBusy = () => session.status() !== "idle" && !props.suggesting?.() const isDisabled = () => !server.isConnected() const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0 const canSend = () => hasInput() && !isDisabled() && !terminal.pending() && !props.blocked?.() diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SuggestBar.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SuggestBar.tsx new file mode 100644 index 00000000000..2eac7976986 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SuggestBar.tsx @@ -0,0 +1,65 @@ +import { Button } from "@kilocode/kilo-ui/button" +import { Icon } from "@kilocode/kilo-ui/icon" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import type { Component } from "solid-js" +import { For, Show } from "solid-js" +import { useLanguage } from "../../context/language" +import { useSession } from "../../context/session" +import type { SuggestionRequest } from "../../types/messages" + +export const SuggestBar: Component<{ request: SuggestionRequest }> = (props) => { + const session = useSession() + const language = useLanguage() + + const error = () => session.suggestionErrors().has(props.request.id) + const responding = () => session.respondingSuggestions().has(props.request.id) + + const accept = (index: number) => { + if (responding()) return + session.acceptSuggestion(props.request.id, index) + } + + const dismiss = () => { + if (responding()) return + session.dismissSuggestion(props.request.id) + } + + return ( +
+
+ + + + {props.request.text} +
+ +
+ + Failed — try again +
+
+
+ + {(action, index) => ( + + )} + + +
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx index eb4067751d2..4d4c64fe677 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx @@ -83,7 +83,8 @@ export const WorkingIndicator: Component = () => { .permissions() .filter((p) => p.sessionID === id && !(p.tool && ["todowrite", "todoread"].includes(p.toolName))) const questions = session.questions().filter((q) => q.sessionID === id) - return perms.length > 0 || questions.length > 0 + const suggestions = session.suggestions().filter((s) => s.sessionID === id) + return perms.length > 0 || questions.length > 0 || suggestions.length > 0 } const isRetrying = () => session.statusInfo().type === "retry" diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index ed1c38b7ef3..ea21f0730a4 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -22,6 +22,7 @@ import type { SessionStatusInfo, PermissionRequest, QuestionRequest, + SuggestionRequest, TodoItem, ModelSelection, ContextUsage, @@ -113,10 +114,14 @@ interface SessionContextValue { // Pending question requests (unscoped — all tracked sessions) questions: Accessor questionErrors: Accessor> + suggestions: Accessor + suggestionErrors: Accessor> + respondingSuggestions: Accessor> // Scoped permissions/questions — filtered to a session's family (self + subagents) scopedPermissions: (sessionID: string | undefined) => PermissionRequest[] scopedQuestions: (sessionID: string | undefined) => QuestionRequest[] + scopedSuggestions: (sessionID: string | undefined) => SuggestionRequest[] // Model selection (global, extension-lifetime) selected: Accessor @@ -191,6 +196,8 @@ interface SessionContextValue { ) => void replyToQuestion: (requestID: string, answers: string[][]) => void rejectQuestion: (requestID: string) => void + acceptSuggestion: (requestID: string, index: number) => void + dismissSuggestion: (requestID: string) => void createSession: () => void clearCurrentSession: () => void loadSessions: () => void @@ -250,6 +257,9 @@ export const SessionProvider: ParentComponent = (props) => { // Tracks question IDs that failed so the UI can reset sending state const [questionErrors, setQuestionErrors] = createSignal>(new Set()) + const [suggestions, setSuggestions] = createSignal([]) + const [suggestionErrors, setSuggestionErrors] = createSignal>(new Set()) + const [respondingSuggestions, setRespondingSuggestions] = createSignal>(new Set()) // Tracks whether the user has explicitly set a model override per agent (to // prevent the default-sync effect from overwriting it). @@ -643,6 +653,9 @@ export const SessionProvider: ParentComponent = (props) => { // Handle messages from extension onMount(() => { const unsubscribe = vscode.onMessage((message: ExtensionMessage) => { + // Route suggestion messages (extracted to stay within complexity limit) + routeSuggestionMessage(message) + switch (message.type) { case "sessionCreated": handleSessionCreated(message.session, message.draftID) @@ -683,7 +696,10 @@ export const SessionProvider: ParentComponent = (props) => { case "clearPendingPrompts": setPermissions([]) setQuestions([]) + setSuggestions([]) setRespondingPermissions(new Set()) + setSuggestionErrors(new Set()) + setRespondingSuggestions(new Set()) break case "sessionsLoaded": @@ -1000,6 +1016,60 @@ export const SessionProvider: ParentComponent = (props) => { setQuestionErrors((prev) => new Set(prev).add(requestID)) } + function handleSuggestionRequest(suggestion: SuggestionRequest) { + setSuggestions((prev) => { + const idx = prev.findIndex((item) => item.id === suggestion.id) + if (idx === -1) return [...prev, suggestion] + const next = prev.slice() + next[idx] = suggestion + return next + }) + } + + function handleSuggestionResolved(requestID: string) { + setSuggestions((prev) => prev.filter((item) => item.id !== requestID)) + setRespondingSuggestions((prev) => { + if (!prev.has(requestID)) return prev + const next = new Set(prev) + next.delete(requestID) + return next + }) + setSuggestionErrors((prev) => { + if (!prev.has(requestID)) return prev + const next = new Set(prev) + next.delete(requestID) + return next + }) + } + + function handleSuggestionError(requestID: string) { + setRespondingSuggestions((prev) => { + if (!prev.has(requestID)) return prev + const next = new Set(prev) + next.delete(requestID) + return next + }) + setSuggestionErrors((prev) => new Set(prev).add(requestID)) + } + + /** + * Route suggestion-related extension messages. + * Extracted from the main message handler to stay within the complexity limit. + */ + function routeSuggestionMessage(message: ExtensionMessage) { + switch (message.type) { + case "suggestionRequest": + handleSuggestionRequest(message.suggestion) + break + case "suggestionResolved": + handleSuggestionResolved(message.requestID) + break + case "suggestionError": + handleSuggestionError(message.requestID) + break + } + } + /** * Handle a failed send: remove the optimistic message from the store * and show a toast. The PromptInput restores the draft text separately @@ -1116,6 +1186,12 @@ export const SessionProvider: ParentComponent = (props) => { return questions().filter((q) => family.has(q.sessionID)) } + function scopedSuggestions(sessionID: string | undefined): SuggestionRequest[] { + if (!sessionID) return [] + const family = sessionFamily(sessionID) + return suggestions().filter((item) => family.has(item.sessionID)) + } + function handleTodoUpdated(sessionID: string, items: TodoItem[]) { setStore("todos", sessionID, items) } @@ -1196,6 +1272,24 @@ export const SessionProvider: ParentComponent = (props) => { return next }) } + const gone = suggestions() + .filter((item) => item.sessionID === sessionID) + .map((item) => item.id) + if (gone.length > 0) { + setSuggestions((prev) => prev.filter((item) => item.sessionID !== sessionID)) + setSuggestionErrors((prev) => { + const next = new Set(prev) + for (const id of gone) next.delete(id) + if (next.size === prev.size) return prev + return next + }) + setRespondingSuggestions((prev) => { + const next = new Set(prev) + for (const id of gone) next.delete(id) + if (next.size === prev.size) return prev + return next + }) + } setPermissions((prev) => removeSessionPermissions(prev, sessionID)) setStatusMap( produce((map) => { @@ -1408,6 +1502,8 @@ export const SessionProvider: ParentComponent = (props) => { } const sid = currentSessionID() + const suggestion = scopedSuggestions(sid)[0] + if (suggestion) dismissSuggestion(suggestion.id) if (sid) addOptimistic(sid, messageID, text, files) const agent = selectedAgentName() !== defaultAgent() ? selectedAgentName() : undefined @@ -1461,6 +1557,8 @@ export const SessionProvider: ParentComponent = (props) => { const messageID = Identifier.ascending("message") const sid = currentSessionID() + const suggestion = scopedSuggestions(sid)[0] + if (suggestion) dismissSuggestion(suggestion.id) if (sid) addOptimistic(sid, messageID, `/${command} ${args}`.trim(), files) @@ -1548,6 +1646,15 @@ export const SessionProvider: ParentComponent = (props) => { }) } + function clearSuggestionError(requestID: string) { + setSuggestionErrors((prev) => { + if (!prev.has(requestID)) return prev + const next = new Set(prev) + next.delete(requestID) + return next + }) + } + function replyToQuestion(requestID: string, answers: string[][]) { clearQuestionError(requestID) const question = questions().find((item) => item.id === requestID) @@ -1571,6 +1678,29 @@ export const SessionProvider: ParentComponent = (props) => { }) } + function acceptSuggestion(requestID: string, index: number) { + clearSuggestionError(requestID) + setRespondingSuggestions((prev) => new Set(prev).add(requestID)) + const sid = suggestions().find((s) => s.id === requestID)?.sessionID ?? currentSessionID() ?? "" + vscode.postMessage({ + type: "suggestionAccept", + requestID, + sessionID: sid, + index, + }) + } + + function dismissSuggestion(requestID: string) { + clearSuggestionError(requestID) + setRespondingSuggestions((prev) => new Set(prev).add(requestID)) + const sid = suggestions().find((s) => s.id === requestID)?.sessionID ?? currentSessionID() ?? "" + vscode.postMessage({ + type: "suggestionDismiss", + requestID, + sessionID: sid, + }) + } + function createSession() { if (!server.isConnected()) { console.warn("[Kilo New] Cannot create session: not connected") @@ -1815,8 +1945,12 @@ export const SessionProvider: ParentComponent = (props) => { respondingPermissions, questions, questionErrors, + suggestions, + suggestionErrors, + respondingSuggestions, scopedPermissions, scopedQuestions, + scopedSuggestions, selected, selectModel, hasModelOverride, @@ -1878,6 +2012,8 @@ export const SessionProvider: ParentComponent = (props) => { respondToPermission, replyToQuestion, rejectQuestion, + acceptSuggestion, + dismissSuggestion, createSession, clearCurrentSession, loadSessions, diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 43eed3f123a..a9a0f10e8b4 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -33,7 +33,13 @@ import { dict as appEn } from "../i18n/en" import { dict as amEn } from "../../agent-manager/i18n/en" import { dict as kiloEn } from "@kilocode/kilo-i18n/en" import { resolveTemplate } from "../context/language-utils" -import type { Config, KilocodeNotification, PermissionRequest, QuestionRequest } from "../types/messages" +import type { + Config, + KilocodeNotification, + PermissionRequest, + QuestionRequest, + SuggestionRequest, +} from "../types/messages" // Merged English dictionary (same merge order as the real LanguageProvider) const dict: Record = { ...appEn, ...amEn, ...uiEn, ...kiloEn } @@ -120,11 +126,13 @@ export function mockSessionValue(overrides?: { id?: string permissions?: PermissionRequest[] questions?: QuestionRequest[] + suggestions?: SuggestionRequest[] status?: string }) { const id = overrides?.id ?? "story-session-001" const permissions = overrides?.permissions ?? [] const qs = overrides?.questions ?? [] + const suggestions = overrides?.suggestions ?? [] const status = (overrides?.status ?? "idle") as "idle" | "busy" return { @@ -154,8 +162,12 @@ export function mockSessionValue(overrides?: { respondingPermissions: () => new Set(), questions: () => qs, questionErrors: () => new Set(), + suggestions: () => suggestions, + suggestionErrors: () => new Set(), + respondingSuggestions: () => new Set(), scopedPermissions: (sid?: string) => (sid ? permissions.filter((p) => p.sessionID === sid) : permissions), scopedQuestions: (sid?: string) => (sid ? qs.filter((q) => q.sessionID === sid) : qs), + scopedSuggestions: (sid?: string) => (sid ? suggestions.filter((item) => item.sessionID === sid) : suggestions), selected: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }), selectModel: noop, hasModelOverride: () => false, @@ -186,11 +198,14 @@ export function mockSessionValue(overrides?: { currentVariant: () => undefined, selectVariant: noop, sendMessage: noop, + sendCommand: noop, abort: noop, compact: noop, respondToPermission: noop, replyToQuestion: noop, rejectQuestion: noop, + acceptSuggestion: noop, + dismissSuggestion: noop, createSession: noop, clearCurrentSession: noop, loadSessions: noop, @@ -211,6 +226,7 @@ interface StoryProvidersProps { data?: any permissions?: PermissionRequest[] questions?: QuestionRequest[] + suggestions?: SuggestionRequest[] notifications?: KilocodeNotification[] status?: string sessionID?: string @@ -242,6 +258,7 @@ export const StoryProviders: ParentComponent = (props) => { id: props.sessionID, permissions: props.permissions, questions: props.questions, + suggestions: props.suggestions, status: props.status, }) const notifications = mockNotificationsValue(props.notifications) diff --git a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx index 93c64ab4ade..1fd60ef1de4 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx @@ -12,9 +12,10 @@ import { StoryProviders, mockSessionValue } from "./StoryProviders" import { ChatView } from "../components/chat/ChatView" import { TaskHeader } from "../components/chat/TaskHeader" import { QuestionDock } from "../components/chat/QuestionDock" +import { SuggestBar } from "../components/chat/SuggestBar" import { SessionContext } from "../context/session" import { ServerContext } from "../context/server" -import type { QuestionRequest, TodoItem } from "../types/messages" +import type { QuestionRequest, SuggestionRequest, TodoItem } from "../types/messages" const SESSION_ID = "story-session-chat-001" @@ -66,6 +67,14 @@ const multiQuestion: QuestionRequest = { tool: { messageID: "asst-msg-001", callID: "call-question-002" }, } +const reviewSuggestion: SuggestionRequest = { + id: "s-review-001", + sessionID: SESSION_ID, + text: "Start a code review of uncommitted changes?", + actions: [{ label: "Start review", description: "Run a local review now", prompt: "/local-review-uncommitted" }], + tool: { messageID: "asst-msg-002", callID: "call-suggest-001" }, +} + // --------------------------------------------------------------------------- // Meta // --------------------------------------------------------------------------- @@ -172,6 +181,17 @@ export const QuestionDockManyOptions: Story = { ), } +export const SuggestBarReview: Story = { + name: "SuggestBar — review suggestion", + render: () => ( + +
+ +
+
+ ), +} + // --------------------------------------------------------------------------- // TaskHeader with todos // --------------------------------------------------------------------------- diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat.css b/packages/kilo-vscode/webview-ui/src/styles/chat.css index e05bb9ded1f..a40375411cc 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat.css @@ -19,5 +19,6 @@ @import "./notifications.css"; @import "./tool-overrides.css"; @import "./question-dock.css"; +@import "./suggest-bar.css"; @import "./settings.css"; @import "./high-contrast.css"; diff --git a/packages/kilo-vscode/webview-ui/src/styles/suggest-bar.css b/packages/kilo-vscode/webview-ui/src/styles/suggest-bar.css new file mode 100644 index 00000000000..d624fadf5fa --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/styles/suggest-bar.css @@ -0,0 +1,49 @@ +/* ============================================ + Suggest Bar + ============================================ */ + +/* Strip the card border from tool-part-wrapper when it contains a suggest bar */ +[data-component="tool-part-wrapper"]:has([data-component="suggest-bar"]) { + border: none !important; + border-radius: 0; + overflow: visible; +} + +[data-component="suggest-bar"] { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 0; + padding: 10px 12px; + background: color-mix(in srgb, var(--background-base) 88%, var(--vscode-textLink-foreground) 12%); + + [data-slot="suggest-bar-copy"] { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; + } + + [data-slot="suggest-bar-icon"] { + display: inline-flex; + align-items: center; + color: var(--text-info, var(--vscode-textLink-foreground)); + flex-shrink: 0; + } + + [data-slot="suggest-bar-text"] { + min-width: 0; + color: var(--text-base); + font-size: 12px; + line-height: 1.4; + } + + [data-slot="suggest-bar-actions"] { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + } +} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 7a2254bfa5f..51d142bc302 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -218,6 +218,25 @@ export interface QuestionRequest { id: string sessionID: string questions: QuestionInfo[] + blocking?: boolean + tool?: { + messageID: string + callID: string + } +} + +export interface SuggestionAction { + label: string + description?: string + prompt: string +} + +export interface SuggestionRequest { + id: string + sessionID: string + text: string + actions: SuggestionAction[] + blocking?: boolean tool?: { messageID: string callID: string @@ -766,6 +785,21 @@ export interface QuestionErrorMessage { requestID: string } +export interface SuggestionRequestMessage { + type: "suggestionRequest" + suggestion: SuggestionRequest +} + +export interface SuggestionResolvedMessage { + type: "suggestionResolved" + requestID: string +} + +export interface SuggestionErrorMessage { + type: "suggestionError" + requestID: string +} + export interface BrowserSettings { enabled: boolean useSystemChrome: boolean @@ -1496,6 +1530,9 @@ export type ExtensionMessage = | QuestionRequestMessage | QuestionResolvedMessage | QuestionErrorMessage + | SuggestionRequestMessage + | SuggestionResolvedMessage + | SuggestionErrorMessage | BrowserSettingsLoadedMessage | ClaudeCompatSettingLoadedMessage | ConfigLoadedMessage @@ -1807,6 +1844,19 @@ export interface QuestionRejectRequest { sessionID?: string } +export interface SuggestionAcceptRequest { + type: "suggestionAccept" + requestID: string + sessionID: string + index: number +} + +export interface SuggestionDismissRequest { + type: "suggestionDismiss" + requestID: string + sessionID: string +} + export interface DeleteSessionRequest { type: "deleteSession" sessionID: string @@ -2443,6 +2493,8 @@ export type WebviewMessage = | SetLanguageRequest | QuestionReplyRequest | QuestionRejectRequest + | SuggestionAcceptRequest + | SuggestionDismissRequest | DeleteSessionRequest | RenameSessionRequest | RequestAutocompleteSettingsMessage diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index d967031edd5..47d06340219 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -102,6 +102,7 @@ export namespace Agent { "*": "ask", ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), }, + suggest: "deny", // kilocode_change question: "deny", plan_enter: "deny", plan_exit: "deny", @@ -130,6 +131,7 @@ export namespace Agent { defaults, Permission.fromConfig({ question: "allow", + suggest: "allow", // kilocode_change plan_enter: "allow", }), user, diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index c82bcc2b054..be9ab690deb 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -9,6 +9,7 @@ import type { Command, PermissionRequest, QuestionRequest, + SuggestionRequest, // kilocode_change SessionNetworkWait, // kilocode_change LspStatus, McpStatus, @@ -27,6 +28,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 { useToast } from "@tui/ui/toast" // kilocode_change import type { Path } from "@kilocode/sdk" @@ -52,6 +54,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ [sessionID: string]: QuestionRequest[] } // kilocode_change start + suggestion: { + [sessionID: string]: SuggestionRequest[] + } network: { [sessionID: string]: SessionNetworkWait[] } @@ -98,6 +103,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ permission: {}, question: {}, // kilocode_change start + suggestion: {}, network: {}, // kilocode_change end command: [], @@ -143,6 +149,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ delete draft.session_diff[sessionID] delete draft.session_status[sessionID] delete draft.todo[sessionID] + delete draft.permission[sessionID] + delete draft.question[sessionID] + delete draft.suggestion[sessionID] delete draft.network[sessionID] }), ) @@ -256,6 +265,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } + // kilocode_change start + case "suggestion.accepted": + case "suggestion.dismissed": + case "suggestion.shown": { + handleSuggestionEvent(event, store, setStore) + break + } + // kilocode_change end + case "session.network.restored": { const requests = store.network[event.properties.sessionID] if (!requests) break @@ -288,7 +306,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } // kilocode_change end - case "todo.updated": setStore("todo", event.properties.sessionID, event.properties.todos) 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 022ab7ae185..09c286b60ea 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -21,7 +21,7 @@ import { Spinner } from "@tui/component/spinner" import { selectedForeground, useTheme } from "@tui/context/theme" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" -import type { AssistantMessage, Part, Provider, ToolPart, UserMessage, TextPart, ReasoningPart } from "@kilocode/sdk/v2" // kilocode_change // kilocode_change +import type { AssistantMessage, Part, Provider, ToolPart, UserMessage, TextPart, ReasoningPart } from "@kilocode/sdk/v2" // kilocode_change import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" @@ -69,6 +69,8 @@ import { Filesystem } from "@/util/filesystem" import { Global } from "@/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" +import { Suggest } from "@/kilocode/suggestion/tui/render" // kilocode_change +import { SuggestPrompt } from "@/kilocode/suggestion/tui/prompt" // kilocode_change import { NetworkPrompt } from "./network" // kilocode_change import { DialogExportOptions } from "../../ui/dialog-export-options" import * as Model from "../../util/model" @@ -133,18 +135,42 @@ export function Session() { return children().flatMap((x) => sync.data.question[x.id] ?? []) }) // kilocode_change start + const suggestions = createMemo(() => { + if (session()?.parentID) return [] + return children().flatMap((x) => sync.data.suggestion[x.id] ?? []) + }) const network = createMemo(() => { if (session()?.parentID) return [] return children().flatMap((x) => sync.data.network[x.id] ?? []) }) + const blockingQuestions = createMemo(() => questions().filter((q) => q.blocking !== false)) + const nonBlockingQuestions = createMemo(() => questions().filter((q) => q.blocking === false)) + const question = createMemo(() => blockingQuestions()[0] ?? nonBlockingQuestions()[0]) + 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 visible = createMemo( - () => !session()?.parentID && permissions().length === 0 && questions().length === 0 && network().length === 0, + () => + !session()?.parentID && + permissions().length === 0 && + blockingQuestions().length === 0 && + blockingSuggestions().length === 0 && + network().length === 0, ) const networkVisible = createMemo( - () => permissions().length === 0 && questions().length === 0 && network().length > 0, + () => + permissions().length === 0 && + blockingQuestions().length === 0 && + blockingSuggestions().length === 0 && + network().length > 0, + ) + const disabled = createMemo( + () => + permissions().length > 0 || + blockingQuestions().length > 0 || + blockingSuggestions().length > 0 || + network().length > 0, ) - const disabled = createMemo(() => permissions().length > 0 || questions().length > 0 || network().length > 0) - // kilocode_change end const pending = createMemo(() => { return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id @@ -153,6 +179,7 @@ export function Session() { const lastAssistant = createMemo(() => { return messages().findLast((x) => x.role === "assistant") }) + // kilocode_change end // kilocode_change start - ring terminal bell on task completion createEffect( @@ -187,7 +214,7 @@ export function Session() { ) createEffect( on( - () => [route.sessionID, network().length] as const, + () => [route.sessionID, suggestions().length + network().length] as const, // kilocode_change ([id, len], prev) => { if (!prev || prev[0] !== id) return if (len > prev[1] && bellEnabled()) bell() @@ -1231,15 +1258,32 @@ export function Session() { {/* kilocode_change start */} - {/* kilocode_change start */} - 0}> - + + {(request) => ( + prompt?.focused ?? false} + /> + )} + + + {/* kilocode_change end */} + {/* kilocode_change start */} + + {(request) => ( + prompt?.focused ?? false} + /> + )} + - {/* kilocode_change end */} - {/* kilocode_change end */} + {/* kilocode_change end */} {/* kilocode_change start */} @@ -1657,6 +1701,11 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess + {/* kilocode_change start */} + + + + {/* kilocode_change end */} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index 51fdde8fbde..98456003550 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -11,7 +11,11 @@ import { useTextareaKeybindings } from "../../component/textarea-keybindings" import { useDialog } from "../../ui/dialog" // kilocode_change start -export function QuestionPrompt(props: { request: QuestionRequest }) { +export function QuestionPrompt(props: { + request: QuestionRequest + nonBlocking?: boolean + inputFocused?: () => boolean +}) { // kilocode_change end const sdk = useSDK() const { theme } = useTheme() @@ -128,6 +132,10 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { // Skip processing if a dialog (e.g., command palette) is open if (dialog.stack.length > 0) return + // kilocode_change start - avoid intrusive key capture for non-blocking review suggestions + if (props.nonBlocking && props.inputFocused?.()) return + // kilocode_change end + // When editing custom answer textarea if (store.editing && !confirm()) { if (evt.name === "escape") { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/suggest.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/suggest.tsx new file mode 100644 index 00000000000..2e01ca68b7e --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/suggest.tsx @@ -0,0 +1,2 @@ +// kilocode_change - new file +export { SuggestPrompt } from "../../../../../kilocode/suggestion/tui/prompt" diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index ceeced6e951..0a61e9756d4 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -202,4 +202,10 @@ export namespace Command { export async function list() { return runPromise((svc) => svc.list()) } + + // kilocode_change start + export async function get(name: string) { + return runPromise((svc) => svc.get(name)) + } + // kilocode_change end } diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 9e324962bf7..e18271264aa 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -8,6 +8,7 @@ export namespace Identifier { message: "msg", permission: "per", question: "que", + suggestion: "sug", // kilocode_change user: "usr", part: "prt", pty: "pty", diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index de57cefd27f..931ada68db4 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -5,6 +5,7 @@ import { Instance } from "@/project/instance" import { Session } from "@/session" import { SessionPrompt } from "@/session/prompt" import { Question } from "@/question" +import { Suggestion } from "@/kilocode/suggestion" // kilocode_change import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { SessionID } from "@/session/schema" @@ -24,6 +25,11 @@ const PermissionData = z.object({ message: z.string().optional(), }) +const SuggestionData = z.object({ + requestID: z.string(), + index: z.number().int().nonnegative(), +}) + // kilocode_change start — lazy init to avoid circular dependency // (Server → RemoteRoutes → RemoteSender → SessionPrompt at module load time) let _remotePromptInput: ReturnType | undefined @@ -33,7 +39,6 @@ function getRemotePromptInput() { })) } // kilocode_change end - function normalizeModel(model: string | undefined) { if (!model) return undefined return { @@ -114,11 +119,24 @@ export namespace RemoteSender { } } - // Replay pending questions/permissions so a newly-subscribed web client + // Replay pending suggestions/questions/permissions so a newly-subscribed web client // sees state that was asked before it connected — analogous to the Cloud // Agent's `connected` event carrying pending question/permission fields. async function replay(sessionId: string) { - const [questions, permissions] = await Promise.all([Question.list(), Permission.list()]) + const [suggestions, questions, permissions] = await Promise.all([ + Suggestion.list(), + Question.list(), + Permission.list(), + ]) + for (const suggestion of suggestions) { + if (suggestion.sessionID !== sessionId) continue + options.conn.send({ + type: "event", + sessionId, + event: "suggestion.shown", + data: suggestion, + }) + } for (const q of questions) { if (q.sessionID !== sessionId) continue options.conn.send({ @@ -289,6 +307,37 @@ export namespace RemoteSender { dispatchQuick(msg, dir, () => Question.reject(QuestionID.make(parsed.data.requestID))) return } + if (msg.command === "suggestion_accept") { + const parsed = SuggestionData.safeParse(msg.data) + if (!parsed.success) { + options.conn.send({ + type: "response", + id: msg.id, + error: "invalid suggestion_accept data: " + parsed.error.message, + }) + return + } + const dir = msg.sessionId ? directoryFor(msg.sessionId) : Promise.resolve(options.directory) + dispatchQuick(msg, dir, async () => { + const ok = await Suggestion.accept(parsed.data) + if (!ok) throw new Error("suggestion not found or invalid action index") + }) + return + } + if (msg.command === "suggestion_dismiss") { + const parsed = z.object({ requestID: z.string() }).safeParse(msg.data) + if (!parsed.success) { + options.conn.send({ + type: "response", + id: msg.id, + error: "invalid suggestion_dismiss data: " + parsed.error.message, + }) + return + } + const dir = msg.sessionId ? directoryFor(msg.sessionId) : Promise.resolve(options.directory) + dispatchQuick(msg, dir, () => Suggestion.dismiss(parsed.data.requestID)) + return + } if (msg.command === "permission_respond") { const parsed = PermissionData.safeParse(msg.data) if (!parsed.success) { diff --git a/packages/opencode/src/kilocode/agent/index.ts b/packages/opencode/src/kilocode/agent/index.ts index 700c6c2c41f..39a1df8009f 100644 --- a/packages/opencode/src/kilocode/agent/index.ts +++ b/packages/opencode/src/kilocode/agent/index.ts @@ -231,6 +231,7 @@ export function patchAgents( defaults, Permission.fromConfig({ question: "allow", + suggest: "allow", // kilocode_change plan_exit: "allow", bash: readOnlyBash, ...kilo.mcpRules, @@ -289,6 +290,7 @@ export function patchAgents( defaults, Permission.fromConfig({ question: "allow", + suggest: "allow", // kilocode_change plan_enter: "allow", }), user, @@ -312,6 +314,7 @@ export function patchAgents( glob: "allow", list: "allow", question: "allow", + suggest: "allow", // kilocode_change task: "allow", todoread: "allow", todowrite: "allow", diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 8171a4716b7..49b239eefc2 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -115,6 +115,7 @@ export async function generateHandover(input: { export namespace PlanFollowup { const log = Log.create({ service: "plan.followup" }) + export const PLAN_PREFIX = "Implement the following plan:" export const ANSWER_NEW_SESSION = "Start new session" export const ANSWER_CONTINUE = "Continue here" diff --git a/packages/opencode/src/kilocode/review/review.ts b/packages/opencode/src/kilocode/review/review.ts index 3e1aad0e144..5d6c8e90eeb 100644 --- a/packages/opencode/src/kilocode/review/review.ts +++ b/packages/opencode/src/kilocode/review/review.ts @@ -351,27 +351,51 @@ export namespace Review { } /** - * Get uncommitted changes (staged + unstaged) + * Get uncommitted changes (staged + unstaged + untracked) * Implements SCOPE-01 * - * Uses: git diff HEAD to capture both staged and unstaged changes + * Uses: git diff HEAD for tracked changes, plus git ls-files for untracked files */ export async function getUncommittedChanges(): Promise { log.info("getting uncommitted changes") - // git diff HEAD shows all uncommitted changes (staged + unstaged) + // git diff HEAD shows all uncommitted changes (staged + unstaged) for tracked files // Using -c core.quotepath=false to handle unicode filenames const result = await $`git -c core.quotepath=false diff HEAD`.cwd(Instance.directory).quiet().nothrow() + let raw = result.exitCode === 0 ? result.stdout.toString() : "" + if (result.exitCode !== 0) { log.warn("git diff failed", { exitCode: result.exitCode, stderr: result.stderr.toString(), }) - return { files: [], raw: "" } } - const raw = result.stdout.toString() + // Also include untracked files — git diff HEAD misses brand-new files + const untracked = await $`git ls-files --others --exclude-standard -z`.cwd(Instance.directory).quiet().nothrow() + if (untracked.exitCode === 0) { + const paths = untracked.stdout.toString().split("\0").filter(Boolean) + // Process in batches to avoid spawning hundreds of git processes + const batch = 20 + for (let i = 0; i < paths.length; i += batch) { + const chunk = paths.slice(i, i + batch) + const diffs = await Promise.all( + chunk.map((p) => + // --no-index exits 1 when files differ, which is expected + $`git -c core.quotepath=false diff --no-index -- /dev/null ${p}` + .cwd(Instance.directory) + .quiet() + .nothrow() + .then((fd) => fd.stdout.toString()), + ), + ) + for (const out of diffs) { + if (out) raw += out + } + } + } + const parsed = parseDiff(raw) log.info("parsed uncommitted changes", { diff --git a/packages/opencode/src/kilocode/server/instance.ts b/packages/opencode/src/kilocode/server/instance.ts index 63062f94c55..0f7adc5f49c 100644 --- a/packages/opencode/src/kilocode/server/instance.ts +++ b/packages/opencode/src/kilocode/server/instance.ts @@ -12,6 +12,7 @@ import { KilocodeRoutes } from "../../server/routes/kilocode" import { PermissionKilocodeRoutes } from "../permission/routes" import { RemoteRoutes } from "../../server/routes/remote" import { NetworkRoutes } from "../../server/routes/network" +import { SuggestionRoutes } from "../suggestion/routes" import { createKiloRoutes } from "@kilocode/kilo-gateway" import { Auth } from "../../auth" import { errors } from "../../server/error" @@ -27,6 +28,7 @@ export function register(app: Hono): Hono { return app .route("/permission", PermissionKilocodeRoutes()) .route("/network", NetworkRoutes()) + .route("/suggestion", SuggestionRoutes()) .route("/telemetry", TelemetryRoutes()) .route("/remote", RemoteRoutes()) .route("/commit-message", CommitMessageRoutes()) diff --git a/packages/opencode/src/kilocode/soul.txt b/packages/opencode/src/kilocode/soul.txt index 70d52164ec1..39443253ef7 100644 --- a/packages/opencode/src/kilocode/soul.txt +++ b/packages/opencode/src/kilocode/soul.txt @@ -12,3 +12,16 @@ You are Kilo, a highly skilled software engineer with extensive knowledge in man # Code - When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices. + +## Suggestions + +- Use the `question` tool only when you need an actual answer from the user. +- If the `suggest` tool is available, use it ONLY to offer a local code review — never for other actions like committing, pushing, running tests, or any other next step. +- When you have completed implementation work and you are at least 90% confident the task is done, use `suggest` to offer a code review of uncommitted changes. +- Only suggest review when the user's request appears fully addressed. Do not suggest it after every edit or partial implementation turn. +- Do not repeat a review suggestion that was already dismissed in this conversation. +- Keep suggestion text concise, use at most 1-2 actions, and make each accepted action prompt self-contained. +- When suggesting a code review, choose the right command for the action prompt: + - `/local-review-uncommitted` — for reviewing uncommitted working-tree changes (staged, unstaged, and untracked files). + - `/local-review` — for reviewing all committed changes on the current branch vs its base branch. + - Prefer `/local-review-uncommitted` when the work you just did has not been committed yet. diff --git a/packages/opencode/src/kilocode/suggestion/index.ts b/packages/opencode/src/kilocode/suggestion/index.ts new file mode 100644 index 00000000000..5f03bb677f3 --- /dev/null +++ b/packages/opencode/src/kilocode/suggestion/index.ts @@ -0,0 +1,194 @@ +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 false + } + + const action = existing.info.actions[input.index] + if (!action) { + log.warn("accept for invalid action index", { requestID: input.requestID, index: input.index }) + delete s.pending[input.requestID] + existing.reject(new Error(`Invalid action index: ${input.index}`)) + return false + } + + 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) + return true + } + + 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 dismissAll(sessionID: string): Promise { + const s = await state() + for (const [id, entry] of Object.entries(s.pending)) { + if (entry.info.sessionID !== sessionID) continue + delete s.pending[id] + log.info("dismissed", { requestID: id }) + Bus.publish(Event.Dismissed, { + sessionID: entry.info.sessionID, + requestID: entry.info.id, + }) + entry.reject(new DismissedError()) + } + } + + 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..c47af53b81c --- /dev/null +++ b/packages/opencode/src/kilocode/suggestion/routes.ts @@ -0,0 +1,99 @@ +import { errors } from "../../server/error" +import { NotFoundError } from "../../storage/db" +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") + const ok = await Suggestion.accept({ + requestID: params.requestID, + index: json.index, + }) + if (!ok) throw new NotFoundError({ message: `Suggestion not found: ${params.requestID}` }) + 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..9f77227eba5 --- /dev/null +++ b/packages/opencode/src/kilocode/suggestion/tool.ts @@ -0,0 +1,108 @@ +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 + truncated: 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 args = prompt.slice(1 + name.length).trim() + + 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 args ? `${template}\n\n${args}` : 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.tool?.callID === ctx.callID) + 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, + truncated: false, + } + return { + title: "Suggestion dismissed", + output: "User dismissed the suggestion.", + metadata, + } + } + + const resolved = await resolve(action.prompt) + + const metadata: Meta = { + accepted: action, + dismissed: false, + truncated: 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/kilocode/suggestion/tool.txt b/packages/opencode/src/kilocode/suggestion/tool.txt new file mode 100644 index 00000000000..fc86ed21b17 --- /dev/null +++ b/packages/opencode/src/kilocode/suggestion/tool.txt @@ -0,0 +1,17 @@ +Use this tool to suggest a local code review to the user after completing implementation work. + +This tool is ONLY for suggesting code review. Do NOT use it to suggest running tests, committing, pushing, or any other action. + +Guidelines: +- Only suggest review when you are at least 90% confident the user's request is fully addressed +- Do not suggest review after every edit or partial implementation turn +- Do not repeat a review suggestion that was already dismissed in this conversation +- Keep the suggestion text concise and actionable +- Provide 1-2 actions maximum +- Make each action prompt self-contained so it can be injected as a synthetic user message +- If you need a real answer from the user, use the `question` tool instead + +Choosing the right review command for the action prompt: +- Use `/local-review-uncommitted` as the action prompt for uncommitted working-tree changes (staged, unstaged, and untracked files) +- Use `/local-review` as the action prompt for committed branch-level changes +- Prefer `/local-review-uncommitted` when the work you just did has not been committed yet \ No newline at end of file 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/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index f868f581296..c6d38c324e7 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -26,6 +26,11 @@ export namespace KiloToolRegistry { return true } + /** Suggest tool is only registered for cli and vscode clients */ + export function suggest(tool: Tool.Def): Tool.Def[] { + return ["cli", "vscode"].includes(Flag.KILO_CLIENT) ? [tool] : [] + } + /** Kilo-specific tools to append to the builtin list */ export function extra( tools: { codebase: Tool.Def; recall: Tool.Def }, diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 615c699ce91..e425a27867b 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -37,6 +37,7 @@ export namespace Question { id: QuestionID.zod, sessionID: SessionID.zod, questions: z.array(Info).describe("Questions to ask"), + blocking: z.boolean().optional().describe("Whether this question blocks prompt input (default: true)"), // kilocode_change tool: z .object({ messageID: MessageID.zod, @@ -97,6 +98,7 @@ export namespace Question { readonly ask: (input: { sessionID: SessionID questions: Info[] + blocking?: boolean // kilocode_change tool?: { messageID: MessageID; callID: string } }) => Effect.Effect readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect @@ -132,6 +134,7 @@ export namespace Question { const ask = Effect.fn("Question.ask")(function* (input: { sessionID: SessionID questions: Info[] + blocking?: boolean // kilocode_change tool?: { messageID: MessageID; callID: string } }) { const pending = (yield* InstanceState.get(state)).pending @@ -143,6 +146,7 @@ export namespace Question { id, sessionID: input.sessionID, questions: input.questions, + blocking: input.blocking, // kilocode_change tool: input.tool, } pending.set(id, { info, deferred }) @@ -205,6 +209,7 @@ export namespace Question { export async function ask(input: { sessionID: SessionID questions: Info[] + blocking?: boolean // kilocode_change tool?: { messageID: MessageID; callID: string } }): Promise { return runPromise((s) => s.ask(input)) diff --git a/packages/opencode/src/server/routes/suggestion.ts b/packages/opencode/src/server/routes/suggestion.ts new file mode 100644 index 00000000000..e3f4d44e464 --- /dev/null +++ b/packages/opencode/src/server/routes/suggestion.ts @@ -0,0 +1,2 @@ +// kilocode_change - new file +export { SuggestionRoutes } from "../../kilocode/suggestion/routes" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 72c4fcd6d37..4c4db59adc7 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -19,6 +19,7 @@ import { SessionSummary } from "./summary" import type { Provider } from "@/provider/provider" import { Question } from "@/question" import { KiloSessionProcessor } from "@/kilocode/session/processor" // kilocode_change +import { Suggestion } from "@/kilocode/suggestion" // kilocode_change import { errorMessage } from "@/util/error" import { isRecord } from "@/util/record" @@ -208,7 +209,11 @@ export namespace SessionProcessor { }, }) // kilocode_change start - if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) { + if ( + error instanceof Permission.RejectedError || + error instanceof Question.RejectedError || + error instanceof Suggestion.DismissedError + ) { // kilocode_change end ctx.blocked = ctx.shouldBreak } @@ -357,6 +362,11 @@ export namespace SessionProcessor { case "tool-result": { yield* completeToolCall(value.toolCallId, value.output) + // kilocode_change start + if (value.output.metadata?.dismissed === true) { + ctx.blocked = ctx.shouldBreak + } + // kilocode_change end return } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7faa83bd66b..19c7c816d96 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -3,6 +3,7 @@ import os from "os" import fs from "fs/promises" import { KiloSessionPrompt } from "@/kilocode/session/prompt" // kilocode_change import { KiloSession } from "@/kilocode/session" // kilocode_change +import { Suggestion } from "@/kilocode/suggestion" // kilocode_change import z from "zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" @@ -1287,6 +1288,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the } if (input.noReply === true) return message + // kilocode_change start — dismiss pending suggestions and cancel the session + // before starting a new loop to avoid the runner ignoring the new work + yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID)) + yield* state.cancel(input.sessionID) + // kilocode_change end return yield* loop({ sessionID: input.sessionID }) }, ) diff --git a/packages/opencode/src/suggestion/index.ts b/packages/opencode/src/suggestion/index.ts new file mode 100644 index 00000000000..04c2bcbf868 --- /dev/null +++ b/packages/opencode/src/suggestion/index.ts @@ -0,0 +1,2 @@ +// kilocode_change - new file +export { Suggestion } from "../kilocode/suggestion/index" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 13f45255f48..115f3a21848 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,5 +1,6 @@ import { PlanExitTool } from "./plan" import { QuestionTool } from "./question" +import { SuggestTool } from "../kilocode/suggestion/tool" // kilocode_change import { BashTool } from "./bash" import { EditTool } from "./edit" import { GlobTool } from "./glob" @@ -161,6 +162,7 @@ export namespace ToolRegistry { question: Tool.init(question), lsp: Tool.init(LspTool), plan: Tool.init(PlanExitTool), + suggest: Tool.init(SuggestTool), // kilocode_change }) const kilo = yield* KiloToolRegistry.build() // kilocode_change @@ -185,6 +187,7 @@ export namespace ToolRegistry { tool.patch, ...(Flag.KILO_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), // kilocode_change ...(KiloToolRegistry.plan() ? [tool.plan] : []), // kilocode_change + ...KiloToolRegistry.suggest(tool.suggest), // kilocode_change ...KiloToolRegistry.extra(kilo, cfg), // kilocode_change ], task: tool.task, diff --git a/packages/opencode/src/tool/suggest.ts b/packages/opencode/src/tool/suggest.ts new file mode 100644 index 00000000000..76debcc98ca --- /dev/null +++ b/packages/opencode/src/tool/suggest.ts @@ -0,0 +1,2 @@ +// kilocode_change - new file +export { SuggestTool } from "../kilocode/suggestion/tool" diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index 798f694cf1a..85ebd29537f 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -6,6 +6,7 @@ import type { RemoteProtocol } from "../../../src/kilo-sessions/remote-protocol" import { SessionPrompt } from "../../../src/session/prompt" import { Question } from "../../../src/question" import { Permission } from "../../../src/permission" +import { Suggestion } from "../../../src/kilocode/suggestion" // kilocode_change function fakeConn() { const sent: any[] = [] @@ -490,6 +491,51 @@ describe("RemoteSender", () => { expect(sent[0].error).toContain("boom") }) + test("suggestion_accept sends response after work completes", async () => { + const { conn, sent } = fakeConn() + const accept = spyOn(Suggestion, "accept").mockResolvedValue(true) + const sender = RemoteSender.create({ + conn, + directory: "/tmp/test", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; init?: () => Promise; fn: () => R }) => input.fn(), + }) + + sender.handle({ + type: "command", + id: "req_suggestion_accept", + command: "suggestion_accept", + data: { requestID: "sug_1", index: 1 }, + }) + + await new Promise((r) => setTimeout(r, 10)) + + expect(accept).toHaveBeenCalledWith({ requestID: "sug_1", index: 1 }) + expect(sent).toContainEqual({ type: "response", id: "req_suggestion_accept", result: {} }) + }) + + test("suggestion_dismiss with invalid data sends error response", () => { + const { conn, sent } = fakeConn() + const sender = RemoteSender.create({ + conn, + directory: "/tmp/test", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async () => ({}) as any, + }) + + sender.handle({ + type: "command", + id: "req_suggestion_dismiss_bad", + command: "suggestion_dismiss", + data: { nope: true }, + }) + + expect(sent).toHaveLength(1) + expect(sent[0].error).toContain("invalid suggestion_dismiss data") + }) + test("question_reject sends response after work completes", async () => { const { conn, sent } = fakeConn() let provideCalled = false @@ -813,6 +859,7 @@ describe("RemoteSender", () => { const { conn, sent } = fakeConn() const bus = fakeBus() + spyOn(Suggestion, "list").mockResolvedValue([]) spyOn(Question, "list").mockResolvedValue([ { id: "question_1", sessionID: "ses_target", questions: [{ type: "text", text: "Continue?" }] } as any, { id: "question_2", sessionID: "ses_other", questions: [{ type: "text", text: "Unrelated?" }] } as any, @@ -844,6 +891,7 @@ describe("RemoteSender", () => { const { conn, sent } = fakeConn() const bus = fakeBus() + spyOn(Suggestion, "list").mockResolvedValue([]) spyOn(Question, "list").mockResolvedValue([]) spyOn(Permission, "list").mockResolvedValue([ { @@ -896,6 +944,9 @@ describe("RemoteSender", () => { const { conn, sent } = fakeConn() const bus = fakeBus() + spyOn(Suggestion, "list").mockResolvedValue([ + { id: "sug_1", sessionID: "ses_other", text: "Review?", actions: [] } as any, + ]) spyOn(Question, "list").mockResolvedValue([{ id: "question_1", sessionID: "ses_other", questions: [] } as any]) spyOn(Permission, "list").mockResolvedValue([ { @@ -923,6 +974,53 @@ describe("RemoteSender", () => { expect(events).toHaveLength(0) }) + test("subscribe replays pending suggestion for the subscribed session", async () => { + const { conn, sent } = fakeConn() + const bus = fakeBus() + + spyOn(Suggestion, "list").mockResolvedValue([ + { + id: "sug_1", + sessionID: "ses_target", + text: "Review?", + actions: [{ label: "Start", prompt: "/local-review-uncommitted" }], + } as any, + { + id: "sug_2", + sessionID: "ses_other", + text: "Ignore", + actions: [{ label: "Skip", prompt: "skip" }], + } as any, + ]) + spyOn(Question, "list").mockResolvedValue([]) + spyOn(Permission, "list").mockResolvedValue([]) + + const sender = RemoteSender.create({ + conn, + directory: "/tmp/test", + log: nolog, + subscribe: bus.subscribe, + provide: async (input: any) => input.fn(), + }) + + sender.handle({ type: "subscribe", sessionId: "ses_target" }) + await new Promise((r) => setTimeout(r, 10)) + + const suggestionEvents = sent.filter((m: any) => m.event === "suggestion.shown") + expect(suggestionEvents).toHaveLength(1) + expect(suggestionEvents[0]).toEqual({ + type: "event", + sessionId: "ses_target", + event: "suggestion.shown", + data: { + id: "sug_1", + sessionID: "ses_target", + text: "Review?", + actions: [{ label: "Start", prompt: "/local-review-uncommitted" }], + }, + }) + }) + test("system message is handled without error", () => { const { conn, sent } = fakeConn() const sender = RemoteSender.create({ 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/question/question.test.ts b/packages/opencode/test/question/question.test.ts index adfeda395a9..712db3f6cee 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -72,6 +72,34 @@ test("ask - adds to pending list", async () => { }) }) +// kilocode_change start - review follow-up uses non-blocking question prompts +test("ask - preserves blocking flag", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const askPromise = Question.ask({ + sessionID: SessionID.make("ses_test"), + blocking: false, + questions: [ + { + question: "Proceed with review suggestion?", + header: "Code review", + options: [{ label: "Start", description: "Run review" }], + }, + ], + }) + + const pending = await Question.list() + expect(pending[0]?.blocking).toBe(false) + + await Question.reject(pending[0].id) + await expect(askPromise).rejects.toBeInstanceOf(Question.RejectedError) + }, + }) +}) +// kilocode_change end + // reply tests test("reply - resolves the pending ask with answers", async () => { diff --git a/packages/opencode/test/suggestion/suggestion.test.ts b/packages/opencode/test/suggestion/suggestion.test.ts new file mode 100644 index 00000000000..10b911a24ff --- /dev/null +++ b/packages/opencode/test/suggestion/suggestion.test.ts @@ -0,0 +1,2 @@ +// kilocode_change - new file +// Moved to test/kilocode/suggestion/suggestion.test.ts. diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 6cd8ad75211..2c6fca70aae 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -32,6 +32,37 @@ describe("tool.registry", () => { }) // kilocode_change end + // kilocode_change start + test("suggest is registered for cli and vscode only", async () => { + const original = process.env["KILO_CLIENT"] + const originalQuestion = process.env["KILO_ENABLE_QUESTION_TOOL"] + const originalConfig = process.env["KILO_CONFIG_DIR"] + try { + for (const client of ["cli", "vscode", "desktop", "app"]) { + process.env["KILO_CLIENT"] = client + process.env["KILO_ENABLE_QUESTION_TOOL"] = client === "vscode" ? "true" : "false" + await using tmp = await tmpdir({ git: true }) + process.env["KILO_CONFIG_DIR"] = tmp.path + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ids = await ToolRegistry.ids() + if (client === "cli" || client === "vscode") expect(ids).toContain("suggest") + else expect(ids).not.toContain("suggest") + }, + }) + } + } finally { + if (original === undefined) delete process.env["KILO_CLIENT"] + else process.env["KILO_CLIENT"] = original + if (originalQuestion === undefined) delete process.env["KILO_ENABLE_QUESTION_TOOL"] + else process.env["KILO_ENABLE_QUESTION_TOOL"] = originalQuestion + if (originalConfig === undefined) delete process.env["KILO_CONFIG_DIR"] + else process.env["KILO_CONFIG_DIR"] = originalConfig + } + }) + // kilocode_change end + test("loads tools from .opencode/tool (singular)", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/tool/suggest.test.ts b/packages/opencode/test/tool/suggest.test.ts new file mode 100644 index 00000000000..448fbb3e940 --- /dev/null +++ b/packages/opencode/test/tool/suggest.test.ts @@ -0,0 +1,2 @@ +// kilocode_change - new file +// Moved to test/kilocode/suggestion/tool.test.ts. diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f024b56d07d..0da83ed7a12 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -203,6 +203,11 @@ import type { SessionUpdateResponses, SessionViewedResponses, SubtaskPartInput, + SuggestionAcceptErrors, + SuggestionAcceptResponses, + SuggestionDismissErrors, + SuggestionDismissResponses, + SuggestionListResponses, TelemetryCaptureErrors, TelemetryCaptureResponses, TextPartInput, @@ -4516,6 +4521,109 @@ export class Network extends HeyApiClient { } } +export class Suggestion extends HeyApiClient { + /** + * List pending suggestions + * + * Get all pending suggestion requests across all sessions. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/suggestion", + ...options, + ...params, + }) + } + + /** + * Accept suggestion request + * + * Accept a suggestion request from the AI assistant. + */ + public accept( + parameters: { + requestID: string + directory?: string + workspace?: string + index?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "index" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/suggestion/{requestID}/accept", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Dismiss suggestion request + * + * Dismiss a suggestion request from the AI assistant. + */ + public dismiss( + parameters: { + requestID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/suggestion/{requestID}/dismiss", + ...options, + ...params, + }) + } +} + export class Telemetry extends HeyApiClient { /** * Capture telemetry event @@ -5721,6 +5829,11 @@ export class KiloClient extends HeyApiClient { return (this._network ??= new Network({ client: this.client })) } + private _suggestion?: Suggestion + get suggestion(): Suggestion { + return (this._suggestion ??= new Suggestion({ client: this.client })) + } + private _telemetry?: Telemetry get telemetry(): Telemetry { return (this._telemetry ??= new Telemetry({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fd35d28c50d..a055d14ab74 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -418,6 +418,10 @@ export type QuestionRequest = { * Questions to ask */ questions: Array + /** + * Whether this question blocks prompt input (default: true) + */ + blocking?: boolean tool?: { messageID: string callID: string @@ -505,6 +509,65 @@ export type EventSessionIdle = { } } +export type SuggestionAction = { + /** + * Button or option label (1-5 words) + */ + label: string + /** + * Brief explanation of what this action does + */ + description?: string + /** + * Synthetic user prompt to inject when this action is accepted + */ + prompt: string +} + +export type SuggestionRequest = { + id: string + sessionID: string + /** + * Suggestion text shown to the user + */ + text: string + /** + * Available actions the user can take + */ + actions: Array + /** + * Whether this suggestion blocks prompt input (default: true) + */ + blocking?: boolean + tool?: { + messageID: string + callID: string + } +} + +export type EventSuggestionShown = { + type: "suggestion.shown" + properties: SuggestionRequest +} + +export type EventSuggestionAccepted = { + type: "suggestion.accepted" + properties: { + sessionID: string + requestID: string + index: number + action: SuggestionAction + } +} + +export type EventSuggestionDismissed = { + type: "suggestion.dismissed" + properties: { + sessionID: string + requestID: string + } +} + export type EventSessionCompacted = { type: "session.compacted" properties: { @@ -1084,6 +1147,9 @@ export type Event = | EventTodoUpdated | EventSessionStatus | EventSessionIdle + | EventSuggestionShown + | EventSuggestionAccepted + | EventSuggestionDismissed | EventSessionCompacted | EventKiloSessionsRemoteStatusChanged | EventWorkspaceReady @@ -5691,6 +5757,98 @@ export type NetworkRejectResponses = { export type NetworkRejectResponse = NetworkRejectResponses[keyof NetworkRejectResponses] +export type SuggestionListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/suggestion" +} + +export type SuggestionListResponses = { + /** + * List of pending suggestions + */ + 200: Array +} + +export type SuggestionListResponse = SuggestionListResponses[keyof SuggestionListResponses] + +export type SuggestionAcceptData = { + body?: { + /** + * Zero-based action index to accept + */ + index: number + } + path: { + requestID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/suggestion/{requestID}/accept" +} + +export type SuggestionAcceptErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SuggestionAcceptError = SuggestionAcceptErrors[keyof SuggestionAcceptErrors] + +export type SuggestionAcceptResponses = { + /** + * Suggestion accepted successfully + */ + 200: boolean +} + +export type SuggestionAcceptResponse = SuggestionAcceptResponses[keyof SuggestionAcceptResponses] + +export type SuggestionDismissData = { + body?: never + path: { + requestID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/suggestion/{requestID}/dismiss" +} + +export type SuggestionDismissErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SuggestionDismissError = SuggestionDismissErrors[keyof SuggestionDismissErrors] + +export type SuggestionDismissResponses = { + /** + * Suggestion dismissed successfully + */ + 200: boolean +} + +export type SuggestionDismissResponse = SuggestionDismissResponses[keyof SuggestionDismissResponses] + export type TelemetryCaptureData = { body?: { /** diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 65c2bf4f9f7..d94e7d72b2d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5247,6 +5247,206 @@ ] } }, + "/suggestion": { + "get": { + "operationId": "suggestion.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "List pending suggestions", + "description": "Get all pending suggestion requests across all sessions.", + "responses": { + "200": { + "description": "List of pending suggestions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SuggestionRequest" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.suggestion.list({\n ...\n})" + } + ] + } + }, + "/suggestion/{requestID}/accept": { + "post": { + "operationId": "suggestion.accept", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "requestID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Accept suggestion request", + "description": "Accept a suggestion request from the AI assistant.", + "responses": { + "200": { + "description": "Suggestion accepted successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "index": { + "description": "Zero-based action index to accept", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["index"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.suggestion.accept({\n ...\n})" + } + ] + } + }, + "/suggestion/{requestID}/dismiss": { + "post": { + "operationId": "suggestion.dismiss", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "requestID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Dismiss suggestion request", + "description": "Dismiss a suggestion request from the AI assistant.", + "responses": { + "200": { + "description": "Suggestion dismissed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.suggestion.dismiss({\n ...\n})" + } + ] + } + }, "/provider": { "get": { "operationId": "provider.list", @@ -10978,6 +11178,7 @@ }, "required": ["name", "data"] }, + "Event.session.error": { "type": "object", "properties": { @@ -13083,6 +13284,18 @@ { "$ref": "#/components/schemas/Event.lsp.updated" }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.suggestion.shown" + }, + { + "$ref": "#/components/schemas/Event.suggestion.accepted" + }, + { + "$ref": "#/components/schemas/Event.suggestion.dismissed" + }, { "$ref": "#/components/schemas/Event.tui.prompt.append" }, From 9ce47254d35ed401289beb101a33e7d5e1f2c139 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 16 Apr 2026 11:55:52 +0200 Subject: [PATCH 02/32] fix(vscode): show local review follow-up questions --- .changeset/local-review-question-dock.md | 5 +++++ .../webview-ui/src/components/chat/AssistantMessage.tsx | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .changeset/local-review-question-dock.md diff --git a/.changeset/local-review-question-dock.md b/.changeset/local-review-question-dock.md new file mode 100644 index 00000000000..7882c8d6c27 --- /dev/null +++ b/.changeset/local-review-question-dock.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep local review follow-up questions visible after review output so prompt input is not blocked by an invisible pending question. diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 02035c8cd1e..93ea4b965c3 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -91,7 +91,6 @@ export const AssistantMessage: Component = (props) => { if (part.type !== "tool") return undefined const tp = part as unknown as ToolPart if (tp.tool !== "question") return undefined - if (tp.state?.status !== "pending" && tp.state?.status !== "running") return undefined return session.questions().find((q) => q.tool?.callID === tp.callID && q.tool?.messageID === tp.messageID) }) From 5948459e08f23ade842955393cc288ec0b0efb85 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 16 Apr 2026 14:04:38 +0300 Subject: [PATCH 03/32] feat(vscode): decouple question tool from input prompt Allow users to type and send messages while a question is pending, matching the existing suggestion decoupling pattern. Pending questions are auto-rejected when the user sends a new prompt. --- .../kilo-vscode/webview-ui/src/components/chat/ChatView.tsx | 5 ++++- .../webview-ui/src/components/chat/PromptInput.tsx | 4 +++- packages/kilo-vscode/webview-ui/src/context/session.tsx | 6 ++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 1f769cae8f7..704f6dc98f4 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -62,9 +62,11 @@ export const ChatView: Component = (props) => { const standaloneQuestions = createMemo(() => familyQuestions().filter((q) => !q.tool)) const standaloneSuggestions = createMemo(() => familySuggestions().filter((s) => !s.tool)) const permissionRequest = () => familyPermissions().find((p) => p.sessionID === id()) ?? familyPermissions()[0] - const blocked = () => familyPermissions().length > 0 || familyQuestions().some((q) => q.blocking !== false) + const blocked = () => familyPermissions().length > 0 // Session is busy only because a suggestion tool call is pending — prompt should behave as idle const suggesting = () => !blocked() && familySuggestions().length > 0 + // Session is busy only because a question tool call is pending — prompt should behave as idle + const questioning = () => !blocked() && familyQuestions().length > 0 const dock = () => !props.readonly || !!permissionRequest() // When a bottom-dock permission disappears while the session is busy, @@ -222,6 +224,7 @@ export const ChatView: Component = (props) => { diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 8fe805e6547..6ef4425bb97 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -52,6 +52,8 @@ interface PromptInputProps { blocked?: () => boolean /** When true, session is busy only because a suggestion is pending — treat as idle for input */ suggesting?: () => boolean + /** When true, session is busy only because a question is pending — treat as idle for input */ + questioning?: () => boolean boxId?: string pendingSessionID?: string } @@ -270,7 +272,7 @@ export const PromptInput: Component = (props) => { window.addEventListener("compactSession", onCompact) onCleanup(() => window.removeEventListener("compactSession", onCompact)) - const isBusy = () => session.status() !== "idle" && !props.suggesting?.() + const isBusy = () => session.status() !== "idle" && !props.suggesting?.() && !props.questioning?.() const isDisabled = () => !server.isConnected() const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0 const canSend = () => hasInput() && !isDisabled() && !terminal.pending() && !props.blocked?.() diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index ea21f0730a4..3004570ef39 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -1504,6 +1504,9 @@ export const SessionProvider: ParentComponent = (props) => { const sid = currentSessionID() const suggestion = scopedSuggestions(sid)[0] if (suggestion) dismissSuggestion(suggestion.id) + for (const q of scopedQuestions(sid)) { + rejectQuestion(q.id) + } if (sid) addOptimistic(sid, messageID, text, files) const agent = selectedAgentName() !== defaultAgent() ? selectedAgentName() : undefined @@ -1559,6 +1562,9 @@ export const SessionProvider: ParentComponent = (props) => { const sid = currentSessionID() const suggestion = scopedSuggestions(sid)[0] if (suggestion) dismissSuggestion(suggestion.id) + for (const q of scopedQuestions(sid)) { + rejectQuestion(q.id) + } if (sid) addOptimistic(sid, messageID, `/${command} ${args}`.trim(), files) From c85d062b95eecff37c803f222455b4bcb87230ce Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 16 Apr 2026 14:53:57 +0300 Subject: [PATCH 04/32] test(vscode,cli): add regression tests for question & suggestion prompt decoupling Extract prompt state logic (isPromptBlocked, isPromptBusy, isSuggesting, isQuestioning) into pure functions in prompt-input-utils.ts and unit test them. Add contract tests verifying sendMessage/sendCommand dismiss suggestions and reject questions. Add CLI contract test protecting prompt.ts Suggestion.dismissAll from upstream merges. Add dismissAll unit tests to the suggestion test suite. --- .../tests/unit/prompt-input-utils.test.ts | 79 +++++++++++++++++++ .../tests/unit/prompt-send-contract.test.ts | 70 ++++++++++++++++ .../src/components/chat/ChatView.tsx | 7 +- .../src/components/chat/PromptInput.tsx | 4 +- .../src/components/chat/prompt-input-utils.ts | 33 ++++++++ .../kilocode/prompt-dismiss-contract.test.ts | 37 +++++++++ .../kilocode/suggestion/suggestion.test.ts | 69 ++++++++++++++++ 7 files changed, 294 insertions(+), 5 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts create mode 100644 packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts diff --git a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts index 54b0e5bf6ee..7e59c30fe46 100644 --- a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts @@ -4,6 +4,10 @@ import { dirName, buildHighlightSegments, atEnd, + isPromptBlocked, + isPromptBusy, + isSuggesting, + isQuestioning, } from "../../webview-ui/src/components/chat/prompt-input-utils" describe("fileName", () => { @@ -145,3 +149,78 @@ describe("atEnd", () => { expect(atEnd(0, 0, 10)).toBe(false) }) }) + +describe("isPromptBlocked", () => { + it("returns false when zero permissions", () => { + expect(isPromptBlocked(0)).toBe(false) + }) + + it("returns true when permissions exist", () => { + expect(isPromptBlocked(1)).toBe(true) + expect(isPromptBlocked(3)).toBe(true) + }) + + it("takes only permission count — no question/suggestion leakage", () => { + // The function signature accepts a single number; verify that zero means unblocked + expect(isPromptBlocked(0)).toBe(false) + }) +}) + +describe("isPromptBusy", () => { + it("returns true when busy and neither suggesting nor questioning", () => { + expect(isPromptBusy("busy", false, false)).toBe(true) + }) + + it("returns false when idle regardless of suggesting/questioning", () => { + expect(isPromptBusy("idle", false, false)).toBe(false) + expect(isPromptBusy("idle", true, false)).toBe(false) + expect(isPromptBusy("idle", false, true)).toBe(false) + expect(isPromptBusy("idle", true, true)).toBe(false) + }) + + it("returns false when busy but suggesting is true (suggestion decoupling)", () => { + expect(isPromptBusy("busy", true, false)).toBe(false) + }) + + it("returns false when busy but questioning is true (question decoupling)", () => { + expect(isPromptBusy("busy", false, true)).toBe(false) + }) + + it("returns false when busy and both suggesting and questioning", () => { + expect(isPromptBusy("busy", true, true)).toBe(false) + }) + + it("returns true for non-idle non-busy status when not suggesting/questioning", () => { + expect(isPromptBusy("retry", false, false)).toBe(true) + }) +}) + +describe("isSuggesting", () => { + it("returns true when not blocked and suggestions > 0", () => { + expect(isSuggesting(false, 1)).toBe(true) + expect(isSuggesting(false, 3)).toBe(true) + }) + + it("returns false when blocked even with suggestions", () => { + expect(isSuggesting(true, 2)).toBe(false) + }) + + it("returns false when not blocked but no suggestions", () => { + expect(isSuggesting(false, 0)).toBe(false) + }) +}) + +describe("isQuestioning", () => { + it("returns true when not blocked and questions > 0", () => { + expect(isQuestioning(false, 1)).toBe(true) + expect(isQuestioning(false, 5)).toBe(true) + }) + + it("returns false when blocked even with questions", () => { + expect(isQuestioning(true, 2)).toBe(false) + }) + + it("returns false when not blocked but no questions", () => { + expect(isQuestioning(false, 0)).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts new file mode 100644 index 00000000000..4e92b874cb3 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -0,0 +1,70 @@ +/** + * Source contract tests for prompt send paths. + * + * Static analysis — reads session.tsx source and verifies that sendMessage and + * sendCommand still dismiss suggestions and reject questions before dispatching. + * Protects against accidental removal during Kilo development. + */ + +import { describe, it, expect } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const ROOT = path.resolve(import.meta.dir, "../..") +const SESSION_FILE = path.join(ROOT, "webview-ui/src/context/session.tsx") + +function readFile(filePath: string): string { + return fs.readFileSync(filePath, "utf-8") +} + +/** + * Extract the body of a named function from the source. + * Finds `function (` and returns everything from there to the next + * `function ` declaration at the same or lower indentation, or to end of file. + */ +function extractFunctionBody(source: string, name: string): string { + const marker = `function ${name}(` + const start = source.indexOf(marker) + if (start === -1) return "" + + // Find the next `function ` declaration after the opening one. + // We search for a newline followed by ` function ` (2-space indent, matching + // the indentation level of sendMessage/sendCommand inside SessionProvider). + const rest = source.slice(start + marker.length) + const next = rest.search(/\n function /) + return next === -1 ? rest : rest.slice(0, next) +} + +describe("sendMessage dismisses pending tool requests", () => { + const source = readFile(SESSION_FILE) + const body = extractFunctionBody(source, "sendMessage") + + it("function sendMessage exists in session.tsx", () => { + expect(body.length).toBeGreaterThan(0) + }) + + it("dismisses suggestions before sending", () => { + expect(body).toContain("dismissSuggestion") + }) + + it("rejects questions before sending", () => { + expect(body).toContain("rejectQuestion") + }) +}) + +describe("sendCommand dismisses pending tool requests", () => { + const source = readFile(SESSION_FILE) + const body = extractFunctionBody(source, "sendCommand") + + it("function sendCommand exists in session.tsx", () => { + expect(body.length).toBeGreaterThan(0) + }) + + it("dismisses suggestions before sending", () => { + expect(body).toContain("dismissSuggestion") + }) + + it("rejects questions before sending", () => { + expect(body).toContain("rejectQuestion") + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 704f6dc98f4..3f95af379b7 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -19,6 +19,7 @@ import { useVSCode } from "../../context/vscode" import { useLanguage } from "../../context/language" import { useWorktreeMode } from "../../context/worktree-mode" import { useServer } from "../../context/server" +import { isPromptBlocked, isSuggesting, isQuestioning } from "./prompt-input-utils" interface ChatViewProps { onSelectSession?: (id: string) => void @@ -62,11 +63,11 @@ export const ChatView: Component = (props) => { const standaloneQuestions = createMemo(() => familyQuestions().filter((q) => !q.tool)) const standaloneSuggestions = createMemo(() => familySuggestions().filter((s) => !s.tool)) const permissionRequest = () => familyPermissions().find((p) => p.sessionID === id()) ?? familyPermissions()[0] - const blocked = () => familyPermissions().length > 0 + const blocked = () => isPromptBlocked(familyPermissions().length) // Session is busy only because a suggestion tool call is pending — prompt should behave as idle - const suggesting = () => !blocked() && familySuggestions().length > 0 + const suggesting = () => isSuggesting(blocked(), familySuggestions().length) // Session is busy only because a question tool call is pending — prompt should behave as idle - const questioning = () => !blocked() && familyQuestions().length > 0 + const questioning = () => isQuestioning(blocked(), familyQuestions().length) const dock = () => !props.readonly || !!permissionRequest() // When a bottom-dock permission disappears while the session is busy, diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 6ef4425bb97..6679a395e5c 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -29,7 +29,7 @@ import { useImageAttachments, type ImageAttachment } from "../../hooks/useImageA import { convertToMentionPath } from "../../utils/path-mentions" import { usePromptHistory } from "../../hooks/usePromptHistory" import { WandSparkles } from "@kilocode/kilo-ui/lucide" -import { fileName, dirName, buildHighlightSegments, atEnd } from "./prompt-input-utils" +import { fileName, dirName, buildHighlightSegments, atEnd, isPromptBusy } from "./prompt-input-utils" import type { ReviewComment, TextPart } from "../../types/messages" import { formatReviewCommentsMarkdown } from "../../utils/review-comment-markdown" import { pendingDraftKey, scopeDraftKey, sessionDraftKey } from "../../utils/prompt-drafts" @@ -272,7 +272,7 @@ export const PromptInput: Component = (props) => { window.addEventListener("compactSession", onCompact) onCleanup(() => window.removeEventListener("compactSession", onCompact)) - const isBusy = () => session.status() !== "idle" && !props.suggesting?.() && !props.questioning?.() + const isBusy = () => isPromptBusy(session.status(), !!props.suggesting?.(), !!props.questioning?.()) const isDisabled = () => !server.isConnected() const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0 const canSend = () => hasInput() && !isDisabled() && !terminal.pending() && !props.blocked?.() diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts index ee785de7bd1..a1ed6acae48 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts @@ -49,3 +49,36 @@ export function buildHighlightSegments(val: string, paths: Set): { text: export function atEnd(start: number, end: number, len: number): boolean { return start === end && end === len } + +/** + * Whether the input prompt should be blocked. + * Only permissions block — questions and suggestions do NOT. + */ +export function isPromptBlocked(permissions: number): boolean { + return permissions > 0 +} + +/** + * Whether the session is busy from the prompt's perspective. + * Returns false (idle-like) when the session is busy only because + * a suggestion or question tool call is pending. + */ +export function isPromptBusy(status: string, suggesting: boolean, questioning: boolean): boolean { + return status !== "idle" && !suggesting && !questioning +} + +/** + * Whether the session is busy only because a suggestion is pending. + * True when no blocking requests exist and at least one suggestion is active. + */ +export function isSuggesting(blocked: boolean, suggestions: number): boolean { + return !blocked && suggestions > 0 +} + +/** + * Whether the session is busy only because a question is pending. + * True when no blocking requests exist and at least one question is active. + */ +export function isQuestioning(blocked: boolean, questions: number): boolean { + return !blocked && questions > 0 +} diff --git a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts new file mode 100644 index 00000000000..80be964c2c0 --- /dev/null +++ b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts @@ -0,0 +1,37 @@ +/** + * Contract test for prompt.ts Kilo-specific invariants. + * + * prompt.ts is a shared upstream file. PR #8988 added Suggestion.dismissAll + * there with kilocode_change markers. An upstream merge that restructures + * the prompt handling could silently remove this call — this test catches that. + */ + +import { describe, test, expect } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const PROMPT_FILE = path.resolve(import.meta.dir, "../../src/session/prompt.ts") + +describe("prompt.ts Kilo-specific invariants", () => { + test("imports Suggestion from kilocode/suggestion", () => { + const content = fs.readFileSync(PROMPT_FILE, "utf-8") + expect(content).toMatch(/import\s*\{[^}]*Suggestion[^}]*\}\s*from\s*["']@\/kilocode\/suggestion["']/) + }) + + test("calls Suggestion.dismissAll before restarting the session loop", () => { + const content = fs.readFileSync(PROMPT_FILE, "utf-8") + expect(content).toContain("Suggestion.dismissAll") + }) + + test("dismissAll and state.cancel appear together in a kilocode_change block", () => { + const content = fs.readFileSync(PROMPT_FILE, "utf-8") + // Both dismissAll and state.cancel are needed in the same block to fix + // the stuck session race condition. state.cancel appears elsewhere in + // prompt.ts (upstream code at line ~111), so we must verify it co-occurs + // with dismissAll inside the same kilocode_change block. + const block = content.match(/kilocode_change start[^\n]*dismiss[\s\S]*?kilocode_change end/) + expect(block).not.toBeNull() + expect(block![0]).toContain("Suggestion.dismissAll") + expect(block![0]).toContain("state.cancel") + }) +}) diff --git a/packages/opencode/test/kilocode/suggestion/suggestion.test.ts b/packages/opencode/test/kilocode/suggestion/suggestion.test.ts index 39e4d02ec92..d6b04a7a2a4 100644 --- a/packages/opencode/test/kilocode/suggestion/suggestion.test.ts +++ b/packages/opencode/test/kilocode/suggestion/suggestion.test.ts @@ -73,4 +73,73 @@ describe("suggestion", () => { }, }) }) + + test("dismissAll clears all pending suggestions for the target session", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Two suggestions for session A + const a1 = Suggestion.show({ + sessionID: "ses_a", + text: "Review?", + actions: [{ label: "Go", prompt: "/review" }], + }) + const a2 = Suggestion.show({ + sessionID: "ses_a", + text: "Test?", + actions: [{ label: "Run", prompt: "/test" }], + }) + + // One suggestion for session B + const b1 = Suggestion.show({ + sessionID: "ses_b", + text: "Deploy?", + actions: [{ label: "Ship", prompt: "/deploy" }], + }) + + expect(await Suggestion.list()).toHaveLength(3) + + // Track whether B's promise settles + let settled = false + b1.then(() => { + settled = true + }).catch(() => { + settled = true + }) + + // Dismiss all for session A only + await Suggestion.dismissAll("ses_a") + + // Both A promises should reject + await expect(a1).rejects.toBeInstanceOf(Suggestion.DismissedError) + await expect(a2).rejects.toBeInstanceOf(Suggestion.DismissedError) + + // Flush microtasks to see if B settled + await new Promise((r) => setTimeout(r, 10)) + expect(settled).toBe(false) + + // Only B's suggestion remains + const remaining = await Suggestion.list() + expect(remaining).toHaveLength(1) + expect(remaining[0]?.sessionID).toBe("ses_b") + + // Clean up B + await Suggestion.dismiss(remaining[0]!.id) + await expect(b1).rejects.toBeInstanceOf(Suggestion.DismissedError) + }, + }) + }) + + test("dismissAll is a no-op when no suggestions exist", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Should not throw + await Suggestion.dismissAll("ses_nonexistent") + expect(await Suggestion.list()).toEqual([]) + }, + }) + }) }) From e7a33dc502d2f5583a65bf7b352e7239c04d5b9b Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 16 Apr 2026 16:54:37 +0300 Subject: [PATCH 05/32] fix(vscode): honor blocking questions in prompt block state --- .../tests/unit/prompt-input-utils.test.ts | 13 +++++++++---- .../webview-ui/src/components/chat/ChatView.tsx | 3 ++- .../src/components/chat/prompt-input-utils.ts | 7 ++++--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts index 7e59c30fe46..f014a3b0723 100644 --- a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts @@ -151,8 +151,9 @@ describe("atEnd", () => { }) describe("isPromptBlocked", () => { - it("returns false when zero permissions", () => { + it("returns false when zero permissions and no blocking questions", () => { expect(isPromptBlocked(0)).toBe(false) + expect(isPromptBlocked(0, 0)).toBe(false) }) it("returns true when permissions exist", () => { @@ -160,9 +161,13 @@ describe("isPromptBlocked", () => { expect(isPromptBlocked(3)).toBe(true) }) - it("takes only permission count — no question/suggestion leakage", () => { - // The function signature accepts a single number; verify that zero means unblocked - expect(isPromptBlocked(0)).toBe(false) + it("returns true when blocking questions exist", () => { + expect(isPromptBlocked(0, 1)).toBe(true) + expect(isPromptBlocked(0, 2)).toBe(true) + }) + + it("returns true when both permissions and blocking questions exist", () => { + expect(isPromptBlocked(1, 1)).toBe(true) }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 3ef19b2ed23..aa03ef22e24 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -62,8 +62,9 @@ export const ChatView: Component = (props) => { // Tool-linked questions render inline at their tool part position via AssistantMessage. const standaloneQuestions = createMemo(() => familyQuestions().filter((q) => !q.tool)) const standaloneSuggestions = createMemo(() => familySuggestions().filter((s) => !s.tool)) + const blockingQuestions = createMemo(() => familyQuestions().filter((q) => q.blocking !== false)) const permissionRequest = () => familyPermissions().find((p) => p.sessionID === id()) ?? familyPermissions()[0] - const blocked = () => isPromptBlocked(familyPermissions().length) + const blocked = () => isPromptBlocked(familyPermissions().length, blockingQuestions().length) // Session is busy only because a suggestion tool call is pending — prompt should behave as idle const suggesting = () => isSuggesting(blocked(), familySuggestions().length) // Session is busy only because a question tool call is pending — prompt should behave as idle diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts index a1ed6acae48..713b1d67e65 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts @@ -52,10 +52,11 @@ export function atEnd(start: number, end: number, len: number): boolean { /** * Whether the input prompt should be blocked. - * Only permissions block — questions and suggestions do NOT. + * Permissions always block. Questions block unless they set `blocking: false`. + * Non-blocking questions and suggestions never block. */ -export function isPromptBlocked(permissions: number): boolean { - return permissions > 0 +export function isPromptBlocked(permissions: number, blocking: number = 0): boolean { + return permissions > 0 || blocking > 0 } /** From 158b65d5fe1939e159ab9fe82bd51f904a7ebf69 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 16 Apr 2026 16:55:21 +0300 Subject: [PATCH 06/32] fix(cli): throw 404 on unknown suggestion dismiss --- packages/opencode/src/kilo-sessions/remote-sender.ts | 4 +++- packages/opencode/src/kilocode/suggestion/index.ts | 5 +++-- packages/opencode/src/kilocode/suggestion/routes.ts | 3 ++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 931ada68db4..4d95e21019d 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -335,7 +335,9 @@ export namespace RemoteSender { return } const dir = msg.sessionId ? directoryFor(msg.sessionId) : Promise.resolve(options.directory) - dispatchQuick(msg, dir, () => Suggestion.dismiss(parsed.data.requestID)) + dispatchQuick(msg, dir, async () => { + await Suggestion.dismiss(parsed.data.requestID) + }) return } if (msg.command === "permission_respond") { diff --git a/packages/opencode/src/kilocode/suggestion/index.ts b/packages/opencode/src/kilocode/suggestion/index.ts index 5f03bb677f3..0c1473f924e 100644 --- a/packages/opencode/src/kilocode/suggestion/index.ts +++ b/packages/opencode/src/kilocode/suggestion/index.ts @@ -149,12 +149,12 @@ export namespace Suggestion { return true } - export async function dismiss(requestID: string): Promise { + 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 + return false } delete s.pending[requestID] @@ -166,6 +166,7 @@ export namespace Suggestion { }) existing.reject(new DismissedError()) + return true } export class DismissedError extends Error { diff --git a/packages/opencode/src/kilocode/suggestion/routes.ts b/packages/opencode/src/kilocode/suggestion/routes.ts index c47af53b81c..72dc0c6fd7b 100644 --- a/packages/opencode/src/kilocode/suggestion/routes.ts +++ b/packages/opencode/src/kilocode/suggestion/routes.ts @@ -92,7 +92,8 @@ export const SuggestionRoutes = lazy(() => ), async (c) => { const params = c.req.valid("param") - await Suggestion.dismiss(params.requestID) + const ok = await Suggestion.dismiss(params.requestID) + if (!ok) throw new NotFoundError({ message: `Suggestion not found: ${params.requestID}` }) return c.json(true) }, ), From 959a8b498de6efd28756683162296dd40eb9b454 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:35:17 +0000 Subject: [PATCH 07/32] fix(cli): move queued user prompts to end of history When a user queued a prompt while the previous turn was still streaming, the message's time_created fell before later assistant steps of that turn. Ordering by time_created alone left the queued prompt in the middle of the prior turn's history, so the next request ended with an assistant message and tripped Anthropic's prefill rejection. Reorder inside KiloSessionPromptQueue.scope so the target user message and any of its own turn's assistants are always placed at the end. --- .changeset/fix-queued-prompt-reorder.md | 6 ++ .../src/kilocode/session/prompt-queue.ts | 20 +++++- .../kilocode/session-prompt-queue.test.ts | 67 +++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-queued-prompt-reorder.md diff --git a/.changeset/fix-queued-prompt-reorder.md b/.changeset/fix-queued-prompt-reorder.md new file mode 100644 index 00000000000..79a373300fe --- /dev/null +++ b/.changeset/fix-queued-prompt-reorder.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Fix "assistant prefill" errors when a user queues a prompt while the previous turn is still streaming. The queued message no longer lands in the middle of the prior turn's history, so the next request always ends with the user prompt. diff --git a/packages/opencode/src/kilocode/session/prompt-queue.ts b/packages/opencode/src/kilocode/session/prompt-queue.ts index 54adcb6c407..ab08c24346d 100644 --- a/packages/opencode/src/kilocode/session/prompt-queue.ts +++ b/packages/opencode/src/kilocode/session/prompt-queue.ts @@ -39,7 +39,25 @@ export namespace KiloSessionPromptQueue { if (item.info.role === "assistant") return !hidden.has(item.info.parentID) return true }) - return visible + + // When a user prompt is queued mid-turn, its time_created falls in the + // middle of the prior turn's messages (a later assistant step in that turn + // was written after the queue event). Ordering by time_created alone puts + // the queued prompt before the prior turn's final assistant reply, which + // makes the next request end with an assistant message and trips Anthropic's + // prefill rejection. Move the target user message and any of its own turn's + // assistant messages to the end so the request always ends with the queued + // user prompt (or with its own turn's latest assistant step). + const owns = (item: MessageV2.WithParts) => { + if (item.info.role === "user") return item.info.id === target + if (item.info.role === "assistant") return item.info.parentID === target + return false + } + const before: MessageV2.WithParts[] = [] + const after: MessageV2.WithParts[] = [] + for (const item of visible) (owns(item) ? after : before).push(item) + if (after.length === 0) return visible + return [...before, ...after] } export function enqueue( diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 39969b645dc..5d5e93ac1d0 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -121,6 +121,73 @@ describe("session prompt queue", () => { expect(ids).toEqual([one, ans, two]) }) + test("moves queued target to the end when prior-turn messages come after it", async () => { + // Regression: when a user queues a prompt while a turn is still running, + // the queued message's time_created falls before later assistant steps of + // that turn. Ordering by time_created alone would leave the queued prompt + // in the middle of the prior turn's messages, ending the next model request + // with an assistant message and tripping Anthropic's prefill rejection. + const sessionID = SessionID.make("session_queue_mid_turn") + const m1 = MessageID.make("message_10") + const a1 = MessageID.make("message_20") + const m2 = MessageID.make("message_30") + const a2step1 = MessageID.make("message_40") + const m3 = MessageID.make("message_50") // queued mid-turn + const a2step2 = MessageID.make("message_60") + const a2final = MessageID.make("message_70") + const messages = [ + user(sessionID, m1), + assistant(sessionID, a1, m1), + user(sessionID, m2), + assistant(sessionID, a2step1, m2), + user(sessionID, m3), + assistant(sessionID, a2step2, m2), + assistant(sessionID, a2final, m2), + ] + + const ids = await Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + m3, + Effect.sync(() => KiloSessionPromptQueue.scope(sessionID, messages).map((item) => item.info.id)), + Effect.succeed([]), + ), + ) + + expect(ids).toEqual([m1, a1, m2, a2step1, a2step2, a2final, m3]) + expect(ids[ids.length - 1]).toBe(m3) + }) + + test("keeps the target turn's own assistant steps grouped at the end", async () => { + // After the first step of a queued turn has produced an assistant message, + // subsequent scope() calls should keep the target user together with its + // own turn's assistants (not interleaved with a prior turn's tail). + const sessionID = SessionID.make("session_queue_step_two") + const m1 = MessageID.make("message_01a") + const a1 = MessageID.make("message_02a") + const m2 = MessageID.make("message_03a") // queued mid-turn + const a1tail = MessageID.make("message_04a") + const a2step1 = MessageID.make("message_05a") + const messages = [ + user(sessionID, m1), + assistant(sessionID, a1, m1), + user(sessionID, m2), + assistant(sessionID, a1tail, m1), // prior turn's tail was written after m2 + assistant(sessionID, a2step1, m2), + ] + + const ids = await Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + m2, + Effect.sync(() => KiloSessionPromptQueue.scope(sessionID, messages).map((item) => item.info.id)), + Effect.succeed([]), + ), + ) + + expect(ids).toEqual([m1, a1, a1tail, m2, a2step1]) + }) + test("continues a queued prompt after the active run finishes", async () => { const ready = Promise.withResolvers() const release = Promise.withResolvers() From 226f9e348314b751dd811469ec17b61ab6fb7057 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 10:12:14 +0300 Subject: [PATCH 08/32] fix(cli): drop state.cancel from prompt enqueue path The prompt queue already serializes follow-up loops, so canceling the in-flight loop on every new prompt breaks the queue contract (see session-prompt-queue.test.ts). Keep only Suggestion.dismissAll so a previous loop blocked on a suggestion can settle. --- packages/opencode/src/session/prompt.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index abd3ab87c34..9499c893a2a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1291,11 +1291,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the } if (input.noReply === true) return message - // kilocode_change start — dismiss pending suggestions and cancel the session - // before enqueuing new work so the previous loop can settle instead of - // staying blocked on a pending suggestion + // kilocode_change start — dismiss pending suggestions so a previous loop + // blocked on a suggestion can settle before the queue runs the next prompt yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID)) - yield* state.cancel(input.sessionID) // kilocode_change end return yield* KiloSessionPromptQueue.enqueue( input.sessionID, From 92c18e45003e2ea69d3bda203324d613098a7b5b Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 10:27:47 +0300 Subject: [PATCH 09/32] fix(vscode): trim KiloProvider slimPart comment to fit max-lines cap Merging PR #9036 pushed KiloProvider.ts to 3352 lines, 2 over the 3350-line ESLint cap. Condense the slimPart documentation block to bring the file back under the cap. --- packages/kilo-vscode/src/KiloProvider.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index bbd2493d2b6..c6db217d115 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -280,12 +280,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - // Edit tool parts carry full file contents in metadata.filediff.before/after. - // A session with many edits can produce multi-MB payloads serialized through - // postMessage on every session switch. Stripping those strings down to just - // file path + addition/deletion counts eliminates the dominant cost. - // Logic extracted to kilo-provider/slim-metadata.ts - + // Strip edit-tool metadata.filediff.before/after (multi-MB for edit-heavy + // sessions) to keep session switches fast. Logic in kilo-provider/slim-metadata.ts. private slimPart(part: T): T { if (!this.slimEditMetadata) return part return slimPart(part) From 233a4569f400039af3f1798222bc181de57aca74 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 12:05:53 +0300 Subject: [PATCH 10/32] fix(vscode): decouple prompt input from question tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompt input was wrongly blocked whenever the question tool had a pending request, because its isPromptBlocked check counted questions whose blocking field was undefined. Drop the two-arg signature and the blockingQuestions memo in ChatView — only permissions block the prompt in the webview; pending questions and suggestions are already auto-dismissed in sendMessage/sendCommand. --- .../tests/unit/prompt-input-utils.test.ts | 14 +++++------- .../src/components/chat/ChatView.tsx | 5 +++-- .../src/components/chat/prompt-input-utils.ts | 22 ++++++++++++++----- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts index f014a3b0723..561b585852f 100644 --- a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts @@ -151,9 +151,8 @@ describe("atEnd", () => { }) describe("isPromptBlocked", () => { - it("returns false when zero permissions and no blocking questions", () => { + it("returns false when zero permissions", () => { expect(isPromptBlocked(0)).toBe(false) - expect(isPromptBlocked(0, 0)).toBe(false) }) it("returns true when permissions exist", () => { @@ -161,13 +160,10 @@ describe("isPromptBlocked", () => { expect(isPromptBlocked(3)).toBe(true) }) - it("returns true when blocking questions exist", () => { - expect(isPromptBlocked(0, 1)).toBe(true) - expect(isPromptBlocked(0, 2)).toBe(true) - }) - - it("returns true when both permissions and blocking questions exist", () => { - expect(isPromptBlocked(1, 1)).toBe(true) + it("accepts exactly one argument (locks the API against regression)", () => { + // Prevents a future change from reintroducing the question/blocking coupling. + // See prompt-send-contract.test.ts for the source-level complement. + expect(isPromptBlocked.length).toBe(1) }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index aa03ef22e24..6e6969b60f5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -62,9 +62,10 @@ export const ChatView: Component = (props) => { // Tool-linked questions render inline at their tool part position via AssistantMessage. const standaloneQuestions = createMemo(() => familyQuestions().filter((q) => !q.tool)) const standaloneSuggestions = createMemo(() => familySuggestions().filter((s) => !s.tool)) - const blockingQuestions = createMemo(() => familyQuestions().filter((q) => q.blocking !== false)) const permissionRequest = () => familyPermissions().find((p) => p.sessionID === id()) ?? familyPermissions()[0] - const blocked = () => isPromptBlocked(familyPermissions().length, blockingQuestions().length) + // Prompt input is decoupled from questions/suggestions — only permissions block. + // Pending questions and suggestions are auto-dismissed in sendMessage/sendCommand. + const blocked = () => isPromptBlocked(familyPermissions().length) // Session is busy only because a suggestion tool call is pending — prompt should behave as idle const suggesting = () => isSuggesting(blocked(), familySuggestions().length) // Session is busy only because a question tool call is pending — prompt should behave as idle diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts index 713b1d67e65..9272e49fe39 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts @@ -52,11 +52,17 @@ export function atEnd(start: number, end: number, len: number): boolean { /** * Whether the input prompt should be blocked. - * Permissions always block. Questions block unless they set `blocking: false`. - * Non-blocking questions and suggestions never block. + * + * Only permission requests block the prompt in the VS Code webview. Questions + * and suggestions never block — they are dismissed automatically when a new + * message is sent (see session.tsx sendMessage/sendCommand). + * + * The single-parameter signature is intentional: taking question-count would + * structurally allow a future regression to re-couple the prompt to pending + * questions. Keep this function at one argument. */ -export function isPromptBlocked(permissions: number, blocking: number = 0): boolean { - return permissions > 0 || blocking > 0 +export function isPromptBlocked(permissions: number): boolean { + return permissions > 0 } /** @@ -70,7 +76,9 @@ export function isPromptBusy(status: string, suggesting: boolean, questioning: b /** * Whether the session is busy only because a suggestion is pending. - * True when no blocking requests exist and at least one suggestion is active. + * True when no permission request is blocking the prompt and at least one + * suggestion is active. The `!blocked` gate keeps the Stop button available + * when permissions block input — it does NOT mean suggestions block. */ export function isSuggesting(blocked: boolean, suggestions: number): boolean { return !blocked && suggestions > 0 @@ -78,7 +86,9 @@ export function isSuggesting(blocked: boolean, suggestions: number): boolean { /** * Whether the session is busy only because a question is pending. - * True when no blocking requests exist and at least one question is active. + * True when no permission request is blocking the prompt and at least one + * question is active. The `!blocked` gate keeps the Stop button available + * when permissions block input — it does NOT mean questions block. */ export function isQuestioning(blocked: boolean, questions: number): boolean { return !blocked && questions > 0 From 15659e8f05a7f1a9ce883f8c2ed4a3914c5daa26 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 12:07:50 +0300 Subject: [PATCH 11/32] test(vscode): lock prompt input/question decoupling Add a source-level contract test that reads ChatView.tsx and fails if isPromptBlocked is ever called with a second argument, if a blockingQuestions memo is reintroduced, or if q.blocking is referenced while building the blocked state. Plus a Storybook story that renders ChatView with a pending question tool call and empty input so the arrow-vs-square send button is locked to a visual-regression baseline. --- .../tests/unit/prompt-send-contract.test.ts | 48 +++++++++++++++++++ .../webview-ui/src/stories/chat.stories.tsx | 38 +++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index 4e92b874cb3..e9f8126a56e 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -3,6 +3,10 @@ * * Static analysis — reads session.tsx source and verifies that sendMessage and * sendCommand still dismiss suggestions and reject questions before dispatching. + * Also reads ChatView.tsx and asserts the prompt-block predicate is fed only + * permission counts, never question counts — guarantees that a pending question + * cannot re-block the prompt input. + * * Protects against accidental removal during Kilo development. */ @@ -12,6 +16,8 @@ import path from "node:path" const ROOT = path.resolve(import.meta.dir, "../..") const SESSION_FILE = path.join(ROOT, "webview-ui/src/context/session.tsx") +const CHATVIEW_FILE = path.join(ROOT, "webview-ui/src/components/chat/ChatView.tsx") +const PROMPT_UTILS_FILE = path.join(ROOT, "webview-ui/src/components/chat/prompt-input-utils.ts") function readFile(filePath: string): string { return fs.readFileSync(filePath, "utf-8") @@ -68,3 +74,45 @@ describe("sendCommand dismisses pending tool requests", () => { expect(body).toContain("rejectQuestion") }) }) + +describe("ChatView prompt-block contract", () => { + const source = readFile(CHATVIEW_FILE) + + it("calls isPromptBlocked with exactly one argument (familyPermissions length)", () => { + // Exact call shape — prettier formatting is deterministic here, so a strict + // match catches both "someone added a second arg" and "someone wrapped it in + // a different expression". + expect(source).toMatch(/blocked\s*=\s*\(\)\s*=>\s*isPromptBlocked\(familyPermissions\(\)\.length\)/) + }) + + it("does not pass any second argument to isPromptBlocked", () => { + expect(source).not.toMatch(/isPromptBlocked\s*\([^,)]*,[^)]*\)/) + }) + + it("does not define a blockingQuestions memo", () => { + expect(source).not.toContain("blockingQuestions") + }) + + it("does not reference q.blocking when building the blocked state", () => { + expect(source).not.toMatch(/q\.blocking/) + }) +}) + +describe("isPromptBlocked signature contract", () => { + const source = readFile(PROMPT_UTILS_FILE) + + it("declares exactly one parameter (source-level guard)", () => { + // Complements the runtime `isPromptBlocked.length === 1` check in + // prompt-input-utils.test.ts. `Function.prototype.length` counts parameters + // before the first default — this regex catches a future regression that + // sneaks in a second param with a default value (which would otherwise keep + // `.length === 1` and slip past the runtime check). + const match = source.match(/export function isPromptBlocked\(([^)]*)\)/) + expect(match).not.toBeNull() + const params = match![1] + .split(",") + .map((p) => p.trim()) + .filter((p) => p.length > 0) + expect(params).toHaveLength(1) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx index 5c10a1eaaab..0bc276128ed 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx @@ -124,6 +124,44 @@ export const ChatViewWithMessages: Story = { }, } +/** + * ChatView with a pending question tool call and an empty input. + * + * Locks in the fix for the regression where the question tool's pending request + * caused the Send button to render as a Stop square. The snapshot captures the + * prompt bar footer — the submit control must be the paper-plane arrow icon, + * not the filled square Stop icon. + * + * If someone re-couples the prompt input to the question tool, this story's + * baseline PNG will diverge and the visual-regression CI job will fail. + */ +const pendingToolQuestion: QuestionRequest = { + id: "q-toolcall-001", + sessionID: SESSION_ID, + questions: [ + { + question: "What would you like to do next?", + header: "Next step", + options: [ + { label: "Continue", description: "Keep going with the current plan" }, + { label: "Revise", description: "Adjust the approach before continuing" }, + ], + }, + ], + tool: { messageID: "asst-q-001", callID: "call-q-001" }, +} + +export const ChatViewWithPendingQuestionEmptyInput: Story = { + name: "ChatView — pending question, empty input (submit must be arrow, not square)", + render: () => ( + +
+ +
+
+ ), +} + // --------------------------------------------------------------------------- // QuestionDock stories // --------------------------------------------------------------------------- From a120efdc44efa831bf2a5d8c255c70515a51814a Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 12:12:37 +0300 Subject: [PATCH 12/32] refactor(cli): drop client-gated blocking flag from suggest tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Webview now ignores the blocking field entirely, and the CLI TUI treats undefined as blocking (s.blocking !== false), so setting blocking: Flag.KILO_CLIENT !== 'vscode' is redundant. Drop the line and the now-unused Flag import — CLI takeover behavior is preserved, webview behavior is unchanged. --- packages/opencode/src/kilocode/suggestion/tool.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/opencode/src/kilocode/suggestion/tool.ts b/packages/opencode/src/kilocode/suggestion/tool.ts index 9f77227eba5..6caf4a8fdd3 100644 --- a/packages/opencode/src/kilocode/suggestion/tool.ts +++ b/packages/opencode/src/kilocode/suggestion/tool.ts @@ -1,5 +1,4 @@ import { Command } from "../../command" -import { Flag } from "../../flag/flag" import { Log } from "../../util/log" import z from "zod" import DESCRIPTION from "./tool.txt" @@ -58,7 +57,6 @@ export const SuggestTool = Tool.define("suggest", { sessionID: ctx.sessionID, text: params.suggest, actions: params.actions, - blocking: Flag.KILO_CLIENT !== "vscode", tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, }) From 7d2f5a7eced0b3599231f75656c0a27c68770d74 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 17 Apr 2026 11:13:56 +0200 Subject: [PATCH 13/32] fix(agent-manager): preserve local sessions on reload (#9040) --- .changeset/preserve-local-agent-sessions.md | 5 +++++ .../src/agent-manager/WorktreeStateManager.ts | 15 +++++++++---- .../tests/unit/worktree-state-manager.test.ts | 21 ++++++++++--------- 3 files changed, 27 insertions(+), 14 deletions(-) create mode 100644 .changeset/preserve-local-agent-sessions.md diff --git a/.changeset/preserve-local-agent-sessions.md b/.changeset/preserve-local-agent-sessions.md new file mode 100644 index 00000000000..735f53acbcc --- /dev/null +++ b/.changeset/preserve-local-agent-sessions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve local Agent Manager sessions after the panel reloads while still pruning stale worktree sessions. diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index e31affefe71..cb38552128f 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -533,8 +533,13 @@ export class WorktreeStateManager { } let pruned = 0 for (const [id, s] of Object.entries(data.sessions ?? {})) { - // Skip orphaned sessions (null worktreeId or referencing a deleted worktree) - if (!s.worktreeId || !this.worktrees.has(s.worktreeId)) { + const ref = s.worktreeId + if (ref === null) { + this.sessions.set(id, { id, ...s }) + continue + } + // Skip orphaned sessions referencing a deleted worktree. + if (!ref || !this.worktrees.has(ref)) { pruned++ continue } @@ -580,9 +585,11 @@ export class WorktreeStateManager { changed = true } } - // Prune orphaned sessions (worktreeId is null or references a deleted worktree) + // Preserve local sessions; prune only sessions that reference missing worktrees. for (const s of [...this.sessions.values()]) { - if (!s.worktreeId || !this.worktrees.has(s.worktreeId)) { + const ref = s.worktreeId + if (ref === null) continue + if (!ref || !this.worktrees.has(ref)) { this.sessions.delete(s.id) changed = true } diff --git a/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts index 42da79bef26..f461b50835f 100644 --- a/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts @@ -150,10 +150,11 @@ describe("WorktreeStateManager", () => { }) describe("persistence", () => { - it("saves and loads state, pruning orphaned sessions", async () => { + it("saves and loads state, preserving local sessions and pruning orphaned sessions", async () => { const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" }) manager.addSession("s1", wt.id) manager.addSession("s2", null) + manager.addSession("s3", "missing") // Flush fire-and-forget saves from mutations, then do a final save await manager.flush() await manager.save() @@ -163,10 +164,10 @@ describe("WorktreeStateManager", () => { expect(loaded.getWorktrees()).toHaveLength(1) expect(loaded.getWorktrees()[0].branch).toBe("fix") - // s2 had null worktreeId so it gets pruned on load - expect(loaded.getSessions()).toHaveLength(1) + expect(loaded.getSessions()).toHaveLength(2) expect(loaded.getSession("s1")?.worktreeId).toBe(wt.id) - expect(loaded.getSession("s2")).toBeUndefined() + expect(loaded.getSession("s2")?.worktreeId).toBeNull() + expect(loaded.getSession("s3")).toBeUndefined() }) it("load is a no-op when file does not exist", async () => { @@ -312,19 +313,20 @@ describe("WorktreeStateManager", () => { expect(manager.getSession("s1")).toBeUndefined() }) - it("prunes orphaned sessions with null worktreeId on validate", async () => { + it("preserves local sessions and prunes missing worktree references on validate", async () => { const existing = path.join(root, "wt-exists") fs.mkdirSync(existing, { recursive: true }) const wt = manager.addWorktree({ branch: "exists", path: existing, parentBranch: "main" }) manager.addSession("s1", wt.id) manager.addSession("s2", null) + manager.addSession("s3", "missing") await manager.validate(root) - // s1 stays (its worktree exists), s2 is pruned (null worktreeId) expect(manager.getSession("s1")).toBeTruthy() - expect(manager.getSession("s2")).toBeUndefined() + expect(manager.getSession("s2")?.worktreeId).toBeNull() + expect(manager.getSession("s3")).toBeUndefined() }) it("resolves relative paths against root", async () => { @@ -446,7 +448,7 @@ describe("WorktreeStateManager", () => { expect(manager.getSessions()).toHaveLength(0) }) - it("handles partial data with missing worktrees key and prunes orphaned sessions", async () => { + it("handles partial data with missing worktrees key and local sessions", async () => { const file = path.join(root, ".kilo", "agent-manager.json") fs.writeFileSync( file, @@ -457,8 +459,7 @@ describe("WorktreeStateManager", () => { await manager.load() expect(manager.getWorktrees()).toHaveLength(0) - // Orphaned session with null worktreeId is pruned on load - expect(manager.getSessions()).toHaveLength(0) + expect(manager.getSession("s-1")?.worktreeId).toBeNull() }) }) From 76f5d5dc083c6bda3b1a924f61e1e78e9f74fc7c Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Fri, 17 Apr 2026 09:30:38 +0000 Subject: [PATCH 14/32] release: v7.2.12 --- .changeset/custom-provider-xhigh-effort.md | 5 --- .../filter-kilo-models-without-tools.md | 7 ---- .changeset/marketplace-contribute-cta.md | 5 --- .changeset/opus-4-7-adaptive-reasoning.md | 5 --- .changeset/preserve-local-agent-sessions.md | 5 --- .changeset/session-streaming-scheduler.md | 5 --- bun.lock | 32 +++++++++---------- package.json | 2 +- packages/app/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++---- packages/kilo-docs/package.json | 2 +- packages/kilo-gateway/package.json | 2 +- packages/kilo-i18n/package.json | 2 +- packages/kilo-telemetry/package.json | 2 +- packages/kilo-ui/package.json | 2 +- packages/kilo-vscode/CHANGELOG.md | 16 ++++++++++ packages/kilo-vscode/package.json | 2 +- packages/opencode/CHANGELOG.md | 12 +++++++ packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/script/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/storybook/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- script/upstream/package.json | 2 +- sdks/vscode/package.json | 2 +- 29 files changed, 69 insertions(+), 73 deletions(-) delete mode 100644 .changeset/custom-provider-xhigh-effort.md delete mode 100644 .changeset/filter-kilo-models-without-tools.md delete mode 100644 .changeset/marketplace-contribute-cta.md delete mode 100644 .changeset/opus-4-7-adaptive-reasoning.md delete mode 100644 .changeset/preserve-local-agent-sessions.md delete mode 100644 .changeset/session-streaming-scheduler.md diff --git a/.changeset/custom-provider-xhigh-effort.md b/.changeset/custom-provider-xhigh-effort.md deleted file mode 100644 index b4d6bd71123..00000000000 --- a/.changeset/custom-provider-xhigh-effort.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Add `xhigh` reasoning effort option to custom provider model variants so users can access the highest tier on models that support it (gpt-5.2, gpt-5.3, gpt-5.4, gpt-5.1-codex-max, etc.). diff --git a/.changeset/filter-kilo-models-without-tools.md b/.changeset/filter-kilo-models-without-tools.md deleted file mode 100644 index 2569b17947c..00000000000 --- a/.changeset/filter-kilo-models-without-tools.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@kilocode/kilo-gateway": patch -"@kilocode/cli": patch -"kilo-code": patch ---- - -Hide Kilo Gateway models that do not support tool calling from the model list. diff --git a/.changeset/marketplace-contribute-cta.md b/.changeset/marketplace-contribute-cta.md deleted file mode 100644 index c805880e0a9..00000000000 --- a/.changeset/marketplace-contribute-cta.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Add a "Contribute on GitHub" call-to-action in the Marketplace view so users can easily propose a new skill, mode, or MCP server. The CTA appears as a subtle footer below the card grid and inside the empty search-results state, linking to the `kilo-marketplace` repository. diff --git a/.changeset/opus-4-7-adaptive-reasoning.md b/.changeset/opus-4-7-adaptive-reasoning.md deleted file mode 100644 index 4f938cdad83..00000000000 --- a/.changeset/opus-4-7-adaptive-reasoning.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Support adaptive reasoning for Claude Opus 4.7 and expose the `xhigh` effort level for adaptive Anthropic models diff --git a/.changeset/preserve-local-agent-sessions.md b/.changeset/preserve-local-agent-sessions.md deleted file mode 100644 index 735f53acbcc..00000000000 --- a/.changeset/preserve-local-agent-sessions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Preserve local Agent Manager sessions after the panel reloads while still pruning stale worktree sessions. diff --git a/.changeset/session-streaming-scheduler.md b/.changeset/session-streaming-scheduler.md deleted file mode 100644 index 4d56c76705a..00000000000 --- a/.changeset/session-streaming-scheduler.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Keep the active chat session responsive while other sessions stream in the background. Applies to both the sidebar and the Agent Manager. diff --git a/bun.lock b/bun.lock index 3859088f604..671eeb9b224 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "7.2.11", + "version": "7.2.12", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -86,7 +86,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "7.2.11", + "version": "7.2.12", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -119,7 +119,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "7.2.11", + "version": "7.2.12", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -170,7 +170,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.11", + "version": "7.2.12", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -199,7 +199,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.11", + "version": "7.2.12", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -234,7 +234,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.2.11", + "version": "7.2.12", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -247,7 +247,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.2.11", + "version": "7.2.12", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "@opentelemetry/api": "1.9.0", @@ -267,7 +267,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.11", + "version": "7.2.12", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/util": "workspace:*", @@ -302,7 +302,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.11", + "version": "7.2.12", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", @@ -355,7 +355,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.11", + "version": "7.2.12", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -498,7 +498,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.11", + "version": "7.2.12", "dependencies": { "@kilocode/sdk": "workspace:*", "zod": "catalog:", @@ -522,7 +522,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.11", + "version": "7.2.12", "dependencies": { "semver": "^7.6.3", }, @@ -533,7 +533,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.11", + "version": "7.2.12", "dependencies": { "cross-spawn": "catalog:", }, @@ -548,7 +548,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.11", + "version": "7.2.12", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -571,7 +571,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.11", + "version": "7.2.12", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -621,7 +621,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "7.2.11", + "version": "7.2.12", "dependencies": { "zod": "catalog:", }, diff --git a/package.json b/package.json index a478a26582b..1833c67cf12 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,6 @@ "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch" }, - "version": "7.2.11", + "version": "7.2.12", "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index a6b403b2fb9..29875997b91 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "7.2.11", + "version": "7.2.12", "description": "", "type": "module", "exports": { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 1ee122df81c..7d688ab2525 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "7.2.11", + "version": "7.2.12", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index beb1e49cfe0..e955e6e35fc 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "7.2.11", + "version": "7.2.12", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index db7834bf404..029f55707e1 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.2.11" +version = "7.2.12" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.11/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.11/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.11/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.11/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.11/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index cc4601ab18e..58b177678d8 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.2.11", + "version": "7.2.12", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 1be0ce106f8..af07ff3de52 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.2.11", + "version": "7.2.12", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 111ef03aeac..15b8d8c5337 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.2.11", + "version": "7.2.12", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 9af56f60aaa..69096f72578 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.2.11", + "version": "7.2.12", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index febf85a3f16..ae60ce27df3 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.2.11", + "version": "7.2.12", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index 3e07b7492f4..8dcc5af4826 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,21 @@ # kilo-code +## 7.2.12 + +### Minor Changes + +- [#9099](https://github.com/Kilo-Org/kilocode/pull/9099) [`49b283e`](https://github.com/Kilo-Org/kilocode/commit/49b283e72a7307a1398c38c0862214faa043c128) - Add a "Contribute on GitHub" call-to-action in the Marketplace view so users can easily propose a new skill, mode, or MCP server. The CTA appears as a subtle footer below the card grid and inside the empty search-results state, linking to the `kilo-marketplace` repository. + +### Patch Changes + +- [#9066](https://github.com/Kilo-Org/kilocode/pull/9066) [`79ba643`](https://github.com/Kilo-Org/kilocode/commit/79ba643cba201971a9779e454fe66769019a7a5a) - Add `xhigh` reasoning effort option to custom provider model variants so users can access the highest tier on models that support it (gpt-5.2, gpt-5.3, gpt-5.4, gpt-5.1-codex-max, etc.). + +- [#9068](https://github.com/Kilo-Org/kilocode/pull/9068) [`e65c2d9`](https://github.com/Kilo-Org/kilocode/commit/e65c2d99c0d234d3dc1dff2e75e58e22bea8ce7f) Thanks [@kilo-code-bot](https://github.com/apps/kilo-code-bot)! - Hide Kilo Gateway models that do not support tool calling from the model list. + +- [#9040](https://github.com/Kilo-Org/kilocode/pull/9040) [`7d2f5a7`](https://github.com/Kilo-Org/kilocode/commit/7d2f5a7eced0b3599231f75656c0a27c68770d74) - Preserve local Agent Manager sessions after the panel reloads while still pruning stale worktree sessions. + +- [#9057](https://github.com/Kilo-Org/kilocode/pull/9057) [`1526a4b`](https://github.com/Kilo-Org/kilocode/commit/1526a4b8a890e01c0c890b4e17b595f35bfd745b) - Keep the active chat session responsive while other sessions stream in the background. Applies to both the sidebar and the Agent Manager. + ## 7.2.11 ### Minor Changes diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 53b83951bc4..13e49b3ac9d 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.2.11", + "version": "7.2.12", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index fdef08281f0..78bbb240cfa 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,17 @@ # @kilocode/cli +## 7.2.12 + +### Patch Changes + +- [#9068](https://github.com/Kilo-Org/kilocode/pull/9068) [`e65c2d9`](https://github.com/Kilo-Org/kilocode/commit/e65c2d99c0d234d3dc1dff2e75e58e22bea8ce7f) Thanks [@kilo-code-bot](https://github.com/apps/kilo-code-bot)! - Hide Kilo Gateway models that do not support tool calling from the model list. + +- [#9069](https://github.com/Kilo-Org/kilocode/pull/9069) [`e60c326`](https://github.com/Kilo-Org/kilocode/commit/e60c3263191c5746bea6bd93cd291c28f5d1ab0f) Thanks [@kilo-code-bot](https://github.com/apps/kilo-code-bot)! - Support adaptive reasoning for Claude Opus 4.7 and expose the `xhigh` effort level for adaptive Anthropic models + +- Updated dependencies [[`e65c2d9`](https://github.com/Kilo-Org/kilocode/commit/e65c2d99c0d234d3dc1dff2e75e58e22bea8ce7f)]: + - @kilocode/kilo-gateway@7.2.12 + - @kilocode/kilo-telemetry@7.2.12 + ## 7.2.11 ### Patch Changes diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 9e925882795..a2b539c4651 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.11", + "version": "7.2.12", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d60fe1b1491..42a9ccadab1 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.2.11", + "version": "7.2.12", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index fb47dee2f52..ec3c42e4095 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.2.11", + "version": "7.2.12", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 887543c5c53..694ed95a525 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.2.11", + "version": "7.2.12", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 44178bbf8c0..148289f0469 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.2.11", + "version": "7.2.12", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 9bf3e48bb01..7ee16a91353 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.2.11", + "version": "7.2.12", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index 7d7714920b1..7305f0d747b 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "7.2.11", + "version": "7.2.12", "private": true, "type": "module", "license": "MIT", diff --git a/script/upstream/package.json b/script/upstream/package.json index b054207b7d6..feef48f5f98 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.2.11", + "version": "7.2.12", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 681539895a5..3c36192e550 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "7.2.11", + "version": "7.2.12", "publisher": "sst-dev", "repository": { "type": "git", From f27063987765bba2443f559629bc8c05fad996df Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 17 Apr 2026 11:26:12 +0200 Subject: [PATCH 15/32] fix(vscode): surface config save errors instead of silently swallowing them (#9109) * fix(vscode): surface config save errors instead of silently swallowing them When saving settings failed (e.g. invalid kilo.json rejected by the Zod validator), the error was only logged to the console and the save bar cleared itself, giving the false impression the settings had saved. Show the validator's message inline in an expandable banner so users can see what's wrong and fix it. * test(vscode): add tests for getErrorMessage and getConfigErrorDetails Also translate the new save-bar strings to all 18 locales and drop a leading blank line from config error details when no path is present. --- .changeset/settings-save-error.md | 5 + packages/kilo-vscode/src/KiloProvider.ts | 54 ++++--- .../kilo-vscode/src/kilo-provider-utils.ts | 123 ++++++++++----- .../tests/unit/kilo-provider-utils.test.ts | 140 ++++++++++++++++++ .../src/components/settings/Settings.tsx | 56 +++++-- .../webview-ui/src/context/config.tsx | 35 ++++- .../kilo-vscode/webview-ui/src/i18n/ar.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 2 + .../webview-ui/src/stories/StoryProviders.tsx | 2 + .../webview-ui/src/styles/settings.css | 76 ++++++++-- .../webview-ui/src/types/messages.ts | 7 + 28 files changed, 437 insertions(+), 99 deletions(-) create mode 100644 .changeset/settings-save-error.md diff --git a/.changeset/settings-save-error.md b/.changeset/settings-save-error.md new file mode 100644 index 00000000000..c6a24f3e073 --- /dev/null +++ b/.changeset/settings-save-error.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show an inline error in the Settings save bar when the configuration fails to save (for example, due to an invalid value) so the user can correct the config and retry instead of losing their unsaved changes silently. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 50ec36eb482..e9c370b2b1e 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -24,6 +24,7 @@ import { buildSettingPath, mapSSEEventToWebviewMessage, getErrorMessage, + getConfigErrorDetails, isEventFromForeignProject, MessageConfirmation, runWithMessageConfirmation, @@ -2248,7 +2249,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper */ private async handleUpdateConfig(partial: Partial): Promise { if (!this.client || this.connectionState !== "connected") { - this.postMessage({ type: "error", message: "Not connected to CLI backend" }) + this.postMessage({ type: "configUpdateFailed", message: "Not connected to CLI backend" }) return } @@ -2257,42 +2258,39 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper partial.disabled_providers !== undefined || partial.enabled_providers !== undefined - // Belt-and-suspenders guard: prevent fetchAndSendConfig from sending a - // stale configLoaded while this write is in flight (the SSE-triggered reload - // races with the async config.update() write on the CLI backend). + // Guard against fetchAndSendConfig pushing stale data while the write is in flight. this.pending++ + + // Phase 1: write. Errors here = real save failures the user can fix + retry. try { - // Reject all pending permissions and questions across every provider - // so their CLI-side Promises resolve before disposeAll() wipes - // Instance state. Throws on failure to abort the config save. await this.connectionService.drainPendingPrompts() - await this.client.global.config.update({ config: partial }, { throwOnError: true }) - - // Re-fetch the full merged config (global + project + all layers) so the - // webview receives the complete resolved config, not just global-only data. - // Config.state is reset by updateGlobal (via Instance.resetStateEntry) so - // config.get() returns fresh data without a full dispose cycle. - const dir = this.getWorkspaceDirectory() - const { data: merged } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })) - - this.cachedConfigMessage = { type: "configLoaded", config: merged } - this.postMessage({ type: "configUpdated", config: merged }) - - if (refreshProviders) { - await this.fetchAndSendProviders() - } } catch (error) { console.error("[Kilo New] KiloProvider: Failed to update config:", error) this.postMessage({ - type: "error", + type: "configUpdateFailed", message: getErrorMessage(error) || "Failed to update config", + details: getConfigErrorDetails(error), }) - // Send configUpdated with the last known good config so the webview - // clears its saving flag and reverts optimistic state. - if (this.cachedConfigMessage) { - this.postMessage({ type: "configUpdated", config: (this.cachedConfigMessage as { config: unknown }).config }) - } + this.pending-- + return + } + + // Phase 2: refresh. Config is already on disk — post-write errors are + // transient, so send an optimistic configUpdated to clear the webview's + // saving/draft state. SSE global.config.updated pushes the real data next. + try { + const dir = this.getWorkspaceDirectory() + const { data: merged } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })) + this.cachedConfigMessage = { type: "configLoaded", config: merged } + this.postMessage({ type: "configUpdated", config: merged }) + if (refreshProviders) await this.fetchAndSendProviders() + } catch (error) { + console.error("[Kilo New] KiloProvider: Config write succeeded but post-write refresh failed:", error) + const cached = (this.cachedConfigMessage as { config?: unknown } | null)?.config + const optimistic = + cached && typeof cached === "object" ? { ...(cached as Record), ...partial } : partial + this.postMessage({ type: "configUpdated", config: optimistic }) } finally { this.pending-- } diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index 2599fde8cf9..29b7711b7b1 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -1,4 +1,5 @@ import type { Session, Agent, Event, ProviderListResponse } from "@kilocode/sdk/v2/client" +import { prettifyError } from "zod/v4" import type { CloudSessionMessage } from "./services/cli-backend/types" import type { PartBatch, PartUpdate } from "./kilo-provider/session-stream-scheduler" @@ -17,48 +18,94 @@ export type ProviderInfo = ProviderListResponse["all"][number] * - NotFoundError: { name: "NotFoundError", data: { message: "..." } } * - Plain string (raw text response) */ +/** Extract a message from the first element of an array of strings or `{ message }` objects. */ +function firstMessage(arr: unknown): string | undefined { + if (!Array.isArray(arr) || arr.length === 0) return undefined + const first = arr[0] + if (typeof first === "string") return first + if (first && typeof first === "object") { + const msg = (first as Record).message + if (typeof msg === "string") return msg + } + return undefined +} + +/** Extract a message from SDK error `data` field shapes (NotFoundError, ConfigInvalidError, Hono validator). */ +function messageFromData(data: Record): string | undefined { + if (typeof data.message === "string") return data.message + // ConfigInvalidError: { path, issues: [{ message, path, code }] } + const fromIssues = firstMessage(data.issues) + if (fromIssues) return fromIssues + // Hono validator: { data, error: [...], success: false } + return firstMessage(data.error) +} + +function safeStringify(value: unknown): string | undefined { + try { + const json = JSON.stringify(value) + if (json !== "{}" && json.length < 500) return json + } catch (err) { + console.warn("[Kilo New] getErrorMessage: JSON.stringify failed", err) + } + return undefined +} + export function getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message if (typeof error === "string") return error - if (error && typeof error === "object") { - const obj = error as Record - // Direct .message field - if (typeof obj.message === "string") return obj.message - // Direct .error field (string) - if (typeof obj.error === "string") return obj.error - // SDK throwOnError shape: { error: { message: "..." } } or { error: { ... } } - if (obj.error && typeof obj.error === "object") { - const nested = obj.error as Record - if (typeof nested.message === "string") return nested.message - } - // NotFoundError shape: { data: { message: "..." } } - if (obj.data && typeof obj.data === "object") { - const data = obj.data as Record - if (typeof data.message === "string") return data.message - // Hono validator shape: { data: ..., error: [...], success: false } - if (Array.isArray(data.error) && data.error.length > 0) { - const first = data.error[0] - if (typeof first === "string") return first - if (first && typeof first === "object" && typeof (first as Record).message === "string") { - return (first as Record).message as string - } - } - } - // BadRequestError shape: { errors: [{ message: "..." }] } - if (Array.isArray(obj.errors) && obj.errors.length > 0) { - const first = obj.errors[0] - if (typeof first === "string") return first - if (first && typeof first.message === "string") return first.message - } - // Last resort: try JSON.stringify for debuggability - try { - const json = JSON.stringify(error) - if (json !== "{}" && json.length < 500) return json - } catch (err) { - console.warn("[Kilo New] getErrorMessage: JSON.stringify failed", err) - } + if (!error || typeof error !== "object") return String(error) + + const obj = error as Record + if (typeof obj.message === "string") return obj.message + if (typeof obj.error === "string") return obj.error + + // SDK throwOnError shape: { error: { message: "..." } } + if (obj.error && typeof obj.error === "object") { + const nested = (obj.error as Record).message + if (typeof nested === "string") return nested } - return String(error) + + if (obj.data && typeof obj.data === "object") { + const fromData = messageFromData(obj.data as Record) + if (fromData) return fromData + } + + // BadRequestError: { errors: [...] } + const fromErrors = firstMessage(obj.errors) + if (fromErrors) return fromErrors + + return safeStringify(error) ?? String(error) +} + +/** + * Format a full human-readable breakdown of a config save failure, including + * the file path and every Zod issue. Used as the expandable details next to + * the short getErrorMessage() summary. + * + * Zod issues are formatted via zod's built-in `prettifyError` so the output + * matches Zod's canonical format (array indices rendered as `foo[0].bar`, etc). + * + * Returns undefined when the error doesn't carry structured config data — + * callers should omit the details section in that case. + */ +export function getConfigErrorDetails(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined + const data = (error as Record).data + if (!data || typeof data !== "object") return undefined + const scoped = data as Record + const path = typeof scoped.path === "string" ? scoped.path : undefined + const issues = Array.isArray(scoped.issues) ? scoped.issues : undefined + if (!path && (!issues || issues.length === 0)) return undefined + + const out: string[] = [] + if (path) out.push(`File: ${path}`) + if (issues && issues.length > 0) { + if (out.length > 0) out.push("") + // prettifyError accepts any object with an `issues` array; the cast is + // safe because it only reads the issues field. + out.push(prettifyError({ issues } as Parameters[0])) + } + return out.join("\n") } export class MessageConfirmation { diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index 5d9f38aae33..f383c5c162a 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -9,6 +9,8 @@ import { mapCloudSessionMessageToWebviewMessage, MessageConfirmation, mergeFileSearchResults, + getErrorMessage, + getConfigErrorDetails, type ProviderInfo, } from "../../src/kilo-provider-utils" import type { CloudSessionMessage } from "../../src/services/cli-backend/types" @@ -658,3 +660,141 @@ describe("mergeFileSearchResults", () => { expect(result).toEqual(["src/utils/path.ts", "src/index.ts"]) }) }) + +describe("getErrorMessage", () => { + it("extracts message from an Error instance", () => { + expect(getErrorMessage(new Error("boom"))).toBe("boom") + }) + + it("returns the string as-is", () => { + expect(getErrorMessage("plain text failure")).toBe("plain text failure") + }) + + it("reads a direct .message field", () => { + expect(getErrorMessage({ message: "bad input" })).toBe("bad input") + }) + + it("reads a direct .error string field", () => { + expect(getErrorMessage({ error: "nope" })).toBe("nope") + }) + + it("reads SDK throwOnError shape { error: { message } }", () => { + expect(getErrorMessage({ error: { message: "sdk said no" } })).toBe("sdk said no") + }) + + it("reads NotFoundError shape { data: { message } }", () => { + expect(getErrorMessage({ name: "NotFoundError", data: { message: "not found" } })).toBe("not found") + }) + + it("reads the first Zod issue message from ConfigInvalidError", () => { + const err = { + name: "ConfigInvalidError", + data: { + path: "/Users/me/.config/kilo/kilo.json", + issues: [ + { code: "unrecognized_keys", keys: ["indexing"], path: [], message: 'Unrecognized key: "indexing"' }, + { code: "invalid_type", path: ["timeout"], message: "Expected number" }, + ], + }, + } + expect(getErrorMessage(err)).toBe('Unrecognized key: "indexing"') + }) + + it("reads Hono validator shape { data: { error: [{ message }] } }", () => { + const err = { data: { error: [{ message: "required" }] }, success: false } + expect(getErrorMessage(err)).toBe("required") + }) + + it("reads Hono validator shape with string errors", () => { + expect(getErrorMessage({ data: { error: ["bad field"] } })).toBe("bad field") + }) + + it("reads BadRequestError shape { errors: [{ message }] }", () => { + expect(getErrorMessage({ errors: [{ message: "first error" }, { message: "second" }] })).toBe("first error") + }) + + it("reads BadRequestError shape with string errors", () => { + expect(getErrorMessage({ errors: ["boom"] })).toBe("boom") + }) + + it("falls back to JSON for unknown object shapes", () => { + expect(getErrorMessage({ weird: true, code: 42 })).toBe('{"weird":true,"code":42}') + }) + + it("falls back to String() for non-serializable values", () => { + expect(getErrorMessage(undefined)).toBe("undefined") + expect(getErrorMessage(null)).toBe("null") + expect(getErrorMessage(42)).toBe("42") + }) + + it("skips JSON fallback for empty objects", () => { + expect(getErrorMessage({})).toBe("[object Object]") + }) + + it("prefers .message over nested shapes", () => { + const err = { message: "outer", data: { issues: [{ message: "inner" }] } } + expect(getErrorMessage(err)).toBe("outer") + }) + + it("falls through when the first issue has no message", () => { + // firstMessage only inspects index 0, so an invalid first entry causes the + // branch to skip and the JSON fallback kicks in. + const err = { data: { issues: [{ code: "bad" }] } } + expect(getErrorMessage(err)).toBe('{"data":{"issues":[{"code":"bad"}]}}') + }) +}) + +describe("getConfigErrorDetails", () => { + it("returns undefined for non-object errors", () => { + expect(getConfigErrorDetails("oops")).toBeUndefined() + expect(getConfigErrorDetails(undefined)).toBeUndefined() + expect(getConfigErrorDetails(null)).toBeUndefined() + expect(getConfigErrorDetails(42)).toBeUndefined() + }) + + it("returns undefined when .data is missing", () => { + expect(getConfigErrorDetails({ message: "hi" })).toBeUndefined() + }) + + it("returns undefined when .data has no path or issues", () => { + expect(getConfigErrorDetails({ data: { unrelated: true } })).toBeUndefined() + }) + + it("formats a single-issue ConfigInvalidError", () => { + const err = { + data: { + path: "/home/me/.config/kilo/kilo.json", + issues: [{ code: "unrecognized_keys", keys: ["indexing"], path: [], message: 'Unrecognized key: "indexing"' }], + }, + } + expect(getConfigErrorDetails(err)).toBe('File: /home/me/.config/kilo/kilo.json\n\n✖ Unrecognized key: "indexing"') + }) + + it("formats a multi-issue ConfigInvalidError with paths (including array indices)", () => { + const err = { + data: { + path: "/cfg.json", + issues: [ + { path: ["timeout"], message: "Expected number" }, + { path: ["agents", 0, "name"], message: "Required" }, + ], + }, + } + expect(getConfigErrorDetails(err)).toBe( + "File: /cfg.json\n\n✖ Expected number\n → at timeout\n✖ Required\n → at agents[0].name", + ) + }) + + it("omits the path line when only issues are present", () => { + const err = { data: { issues: [{ path: [], message: "something" }] } } + expect(getConfigErrorDetails(err)).toBe("✖ something") + }) + + it("omits the issues section when only the path is present", () => { + expect(getConfigErrorDetails({ data: { path: "/cfg.json" } })).toBe("File: /cfg.json") + }) + + it("returns undefined when issues array is empty and no path", () => { + expect(getConfigErrorDetails({ data: { issues: [] } })).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx index 93e51157546..00baf09c9e3 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx @@ -34,9 +34,10 @@ const Settings: Component = (props) => { const server = useServer() const language = useLanguage() const vscode = useVSCode() - const { isDirty, saveConfig, discardConfig } = useConfig() + const { isDirty, saving, saveError, saveConfig, discardConfig } = useConfig() const session = useSession() const [active, setActive] = createSignal(props.tab ?? "models") + const [errorExpanded, setErrorExpanded] = createSignal(false) const busyCount = () => Object.values(session.allStatusMap()).filter((s) => s.type === "busy").length @@ -222,19 +223,46 @@ const Settings: Component = (props) => { {/* Save bar — slides in when there are unsaved config changes */} -
- {language.t("settings.saveBar.unsavedChanges")} - - -
+ +
+ + {(err) => ( +
+
setErrorExpanded((v) => !v)} + role="button" + aria-expanded={errorExpanded()} + > + + + + + {language.t("settings.saveBar.saveFailed")}:{" "} + {err().message} + +
+ +
{err().details ?? err().message}
+
+
+ )} +
+
+ {language.t("settings.saveBar.unsavedChanges")} + + +
+
+
) } diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index 4f98a1b957c..356e74f5d9f 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -14,10 +14,17 @@ import { useVSCode } from "./vscode" import type { Config, ExtensionMessage } from "../types/messages" import { deepMerge, stripNulls, resolveConfig } from "../utils/config-utils" +export interface SaveError { + message: string + details?: string +} + interface ConfigContextValue { config: Accessor loading: Accessor isDirty: Accessor + saving: Accessor + saveError: Accessor updateConfig: (partial: Partial) => void saveConfig: () => void discardConfig: () => void @@ -36,7 +43,10 @@ export const ConfigProvider: ParentComponent = (props) => { const [saved, setSaved] = createSignal({}) // True while a saveConfig() write is in-flight — used to clear draft on success // and to guard against stale configLoaded messages overwriting optimistic state. - let saving = false + const [saving, setSaving] = createSignal(false) + // Error from the most recent saveConfig() attempt, or null if no error. + // Cleared when the user edits the draft again or starts a new save. + const [saveError, setSaveError] = createSignal(null) // Register handler immediately (not in onMount) so we never miss // a configLoaded message that arrives before the DOM mount. @@ -44,7 +54,7 @@ export const ConfigProvider: ParentComponent = (props) => { if (message.type === "configLoaded") { // Skip if a save is in-flight — a stale configLoaded must not overwrite // the optimistically-updated state while the write is being confirmed. - if (saving) return + if (saving()) return // Re-apply the draft on top so pending changes (e.g. a toggled switch the // user hasn't saved yet) stay visible instead of snapping back. setConfig(resolveConfig(message.config, draft(), isDirty())) @@ -53,12 +63,13 @@ export const ConfigProvider: ParentComponent = (props) => { return } if (message.type === "configUpdated") { - if (saving) { + if (saving()) { // This configUpdated is the confirmation of our saveConfig() write. // Clear the draft now that the server has confirmed the write. - saving = false + setSaving(false) setDraft({}) setIsDirty(false) + setSaveError(null) setConfig(message.config) } else { // configUpdated from a different source (e.g. PermissionDock save). @@ -68,6 +79,13 @@ export const ConfigProvider: ParentComponent = (props) => { setSaved(message.config) return } + if (message.type === "configUpdateFailed") { + // The write was rejected (e.g. schema validation) — surface the error + // and keep the draft + isDirty so the user can correct and retry. + setSaving(false) + setSaveError({ message: message.message, details: message.details }) + return + } }) onCleanup(unsubscribe) @@ -102,6 +120,9 @@ export const ConfigProvider: ParentComponent = (props) => { // Accumulate in draft — will be sent on saveConfig() setDraft((prev) => deepMerge(prev as Config, partial)) setIsDirty(true) + // Clear any stale error from a previous failed save — the user is editing + // again, so the old error message no longer reflects the current draft. + setSaveError(null) } function saveConfig() { @@ -109,7 +130,8 @@ export const ConfigProvider: ParentComponent = (props) => { if (Object.keys(changes).length === 0) return // Don't clear draft/isDirty yet — wait for configUpdated confirmation. // If the write fails, the save bar stays visible so the user can retry. - saving = true + setSaving(true) + setSaveError(null) vscode.postMessage({ type: "updateConfig", config: changes }) } @@ -117,12 +139,15 @@ export const ConfigProvider: ParentComponent = (props) => { setConfig(saved()) setDraft({}) setIsDirty(false) + setSaveError(null) } const value: ConfigContextValue = { config, loading, isDirty, + saving, + saveError, updateConfig, saveConfig, discardConfig, diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index d9af1a49a08..18e2c7b51ce 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1387,6 +1387,8 @@ export const dict = { "settings.saveBar.warning.many": "عدة جلسات تعمل وستتوقف", "settings.saveBar.saveAnyway": "حفظ على أي حال", "settings.saveBar.cancel": "إلغاء", + "settings.saveBar.saving": "جارٍ الحفظ…", + "settings.saveBar.saveFailed": "تعذر حفظ الإعدادات", "notifications.action.next": "التالي", "notifications.action.close": "إغلاق", "notifications.action.tryModel": "جرّب {{model}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index e9750f2a777..e670c5c4edf 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1416,6 +1416,8 @@ export const dict = { "settings.saveBar.warning.many": "Várias sessões estão em execução e serão interrompidas", "settings.saveBar.saveAnyway": "Salvar mesmo assim", "settings.saveBar.cancel": "Cancelar", + "settings.saveBar.saving": "Salvando…", + "settings.saveBar.saveFailed": "Não foi possível salvar as configurações", "notifications.action.next": "Próximo", "notifications.action.close": "Fechar", "notifications.action.tryModel": "Experimentar {{model}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 3e52dd0d826..286b85c3ab9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1412,6 +1412,8 @@ export const dict = { "settings.saveBar.warning.many": "Nekoliko sesija je pokrenuto i bit će prekinuto", "settings.saveBar.saveAnyway": "Spremi svejedno", "settings.saveBar.cancel": "Otkaži", + "settings.saveBar.saving": "Spremanje…", + "settings.saveBar.saveFailed": "Postavke nije moguće spremiti", "notifications.action.next": "Sljedeći", "notifications.action.close": "Zatvori", "notifications.action.tryModel": "Probaj {{model}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 36c50b8f2bf..b384f04935e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1403,6 +1403,8 @@ export const dict = { "settings.saveBar.warning.many": "Flere sessioner kører og vil blive afbrudt", "settings.saveBar.saveAnyway": "Gem alligevel", "settings.saveBar.cancel": "Annuller", + "settings.saveBar.saving": "Gemmer…", + "settings.saveBar.saveFailed": "Kunne ikke gemme indstillinger", "notifications.action.next": "Næste", "notifications.action.close": "Luk", "notifications.action.tryModel": "Prøv {{model}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index b12d55c6f05..a4a48c4205b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1430,6 +1430,8 @@ export const dict = { "settings.saveBar.warning.many": "Mehrere Sitzungen laufen und werden unterbrochen", "settings.saveBar.saveAnyway": "Trotzdem speichern", "settings.saveBar.cancel": "Abbrechen", + "settings.saveBar.saving": "Speichern…", + "settings.saveBar.saveFailed": "Einstellungen konnten nicht gespeichert werden", "notifications.action.next": "Weiter", "notifications.action.close": "Schließen", "notifications.action.tryModel": "{{model}} ausprobieren", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 974ab2cc136..456fa712f27 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1408,10 +1408,12 @@ export const dict = { "settings.saveBar.unsavedChanges": "Unsaved changes", "settings.saveBar.discard": "Discard", "settings.saveBar.save": "Save", + "settings.saveBar.saving": "Saving…", "settings.saveBar.warning.one": "One session is running and will be interrupted", "settings.saveBar.warning.many": "Several sessions are running and will be interrupted", "settings.saveBar.saveAnyway": "Save anyway", "settings.saveBar.cancel": "Cancel", + "settings.saveBar.saveFailed": "Couldn't save settings", "notifications.action.next": "Next", "notifications.action.close": "Close", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index ea635c224d5..d6d289188ef 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1419,6 +1419,8 @@ export const dict = { "settings.saveBar.warning.many": "Varias sesiones están en ejecución y se interrumpirán", "settings.saveBar.saveAnyway": "Guardar de todas formas", "settings.saveBar.cancel": "Cancelar", + "settings.saveBar.saving": "Guardando…", + "settings.saveBar.saveFailed": "No se pudieron guardar los ajustes", "notifications.action.next": "Siguiente", "notifications.action.close": "Cerrar", "notifications.action.tryModel": "Probar {{model}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 88d81a047f6..44747f5378a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1434,6 +1434,8 @@ export const dict = { "settings.saveBar.warning.one": "Une session est en cours et sera interrompue", "settings.saveBar.warning.many": "Plusieurs sessions sont en cours et seront interrompues", "settings.saveBar.saveAnyway": "Enregistrer quand même", + "settings.saveBar.saving": "Enregistrement…", + "settings.saveBar.saveFailed": "Impossible d'enregistrer les paramètres", "settings.saveBar.cancel": "Annuler", "notifications.action.next": "Suivant", "notifications.action.close": "Fermer", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 26c27eed570..dd0b62b62a5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1402,6 +1402,8 @@ export const dict = { "settings.saveBar.warning.many": "複数のセッションが実行中で中断されます", "settings.saveBar.saveAnyway": "それでも保存", "settings.saveBar.cancel": "キャンセル", + "settings.saveBar.saving": "保存中…", + "settings.saveBar.saveFailed": "設定を保存できませんでした", "notifications.action.next": "次へ", "notifications.action.close": "閉じる", "notifications.action.tryModel": "{{model}}を試す", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 646752102e8..b4135277e05 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1390,6 +1390,8 @@ export const dict = { "settings.saveBar.warning.many": "여러 세션이 실행 중이며 중단됩니다", "settings.saveBar.saveAnyway": "그래도 저장", "settings.saveBar.cancel": "취소", + "settings.saveBar.saving": "저장 중…", + "settings.saveBar.saveFailed": "설정을 저장할 수 없습니다", "notifications.action.next": "다음", "notifications.action.close": "닫기", "notifications.action.tryModel": "{{model}} 시도", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index ad43beb6a0d..65618ee54ee 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1397,6 +1397,8 @@ export const dict = { "settings.saveBar.warning.many": "Meerdere sessies zijn actief en worden onderbroken", "settings.saveBar.saveAnyway": "Toch opslaan", "settings.saveBar.cancel": "Annuleren", + "settings.saveBar.saving": "Bezig met opslaan…", + "settings.saveBar.saveFailed": "Instellingen konden niet worden opgeslagen", "notifications.action.next": "Volgende", "notifications.action.close": "Sluiten", "notifications.action.tryModel": "Probeer {{model}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 938921c4376..76478b1d3fd 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1401,6 +1401,8 @@ export const dict = { "settings.saveBar.warning.many": "Flere økter kjører og vil bli avbrutt", "settings.saveBar.saveAnyway": "Lagre uansett", "settings.saveBar.cancel": "Avbryt", + "settings.saveBar.saving": "Lagrer…", + "settings.saveBar.saveFailed": "Kunne ikke lagre innstillinger", "notifications.action.next": "Neste", "notifications.action.close": "Lukk", "notifications.action.tryModel": "Prøv {{model}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 0185c1b2541..c6f9704bd1a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1411,6 +1411,8 @@ export const dict = { "settings.saveBar.warning.many": "Kilka sesji jest uruchomionych i zostanie przerwanych", "settings.saveBar.saveAnyway": "Zapisz mimo to", "settings.saveBar.cancel": "Anuluj", + "settings.saveBar.saving": "Zapisywanie…", + "settings.saveBar.saveFailed": "Nie można zapisać ustawień", "notifications.action.next": "Następny", "notifications.action.close": "Zamknij", "notifications.action.tryModel": "Wypróbuj {{model}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 42d957833a9..53dc94605e1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1410,6 +1410,8 @@ export const dict = { "settings.saveBar.warning.many": "Несколько сеансов выполняются и будут прерваны", "settings.saveBar.saveAnyway": "Сохранить в любом случае", "settings.saveBar.cancel": "Отмена", + "settings.saveBar.saving": "Сохранение…", + "settings.saveBar.saveFailed": "Не удалось сохранить настройки", "notifications.action.next": "Далее", "notifications.action.close": "Закрыть", "notifications.action.tryModel": "Попробовать {{model}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index b0b5ea64f58..a3fe5041711 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1386,6 +1386,8 @@ export const dict = { "settings.saveBar.warning.many": "มีหลายเซสชันกำลังทำงานและจะถูกขัดจังหวะ", "settings.saveBar.saveAnyway": "บันทึกต่อไป", "settings.saveBar.cancel": "ยกเลิก", + "settings.saveBar.saving": "กำลังบันทึก…", + "settings.saveBar.saveFailed": "ไม่สามารถบันทึกการตั้งค่าได้", "notifications.action.next": "ถัดไป", "notifications.action.close": "ปิด", "notifications.action.tryModel": "ลองใช้ {{model}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 0af11b4da0f..f1131b1a696 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1389,6 +1389,8 @@ export const dict = { "settings.saveBar.warning.many": "Birden fazla oturum çalışıyor ve kesintiye uğrayacak", "settings.saveBar.saveAnyway": "Yine de kaydet", "settings.saveBar.cancel": "İptal", + "settings.saveBar.saving": "Kaydediliyor…", + "settings.saveBar.saveFailed": "Ayarlar kaydedilemedi", "notifications.action.next": "Sonraki", "notifications.action.close": "Kapat", "notifications.action.tryModel": "Dene {{model}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index c88a605fd50..faac8c4e784 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1390,6 +1390,8 @@ export const dict = { "settings.saveBar.warning.many": "Кілька сесій виконуються і будуть перервані", "settings.saveBar.saveAnyway": "Зберегти все одно", "settings.saveBar.cancel": "Скасувати", + "settings.saveBar.saving": "Збереження…", + "settings.saveBar.saveFailed": "Не вдалося зберегти налаштування", "notifications.action.next": "Далі", "notifications.action.close": "Закрити", "notifications.action.tryModel": "Спробувати {{model}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 88d01c67f0d..41db11abaf7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1358,6 +1358,8 @@ export const dict = { "settings.saveBar.warning.one": "一个会话正在运行,将被中断", "settings.saveBar.warning.many": "多个会话正在运行,将被中断", "settings.saveBar.saveAnyway": "仍然保存", + "settings.saveBar.saving": "保存中…", + "settings.saveBar.saveFailed": "无法保存设置", "settings.saveBar.cancel": "取消", "notifications.action.next": "下一个", "notifications.action.close": "关闭", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index fde6078ccf6..0be02d147b2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1363,6 +1363,8 @@ export const dict = { "settings.saveBar.warning.many": "多個工作階段正在執行,將被中斷", "settings.saveBar.saveAnyway": "仍然儲存", "settings.saveBar.cancel": "取消", + "settings.saveBar.saving": "儲存中…", + "settings.saveBar.saveFailed": "無法儲存設定", "notifications.action.next": "下一個", "notifications.action.close": "關閉", "notifications.action.tryModel": "嘗試 {{model}}", diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 43eed3f123a..47f3b61e3ab 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -227,6 +227,8 @@ const ConfigWrapper: ParentComponent<{ config?: Config }> = (props) => { config: () => props.config!, loading: () => false, isDirty: () => false, + saving: () => false, + saveError: () => null, updateConfig: noop, saveConfig: noop, discardConfig: noop, diff --git a/packages/kilo-vscode/webview-ui/src/styles/settings.css b/packages/kilo-vscode/webview-ui/src/styles/settings.css index fb56f04f714..b2d6eada5fa 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/settings.css +++ b/packages/kilo-vscode/webview-ui/src/styles/settings.css @@ -1,23 +1,15 @@ /* Settings save bar */ +.settings-save-bar-wrap { + display: flex; + flex-direction: column; + border-top: 1px solid var(--border-weak-base); +} + .settings-save-bar { display: flex; align-items: center; justify-content: flex-end; gap: 8px; - padding: 0 16px; - border-top: 1px solid var(--border-weak-base); - overflow: hidden; - max-height: 0; - opacity: 0; - transition: - max-height 0.2s ease, - opacity 0.2s ease, - padding 0.2s ease; -} - -.settings-save-bar--visible { - max-height: 52px; - opacity: 1; padding: 8px 16px; } @@ -27,6 +19,62 @@ margin-right: auto; } +/* Save error — mirrors the startup-error-banner style */ +.settings-save-bar-error { + border: 1px solid var(--vscode-inputValidation-errorBorder, #f14c4c); + border-radius: 4px; + background: var(--vscode-inputValidation-errorBackground, rgba(241, 76, 76, 0.1)); + margin: 8px 16px 0 16px; + font-size: 12px; +} + +.settings-save-bar-error-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + cursor: pointer; + user-select: none; + min-width: 0; +} + +.settings-save-bar-error-title { + flex: 1; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.settings-save-bar-error-firstline { + font-weight: 400; +} + +.settings-save-bar-error-chevron { + display: flex; + align-items: center; + flex-shrink: 0; + transition: transform 150ms; +} + +.settings-save-bar-error-chevron-expanded { + transform: rotate(90deg); +} + +.settings-save-bar-error-details { + margin: 0; + padding: 8px 10px; + border-top: 1px solid var(--vscode-inputValidation-errorBorder, #f14c4c); + background: var(--vscode-editor-background); + font-family: var(--vscode-editor-font-family, monospace); + font-size: 11px; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; +} + [data-slot="settings-row"] { display: flex; flex-wrap: wrap; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 7d3e0e9ec7d..7d5d912474c 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -786,6 +786,12 @@ export interface ConfigUpdatedMessage { config: Config } +export interface ConfigUpdateFailedMessage { + type: "configUpdateFailed" + message: string + details?: string +} + export interface GlobalConfigLoadedMessage { type: "globalConfigLoaded" config: Config @@ -1500,6 +1506,7 @@ export type ExtensionMessage = | ClaudeCompatSettingLoadedMessage | ConfigLoadedMessage | ConfigUpdatedMessage + | ConfigUpdateFailedMessage | GlobalConfigLoadedMessage | NotificationSettingsLoadedMessage | TimelineSettingLoadedMessage From 5a4daf692cbe2c03fb64786cd75adda15ebf86c0 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 12:40:23 +0300 Subject: [PATCH 16/32] ci: trigger workflows From 343455b87895a0551760b5710b1ffe58fae21efd Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 12:47:35 +0300 Subject: [PATCH 17/32] fix(cli): preserve per-agent model overrides (#9050) Gate the agent-change auto-apply effect on model.json load and skip it when a per-agent pick already exists, so user-selected models for agents with a configured default no longer revert on agent switches or restarts. --- .changeset/fix-per-agent-model-override.md | 6 + .../src/cli/cmd/tui/context/local.tsx | 36 ++++-- .../test/kilocode/local-model.test.ts | 120 ++++++++++++++++++ 3 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-per-agent-model-override.md diff --git a/.changeset/fix-per-agent-model-override.md b/.changeset/fix-per-agent-model-override.md new file mode 100644 index 00000000000..a58ec1106fd --- /dev/null +++ b/.changeset/fix-per-agent-model-override.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Respect per-agent model selections when an agent has a `model` configured in `kilo.jsonc`. Switching the model for such an agent now sticks across agent switches and CLI restarts. To pick up a newly edited agent default, re-select the model once (or clear `~/.local/share/kilo/storage/model.json`). diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 895aad8d28e..c24f167c057 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -216,6 +216,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ get ready() { return modelStore.ready }, + // kilocode_change start - expose saved per-agent pick for auto-apply guard + saved(name: string) { + return modelStore.model[name] + }, + // kilocode_change end recent() { return modelStore.recent }, @@ -409,21 +414,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // Automatically update model when agent changes createEffect(() => { + // kilocode_change start - wait for persistence load; don't overwrite saved per-agent picks (#9050) + if (!model.ready) return + // kilocode_change end const value = agent.current() if (!value) return // kilocode_change - guard against empty agent list during org switch - if (value.model) { - if (isModelValid(value.model)) - model.set({ - providerID: value.model.providerID, - modelID: value.model.modelID, - }) - else - toast.show({ - variant: "warning", - message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, - duration: 3000, - }) - } + // kilocode_change start - skip when the user (or a previous session) already picked a model + if (!value.model) return + if (model.saved(value.name)) return + // kilocode_change end + if (isModelValid(value.model)) + model.set({ + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + else + toast.show({ + variant: "warning", + message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, + duration: 3000, + }) }) const result = { diff --git a/packages/opencode/test/kilocode/local-model.test.ts b/packages/opencode/test/kilocode/local-model.test.ts index 1920f1b7070..43f61938606 100644 --- a/packages/opencode/test/kilocode/local-model.test.ts +++ b/packages/opencode/test/kilocode/local-model.test.ts @@ -473,3 +473,123 @@ describe("edge cases and error handling", () => { } }) }) + +// ── Regression tests for #9050 ────────────────────────────────────────────── +// The auto-apply createEffect in local.tsx previously clobbered user-selected +// per-agent models whenever it re-fired. The fix gates it on (a) modelStore.ready +// and (b) the absence of an existing saved entry for that agent. + +describe("#9050: auto-apply effect respects saved per-agent selection", () => { + test("13: fresh start — config model for active agent is applied after ready", async () => { + // plan is second; code (first) has no config model. Switch to plan post-init. + mockAgents = [ + { name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, + { name: "plan", mode: "primary", hidden: false, model: OPUS, color: undefined, permission: {} }, + ] + const { local, dispose } = await initLocal() + try { + // Effect should not touch the code agent (no config model). + expect(local.model.saved("code")).toBeUndefined() + + local.agent.set("plan") + // Give the effect time to re-run now that agent.current() changed. + await Bun.sleep(50) + + // First-time application: no saved entry → config model applied and persisted. + expect(local.model.saved("plan")).toEqual(OPUS) + const data = await readModelJson() + expect(data.model.plan).toEqual(OPUS) + } finally { + dispose() + } + }) + + test("14: saved entry from model.json is preserved over a differing config model", async () => { + // Config says plan → OPUS; saved file says plan → SONNET. Saved must win. + mockAgents = [ + { name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, + { name: "plan", mode: "primary", hidden: false, model: OPUS, color: undefined, permission: {} }, + ] + const { local, dispose } = await initLocal({ + prewrite: { + recent: [SONNET], + model: { plan: SONNET }, + favorite: [], + variant: {}, + }, + }) + try { + local.agent.set("plan") + await Bun.sleep(50) + + // The fix: effect sees an existing saved entry and leaves it alone. + expect(local.model.saved("plan")).toEqual(SONNET) + const data = await readModelJson() + expect(data.model.plan).toEqual(SONNET) + } finally { + dispose() + } + }) + + test("15: user override of a config-model agent sticks across agent switches", async () => { + // plan has config model OPUS; user picks SONNET for plan; switching away + // and back must not revert to OPUS. + mockAgents = [ + { name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, + { name: "plan", mode: "primary", hidden: false, model: OPUS, color: undefined, permission: {} }, + ] + const { local, dispose } = await initLocal() + try { + local.agent.set("plan") + await Bun.sleep(50) + // Effect applied config default (no saved entry yet). + expect(local.model.saved("plan")).toEqual(OPUS) + + // User picks a different model. + local.model.set(SONNET, { recent: true }) + await Bun.sleep(50) + expect(local.model.saved("plan")).toEqual(SONNET) + + // Bounce agents. + local.agent.set("code") + await Bun.sleep(50) + local.agent.set("plan") + await Bun.sleep(50) + + // Saved pick survives. + expect(local.model.saved("plan")).toEqual(SONNET) + const data = await readModelJson() + expect(data.model.plan).toEqual(SONNET) + } finally { + dispose() + } + }) + + test("16: invalid config model still emits a warning toast", async () => { + // Ensure the fix didn't silence the existing invalid-model warning path. + mockAgents = [ + { name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, + { + name: "plan", + mode: "primary", + hidden: false, + model: { providerID: "nonexistent", modelID: "fake-model" }, + color: undefined, + permission: {}, + }, + ] + const { local, dispose } = await initLocal() + try { + toastMessages = [] + local.agent.set("plan") + await Bun.sleep(50) + + const warnings = toastMessages.filter((t) => t.variant === "warning" && t.message.includes("not valid")) + expect(warnings.length).toBeGreaterThan(0) + // And no bogus value was written. + expect(local.model.saved("plan")).toBeUndefined() + } finally { + dispose() + } + }) +}) From 64eb4343f82566dbfc30e5e669372a69c9ab98a8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 17 Apr 2026 09:48:32 +0000 Subject: [PATCH 18/32] chore: update kilo-vscode visual regression baselines --- ...t-view-with-pending-question-empty-input-chromium-linux.png | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/chat-view-with-pending-question-empty-input-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/chat-view-with-pending-question-empty-input-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/chat-view-with-pending-question-empty-input-chromium-linux.png new file mode 100644 index 00000000000..2efeec8e48b --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/chat-view-with-pending-question-empty-input-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8476ecb49183dbfed9be2924a8aa4622e8b95eb098c50609b986f9b8eb6cfe2d +size 14390 From 530125828e891d3c50fe8d783201b65e3c4db8e4 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 17 Apr 2026 11:59:31 +0200 Subject: [PATCH 19/32] Add folder mentions (#9023) * feat(vscode): add folder mentions * fix(vscode): harden folder mentions * refactor(vscode): tighten folder mention plumbing * fix(cli): annotate kilo-specific read.ts exports --- .changeset/folder-mentions.md | 6 + packages/kilo-vscode/src/KiloProvider.ts | 34 ++--- .../src/agent-manager/AgentManagerProvider.ts | 3 + .../__tests__/AgentManagerProvider.spec.ts | 14 +++ .../kilo-vscode/src/kilo-provider-utils.ts | 14 ++- .../src/kilo-provider/file-search-items.ts | 45 +++++++ .../src/kilo-provider/file-search.ts | 54 ++++++++ .../tests/unit/file-mention-utils.test.ts | 5 + .../tests/unit/kilo-provider-utils.test.ts | 52 ++++++++ .../src/components/chat/PromptInput.tsx | 16 ++- .../src/hooks/file-mention-utils.ts | 14 ++- .../webview-ui/src/hooks/useFileMention.ts | 21 ++-- .../webview-ui/src/types/messages.ts | 7 ++ .../src/kilocode/tool/read-directory.ts | 47 +++++++ packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/tool/read.ts | 23 +++- .../test/kilocode/read-directory.test.ts | 116 ++++++++++++++++++ 17 files changed, 426 insertions(+), 47 deletions(-) create mode 100644 .changeset/folder-mentions.md create mode 100644 packages/kilo-vscode/src/kilo-provider/file-search-items.ts create mode 100644 packages/kilo-vscode/src/kilo-provider/file-search.ts create mode 100644 packages/opencode/src/kilocode/tool/read-directory.ts create mode 100644 packages/opencode/test/kilocode/read-directory.test.ts diff --git a/.changeset/folder-mentions.md b/.changeset/folder-mentions.md new file mode 100644 index 00000000000..2d7a9983a2e --- /dev/null +++ b/.changeset/folder-mentions.md @@ -0,0 +1,6 @@ +--- +"kilo-code": minor +"@kilocode/cli": patch +--- + +Support mentioning folders in the prompt with @ references, including top-level folder file contents. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index fef52ba77fd..d7c66637e03 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -32,7 +32,6 @@ import { flushPendingSessionRefresh as flushPendingSessionRefreshUtil, resolveContextDirectory, resolveWorkspaceDirectory, - mergeFileSearchResults, SessionStreamScheduler, type SessionRefreshContext, } from "./kilo-provider-utils" @@ -47,6 +46,7 @@ import { retry } from "./services/cli-backend/retry" import { slimPart, slimParts } from "./kilo-provider/slim-metadata" import { handleContinueInWorktree } from "./kilo-provider/continue-worktree" import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files" +import { handleFileSearch } from "./kilo-provider/file-search" import { getTerminalContents } from "./services/terminal/context" import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session" import { childID } from "./kilo-provider/task-session" @@ -815,29 +815,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) break } - case "requestFileSearch": { - const sdkClient = this.client - if (sdkClient) { - const dir = this.getWorkspaceDirectory(this.currentSession?.id) - const openPaths = dir ? await this.getOpenTabPaths(dir) : new Set() - void sdkClient.find - .files({ query: message.query, directory: dir, type: "file", limit: 50 }, { throwOnError: true }) - .then(({ data: paths }) => { - const uri = vscode.window.activeTextEditor?.document.uri - const active = - uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath).replaceAll("\\", "/") : undefined - const result = mergeFileSearchResults({ query: message.query, backend: paths, open: openPaths, active }) - this.postMessage({ type: "fileSearchResult", paths: result, dir, requestId: message.requestId }) - }) - .catch((error: unknown) => { - console.error("[Kilo New] File search failed:", error) - this.postMessage({ type: "fileSearchResult", paths: [], dir, requestId: message.requestId }) - }) - } else { - this.postMessage({ type: "fileSearchResult", paths: [], dir: "", requestId: message.requestId }) - } + case "requestFileSearch": + await handleFileSearch({ + client: this.client, + message, + current: this.currentSession?.id, + context: this.contextSessionID, + dir: (id) => this.getWorkspaceDirectory(id), + open: (dir) => this.getOpenTabPaths(dir), + post: (msg) => this.postMessage(msg), + }) break - } case "requestTerminalContext": void this.handleTerminalContext(message.requestId) break diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 1777c8c8e93..d9ffd42f01d 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -265,6 +265,9 @@ export class AgentManagerProvider implements Disposable { private async onMessage(msg: Record): Promise | null> { if (this.prBridge.handleMessage(msg)) return null + if (msg.type === "requestFileSearch" && typeof msg.sessionID !== "string" && this.activeSessionId) { + return { ...msg, sessionID: this.activeSessionId } + } const m = msg as unknown as AgentManagerInMessage const worktree = await this.onWorktreeMessage(m) diff --git a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts index e8abf7d769e..139962dd4c5 100644 --- a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts +++ b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts @@ -98,6 +98,8 @@ function createHarness() { const manager = Object.create(AgentManagerProvider.prototype) as { host: Host panel: { sessions: { registerSession: ReturnType } } | undefined + prBridge: { handleMessage: ReturnType } + activeSessionId: string | undefined stateReady: Promise | undefined createWorktreeOnDisk: ReturnType runSetupScriptForWorktree: ReturnType @@ -107,6 +109,7 @@ function createHarness() { notifyWorktreeReady: ReturnType log: ReturnType onCreateWorktree: () => Promise + onMessage: (msg: Record) => Promise | null> } manager.host = host @@ -115,6 +118,8 @@ function createHarness() { registerSession: vi.fn(), }, } + manager.prBridge = { handleMessage: vi.fn().mockReturnValue(false) } + manager.activeSessionId = undefined manager.stateReady = Promise.resolve() manager.createWorktreeOnDisk = vi.fn() manager.runSetupScriptForWorktree = vi.fn().mockResolvedValue(undefined) @@ -169,4 +174,13 @@ describe("AgentManagerProvider worktree creation", () => { expect(manager.createWorktreeOnDisk).toHaveBeenCalledTimes(1) }) + + it("routes file search through the active worktree session", async () => { + const manager = createHarness() + manager.activeSessionId = "session-wt" + + const result = await manager.onMessage({ type: "requestFileSearch", query: "src", requestId: "r1" }) + + expect(result).toEqual({ type: "requestFileSearch", query: "src", requestId: "r1", sessionID: "session-wt" }) + }) }) diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index f1e8df50e9e..3c27dec87c8 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -564,12 +564,16 @@ export function mergeFileSearchResults(input: { open: Set active?: string }): string[] { - const query = input.query.trim().toLowerCase() + const norm = (p: string) => p.replaceAll("\\", "/") + const query = norm(input.query).trim().toLowerCase() + const open = new Set([...input.open].map(norm)) + const active = input.active ? norm(input.active) : undefined + const backend = input.backend.map(norm) const ok = (p: string) => !query || p.toLowerCase().includes(query) const tabs = - input.active && input.open.has(input.active) && ok(input.active) - ? [input.active, ...[...input.open].filter((p) => p !== input.active && ok(p))] - : [...input.open].filter(ok) + active && open.has(active) && ok(active) + ? [active, ...[...open].filter((p) => p !== active && ok(p))] + : [...open].filter(ok) const seen = new Set(tabs) - return [...tabs, ...input.backend.filter((p) => !seen.has(p))] + return [...tabs, ...backend.filter((p) => !seen.has(p))] } diff --git a/packages/kilo-vscode/src/kilo-provider/file-search-items.ts b/packages/kilo-vscode/src/kilo-provider/file-search-items.ts new file mode 100644 index 00000000000..83bb074b64e --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/file-search-items.ts @@ -0,0 +1,45 @@ +export type FileSearchItem = { path: string; type: "file" | "folder" } + +const normalize = (p: string) => p.replaceAll("\\", "/") +const trim = (p: string) => normalize(p).replace(/\/+$/, "") + +function base(p: string): string { + const clean = trim(p) + return clean.split("/").pop() ?? clean +} + +function rank(query: string, p: string): number { + const clean = trim(p).toLowerCase() + const name = base(p).toLowerCase() + if (clean === query || name === query) return 0 + if (name.startsWith(query) || (query.includes("/") && clean.startsWith(query))) return 1 + if (name.includes(query)) return 2 + if (clean.includes(query)) return 3 + return 4 +} + +export function mergeFileSearchItems(input: { query: string; files: string[]; folders: string[] }): FileSearchItem[] { + const query = normalize(input.query).trim().toLowerCase() + const files = input.files.map((p) => ({ path: normalize(p), type: "file" as const })) + // Dedup folders against themselves; a file and a folder that share a stem are distinct entries. + const seen = new Set() + const folders = input.folders + .filter((p) => { + const key = trim(p) + if (seen.has(key)) return false + seen.add(key) + return true + }) + .map((p, index) => ({ + item: { path: normalize(p), type: "folder" as const }, + index, + rank: query ? rank(query, p) : 4, + })) + + if (!query) return [...files, ...folders.map((x) => x.item)] + + const sorted = [...folders].sort((a, b) => a.rank - b.rank || a.index - b.index) + const boosted = sorted.filter((x) => x.rank <= 1).map((x) => x.item) + const rest = sorted.filter((x) => x.rank > 1).map((x) => x.item) + return [...boosted, ...files, ...rest] +} diff --git a/packages/kilo-vscode/src/kilo-provider/file-search.ts b/packages/kilo-vscode/src/kilo-provider/file-search.ts new file mode 100644 index 00000000000..dbac4cba7f3 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/file-search.ts @@ -0,0 +1,54 @@ +import * as path from "path" +import * as vscode from "vscode" +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { mergeFileSearchResults } from "../kilo-provider-utils" +import { mergeFileSearchItems } from "./file-search-items" + +type Message = { + query: string + requestId: string + sessionID?: string +} + +type Input = { + client: KiloClient | null + message: Message + current?: string + context?: string + dir: (id?: string) => string + open: (dir: string) => Promise> + post: (message: unknown) => void +} + +export async function handleFileSearch(input: Input): Promise { + const client = input.client + if (!client) { + input.post({ type: "fileSearchResult", paths: [], items: [], dir: "", requestId: input.message.requestId }) + return + } + + const id = input.message.sessionID ?? input.current ?? input.context + const dir = input.dir(id) + const open = dir ? await input.open(dir) : new Set() + + const query = input.message.query + void Promise.allSettled([ + client.find.files({ query, directory: dir, type: "file", limit: 50 }, { throwOnError: true }), + client.find.files({ query, directory: dir, type: "directory", limit: 50 }, { throwOnError: true }), + ]).then(([fileRes, folderRes]) => { + const files = settled(fileRes, "file") + const folders = settled(folderRes, "folder") + const uri = vscode.window.activeTextEditor?.document.uri + const rel = uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath) : undefined + const active = rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.replaceAll("\\", "/") : undefined + const result = mergeFileSearchResults({ query, backend: files, open, active }) + const items = mergeFileSearchItems({ query, files: result, folders }) + input.post({ type: "fileSearchResult", paths: result, items, dir, requestId: input.message.requestId }) + }) +} + +function settled(result: PromiseSettledResult<{ data: string[] }>, kind: "file" | "folder"): string[] { + if (result.status === "fulfilled") return result.value.data + console.error(`[Kilo New] File search (${kind}) failed:`, result.reason) + return [] +} diff --git a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts index 56e7d31d0c5..9c8f71e8841 100644 --- a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -50,6 +50,11 @@ describe("buildMentionResults", () => { const result = buildMentionResults("src", ["src/index.ts"]) expect(result.map((item) => item.type)).toEqual(["file"]) }) + + it("includes folder results", () => { + const result = buildMentionResults("src", [{ path: "src", type: "folder" }]) + expect(result).toEqual([{ type: "folder", value: "src" }]) + }) }) describe("syncMentionedPaths", () => { diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index e653cfa507f..06641023b9a 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -13,6 +13,7 @@ import { getConfigErrorDetails, type ProviderInfo, } from "../../src/kilo-provider-utils" +import { mergeFileSearchItems } from "../../src/kilo-provider/file-search-items" import type { CloudSessionMessage } from "../../src/services/cli-backend/types" import type { Session, @@ -606,6 +607,47 @@ describe("mapCloudSessionMessage", () => { }) }) +describe("mergeFileSearchItems", () => { + it("puts exact folder matches before file matches", () => { + const result = mergeFileSearchItems({ + query: "script", + files: ["script/hooks", "script/release", "script/beta.ts"], + folders: ["script/", "script/run-script/"], + }) + expect(result).toEqual([ + { path: "script/", type: "folder" }, + { path: "script/hooks", type: "file" }, + { path: "script/release", type: "file" }, + { path: "script/beta.ts", type: "file" }, + { path: "script/run-script/", type: "folder" }, + ]) + }) + + it("keeps file ordering before non-prefix folder matches", () => { + const result = mergeFileSearchItems({ + query: "test", + files: ["src/test.ts"], + folders: ["src/latest/"], + }) + expect(result).toEqual([ + { path: "src/test.ts", type: "file" }, + { path: "src/latest/", type: "folder" }, + ]) + }) + + it("normalizes Windows separators for matching and output", () => { + const result = mergeFileSearchItems({ + query: "kilo-vscode", + files: ["packages\\kilo-vscode\\src\\KiloProvider.ts"], + folders: ["packages\\kilo-vscode\\"], + }) + expect(result).toEqual([ + { path: "packages/kilo-vscode/", type: "folder" }, + { path: "packages/kilo-vscode/src/KiloProvider.ts", type: "file" }, + ]) + }) +}) + describe("mergeFileSearchResults", () => { it("returns backend results when no open files", () => { const result = mergeFileSearchResults({ @@ -699,6 +741,16 @@ describe("mergeFileSearchResults", () => { }) expect(result).toEqual(["src/utils/path.ts", "src/index.ts"]) }) + + it("normalizes backslash paths before filtering and deduping", () => { + const result = mergeFileSearchResults({ + query: "utils/path", + backend: ["src\\utils\\path.ts"], + open: new Set(["src/utils/path.ts"]), + active: "src\\utils\\path.ts", + }) + expect(result).toEqual(["src/utils/path.ts"]) + }) }) describe("getErrorMessage", () => { diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 6679a395e5c..98937c95713 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -65,7 +65,10 @@ export const PromptInput: Component = (props) => { const vscode = useVSCode() const worktree = useWorktreeMode() const dialog = useDialog() - const mention = useFileMention(vscode) + const mention = useFileMention( + vscode, + () => session.currentSessionID() ?? props.pendingSessionID ?? session.draftSessionID(), + ) const terminal = useTerminalContext(vscode) const excluded = worktree ? new Set(["sessions"]) : undefined const slash = useSlashCommand(vscode, excluded) @@ -715,7 +718,7 @@ export const PromptInput: Component = (props) => {
0} - fallback={
No files found
} + fallback={
No files or folders found
} > {(item, index) => ( @@ -736,8 +739,13 @@ export const PromptInput: Component = (props) => { ) : ( <> - - {fileName(item.value)} + + + {item.type === "folder" ? `${fileName(item.value)}/` : fileName(item.value)} + {dirName(item.value)} )} diff --git a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts index 5536ed434bd..bcb197a3455 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts @@ -1,4 +1,4 @@ -import type { FileAttachment } from "../types/messages" +import type { FileAttachment, FileSearchItem } from "../types/messages" import { TERMINAL_MENTION } from "./terminal-context-utils" export const AT_PATTERN = /(?:^|\s)@(\S*)$/ @@ -6,6 +6,7 @@ export const AT_PATTERN = /(?:^|\s)@(\S*)$/ export type MentionResult = | { type: "terminal"; value: typeof TERMINAL_MENTION; label: string; description: string } | { type: "file"; value: string } + | { type: "folder"; value: string } export const TERMINAL_RESULT: MentionResult = { type: "terminal", @@ -27,8 +28,13 @@ export function getTerminalMentionResult(query: string): MentionResult[] { return [TERMINAL_RESULT] } -export function buildMentionResults(query: string, paths: string[]): MentionResult[] { - return [...getTerminalMentionResult(query), ...paths.map((path) => ({ type: "file" as const, value: path }))] +export function buildMentionResults(query: string, items: Array): MentionResult[] { + const results: MentionResult[] = items.map((item) => { + if (typeof item === "string") return { type: "file", value: item } + if (item.type === "folder") return { type: "folder", value: item.path } + return { type: "file", value: item.path } + }) + return [...getTerminalMentionResult(query), ...results] } /** @@ -50,7 +56,7 @@ export function syncMentionedPaths(prev: Set, text: string): Set } /** - * Replace the @mention pattern before the cursor with the selected file path. + * Replace the @mention pattern before the cursor with the selected path. * Returns the new text string. */ export function buildTextAfterMentionSelect(before: string, after: string, path: string): string { diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts index d3643722830..dc11f4afef1 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts @@ -42,7 +42,7 @@ export interface FileMention { addPaths: (paths: string[], cwd: string) => void } -export function useFileMention(vscode: VSCodeContext): FileMention { +export function useFileMention(vscode: VSCodeContext, sessionID?: Accessor): FileMention { const [mentionedPaths, setMentionedPaths] = createSignal>(new Set()) const [mentionQuery, setMentionQuery] = createSignal(null) const [mentionResults, setMentionResults] = createSignal([]) @@ -60,10 +60,10 @@ export function useFileMention(vscode: VSCodeContext): FileMention { const unsubscribe = vscode.onMessage((message) => { if (message.type !== "fileSearchResult") return - const result = message as { type: "fileSearchResult"; paths: string[]; dir: string; requestId: string } - if (result.requestId === `file-search-${fileSearchCounter}`) { - workspaceDir = result.dir - setMentionResults(buildMentionResults(mentionQuery() ?? "", result.paths)) + if (message.requestId === `file-search-${fileSearchCounter}`) { + const items = message.items ?? message.paths.map((path) => ({ path, type: "file" as const })) + workspaceDir = message.dir + setMentionResults(buildMentionResults(mentionQuery() ?? "", items)) setMentionIndex(0) } }) @@ -77,7 +77,13 @@ export function useFileMention(vscode: VSCodeContext): FileMention { if (fileSearchTimer) clearTimeout(fileSearchTimer) fileSearchTimer = setTimeout(() => { fileSearchCounter++ - vscode.postMessage({ type: "requestFileSearch", query, requestId: `file-search-${fileSearchCounter}` }) + const id = sessionID?.() + vscode.postMessage({ + type: "requestFileSearch", + query, + requestId: `file-search-${fileSearchCounter}`, + ...(id ? { sessionID: id } : {}), + }) }, FILE_SEARCH_DEBOUNCE_MS) } @@ -110,7 +116,8 @@ export function useFileMention(vscode: VSCodeContext): FileMention { textarea.setSelectionRange(pos, pos) textarea.focus() - if (result.type === "file") setMentionedPaths((prev) => new Set([...prev, result.value])) + if (result.type === "file" || result.type === "folder") + setMentionedPaths((prev) => new Set([...prev, result.value])) closeMention() onSelect?.() } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 667bcac38a7..1e6c18dc35f 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -749,9 +749,15 @@ export interface ChatCompletionResultMessage { requestId: string } +export interface FileSearchItem { + path: string + type: "file" | "folder" +} + export interface FileSearchResultMessage { type: "fileSearchResult" paths: string[] + items?: FileSearchItem[] dir: string requestId: string } @@ -1896,6 +1902,7 @@ export interface RequestFileSearchMessage { type: "requestFileSearch" query: string requestId: string + sessionID?: string } export interface RequestTerminalContextMessage { diff --git a/packages/opencode/src/kilocode/tool/read-directory.ts b/packages/opencode/src/kilocode/tool/read-directory.ts new file mode 100644 index 00000000000..f26a65bf0ea --- /dev/null +++ b/packages/opencode/src/kilocode/tool/read-directory.ts @@ -0,0 +1,47 @@ +import { Effect } from "effect" +import { lstat } from "fs/promises" +import * as path from "path" +import { AppFileSystem } from "../../filesystem" +import { Instance } from "../../project/instance" +import { isBinaryFile, lines } from "../../tool/read" + +const LIMIT = 2000 +const CONCURRENCY = 8 + +export type DirectoryFile = { + filepath: string + content: string +} + +export const readDirectoryFiles = Effect.fn("KiloReadDirectory.files")(function* ( + fs: AppFileSystem.Interface, + filepath: string, + items: string[], +) { + const entries = yield* fs.readDirectoryEntries(filepath).pipe(Effect.catch(() => Effect.succeed([]))) + const types = new Map(entries.map((entry) => [entry.name, entry.type])) + const files = yield* Effect.forEach( + items.filter((item) => !item.endsWith("/") && types.get(item) === "file"), + Effect.fnUntraced(function* (item) { + const child = path.join(filepath, item) + const info = yield* Effect.promise(() => lstat(child)).pipe(Effect.catch(() => Effect.void)) + if (!info?.isFile()) return + const binary = yield* Effect.promise(() => isBinaryFile(child, info.size)).pipe( + Effect.catch(() => Effect.succeed(true)), + ) + if (binary) return + const file = yield* Effect.promise(() => lines(child, { limit: LIMIT, offset: 1 })).pipe( + Effect.catch(() => Effect.void), + ) + if (!file) return + const rel = path.relative(Instance.directory, child).replaceAll("\\", "/") + const note = file.cut || file.more ? "\n\n(File truncated)" : "" + return { + filepath: child, + content: `\n${file.raw.join("\n")}${note}\n`, + } + }), + { concurrency: CONCURRENCY }, + ) + return files.filter((item): item is DirectoryFile => item !== undefined) +}) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9499c893a2a..c17e29e979c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1142,7 +1142,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (part.mime === "application/x-directory") { const args = { filePath: filepath } - const exit = yield* execRead(args).pipe(Effect.exit) + const exit = yield* execRead(args, { includeDirectoryFiles: true }).pipe(Effect.exit) // kilocode_change inline folder files if (Exit.isFailure(exit)) { const error = Cause.squash(exit.cause) log.error("failed to read directory", { error }) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index f963b415bec..9238d73b604 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -12,6 +12,9 @@ import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" +// kilocode_change start +import { readDirectoryFiles } from "../kilocode/tool/read-directory" +// kilocode_change end const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -124,6 +127,11 @@ export const ReadTool = Tool.defineEffect( const start = offset - 1 const sliced = items.slice(start, start + limit) const truncated = start + sliced.length < items.length + // kilocode_change start + const expand = Boolean(ctx.extra?.["includeDirectoryFiles"]) + const loaded = expand ? yield* readDirectoryFiles(fs, filepath, sliced) : [] + const content = loaded.map((item) => item.content).join("\n\n") + // kilocode_change end return { title, @@ -136,11 +144,16 @@ export const ReadTool = Tool.defineEffect( ? `\n(Showing ${sliced.length} of ${items.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})` : `\n(${items.length} entries)`, ``, + // kilocode_change start + ...(content ? [`\n${content}`] : []), + // kilocode_change end ].join("\n"), metadata: { preview: sliced.slice(0, 20).join("\n"), truncated, - loaded: [] as string[], + // kilocode_change start + loaded: loaded.map((item) => item.filepath), + // kilocode_change end }, } } @@ -225,7 +238,9 @@ export const ReadTool = Tool.defineEffect( }), ) -async function lines(filepath: string, opts: { limit: number; offset: number }) { +// kilocode_change start +export async function lines(filepath: string, opts: { limit: number; offset: number }) { + // kilocode_change end const stream = createReadStream(filepath, { encoding: "utf8" }) const rl = createInterface({ input: stream, @@ -269,7 +284,9 @@ async function lines(filepath: string, opts: { limit: number; offset: number }) return { raw, count, cut, more, offset: opts.offset } } -async function isBinaryFile(filepath: string, fileSize: number): Promise { +// kilocode_change start +export async function isBinaryFile(filepath: string, fileSize: number): Promise { + // kilocode_change end const ext = path.extname(filepath).toLowerCase() // binary check for common non-text extensions switch (ext) { diff --git a/packages/opencode/test/kilocode/read-directory.test.ts b/packages/opencode/test/kilocode/read-directory.test.ts new file mode 100644 index 00000000000..ca40c3b25d1 --- /dev/null +++ b/packages/opencode/test/kilocode/read-directory.test.ts @@ -0,0 +1,116 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { symlink } from "fs/promises" +import path from "path" +import { Agent } from "../../src/agent/agent" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { AppFileSystem } from "../../src/filesystem" +import { FileTime } from "../../src/file/time" +import { LSP } from "../../src/lsp" +import { Instruction } from "../../src/session/instruction" +import { MessageID, SessionID } from "../../src/session/schema" +import { ReadTool } from "../../src/tool/read" +import { Tool } from "../../src/tool/tool" +import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const baseCtx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "code", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +const expandCtx = { ...baseCtx, extra: { includeDirectoryFiles: true } } + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + AppFileSystem.defaultLayer, + CrossSpawnSpawner.defaultLayer, + FileTime.defaultLayer, + Instruction.defaultLayer, + LSP.defaultLayer, + ), +) + +const init = Effect.fn("ReadDirectoryTest.init")(function* () { + const info = yield* ReadTool + return yield* Effect.promise(() => info.init()) +}) + +const run = Effect.fn("ReadDirectoryTest.run")(function* ( + args: Tool.InferParameters, + ctx = expandCtx, +) { + const tool = yield* init() + return yield* Effect.promise(() => tool.execute(args, ctx)) +}) + +const exec = Effect.fn("ReadDirectoryTest.exec")(function* ( + dir: string, + args: Tool.InferParameters, + ctx = expandCtx, +) { + return yield* provideInstance(dir)(run(args, ctx)) +}) + +const put = Effect.fn("ReadDirectoryTest.put")(function* (p: string, content: string | Uint8Array) { + const fs = yield* AppFileSystem.Service + yield* fs.writeWithDirs(p, content) +}) + +describe("kilocode directory reads", () => { + it.live("includes top-level file contents for directory reads", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* put(path.join(dir, "folder", "a.txt"), "alpha") + yield* put(path.join(dir, "folder", "nested", "b.txt"), "beta") + yield* put(path.join(dir, "folder", "binary.bin"), new Uint8Array([0, 1, 2])) + + const result = yield* exec(dir, { filePath: path.join(dir, "folder") }) + + expect(result.output).toContain("a.txt") + expect(result.output).toContain('\nalpha\n') + expect(result.output).not.toContain('') + expect(result.output).not.toContain('') + expect(result.metadata.loaded).toContain(path.join(dir, "folder", "a.txt")) + }), + ) + + it.live("skips content inlining without the kilo flag", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* put(path.join(dir, "folder", "a.txt"), "alpha") + + const result = yield* exec(dir, { filePath: path.join(dir, "folder") }, baseCtx) + + expect(result.output).toContain("a.txt") + expect(result.output).not.toContain('') + expect(result.metadata.loaded).toEqual([]) + }), + ) + + if (process.platform !== "win32") { + it.live("skips symlinked top-level files", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const outer = yield* tmpdirScoped() + yield* put(path.join(dir, "folder", "a.txt"), "alpha") + yield* put(path.join(outer, "secret.txt"), "secret") + yield* Effect.promise(() => symlink(path.join(outer, "secret.txt"), path.join(dir, "folder", "secret.txt"))) + + const result = yield* exec(dir, { filePath: path.join(dir, "folder") }) + + expect(result.output).toContain("secret.txt") + expect(result.output).not.toContain('') + expect(result.output).not.toContain("secret\n") + expect(result.metadata.loaded).not.toContain(path.join(dir, "folder", "secret.txt")) + }), + ) + } +}) From 4ef6bbff5093dc68a607f1f268e6ab662781922e Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 17 Apr 2026 12:21:47 +0200 Subject: [PATCH 20/32] feat(agent-manager): enable /sessions command to browse and resume session history (#8976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent-manager): enable /sessions command to browse and resume session history * fix(agent-manager): prevent duplicate tabs when selecting already-open session from history * fix(agent-manager): preserve tab memory when reopening local history sessions * docs(vscode): add agent manager sessions changeset * fix(cli): eliminate mock.module race in commit-message tests git-context.test.ts and generate.test.ts both called mock.module on the same path. Bun mock.module is process-wide and permanent, so whichever file loaded second saw the other's mock. Replace with injectable test seams (setGitRunnerForTest, setGitContextForTest) that are cleaned up in afterEach. * test(cli): retry model json reads during async persistence * fix(agent-manager): hide review tab when picking session from history Also revert the commit-message test seam refactor — upstream rewrote git-context.test.ts to use real git repos and the test runner isolates each file in its own process, so the mock.module race no longer exists. * docs(vscode): bump agent manager sessions changeset to minor --- .changeset/agent-manager-sessions-history.md | 5 ++ .../agent-manager/AgentManagerApp.tsx | 90 +++++++++---------- .../src/components/chat/PromptInput.tsx | 3 +- .../test/kilocode/local-model.test.ts | 12 ++- 4 files changed, 61 insertions(+), 49 deletions(-) create mode 100644 .changeset/agent-manager-sessions-history.md diff --git a/.changeset/agent-manager-sessions-history.md b/.changeset/agent-manager-sessions-history.md new file mode 100644 index 00000000000..2df85d93802 --- /dev/null +++ b/.changeset/agent-manager-sessions-history.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support browsing and resuming sessions from Agent Manager with `/sessions`. diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index da51d775c96..53f8d494d83 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -78,6 +78,7 @@ import { NotificationsProvider } from "../src/context/notifications" import { SessionProvider, useSession } from "../src/context/session" import { WorktreeModeProvider } from "../src/context/worktree-mode" import { ChatView } from "../src/components/chat" +import HistoryView from "../src/components/history/HistoryView" import { NewWorktreeDialog } from "./NewWorktreeDialog" import { LanguageBridge, DataBridge } from "../src/App" import { useLanguage } from "../src/context/language" @@ -348,6 +349,7 @@ const AgentManagerContent: Component = () => { let diffRaf: number | undefined let pendingDiffWidth: number | undefined + const [history, setHistory] = createSignal(false) const [sidePanel, setSidePanel] = createSignal(null) const diffOpen = () => sidePanel() === "diff" const [diffDatas, setDiffDatas] = createSignal>({}) @@ -697,28 +699,23 @@ const AgentManagerContent: Component = () => { const valid = prev.filter((lid) => isPending(lid) || validateLocalSession(lid, ids)) if (valid.length !== prev.length) { const removed = prev.filter((lid) => !isPending(lid) && !valid.includes(lid)) - for (const id of removed) { - vscode.postMessage({ type: "agentManager.forgetSession", sessionId: id }) - } + for (const id of removed) vscode.postMessage({ type: "agentManager.forgetSession", sessionId: id }) setLocalSessionIDs(valid) } }) // Drop in-memory review state for worktrees that no longer exist. createEffect(() => { const ids = new Set(worktrees().map((wt) => wt.id)) - setReviewOpenByContext((prev) => { const next = Object.fromEntries(Object.entries(prev).filter(([id]) => id === LOCAL || ids.has(id))) if (Object.keys(next).length === Object.keys(prev).length) return prev return next }) - setReviewCommentsByContext((prev) => { const next = Object.fromEntries(Object.entries(prev).filter(([id]) => id === LOCAL || ids.has(id))) if (Object.keys(next).length === Object.keys(prev).length) return prev return next }) - setApplyStates((prev) => { const next = Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id))) if (Object.keys(next).length === Object.keys(prev).length) return prev @@ -1000,12 +997,8 @@ const AgentManagerContent: Component = () => { if (fallback && !isPending(fallback.id)) { setActivePendingId(undefined) session.selectSession(fallback.id) - } else if (fallback && isPending(fallback.id)) { - setActivePendingId(fallback.id) - session.clearCurrentSession() - vscode.postMessage({ type: "agentManager.showExistingLocalTerminal" }) } else { - setActivePendingId(undefined) + setActivePendingId(fallback && isPending(fallback.id) ? fallback.id : undefined) session.clearCurrentSession() vscode.postMessage({ type: "agentManager.showExistingLocalTerminal" }) } @@ -1021,11 +1014,8 @@ const AgentManagerContent: Component = () => { const remembered = tabMemory()[worktreeId] const target = remembered ? sessions.find((s) => s.id === remembered) : undefined const fallback = target ?? sessions[0] - if (fallback) { - session.selectSession(fallback.id) - } else { - session.setCurrentSessionID(undefined) - } + if (fallback) session.selectSession(fallback.id) + else session.setCurrentSessionID(undefined) setReviewActive(remembered === REVIEW_TAB_ID && reviewOpenByContext()[worktreeId] === true) } @@ -1048,7 +1038,8 @@ const AgentManagerContent: Component = () => { onMount(() => { const handler = (event: MessageEvent) => { - const msg = event.data as ExtensionMessage + const msg = event.data + if (msg?.type === "navigate" && msg.view === "history") return setHistory(true) if (msg?.type !== "action") return if (msg.action === "sessionPrevious") navigate("up") else if (msg.action === "sessionNext") navigate("down") @@ -1062,9 +1053,7 @@ const AgentManagerContent: Component = () => { if (reviewActive()) { closeReviewTab() setSidePanel("diff") - } else { - setSidePanel((prev) => (prev === "diff" ? null : "diff")) - } + } else setSidePanel((prev) => (prev === "diff" ? null : "diff")) } else if (msg.action === "newTab") handleNewTabForCurrentSelection() else if (msg.action === "closeTab") closeActiveTab() else if (msg.action === "newWorktree") handleNewWorktreeOrPromote() @@ -1886,9 +1875,7 @@ const AgentManagerContent: Component = () => { if (pending) { setLocalSessionIDs((prev) => prev.map((id) => (id === pending ? sid : id))) setActivePendingId(undefined) - } else { - setLocalSessionIDs((prev) => [...prev, sid]) - } + } else setLocalSessionIDs((prev) => [...prev, sid]) setSelection(LOCAL) setReviewActive(false) session.selectSession(sid) @@ -1897,20 +1884,14 @@ const AgentManagerContent: Component = () => { const handleAddSession = () => { const sel = selection() - if (sel === LOCAL) { - addPendingTab() - } else if (sel) { - vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel }) - } + if (sel === LOCAL) addPendingTab() + else if (sel) vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel }) } const handleForkSession = (sessionId: string) => { const sel = selection() - if (sel === LOCAL) { - vscode.postMessage({ type: "agentManager.forkSession", sessionId }) - } else if (sel) { - vscode.postMessage({ type: "agentManager.forkSession", sessionId, worktreeId: sel }) - } + if (sel === LOCAL) vscode.postMessage({ type: "agentManager.forkSession", sessionId }) + else if (sel) vscode.postMessage({ type: "agentManager.forkSession", sessionId, worktreeId: sel }) } const handleCloseTab = (sessionId: string) => { @@ -1920,14 +1901,12 @@ const AgentManagerContent: Component = () => { const tabs = activeTabs() const idx = tabs.findIndex((s) => s.id === sessionId) const next = tabs[idx + 1] ?? tabs[idx - 1] - if (next) { - if (isPending(next.id)) { - setActivePendingId(next.id) - session.clearCurrentSession() - } else { - setActivePendingId(undefined) - session.selectSession(next.id) - } + if (next && isPending(next.id)) { + setActivePendingId(next.id) + session.clearCurrentSession() + } else if (next) { + setActivePendingId(undefined) + session.selectSession(next.id) } else { setActivePendingId(undefined) session.clearCurrentSession() @@ -1935,9 +1914,7 @@ const AgentManagerContent: Component = () => { } if (pending || localSet().has(sessionId)) { setLocalSessionIDs((prev) => prev.filter((id) => id !== sessionId)) - if (!pending) { - vscode.postMessage({ type: "agentManager.forgetSession", sessionId }) - } + if (!pending) vscode.postMessage({ type: "agentManager.forgetSession", sessionId }) } else { vscode.postMessage({ type: "agentManager.closeSession", sessionId }) } @@ -2921,7 +2898,29 @@ const AgentManagerContent: Component = () => {
) })()} - + + { + setHistory(false) + if (localSessionIDs().includes(id)) { + saveTabMemory() + session.selectSession(id) + setSelection(LOCAL) + return + } + const ms = worktreeSessionIds().has(id) ? managedSessions().find((s) => s.id === id) : undefined + if (ms?.worktreeId) { + selectWorktree(ms.worktreeId) + session.selectSession(id) + setReviewActive(false) + return + } + openLocally(id) + }} + onBack={() => setHistory(false)} + /> + + {/* Chat + side diff panel (hidden when review tab is active) */}
{ } openLocally(id) }} + onShowHistory={() => setHistory(true)} readonly={readOnly()} continueInWorktree={selection() === LOCAL} promptBoxId={`agent-manager:${selection() ?? "unassigned"}`} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 98937c95713..c9f6c2bec0a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -70,8 +70,7 @@ export const PromptInput: Component = (props) => { () => session.currentSessionID() ?? props.pendingSessionID ?? session.draftSessionID(), ) const terminal = useTerminalContext(vscode) - const excluded = worktree ? new Set(["sessions"]) : undefined - const slash = useSlashCommand(vscode, excluded) + const slash = useSlashCommand(vscode) const imageAttach = useImageAttachments() imageAttach.setFilePathDropHandler((paths) => { const cwd = server.workspaceDirectory() diff --git a/packages/opencode/test/kilocode/local-model.test.ts b/packages/opencode/test/kilocode/local-model.test.ts index 1920f1b7070..106651478c4 100644 --- a/packages/opencode/test/kilocode/local-model.test.ts +++ b/packages/opencode/test/kilocode/local-model.test.ts @@ -189,8 +189,16 @@ async function initLocal(options?: { prewrite?: Record }): Promise< } async function readModelJson(): Promise { - const text = await fs.readFile(modelJsonPath, "utf-8") - return JSON.parse(text) + const until = Date.now() + 2000 + while (true) { + try { + const text = await fs.readFile(modelJsonPath, "utf-8") + return JSON.parse(text) + } catch (err) { + if (Date.now() >= until) throw err + await Bun.sleep(10) + } + } } async function removeModelJson() { From 31808ccc64f057e885c95e8cc172b4ce46a7a53d Mon Sep 17 00:00:00 2001 From: Josh Holmer Date: Fri, 17 Apr 2026 06:36:21 -0400 Subject: [PATCH 21/32] fix: improve GPT and Codex subagent usage (#9076) --- packages/opencode/src/session/prompt/codex.txt | 1 + packages/opencode/src/session/prompt/gpt.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/opencode/src/session/prompt/codex.txt b/packages/opencode/src/session/prompt/codex.txt index 8524019eb56..2f31b1d9417 100644 --- a/packages/opencode/src/session/prompt/codex.txt +++ b/packages/opencode/src/session/prompt/codex.txt @@ -8,6 +8,7 @@ You are an interactive CLI tool that helps users with software engineering tasks - Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). ## Tool usage +- If the Task tool is available, use it proactively to delegate focused subtasks to a subagent instance. You can spawn multiple subagents in parallel. - Prefer specialized tools over shell for file operations: - Use Read to view files, Edit to modify files, and Write only when needed. - Use Glob to find files by name and Grep to search file contents. diff --git a/packages/opencode/src/session/prompt/gpt.txt b/packages/opencode/src/session/prompt/gpt.txt index 76dc41063af..da9f94e6044 100644 --- a/packages/opencode/src/session/prompt/gpt.txt +++ b/packages/opencode/src/session/prompt/gpt.txt @@ -2,6 +2,7 @@ You are Kilo Code, You and the user share the same workspace and collaborate to You are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail. You build context by examining the codebase first without making assumptions or jumping to conclusions. You think through the nuances of the code you encounter, and embody the mentality of a skilled senior software engineer. +- If the Task tool is available, use it proactively to delegate focused subtasks to a subagent instance. You can spawn multiple subagents in parallel. - When searching for text or files, prefer using Glob and Grep tools (they are powered by `rg`) - Parallelize tool calls whenever possible - especially file reads. Use `multi_tool_use.parallel` to parallelize tool calls and only this. Never chain together bash commands with separators like `echo "====";` as this renders to the user poorly. From a98b10ebc98407d323df51a93a87f554bef20f2c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 17 Apr 2026 12:48:40 +0200 Subject: [PATCH 22/32] docs: document changeset workflow in contributing guide --- .../kilo-docs/pages/contributing/index.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/kilo-docs/pages/contributing/index.md b/packages/kilo-docs/pages/contributing/index.md index 1b8fa9e8604..a3f17423b64 100644 --- a/packages/kilo-docs/pages/contributing/index.md +++ b/packages/kilo-docs/pages/contributing/index.md @@ -58,6 +58,33 @@ git checkout -b docs/your-change-description - Reference issue numbers when applicable - Keep commits focused on a single change +### Changesets + +User-facing changes (features, fixes, breaking changes) require a changeset file so the update shows up in the next release notes. Run the interactive tool, or create the file by hand: + +```bash +bunx changeset add +``` + +Or create `.changeset/.md` manually: + +```md +--- +"kilo-code": minor +--- + +Short description of the change for the changelog. +``` + +Guidelines: + +- Use `patch` for bug fixes, `minor` for new features, `major` for breaking changes. +- Descriptions are read by end users in release notes — keep them concise and feature-oriented. Describe **what changed from the user's perspective**, not implementation details. +- Write in imperative mood (e.g. "Support exporting conversations as markdown" rather than "Add a new export handler that serializes session messages to .md files"). +- Changesets are consumed at release time by the `publish.yml` workflow, which generates changelog entries for the GitHub release notes. + +Skip the changeset only for internal refactors, CI tweaks, test-only changes, or docs that do not affect users. + ### Testing Your Changes - Run the test suite: From 07df796591fce924de1d45af08acbf119572213b Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 14:12:48 +0300 Subject: [PATCH 23/32] fix(cli): wrap auto-apply effect in change markers --- packages/opencode/src/cli/cmd/tui/context/local.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index c24f167c057..879b0eb4c72 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -414,15 +414,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // Automatically update model when agent changes createEffect(() => { - // kilocode_change start - wait for persistence load; don't overwrite saved per-agent picks (#9050) + // kilocode_change start - wait for persistence load and skip when a per-agent pick already exists (#9050) if (!model.ready) return - // kilocode_change end const value = agent.current() - if (!value) return // kilocode_change - guard against empty agent list during org switch - // kilocode_change start - skip when the user (or a previous session) already picked a model + if (!value) return // guard against empty agent list during org switch if (!value.model) return if (model.saved(value.name)) return - // kilocode_change end if (isModelValid(value.model)) model.set({ providerID: value.model.providerID, @@ -434,6 +431,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, duration: 3000, }) + // kilocode_change end }) const result = { From 03276137581e5139bc1e4a9545203b66489e65bb Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:13:28 +0000 Subject: [PATCH 24/32] docs(kilo-docs): update kilo-auto model mappings Sync frontier/balanced/free/small tier mappings with the current kilo-auto resolution logic in Kilo-Org/cloud. --- .../architecture/auto-model-tiers.md | 8 ++-- .../pages/gateway/models-and-providers.md | 39 +++++++++---------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md index e2e13938ce2..9b82541e6d1 100644 --- a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md +++ b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md @@ -52,7 +52,7 @@ For the current mode-to-model mappings, see the [Auto Model user docs](/docs/cod **Who it's for**: Cost-conscious developers who want better results than free models at a fraction of frontier cost. -**What it does**: Follows the same mode-based routing structure as Frontier but uses GPT 5.3 Codex — a cost-effective model with strong reasoning and coding capabilities — across all modes. +**What it does**: Uses GPT 5.3 Codex — a cost-effective model with strong reasoning and coding capabilities — for every mode. Unlike Frontier, Balanced does not vary its underlying model by mode. The legacy `kilo/auto` model ID also resolves to Balanced. **Pricing**: Paid, but significantly cheaper than Frontier. @@ -62,17 +62,17 @@ For the current mode-to-model mappings, see the [Auto Model user docs](/docs/cod **Who it's for**: Users who want to try Kilo without a credit card, students, hobbyists, and anyone exploring AI-assisted coding. -**What it does**: Automatically maps to the best available free model(s) for each mode. As free model availability changes due to promotional periods, the mapping updates transparently. Users always get the best free option without having to track which models are currently available. +**What it does**: Splits requests across the best available free models, weighted by a deterministic per-session hash so a given session sticks with one model. As free model availability changes due to promotional periods, the split and the underlying models are updated transparently server-side. Users always get the best free option without having to track which models are currently available. **Pricing**: Free. No credits required. -**Constraints**: Free models may not provide sufficient breadth to justify different models per mode. In that case, a single model may be used for all modes. Quality will be lower than Frontier or Balanced tiers — this is a tradeoff users accept by choosing free. +**Constraints**: Free models do not vary by mode — the same model is used for every mode within a session. Quality will be lower than Frontier or Balanced tiers — this is a tradeoff users accept by choosing free. ### Auto: Small (internal) **Who it's for**: Not user-facing. Used internally by Kilo for lightweight background tasks (session titles, commit messages, conversation summaries). -**What it does**: Automatically selects the right small model for lightweight tasks. When credits are available, it uses a fast paid small model. +**What it does**: Automatically selects the right small model for lightweight tasks. When the account has a positive balance, it uses a fast paid small model; otherwise it falls back to a free small model. **Why it matters**: Users never think about background tasks, and they shouldn't have to. Auto: Small ensures these tasks always work, always feel fast, and never waste credits on an expensive model when a cheap one will do. diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index 455eae7831a..ccf7ade164c 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -78,40 +78,39 @@ Kilo Auto virtual models automatically select the best underlying model based on ### `kilo-auto/frontier` -Highest performance and capability for any task. +Highest performance and capability for any task. Frontier requests are sent with medium reasoning effort and medium verbosity. | Mode | Resolved Model | | -------------------------------------------------------------- | ----------------------------- | -| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `anthropic/claude-opus-4.6` | +| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `anthropic/claude-opus-4.7` | | `build`, `explore`, `code` | `anthropic/claude-sonnet-4.6` | -| Default (no mode specified) | `anthropic/claude-sonnet-4.6` | +| Default (no / unknown mode) | `anthropic/claude-sonnet-4.6` | ### `kilo-auto/balanced` -Great balance of price and capability. - -| Mode | Resolved Model | -| -------------------------------------------------------------- | ---------------------- | -| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `openai/gpt-5.3-codex` | -| `build`, `explore`, `code` | `openai/gpt-5.3-codex` | -| Default (no mode specified) | `openai/gpt-5.3-codex` | - -### `kilo-auto/free` - -Free with limited capability. No credits required. +Great balance of price and capability. Balanced routes to the same model regardless of mode, with low reasoning effort. The legacy `kilo/auto` alias resolves to the same behavior. | Mode | Resolved Model | | --------- | ---------------------- | -| All modes | `minimax/minimax-m2.5` | +| All modes | `openai/gpt-5.3-codex` | + +### `kilo-auto/free` + +Free with limited capability. No credits required. Requests are split across the available free models; the mapping updates server-side as free model availability shifts. + +| Routing | Resolved Model | +| ------- | ----------------------------- | +| 80% | `minimax/minimax-m2.5:free` | +| 20% | `stepfun/step-3.5-flash:free` | ### `kilo-auto/small` -Automatically routes to a small, fast model. +Automatically routes to a small, fast model for lightweight background tasks (session titles, commit messages, summaries). -| Mode | Resolved Model | -| ------------- | -------------------- | -| Default | `openai/gpt-5-nano` | -| Free fallback | `openai/gpt-oss-20b` | +| Condition | Resolved Model | +| ------------------------- | -------------------------------- | +| Account has paid balance | `google/gemma-4-31b-it` | +| No balance / free account | `google/gemma-4-26b-a4b-it:free` | ### Example usage From 6231a54a5728109458eaf264ef361f09b1927722 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:18:43 +0000 Subject: [PATCH 25/32] docs(kilo-docs): clarify balanced and free routing in auto-model guide --- packages/kilo-docs/pages/code-with-ai/agents/auto-model.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index c96989df403..ac7bc6854c8 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -26,8 +26,8 @@ You can see which underlying models are used, as well as the cost, in the expand ## Tiers - **Frontier** — Routes to the latest and most capable paid models. Uses different models for reasoning-heavy tasks (planning, architecture, debugging) versus implementation tasks (coding, building, exploring), pairing the right capability to each type of work. -- **Balanced** — Follows the same mode-based routing structure as Frontier but uses a more cost-effective model across all modes. A good default for most developers who want strong AI assistance without paying frontier prices. -- **Free** — Routes to the best available free model on OpenRouter. Because free model availability shifts over time as providers change promotional periods, the mapping is updated server-side — you always get the best free option without having to track what's currently available. Quality will be lower than paid tiers, and the model may change over time. +- **Balanced** — Uses a single cost-effective model across all modes. A good default for most developers who want strong AI assistance without paying frontier prices. +- **Free** — Routes to the best available free models on OpenRouter, splitting traffic across them. Because free model availability shifts over time as providers change promotional periods, the mapping is updated server-side — you always get the best free option without having to track what's currently available. Quality will be lower than paid tiers, and the models may change over time. ## Benefits From 9749cc178d999f96669cc815709a7cdf3129aefd Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 17 Apr 2026 13:24:35 +0200 Subject: [PATCH 26/32] feat(kilo-ui): enhance MCP tool display with input/output sections and improved styling (#9123) * feat(kilo-ui): enhance MCP tool display with input/output sections and improved styling - Add subtitle and args display to MCP tool triggers for better context - Separate input and output sections with labels and dividers - Format input and output as JSON with proper syntax highlighting - Reposition copy button tooltip to prevent clipping in tool output - Add styling for MCP section labels and tool dividers * feat(kilo-ui): i18n for MCP input/output labels * chore: add changeset --------- Co-authored-by: Sylwester Liljegren --- .changeset/mcp-input-output-i18n.md | 5 ++ .../kilo-ui/src/components/basic-tool.css | 18 +++++ .../kilo-ui/src/components/message-part.tsx | 76 +++++++++++++++++-- ...-to-queued-user-spacing-chromium-linux.png | 4 +- .../shell-execution-chromium-linux.png | 4 +- .../mcp-tool-cards-chromium-linux.png | 4 +- .../mcp-tool-expanded-chromium-linux.png | 4 +- .../multiple-tool-calls-chromium-linux.png | 4 +- .../question-dismissed-chromium-linux.png | 4 +- .../tool-cards-chromium-linux.png | 4 +- packages/ui/src/i18n/ar.ts | 2 + packages/ui/src/i18n/br.ts | 2 + packages/ui/src/i18n/bs.ts | 2 + packages/ui/src/i18n/da.ts | 2 + packages/ui/src/i18n/de.ts | 2 + packages/ui/src/i18n/en.ts | 2 + packages/ui/src/i18n/es.ts | 2 + packages/ui/src/i18n/fr.ts | 2 + packages/ui/src/i18n/ja.ts | 2 + packages/ui/src/i18n/ko.ts | 2 + packages/ui/src/i18n/nl.ts | 2 + packages/ui/src/i18n/no.ts | 2 + packages/ui/src/i18n/pl.ts | 2 + packages/ui/src/i18n/ru.ts | 2 + packages/ui/src/i18n/th.ts | 2 + packages/ui/src/i18n/tr.ts | 2 + packages/ui/src/i18n/uk.ts | 2 + packages/ui/src/i18n/zh.ts | 2 + packages/ui/src/i18n/zht.ts | 2 + 29 files changed, 143 insertions(+), 22 deletions(-) create mode 100644 .changeset/mcp-input-output-i18n.md diff --git a/.changeset/mcp-input-output-i18n.md b/.changeset/mcp-input-output-i18n.md new file mode 100644 index 00000000000..607911264c1 --- /dev/null +++ b/.changeset/mcp-input-output-i18n.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show MCP tool call inputs alongside outputs in chat, with JSON syntax highlighting for both. diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css index d8a66e011cb..17cb5d9d07c 100644 --- a/packages/kilo-ui/src/components/basic-tool.css +++ b/packages/kilo-ui/src/components/basic-tool.css @@ -156,6 +156,24 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty } } + /* Reposition copy button tooltip to appear below (not above) to avoid clipping */ + [data-component="tool-output"] [data-slot="markdown-copy-button"]::after { + bottom: auto; + top: calc(100% + 4px); + } + + [data-slot="mcp-section-label"] { + padding: 6px 12px 0; + font-size: 11px; + color: var(--text-weak, var(--vscode-descriptionForeground)); + } + + [data-slot="mcp-tool-divider"] { + height: 1px; + background: var(--border-weak-base, var(--vscode-panel-border)); + margin-top: 4px; + } + /* Expandable tool output content */ [data-component="tool-output"] { padding: 8px 12px; diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index aa30400b5d2..1bbbdb1eb7a 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -1026,24 +1026,84 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre // GenericTool (upstream) does not render output; this override does. // When hideDetails is true, render as a row (no content), otherwise as a panel with markdown output. function McpTool(props: ToolProps) { + const i18n = useI18n() + const labelKeys = ["description", "query", "url", "filePath", "path", "pattern", "name"] + const skipKeys = new Set(labelKeys) + + const subtitle = () => + labelKeys + .map((key) => props.input?.[key]) + .find((value): value is string => typeof value === "string" && value.length > 0) + + const inputArgs = () => { + if (!props.input) return [] + return Object.entries(props.input) + .filter(([key]) => !skipKeys.has(key)) + .flatMap(([key, value]) => { + if (typeof value === "string") return [`${key}=${value}`] + if (typeof value === "number") return [`${key}=${value}`] + if (typeof value === "boolean") return [`${key}=${value}`] + return [] + }) + .slice(0, 3) + } + + const formatted = createMemo(() => { + if (!props.input || Object.keys(props.input).length === 0) return "" + return "```json\n" + JSON.stringify(props.input, null, 2) + "\n```" + }) + + const formattedOutput = createMemo(() => { + if (!props.output) return undefined + try { + const parsed = JSON.parse(props.output) + return "```json\n" + JSON.stringify(parsed, null, 2) + "\n```" + } catch { + return props.output + } + }) + return ( } + fallback={ + + } > - - {(output) => ( -
- -
+ + {(text) => ( + <> +
{i18n.t("ui.messagePart.mcp.input")}
+
+ +
+ + )} +
+ + {(text) => ( + <> + +
+ +
{i18n.t("ui.messagePart.mcp.output")}
+
+ +
+ )} @@ -1068,7 +1128,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { return ( -
+
{(error) => { diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png index fa65a4f8a84..92378b5e3c5 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:123f32340a93af21146d08bb40738710bdd84099a27d0310316bb937360ca9f9 -size 14605 +oid sha256:07b3bf094c0a82e64fc7cb4e22aa37ada3dd5904a19a313879c6016c2ab55a00 +size 14586 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/components-shell/shell-execution-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/components-shell/shell-execution-chromium-linux.png index e09f627f07c..4d612a7ad03 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/components-shell/shell-execution-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/components-shell/shell-execution-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9dbdd4b71bea71529e8835def452bbb627837d5bb6dbe460d882b20c4add6e98 -size 17650 +oid sha256:74a42ec77a8ac8d5a1c555b46e3d550c9acf3198f45cc87da6582c0ff924d5f9 +size 17677 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-cards-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-cards-chromium-linux.png index 887e38a6c50..4ec7183fdad 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-cards-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-cards-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4f50e8ca1c012b64797cf57d5293ac59afefa79c8f1d703b66ac796e5b85fbaf -size 6402 +oid sha256:c583144b11ac9608ec755e9a683dced8ae6ffae9a6f3833e1c0eec6b664f358e +size 7720 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-expanded-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-expanded-chromium-linux.png index cb685b058e1..238361cf9e6 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-expanded-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-expanded-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3fca4a95e3754b2d1316635fd756fa3fe8895213de7332d130a2f44075f06485 -size 19072 +oid sha256:8348db9191e1e616c93bf54ae33702a57e25c092a8f80a24d1da04811e523500 +size 26519 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png index 4a06e7da60d..fa1a4b2466a 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f539f72e2c53c6ce84d26397715bb9c4e80c2c107e4584023f2bdbeba0086e62 -size 6958 +oid sha256:15116299b4700cceb501114e8fdca8b3ddfa5fb9d793f7cbab44b2647bbe64a4 +size 8070 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-dismissed-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-dismissed-chromium-linux.png index 5a6833dfadf..85da22e8f7e 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-dismissed-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-dismissed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c4ea685888b585c50e8902a3df4a0fc85104a217b41bf6763d8e7e2f113c32ab -size 4356 +oid sha256:ed66186a7aacdd4d31ff250a9c31fb1b244834cbb23e36abc51af706545f4518 +size 5059 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png index 7f614a23d6f..7959059a155 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e0fecc40d49bdd601a7d1ed6c8d4bb6c3854843c3e67eca408d3cd0a42b9f4f -size 8193 +oid sha256:1d21dc25072b1c52cacaf0a91369f45e89f43ccc181d643e6b65030b829e5b1e +size 8591 diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 72e834e5e0c..f31f108cb13 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -76,6 +76,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} قائمة", "ui.messagePart.context.list.other": "{{count}} قوائم", "ui.messagePart.diagnostic.error": "خطأ", + "ui.messagePart.mcp.input": "الإدخال", + "ui.messagePart.mcp.output": "الإخراج", "ui.messagePart.title.edit": "تحرير", "ui.messagePart.title.write": "كتابة", "ui.messagePart.option.typeOwnAnswer": "اكتب إجابتك الخاصة", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index e14a3fed47c..6d4a826bcde 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -76,6 +76,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} lista", "ui.messagePart.context.list.other": "{{count}} listas", "ui.messagePart.diagnostic.error": "Erro", + "ui.messagePart.mcp.input": "Entrada", + "ui.messagePart.mcp.output": "Saída", "ui.messagePart.title.edit": "Editar", "ui.messagePart.title.write": "Escrever", "ui.messagePart.option.typeOwnAnswer": "Digite sua própria resposta", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index ccea10c1e6e..62970c7279f 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -80,6 +80,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} lista", "ui.messagePart.context.list.other": "{{count}} liste", "ui.messagePart.diagnostic.error": "Greška", + "ui.messagePart.mcp.input": "Ulaz", + "ui.messagePart.mcp.output": "Izlaz", "ui.messagePart.title.edit": "Uredi", "ui.messagePart.title.write": "Napiši", "ui.messagePart.option.typeOwnAnswer": "Unesi svoj odgovor", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 71bb5236667..20303221038 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -75,6 +75,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} liste", "ui.messagePart.context.list.other": "{{count}} lister", "ui.messagePart.diagnostic.error": "Fejl", + "ui.messagePart.mcp.input": "Input", + "ui.messagePart.mcp.output": "Output", "ui.messagePart.title.edit": "Rediger", "ui.messagePart.title.write": "Skriv", "ui.messagePart.option.typeOwnAnswer": "Skriv dit eget svar", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 9a0cb10cb93..bc0358ea056 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -81,6 +81,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} Liste", "ui.messagePart.context.list.other": "{{count}} Listen", "ui.messagePart.diagnostic.error": "Fehler", + "ui.messagePart.mcp.input": "Eingabe", + "ui.messagePart.mcp.output": "Ausgabe", "ui.messagePart.title.edit": "Bearbeiten", "ui.messagePart.title.write": "Schreiben", "ui.messagePart.option.typeOwnAnswer": "Eigene Antwort eingeben", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 0450e757afb..4ed9e9c01b6 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -69,6 +69,8 @@ export const dict: Record = { "ui.sessionTurn.status.consideringNextSteps": "Considering next steps", "ui.messagePart.diagnostic.error": "Error", + "ui.messagePart.mcp.input": "Input", + "ui.messagePart.mcp.output": "Output", "ui.messagePart.title.edit": "Edit", "ui.messagePart.title.write": "Write", "ui.messagePart.option.typeOwnAnswer": "Type your own answer", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index e952c098be1..90899358219 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -76,6 +76,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} lista", "ui.messagePart.context.list.other": "{{count}} listas", "ui.messagePart.diagnostic.error": "Error", + "ui.messagePart.mcp.input": "Entrada", + "ui.messagePart.mcp.output": "Salida", "ui.messagePart.title.edit": "Editar", "ui.messagePart.title.write": "Escribir", "ui.messagePart.option.typeOwnAnswer": "Escribe tu propia respuesta", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index 9d48158a73c..35f6702c5d4 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -76,6 +76,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} liste", "ui.messagePart.context.list.other": "{{count}} listes", "ui.messagePart.diagnostic.error": "Erreur", + "ui.messagePart.mcp.input": "Entrée", + "ui.messagePart.mcp.output": "Sortie", "ui.messagePart.title.edit": "Modifier", "ui.messagePart.title.write": "Écrire", "ui.messagePart.option.typeOwnAnswer": "Tapez votre propre réponse", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 71e78ffa85c..2daf8cf2443 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -75,6 +75,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} 件のリスト", "ui.messagePart.context.list.other": "{{count}} 件のリスト", "ui.messagePart.diagnostic.error": "エラー", + "ui.messagePart.mcp.input": "入力", + "ui.messagePart.mcp.output": "出力", "ui.messagePart.title.edit": "編集", "ui.messagePart.title.write": "作成", "ui.messagePart.option.typeOwnAnswer": "自分の回答を入力", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index cf56b257e37..ee6e1f83096 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -76,6 +76,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}}개 목록", "ui.messagePart.context.list.other": "{{count}}개 목록", "ui.messagePart.diagnostic.error": "오류", + "ui.messagePart.mcp.input": "입력", + "ui.messagePart.mcp.output": "출력", "ui.messagePart.title.edit": "편집", "ui.messagePart.title.write": "작성", "ui.messagePart.option.typeOwnAnswer": "직접 답변 입력", diff --git a/packages/ui/src/i18n/nl.ts b/packages/ui/src/i18n/nl.ts index b86fa54ea11..c3cab8540d2 100644 --- a/packages/ui/src/i18n/nl.ts +++ b/packages/ui/src/i18n/nl.ts @@ -69,6 +69,8 @@ export const dict: Record = { "ui.sessionTurn.status.consideringNextSteps": "Volgende stappen overwegen", "ui.messagePart.diagnostic.error": "Fout", + "ui.messagePart.mcp.input": "Invoer", + "ui.messagePart.mcp.output": "Uitvoer", "ui.messagePart.title.edit": "Bewerken", "ui.messagePart.title.write": "Schrijven", "ui.messagePart.option.typeOwnAnswer": "Typ je eigen antwoord", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index e2ac20fb836..377fa7a95a8 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -79,6 +79,8 @@ export const dict: Record = { "ui.messagePart.context.list.one": "{{count}} liste", "ui.messagePart.context.list.other": "{{count}} lister", "ui.messagePart.diagnostic.error": "Feil", + "ui.messagePart.mcp.input": "Inndata", + "ui.messagePart.mcp.output": "Utdata", "ui.messagePart.title.edit": "Rediger", "ui.messagePart.title.write": "Skriv", "ui.messagePart.option.typeOwnAnswer": "Skriv ditt eget svar", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index fa21eea0d4a..05a1a2e1662 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -75,6 +75,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} lista", "ui.messagePart.context.list.other": "{{count}} listy", "ui.messagePart.diagnostic.error": "Błąd", + "ui.messagePart.mcp.input": "Wejście", + "ui.messagePart.mcp.output": "Wyjście", "ui.messagePart.title.edit": "Edycja", "ui.messagePart.title.write": "Pisanie", "ui.messagePart.option.typeOwnAnswer": "Wpisz własną odpowiedź", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 5c01b07d09d..067cbd0e67b 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -75,6 +75,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} список", "ui.messagePart.context.list.other": "{{count}} списков", "ui.messagePart.diagnostic.error": "Ошибка", + "ui.messagePart.mcp.input": "Ввод", + "ui.messagePart.mcp.output": "Вывод", "ui.messagePart.title.edit": "Редактировать", "ui.messagePart.title.write": "Написать", "ui.messagePart.option.typeOwnAnswer": "Введите свой ответ", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 15d94f0fb11..92299360185 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -77,6 +77,8 @@ export const dict = { "ui.messagePart.context.list.one": "รายการ {{count}} รายการ", "ui.messagePart.context.list.other": "รายการ {{count}} รายการ", "ui.messagePart.diagnostic.error": "ข้อผิดพลาด", + "ui.messagePart.mcp.input": "อินพุต", + "ui.messagePart.mcp.output": "เอาต์พุต", "ui.messagePart.title.edit": "แก้ไข", "ui.messagePart.title.write": "เขียน", "ui.messagePart.option.typeOwnAnswer": "พิมพ์คำตอบของคุณเอง", diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index f3f9886ca6c..f7b55f6ad8e 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -82,6 +82,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} liste", "ui.messagePart.context.list.other": "{{count}} liste", "ui.messagePart.diagnostic.error": "Hata", + "ui.messagePart.mcp.input": "Giriş", + "ui.messagePart.mcp.output": "Çıkış", "ui.messagePart.title.edit": "Düzenle", "ui.messagePart.title.write": "Yaz", "ui.messagePart.option.typeOwnAnswer": "Kendi cevabınızı yazın", diff --git a/packages/ui/src/i18n/uk.ts b/packages/ui/src/i18n/uk.ts index 090a0481874..3ebf9ce3396 100644 --- a/packages/ui/src/i18n/uk.ts +++ b/packages/ui/src/i18n/uk.ts @@ -82,6 +82,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} список", "ui.messagePart.context.list.other": "{{count}} списків", "ui.messagePart.diagnostic.error": "Помилка", + "ui.messagePart.mcp.input": "Вхід", + "ui.messagePart.mcp.output": "Вихід", "ui.messagePart.title.edit": "Редагувати", "ui.messagePart.title.write": "Записати", "ui.messagePart.option.typeOwnAnswer": "Введіть власну відповідь", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index c9b235a2a76..48423ab232c 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -80,6 +80,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} 个列表", "ui.messagePart.context.list.other": "{{count}} 个列表", "ui.messagePart.diagnostic.error": "错误", + "ui.messagePart.mcp.input": "输入", + "ui.messagePart.mcp.output": "输出", "ui.messagePart.title.edit": "编辑", "ui.messagePart.title.write": "写入", "ui.messagePart.option.typeOwnAnswer": "输入自己的答案", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 64728cb4565..276db511540 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -80,6 +80,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} 個清單", "ui.messagePart.context.list.other": "{{count}} 個清單", "ui.messagePart.diagnostic.error": "錯誤", + "ui.messagePart.mcp.input": "輸入", + "ui.messagePart.mcp.output": "輸出", "ui.messagePart.title.edit": "編輯", "ui.messagePart.title.write": "寫入", "ui.messagePart.option.typeOwnAnswer": "輸入自己的答案", From b5bc3fe025a9104511a51bfb042ff897cfbb0ff6 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:26:34 +0000 Subject: [PATCH 27/32] docs(kilo-docs): remove legacy kilo/auto alias mentions --- .../pages/contributing/architecture/auto-model-tiers.md | 2 +- packages/kilo-docs/pages/gateway/models-and-providers.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md index 9b82541e6d1..9f47cd20bab 100644 --- a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md +++ b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md @@ -52,7 +52,7 @@ For the current mode-to-model mappings, see the [Auto Model user docs](/docs/cod **Who it's for**: Cost-conscious developers who want better results than free models at a fraction of frontier cost. -**What it does**: Uses GPT 5.3 Codex — a cost-effective model with strong reasoning and coding capabilities — for every mode. Unlike Frontier, Balanced does not vary its underlying model by mode. The legacy `kilo/auto` model ID also resolves to Balanced. +**What it does**: Uses GPT 5.3 Codex — a cost-effective model with strong reasoning and coding capabilities — for every mode. Unlike Frontier, Balanced does not vary its underlying model by mode. **Pricing**: Paid, but significantly cheaper than Frontier. diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index ccf7ade164c..73d528c58d8 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -88,7 +88,7 @@ Highest performance and capability for any task. Frontier requests are sent with ### `kilo-auto/balanced` -Great balance of price and capability. Balanced routes to the same model regardless of mode, with low reasoning effort. The legacy `kilo/auto` alias resolves to the same behavior. +Great balance of price and capability. Balanced routes to the same model regardless of mode, with low reasoning effort. | Mode | Resolved Model | | --------- | ---------------------- | From 20331784f5c2b3c59bf489bb4b8d176ebd066639 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:30:02 +0000 Subject: [PATCH 28/32] docs(kilo-docs): bump stale claude model IDs to current versions --- .../pages/contributing/architecture/auto-model-tiers.md | 4 ++-- packages/kilo-docs/pages/gateway/models-and-providers.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md index 9f47cd20bab..0821d610f95 100644 --- a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md +++ b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md @@ -115,8 +115,8 @@ The Kilo API at `api.kilo.ai` defines which underlying models each `kilo-auto/*` { "opencode": { "variants": { - "architect": { "model": "anthropic/claude-opus-4-6", ... }, - "code": { "model": "anthropic/claude-sonnet-4-6", ... } + "architect": { "model": "anthropic/claude-opus-4.7", ... }, + "code": { "model": "anthropic/claude-sonnet-4.6", ... } } } } diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index 73d528c58d8..63cff2caef1 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -41,7 +41,7 @@ This returns model information including pricing, context window, and supported | Model ID | Provider | Description | | ------------------------------- | --------- | ----------------------------------------------- | -| `anthropic/claude-opus-4.6` | Anthropic | Most capable Claude model for complex reasoning | +| `anthropic/claude-opus-4.7` | Anthropic | Most capable Claude model for complex reasoning | | `anthropic/claude-sonnet-4.6` | Anthropic | Balanced performance and cost | | `anthropic/claude-haiku-4.5` | Anthropic | Fast and cost-effective | | `openai/gpt-5.4` | OpenAI | Latest GPT model | From c8fd4218236afb7d9f525ca667ddf53734c47d4a Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 17 Apr 2026 14:12:19 +0200 Subject: [PATCH 29/32] fix(vscode): restore sidebar diff viewer parity with Agent Manager (#9121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(vscode): restore sidebar diff viewer parity with Agent Manager The sidebar diff viewer bundle was missing agent-manager-review.css, so FullScreenDiffView rendered with a broken file tree (no flex layout, no revert styles) since PR #7455. Co-locate the CSS imports with the component so every consumer gets them automatically, and wire per-file revert end-to-end via a shared WorktreeDiffClient used by both the sidebar provider and the Agent Manager controller. * fix(cli): skip flaky shell-completion test on Windows CI The 'shell completion resumes queued loop callers' test relies on shell process spawn timing that is unreliable on Windows CI and times out at 3s. Every other shell-process test in this file already uses the existing `unix(...)` helper for the same reason — align this test with that pattern. --- .changeset/diff-viewer-parity.md | 5 ++ .../kilo-vscode/src/DiffViewerProvider.ts | 41 +++++++++++++- .../agent-manager/worktree-diff-controller.ts | 22 ++------ .../kilo-vscode/src/worktree-diff-client.ts | 54 +++++++++++++++++++ .../tests/unit/diff-viewer-css-arch.test.ts | 42 +++++++++++++++ .../agent-manager/FullScreenDiffView.tsx | 5 ++ .../webview-ui/diff-viewer/DiffViewerApp.tsx | 20 +++++++ .../webview-ui/src/types/messages.ts | 8 +++ .../test/session/prompt-effect.test.ts | 5 +- 9 files changed, 180 insertions(+), 22 deletions(-) create mode 100644 .changeset/diff-viewer-parity.md create mode 100644 packages/kilo-vscode/src/worktree-diff-client.ts create mode 100644 packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts diff --git a/.changeset/diff-viewer-parity.md b/.changeset/diff-viewer-parity.md new file mode 100644 index 00000000000..bfc5040b863 --- /dev/null +++ b/.changeset/diff-viewer-parity.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix the sidebar "Show Changes" diff viewer: the file tree now renders correctly (previously the file rows were cramped onto a single line due to missing styles), and per-file revert buttons are available, matching the Agent Manager. diff --git a/packages/kilo-vscode/src/DiffViewerProvider.ts b/packages/kilo-vscode/src/DiffViewerProvider.ts index 84196113351..a26b71a18a7 100644 --- a/packages/kilo-vscode/src/DiffViewerProvider.ts +++ b/packages/kilo-vscode/src/DiffViewerProvider.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode" import type { KiloConnectionService } from "./services/cli-backend" import { buildWebviewHtml } from "./utils" import { GitOps } from "./agent-manager/GitOps" +import { WorktreeDiffClient, type DiffTarget } from "./worktree-diff-client" import { appendOutput, getWorkspaceRoot, @@ -20,7 +21,7 @@ export class DiffViewerProvider implements vscode.Disposable { private panel: vscode.WebviewPanel | undefined private diffInterval: ReturnType | undefined private lastDiffHash: string | undefined - private cachedDiffTarget: { directory: string; baseBranch: string } | undefined + private cachedDiffTarget: DiffTarget | undefined private gitOps: GitOps private outputChannel: vscode.OutputChannel private onSendComments: ((comments: unknown[], autoSend: boolean) => void) | undefined @@ -107,12 +108,48 @@ export class DiffViewerProvider implements vscode.Disposable { return } + if (type === "diffViewer.revertFile" && typeof msg.file === "string") { + void this.revertFile(msg.file) + return + } + if (type === "openFile" && typeof msg.filePath === "string") { openWorkspaceRelativeFile(msg.filePath, typeof msg.line === "number" ? msg.line : undefined) } } - private async resolveLocalDiffTarget(): Promise<{ directory: string; baseBranch: string } | undefined> { + private async revertFile(file: string): Promise { + const target = this.cachedDiffTarget ?? (await this.resolveLocalDiffTarget()) + if (!target) { + this.post({ + type: "diffViewer.revertFileResult", + file, + status: "error", + message: "Could not resolve diff target", + }) + return + } + + try { + const diff = new WorktreeDiffClient(this.connectionService.getClient(), this.gitOps, (...args) => + this.log(...args), + ) + const result = await diff.revertFile(target, file) + this.post({ + type: "diffViewer.revertFileResult", + file, + status: result.ok ? "success" : "error", + message: result.message, + }) + if (result.ok) void this.pollDiff() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + this.log("Failed to revert file:", message) + this.post({ type: "diffViewer.revertFileResult", file, status: "error", message }) + } + } + + private async resolveLocalDiffTarget(): Promise { return await resolveLocalDiffTarget(this.gitOps, (...args) => this.log(...args), getWorkspaceRoot()) } diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index a4ec1dabbd9..1202ce92a21 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -1,5 +1,6 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" import { hashFileDiffs, resolveLocalDiffTarget } from "../review-utils" +import { WorktreeDiffClient } from "../worktree-diff-client" import type { ApplyConflict, GitOps } from "./GitOps" import { shouldStopDiffPolling } from "./delete-worktree" import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager" @@ -8,7 +9,6 @@ import type { AgentManagerOutMessage } from "./types" const LOCAL_DIFF_ID = "local" as const type Target = { sessionId: string; directory: string; baseBranch: string } -type Status = "added" | "deleted" | "modified" export interface WorktreeDiffControllerContext { getState: () => WorktreeStateManager | undefined @@ -111,12 +111,8 @@ export class WorktreeDiffController { } try { - const result = await this.ctx.git.revertFile( - target.directory, - target.baseBranch, - file, - await this.status(target, file), - ) + const diff = new WorktreeDiffClient(this.ctx.getClient(), this.ctx.git, (...args) => this.ctx.log(...args)) + const result = await diff.revertFile(target, file) this.ctx.post({ type: "agentManager.revertWorktreeFileResult", sessionId, @@ -266,18 +262,6 @@ export class WorktreeDiffController { return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), this.ctx.getRoot()) } - private async status(target: { directory: string; baseBranch: string }, file: string): Promise { - try { - const { data } = await this.ctx - .getClient() - .worktree.diffFile({ directory: target.directory, base: target.baseBranch, file }, { throwOnError: true }) - return data?.status - } catch (error) { - this.ctx.log("Failed to look up file status for revert:", error) - return undefined - } - } - private async ready(msg: string): Promise { await this.ctx.getStateReady()?.catch((err) => this.ctx.log(msg, err)) } diff --git a/packages/kilo-vscode/src/worktree-diff-client.ts b/packages/kilo-vscode/src/worktree-diff-client.ts new file mode 100644 index 00000000000..0b8ecce7df6 --- /dev/null +++ b/packages/kilo-vscode/src/worktree-diff-client.ts @@ -0,0 +1,54 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" +import type { GitOps } from "./agent-manager/GitOps" + +/** + * A worktree diff target: the working directory and the base branch we diff + * against (usually the tracking branch). + */ +export type DiffTarget = { directory: string; baseBranch: string } + +type Status = "added" | "deleted" | "modified" + +/** + * Thin coordinator that wraps (KiloClient, GitOps, DiffTarget) and exposes the + * small set of operations used by both the sidebar DiffViewerProvider and the + * agent manager's WorktreeDiffController. + * + * Keeping the helper off review-utils.ts: this deals in HTTP + git orchestration, + * not the small path/vscode helpers that file is scoped to. + */ +export class WorktreeDiffClient { + constructor( + private readonly client: KiloClient, + private readonly git: GitOps, + private readonly log: (...args: unknown[]) => void, + ) {} + + /** + * Look up the diff status for a single file. Used by revert flows to pick + * the right git strategy (added → delete, modified/deleted → checkout). + * Returns `undefined` on error so callers can still attempt a best-effort + * revert — `GitOps.revertFile` defaults to a modified-file strategy. + */ + async fileStatus(target: DiffTarget, file: string): Promise { + try { + const { data } = await this.client.worktree.diffFile( + { directory: target.directory, base: target.baseBranch, file }, + { throwOnError: true }, + ) + return data?.status + } catch (err) { + this.log("Failed to look up file status for revert:", err) + return undefined + } + } + + /** + * Revert a single file in the worktree. Composes `fileStatus` + `GitOps.revertFile`. + * Returns a normalized result; callers handle UI/messaging. + */ + async revertFile(target: DiffTarget, file: string): Promise<{ ok: boolean; message: string }> { + const status = await this.fileStatus(target, file) + return this.git.revertFile(target.directory, target.baseBranch, file, status) + } +} diff --git a/packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts b/packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts new file mode 100644 index 00000000000..ce6614c519b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts @@ -0,0 +1,42 @@ +/** + * Architecture test: FullScreenDiffView CSS co-location. + * + * `FullScreenDiffView` and its children (`FileTree`, etc.) rely on classes + * defined in BOTH `agent-manager.css` and `agent-manager-review.css`. The + * component is shared by multiple webview bundles (sidebar diff viewer, + * agent manager, storybook). Historically, each bundle was responsible for + * importing its own CSS, which led to regressions when someone forgot to + * wire the review stylesheet into a new entry point (see PR #7455 fallout). + * + * Current invariant: `FullScreenDiffView.tsx` imports both stylesheets at the + * top of the file, so any bundle pulling in the component transitively gets + * the styles via esbuild's CSS bundling. + * + * If this test fails, do NOT move the CSS imports elsewhere — fix the + * component file to import the missing stylesheet, or add a new stylesheet + * to the REQUIRED list if you intentionally split the styles. + */ + +import { describe, it, expect } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const ROOT = path.resolve(import.meta.dir, "../..") +const FULL_SCREEN_DIFF_VIEW = path.join(ROOT, "webview-ui/agent-manager/FullScreenDiffView.tsx") +const REQUIRED = ["./agent-manager.css", "./agent-manager-review.css"] as const + +describe("FullScreenDiffView — CSS co-location", () => { + it("imports every stylesheet required to render correctly", () => { + const src = fs.readFileSync(FULL_SCREEN_DIFF_VIEW, "utf-8") + const missing = REQUIRED.filter((css) => !src.includes(`import "${css}"`)) + + expect( + missing, + `FullScreenDiffView is missing required CSS imports:\n` + + missing.map((m) => ` - import "${m}"`).join("\n") + + `\n\nAdd them at the top of FullScreenDiffView.tsx. The component is\n` + + `shared by multiple webview bundles (sidebar diff viewer, agent manager,\n` + + `storybook) and every bundle relies on these imports for complete styling.\n`, + ).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx index 0870c1c4b9e..bba856b6a9a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx @@ -1,4 +1,9 @@ import { type Component, createSignal, createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" +// Styles are co-located with the component so every consumer (sidebar diff viewer, +// agent manager, storybook) picks them up automatically. Do not move these out — +// see tests/unit/diff-viewer-css-arch.test.ts for the invariant. +import "./agent-manager.css" +import "./agent-manager-review.css" import { Diff } from "@kilocode/kilo-ui/diff" import { Accordion } from "@kilocode/kilo-ui/accordion" import { StickyAccordionHeader } from "@kilocode/kilo-ui/sticky-accordion-header" diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx index e8aecd76bad..d009445202e 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx @@ -26,6 +26,16 @@ const DiffViewerContent: Component = () => { const [loading, setLoading] = createSignal(true) const [comments, setComments] = createSignal([]) const [diffStyle, setDiffStyle] = createSignal("unified") + const [reverting, setReverting] = createSignal>(new Set()) + + const markReverting = (file: string, active: boolean) => { + setReverting((prev) => { + const next = new Set(prev) + if (active) next.add(file) + else next.delete(file) + return next + }) + } const unsubscribe = vscode.onMessage((msg) => { if (msg.type === "diffViewer.diffs") { @@ -37,6 +47,11 @@ const DiffViewerContent: Component = () => { setLoading(msg.loading) return } + + if (msg.type === "diffViewer.revertFileResult") { + markReverting(msg.file, false) + return + } }) const handler = (event: MessageEvent) => { @@ -67,6 +82,11 @@ const DiffViewerContent: Component = () => { onOpenFile={(relativePath) => { post({ type: "openFile", filePath: relativePath }) }} + onRevertFile={(file) => { + markReverting(file, true) + post({ type: "diffViewer.revertFile", file }) + }} + revertingFiles={reverting()} onClose={() => { post({ type: "diffViewer.close" }) }} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 1e6c18dc35f..6ddbcda6842 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -1403,6 +1403,13 @@ export interface DiffViewerLoadingMessage { loading: boolean } +export interface DiffViewerRevertFileResultMessage { + type: "diffViewer.revertFileResult" + file: string + status: "success" | "error" + message: string +} + export interface ClearPendingPromptsMessage { type: "clearPendingPrompts" } @@ -1598,6 +1605,7 @@ export type ExtensionMessage = | ViewSubAgentSessionMessage | DiffViewerDiffsMessage | DiffViewerLoadingMessage + | DiffViewerRevertFileResultMessage | MarketplaceDataMessage | MarketplaceInstallResultMessage | MarketplaceRemoveResultMessage diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 8e496f60e3b..b5701aeb910 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -1160,7 +1160,10 @@ it.live( 3_000, ) -it.live( +// kilocode_change start - shell process timing is unreliable on Windows CI; +// aligns with every other shell-* test in this file that uses `unix(...)`. +unix( + // kilocode_change end "shell completion resumes queued loop callers", () => provideTmpdirServer( From 2ba069350de8cd74ce24fb7fc8c773dd73b6fbbd Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:14:46 +0000 Subject: [PATCH 30/32] docs(kilo-docs): note that kilo-auto underlying models can change --- packages/kilo-docs/pages/code-with-ai/agents/auto-model.md | 4 ++++ packages/kilo-docs/pages/gateway/models-and-providers.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index ac7bc6854c8..7cf19caf359 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -23,6 +23,10 @@ That's it. No configuration needed. You can see which underlying models are used, as well as the cost, in the expanded model picker. Model mapping information is also available on the [Gateway Model page](/docs/gateway/models-and-providers#kilo-autofrontier). +{% callout type="info" title="Models can change" %} +The underlying models behind each Auto Model tier are updated server-side as better options become available or as providers change pricing and availability. The tier you select stays the same; the model it routes to may change over time. +{% /callout %} + ## Tiers - **Frontier** — Routes to the latest and most capable paid models. Uses different models for reasoning-heavy tasks (planning, architecture, debugging) versus implementation tasks (coding, building, exploring), pairing the right capability to each type of work. diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index 63cff2caef1..2eb81138698 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -76,6 +76,10 @@ Provided under the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia Kilo Auto virtual models automatically select the best underlying model based on the task type. The selection is controlled by the `x-kilocode-mode` request header. +{% callout type="info" title="Underlying models can change" %} +The mappings below reflect the current routing. The underlying models behind each `kilo-auto/*` tier are updated server-side as better options become available or as providers change pricing and availability — the tier IDs themselves remain stable. +{% /callout %} + ### `kilo-auto/frontier` Highest performance and capability for any task. Frontier requests are sent with medium reasoning effort and medium verbosity. From 3895139f5ca11db6236492721a961873ee85c384 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:16:09 +0000 Subject: [PATCH 31/32] chore: add dependabot[bot] to team list dependabot[bot] opens PRs regularly; suppress the 'Thanks' line in release notes the same way we do for other team bots. --- packages/script/src/index.ts | 1 + script/changelog-github.cjs | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index c6109f80a34..99402538cb3 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -118,6 +118,7 @@ const team = [ "chrarnoldus", "codingelves", "darkogj", + "dependabot[bot]", "dosire", "DScdng", "emilieschario", diff --git a/script/changelog-github.cjs b/script/changelog-github.cjs index f0aecd0e21d..a8301cf68fc 100644 --- a/script/changelog-github.cjs +++ b/script/changelog-github.cjs @@ -16,6 +16,7 @@ const team = new Set([ "chrarnoldus", "codingelves", "darkogj", + "dependabot[bot]", "dosire", "DScdng", "emilieschario", From 96bd75067a4c414afb8aaedbd3d49cddb37902e1 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:29:16 +0000 Subject: [PATCH 32/32] fix: suppress 'Thanks' credit for kilo-code-bot app in changelog @changesets/changelog-github renders GitHub App credits with the app slug (e.g. [@kilo-code-bot](https://github.com/apps/kilo-code-bot)), not the '[bot]'-suffixed commit author login. v7.2.12 leaked a 'Thanks @kilo-code-bot!' line because the team set only contained 'kilo-code-bot[bot]'. Add the bare slug. --- packages/script/src/index.ts | 1 + script/changelog-github.cjs | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index 99402538cb3..7ee6d5e2e7e 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -132,6 +132,7 @@ const team = [ "alex-alecu", "imanolmzd-svg", "kilocode-bot", + "kilo-code-bot", "kilo-code-bot[bot]", "kirillk", "lambertjosh", diff --git a/script/changelog-github.cjs b/script/changelog-github.cjs index a8301cf68fc..7669e589e8f 100644 --- a/script/changelog-github.cjs +++ b/script/changelog-github.cjs @@ -30,6 +30,7 @@ const team = new Set([ "alex-alecu", "imanolmzd-svg", "kilocode-bot", + "kilo-code-bot", "kilo-code-bot[bot]", "kirillk", "lambertjosh",