From 2f3408acd266db9e7dbd9c0358b7887450cb9cb6 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 16 Apr 2026 09:22:18 +0300 Subject: [PATCH 01/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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 e65c2d99c0d234d3dc1dff2e75e58e22bea8ce7f Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:47:47 +0000 Subject: [PATCH 08/28] fix(gateway): hide models without tool support from list --- .changeset/filter-kilo-models-without-tools.md | 7 +++++++ packages/kilo-gateway/src/api/models.ts | 5 +++++ 2 files changed, 12 insertions(+) create mode 100644 .changeset/filter-kilo-models-without-tools.md diff --git a/.changeset/filter-kilo-models-without-tools.md b/.changeset/filter-kilo-models-without-tools.md new file mode 100644 index 00000000000..2569b17947c --- /dev/null +++ b/.changeset/filter-kilo-models-without-tools.md @@ -0,0 +1,7 @@ +--- +"@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/packages/kilo-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index e5d9dacb5e1..8712f3d47bd 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -118,6 +118,11 @@ export async function fetchKiloModels(options?: { continue } + // Skip models that don't support tools — Kilo requires tool calling + if (!model.supported_parameters?.includes("tools")) { + continue + } + const transformedModel = transformToModelDevFormat(model) models[model.id] = transformedModel } From 226f9e348314b751dd811469ec17b61ab6fb7057 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 10:12:14 +0300 Subject: [PATCH 09/28] 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 10/28] 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 e60c3263191c5746bea6bd93cd291c28f5d1ab0f Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:01:17 +0000 Subject: [PATCH 11/28] fix(cli): add opus-4.7 adaptive reasoning and xhigh effort variant --- .changeset/opus-4-7-adaptive-reasoning.md | 5 ++ packages/opencode/src/provider/transform.ts | 10 ++- .../test/kilocode/transform-opus-4.7.test.ts | 89 +++++++++++++++++++ 3 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 .changeset/opus-4-7-adaptive-reasoning.md create mode 100644 packages/opencode/test/kilocode/transform-opus-4.7.test.ts diff --git a/.changeset/opus-4-7-adaptive-reasoning.md b/.changeset/opus-4-7-adaptive-reasoning.md new file mode 100644 index 00000000000..4f938cdad83 --- /dev/null +++ b/.changeset/opus-4-7-adaptive-reasoning.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Support adaptive reasoning for Claude Opus 4.7 and expose the `xhigh` effort level for adaptive Anthropic models diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 0b04fd45985..6385fb3ee8b 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -383,10 +383,12 @@ export namespace ProviderTransform { if (!model.capabilities.reasoning) return {} const id = model.id.toLowerCase() - const isAnthropicAdaptive = ["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => - model.api.id.includes(v), - ) - const adaptiveEfforts = ["low", "medium", "high", "max"] + // kilocode_change start: add opus-4.7 (with xhigh effort) + const isOpus47 = ["opus-4-7", "opus-4.7"].some((v) => model.api.id.includes(v)) + const isAnthropicAdaptive = + isOpus47 || ["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => model.api.id.includes(v)) + const adaptiveEfforts = isOpus47 ? ["low", "medium", "high", "xhigh", "max"] : ["low", "medium", "high", "max"] + // kilocode_change end if ( id.includes("deepseek") || id.includes("minimax") || diff --git a/packages/opencode/test/kilocode/transform-opus-4.7.test.ts b/packages/opencode/test/kilocode/transform-opus-4.7.test.ts new file mode 100644 index 00000000000..366fdefa656 --- /dev/null +++ b/packages/opencode/test/kilocode/transform-opus-4.7.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test" +import { ProviderTransform } from "../../src/provider/transform" + +function mockModel(overrides: Partial = {}): any { + return { + id: "test/test-model", + providerID: "test", + api: { + id: "test-model", + url: "https://api.test.com", + npm: "@ai-sdk/anthropic", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } }, + limit: { context: 200_000, output: 64_000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2024-01-01", + ...overrides, + } +} + +describe("ProviderTransform.variants - Claude Opus 4.7", () => { + test("opus-4-7 returns adaptive thinking variants including xhigh (native anthropic)", () => { + const model = mockModel({ + api: { + id: "claude-opus-4-7", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.xhigh).toEqual({ + thinking: { type: "adaptive" }, + effort: "xhigh", + }) + }) + + test("opus-4.7 dot-form returns adaptive thinking variants via @ai-sdk/gateway", () => { + const model = mockModel({ + id: "anthropic/claude-opus-4-7", + api: { + id: "anthropic/claude-opus-4.7", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + }) + + test("opus-4-7 on bedrock returns adaptive reasoningConfig with xhigh", () => { + const model = mockModel({ + api: { + id: "anthropic.claude-opus-4-7", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.xhigh).toEqual({ + reasoningConfig: { type: "adaptive", maxReasoningEffort: "xhigh" }, + }) + }) + + test("opus-4-6 keeps original adaptive efforts without xhigh", () => { + const model = mockModel({ + api: { + id: "claude-opus-4-6", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) + }) +}) From 49b283e72a7307a1398c38c0862214faa043c128 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 17 Apr 2026 10:23:19 +0200 Subject: [PATCH 12/28] feat(vscode): add contribute CTA to marketplace (#9099) Invites users to propose new skills, modes, or MCP servers via github.com/Kilo-Org/kilo-marketplace. Shown as a subtle footer under the card grid and inside the empty search-results state. --- .changeset/marketplace-contribute-cta.md | 5 +++ packages/kilo-docs/source-links.md | 4 ++- packages/kilo-i18n/src/ar.ts | 2 ++ packages/kilo-i18n/src/br.ts | 2 ++ packages/kilo-i18n/src/bs.ts | 2 ++ packages/kilo-i18n/src/da.ts | 2 ++ packages/kilo-i18n/src/de.ts | 2 ++ packages/kilo-i18n/src/en.ts | 2 ++ packages/kilo-i18n/src/es.ts | 2 ++ packages/kilo-i18n/src/fr.ts | 2 ++ packages/kilo-i18n/src/ja.ts | 2 ++ packages/kilo-i18n/src/ko.ts | 2 ++ packages/kilo-i18n/src/nl.ts | 2 ++ packages/kilo-i18n/src/no.ts | 2 ++ packages/kilo-i18n/src/pl.ts | 2 ++ packages/kilo-i18n/src/ru.ts | 2 ++ packages/kilo-i18n/src/th.ts | 2 ++ packages/kilo-i18n/src/tr.ts | 2 ++ packages/kilo-i18n/src/uk.ts | 2 ++ packages/kilo-i18n/src/zh.ts | 2 ++ packages/kilo-i18n/src/zht.ts | 2 ++ .../marketplace/MarketplaceContribute.tsx | 19 +++++++++++ .../marketplace/MarketplaceListView.tsx | 12 ++++++- .../components/marketplace/marketplace.css | 32 ++++++++++++++++++- 24 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 .changeset/marketplace-contribute-cta.md create mode 100644 packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceContribute.tsx diff --git a/.changeset/marketplace-contribute-cta.md b/.changeset/marketplace-contribute-cta.md new file mode 100644 index 00000000000..c805880e0a9 --- /dev/null +++ b/.changeset/marketplace-contribute-cta.md @@ -0,0 +1,5 @@ +--- +"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/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 6ba0ad51c31..d003e3781e2 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -1,7 +1,7 @@ # Source Code Links - + - @@ -51,6 +51,8 @@ - +- + - - diff --git a/packages/kilo-i18n/src/ar.ts b/packages/kilo-i18n/src/ar.ts index 8c9611cc32b..ee2a0ffdaf2 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -69,4 +69,6 @@ export const dict = { "marketplace.warning.busyMany": "عدة جلسات تعمل وستتوقف", "marketplace.warning.installAnyway": "تثبيت على أي حال", "marketplace.warning.cancel": "إلغاء", + "marketplace.contribute.prompt": "هل تفتقد مهارة أو وضعًا أو خادم MCP؟", + "marketplace.contribute.cta": "ساهم على GitHub", } diff --git a/packages/kilo-i18n/src/br.ts b/packages/kilo-i18n/src/br.ts index b876ead09e7..38bfb6ae825 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -69,4 +69,6 @@ export const dict = { "marketplace.warning.busyMany": "Várias sessões estão em execução e serão interrompidas", "marketplace.warning.installAnyway": "Instalar mesmo assim", "marketplace.warning.cancel": "Cancelar", + "marketplace.contribute.prompt": "Está faltando uma skill, modo ou servidor MCP?", + "marketplace.contribute.cta": "Contribuir no GitHub", } diff --git a/packages/kilo-i18n/src/bs.ts b/packages/kilo-i18n/src/bs.ts index b74b790c3ab..63aad6b807e 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -74,4 +74,6 @@ export const dict = { "marketplace.warning.busyMany": "Nekoliko sesija je pokrenuto i bit će prekinuto", "marketplace.warning.installAnyway": "Instaliraj svejedno", "marketplace.warning.cancel": "Otkaži", + "marketplace.contribute.prompt": "Nedostaje vještina, način rada ili MCP server?", + "marketplace.contribute.cta": "Doprinesi na GitHub-u", } diff --git a/packages/kilo-i18n/src/da.ts b/packages/kilo-i18n/src/da.ts index cbada5ccba1..53442d3736b 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -69,4 +69,6 @@ export const dict = { "marketplace.warning.busyMany": "Flere sessioner kører og vil blive afbrudt", "marketplace.warning.installAnyway": "Installer alligevel", "marketplace.warning.cancel": "Annuller", + "marketplace.contribute.prompt": "Mangler du en skill, tilstand eller MCP-server?", + "marketplace.contribute.cta": "Bidrag på GitHub", } diff --git a/packages/kilo-i18n/src/de.ts b/packages/kilo-i18n/src/de.ts index 5323d532381..190ceff2d67 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -69,4 +69,6 @@ export const dict = { "marketplace.warning.busyMany": "Mehrere Sitzungen laufen und werden unterbrochen", "marketplace.warning.installAnyway": "Trotzdem installieren", "marketplace.warning.cancel": "Abbrechen", + "marketplace.contribute.prompt": "Fehlt ein Skill, Modus oder MCP-Server?", + "marketplace.contribute.cta": "Auf GitHub beitragen", } diff --git a/packages/kilo-i18n/src/en.ts b/packages/kilo-i18n/src/en.ts index 4047f1e2112..2e1f38828c5 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -71,4 +71,6 @@ export const dict = { "marketplace.warning.busyMany": "Several sessions are running and will be interrupted", "marketplace.warning.installAnyway": "Install anyway", "marketplace.warning.cancel": "Cancel", + "marketplace.contribute.prompt": "Missing a skill, mode, or MCP server?", + "marketplace.contribute.cta": "Contribute on GitHub", } diff --git a/packages/kilo-i18n/src/es.ts b/packages/kilo-i18n/src/es.ts index ce1f6b681bb..62fa5ec0ac2 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -69,4 +69,6 @@ export const dict = { "marketplace.warning.busyMany": "Varias sesiones están en ejecución y se interrumpirán", "marketplace.warning.installAnyway": "Instalar de todas formas", "marketplace.warning.cancel": "Cancelar", + "marketplace.contribute.prompt": "¿Falta una skill, modo o servidor MCP?", + "marketplace.contribute.cta": "Contribuir en GitHub", } diff --git a/packages/kilo-i18n/src/fr.ts b/packages/kilo-i18n/src/fr.ts index a5fe80cc440..43e34edc4dd 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -69,4 +69,6 @@ export const dict = { "marketplace.warning.busyMany": "Plusieurs sessions sont en cours et seront interrompues", "marketplace.warning.installAnyway": "Installer quand même", "marketplace.warning.cancel": "Annuler", + "marketplace.contribute.prompt": "Il manque une skill, un mode ou un serveur MCP ?", + "marketplace.contribute.cta": "Contribuer sur GitHub", } diff --git a/packages/kilo-i18n/src/ja.ts b/packages/kilo-i18n/src/ja.ts index d648941a8d6..f75cccfb4ae 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -67,4 +67,6 @@ export const dict = { "marketplace.warning.busyMany": "複数のセッションが実行中で中断されます", "marketplace.warning.installAnyway": "それでもインストール", "marketplace.warning.cancel": "キャンセル", + "marketplace.contribute.prompt": "スキル、モード、MCP サーバーが見つかりませんか?", + "marketplace.contribute.cta": "GitHub で貢献する", } diff --git a/packages/kilo-i18n/src/ko.ts b/packages/kilo-i18n/src/ko.ts index 8f34191c5d5..23c9fe1740f 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -67,4 +67,6 @@ export const dict = { "marketplace.warning.busyMany": "여러 세션이 실행 중이며 중단됩니다", "marketplace.warning.installAnyway": "그래도 설치", "marketplace.warning.cancel": "취소", + "marketplace.contribute.prompt": "스킬, 모드 또는 MCP 서버가 없나요?", + "marketplace.contribute.cta": "GitHub에서 기여하기", } diff --git a/packages/kilo-i18n/src/nl.ts b/packages/kilo-i18n/src/nl.ts index d306f4fdd80..00072f19847 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -71,4 +71,6 @@ export const dict = { "marketplace.warning.busyMany": "Er zijn meerdere sessies actief en deze zullen worden onderbroken", "marketplace.warning.installAnyway": "Toch installeren", "marketplace.warning.cancel": "Annuleren", + "marketplace.contribute.prompt": "Mist er een skill, modus of MCP-server?", + "marketplace.contribute.cta": "Bijdragen op GitHub", } diff --git a/packages/kilo-i18n/src/no.ts b/packages/kilo-i18n/src/no.ts index 482e7604c81..85cfce2679e 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -69,4 +69,6 @@ export const dict = { "marketplace.warning.busyMany": "Flere økter kjører og vil bli avbrutt", "marketplace.warning.installAnyway": "Installer uansett", "marketplace.warning.cancel": "Avbryt", + "marketplace.contribute.prompt": "Mangler du en skill, modus eller MCP-server?", + "marketplace.contribute.cta": "Bidra på GitHub", } diff --git a/packages/kilo-i18n/src/pl.ts b/packages/kilo-i18n/src/pl.ts index 081bbef0351..48d436413c3 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -69,4 +69,6 @@ export const dict = { "marketplace.warning.busyMany": "Kilka sesji jest uruchomionych i zostanie przerwanych", "marketplace.warning.installAnyway": "Zainstaluj mimo to", "marketplace.warning.cancel": "Anuluj", + "marketplace.contribute.prompt": "Brakuje skilla, trybu lub serwera MCP?", + "marketplace.contribute.cta": "Wnieś wkład na GitHubie", } diff --git a/packages/kilo-i18n/src/ru.ts b/packages/kilo-i18n/src/ru.ts index b3390b0018e..0ac0e661fd8 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -69,4 +69,6 @@ export const dict = { "marketplace.warning.busyMany": "Несколько сеансов выполняются и будут прерваны", "marketplace.warning.installAnyway": "Установить в любом случае", "marketplace.warning.cancel": "Отмена", + "marketplace.contribute.prompt": "Не хватает навыка, режима или MCP-сервера?", + "marketplace.contribute.cta": "Внести вклад на GitHub", } diff --git a/packages/kilo-i18n/src/th.ts b/packages/kilo-i18n/src/th.ts index f33f59806d6..d8a03606c75 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -69,4 +69,6 @@ export const dict = { "marketplace.warning.busyMany": "มีหลายเซสชันกำลังทำงานและจะถูกขัดจังหวะ", "marketplace.warning.installAnyway": "ติดตั้งต่อไป", "marketplace.warning.cancel": "ยกเลิก", + "marketplace.contribute.prompt": "ไม่พบทักษะ โหมด หรือเซิร์ฟเวอร์ MCP ที่ต้องการ?", + "marketplace.contribute.cta": "ร่วมสมทบบน GitHub", } diff --git a/packages/kilo-i18n/src/tr.ts b/packages/kilo-i18n/src/tr.ts index bcef0dc3a6c..0a3a39d3dbd 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -69,4 +69,6 @@ export const dict = { "marketplace.warning.busyMany": "Birden fazla oturum çalışıyor ve kesintiye uğrayacak", "marketplace.warning.installAnyway": "Yine de yükle", "marketplace.warning.cancel": "İptal", + "marketplace.contribute.prompt": "Bir yetenek, mod veya MCP sunucusu mu eksik?", + "marketplace.contribute.cta": "GitHub'da katkıda bulun", } diff --git a/packages/kilo-i18n/src/uk.ts b/packages/kilo-i18n/src/uk.ts index 458772d31b1..cb3b1090ce0 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -69,4 +69,6 @@ export const dict = { "marketplace.warning.busyMany": "Виконується кілька сесій, їх буде перервано", "marketplace.warning.installAnyway": "Встановити все одно", "marketplace.warning.cancel": "Скасувати", + "marketplace.contribute.prompt": "Бракує навички, режиму або MCP-сервера?", + "marketplace.contribute.cta": "Зробити внесок на GitHub", } diff --git a/packages/kilo-i18n/src/zh.ts b/packages/kilo-i18n/src/zh.ts index 67ede7176fc..d444f4e8eec 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -66,4 +66,6 @@ export const dict = { "marketplace.warning.busyMany": "多个会话正在运行,将被中断", "marketplace.warning.installAnyway": "仍然安装", "marketplace.warning.cancel": "取消", + "marketplace.contribute.prompt": "缺少技能、模式或 MCP 服务器?", + "marketplace.contribute.cta": "在 GitHub 上贡献", } diff --git a/packages/kilo-i18n/src/zht.ts b/packages/kilo-i18n/src/zht.ts index 46171c6fd63..275727428b8 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -66,4 +66,6 @@ export const dict = { "marketplace.warning.busyMany": "多個工作階段正在執行,將被中斷", "marketplace.warning.installAnyway": "仍然安裝", "marketplace.warning.cancel": "取消", + "marketplace.contribute.prompt": "缺少技能、模式或 MCP 伺服器?", + "marketplace.contribute.cta": "在 GitHub 上貢獻", } diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceContribute.tsx b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceContribute.tsx new file mode 100644 index 00000000000..efad57c4ba0 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceContribute.tsx @@ -0,0 +1,19 @@ +import { Button } from "@kilocode/kilo-ui/button" +import { useVSCode } from "../../context/vscode" +import { useLanguage } from "../../context/language" + +const REPO_URL = "https://github.com/Kilo-Org/kilo-marketplace" + +export const MarketplaceContribute = () => { + const vscode = useVSCode() + const { t } = useLanguage() + const open = () => vscode.postMessage({ type: "openExternal", url: REPO_URL }) + return ( +
+ {t("marketplace.contribute.prompt")} + +
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx index 5f08b965e6d..62ea13eed68 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx @@ -12,6 +12,7 @@ import type { import { useLanguage } from "../../context/language" import { isInstalled } from "./utils" import { ItemCard } from "./ItemCard" +import { MarketplaceContribute } from "./MarketplaceContribute" interface StatusOption { value: string @@ -124,7 +125,15 @@ export const MarketplaceListView = (props: Props) => { } > - 0} fallback={

{props.emptyMessage}

}> + 0} + fallback={ +
+ {props.emptyMessage} + +
+ } + >
{(item) => { @@ -144,6 +153,7 @@ export const MarketplaceListView = (props: Props) => { }}
+
diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css b/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css index bf56c52d0c4..4c833147c51 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css @@ -77,9 +77,39 @@ } .marketplace-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 32px 16px; text-align: center; color: var(--text-weak); - padding: 24px; +} + +.marketplace-empty-message { + font-size: 13px; +} + +/* Contribute CTA (footer + inside empty state) */ + +.marketplace-contribute { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 0; + margin-top: 12px; + border-top: 1px solid var(--border-base, var(--vscode-widget-border, transparent)); + color: var(--text-weak); + font-size: 12px; +} + +/* Inside the empty state, drop the border — message is already the lead-in. */ +.marketplace-empty .marketplace-contribute { + border-top: none; + margin-top: 0; + padding-top: 0; } /* Grid */ From 1526a4b8a890e01c0c890b4e17b595f35bfd745b Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 17 Apr 2026 10:23:41 +0200 Subject: [PATCH 13/28] fix(vscode): prioritize active session in streaming scheduler (#9057) * fix(vscode): coalesce streaming part updates * fix(vscode): prioritize active session in streaming scheduler Active session flushes every 16ms; background sessions batch into a single partsUpdated message every 150-400ms (adaptive). Drops queued updates on session delete and keeps background streams from blocking the active chat. * fix(vscode): address review on streaming scheduler Broaden changeset scope (sidebar and Agent Manager), trim KiloProvider to stay under the line cap, and add tests pinning session/message/part identity invariants that provider caching depends on. * docs(vscode): explain streaming scheduler tuning defaults * refactor(vscode): extract SessionStreamScheduler and expand coverage Move the scheduler into its own file under src/kilo-provider/ alongside the other provider helpers. Background lane now recomputes its flush target on every push so adaptive backoff kicks in within a single streaming burst, bounded by backgroundMaxMs. Adds a dedicated test file covering coalescing, session isolation, focus/lifecycle, adaptive scheduling, backoff cap, and stats. * fix(vscode): drop queued deltas before authoritative snapshot handleLoadMessages and handleSyncSession post a server-authoritative messagesLoaded snapshot. A text-delta that was queued by the scheduler during the fetch may already be reflected in that snapshot; emitting it afterwards would duplicate streamed text in the webview. Drop queued updates for the session before posting messagesLoaded. drop() no longer clears focus (callers manage that explicitly) so focus set by handleLoadMessages survives the pre-snapshot drop. handleDeleteSession now clears focus when the active session is deleted. * refactor(vscode): unify streaming wire types and document snapshot assumption Single source of truth for the partUpdated/partsUpdated wire shape in src/shared/stream-messages.ts using a generic PartUpdate

/ PartBatch

. The scheduler uses the default payload-agnostic unknown, the webview narrows to its concrete Part union. Removes the structural-compat drift risk between extension- and webview-side type definitions. Also documents the snapshot-freshness precondition that governs drop() at both messagesLoaded sites. * test(vscode): extend message-contract scan to shared stream-messages The partUpdated / partsUpdated wire-type literals moved into src/shared/stream-messages.ts in the previous commit. The static-analysis contract tests only scanned webview-ui/src/types/messages.ts, so they regressed. Concatenate both files for the literal/type scan. --- .changeset/session-streaming-scheduler.md | 5 + packages/kilo-vscode/src/KiloProvider.ts | 54 ++- .../kilo-vscode/src/kilo-provider-utils.ts | 12 +- .../kilo-provider/session-stream-scheduler.ts | 327 ++++++++++++++ .../services/cli-backend/sdk-sse-adapter.ts | 6 +- .../kilo-vscode/src/shared/stream-messages.ts | 24 ++ .../tests/unit/message-contract.test.ts | 19 +- .../unit/session-stream-scheduler.test.ts | 406 ++++++++++++++++++ .../webview-ui/src/context/session.tsx | 246 ++++++----- .../webview-ui/src/types/messages.ts | 13 +- 10 files changed, 946 insertions(+), 166 deletions(-) create mode 100644 .changeset/session-streaming-scheduler.md create mode 100644 packages/kilo-vscode/src/kilo-provider/session-stream-scheduler.ts create mode 100644 packages/kilo-vscode/src/shared/stream-messages.ts create mode 100644 packages/kilo-vscode/tests/unit/session-stream-scheduler.test.ts diff --git a/.changeset/session-streaming-scheduler.md b/.changeset/session-streaming-scheduler.md new file mode 100644 index 00000000000..4d56c76705a --- /dev/null +++ b/.changeset/session-streaming-scheduler.md @@ -0,0 +1,5 @@ +--- +"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/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index a614613d05d..50ec36eb482 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -32,6 +32,7 @@ import { resolveContextDirectory, resolveWorkspaceDirectory, mergeFileSearchResults, + SessionStreamScheduler, type SessionRefreshContext, } from "./kilo-provider-utils" import { GitOps } from "./agent-manager/GitOps" @@ -172,6 +173,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** Set when refreshSessions() is called before the client is ready. * Cleared and retried once the connection transitions to "connected". */ private pendingSessionRefresh = false + private readonly streams = new SessionStreamScheduler((msg) => this.postMessage(msg)) private readonly confirmations = new MessageConfirmation() private unsubscribeEvent: (() => void) | null = null private unsubscribeState: (() => void) | null = null @@ -241,6 +243,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (s) this.postMessage({ type: "remoteStatus", enabled: s.enabled, connected: s.connected }) } private focusSession(id?: string): void { + this.streams.focus(id) if (id) this.connectionService.registerFocused(this.instanceId, id) else this.connectionService.unregisterFocused(this.instanceId) } @@ -279,12 +282,6 @@ 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 - private slimPart(part: T): T { if (!this.slimEditMetadata) return part return slimPart(part) @@ -1368,12 +1365,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.connectionService.recordMessageSessionId(message.id, message.sessionID) } - this.postMessage({ - type: "messagesLoaded", - sessionID, - messages, - }) - + // Snapshot must reflect every SSE event up to its taken-time; any + // delta still queued here is either already applied in the snapshot + // (re-emitting would duplicate streamed text) or trails the snapshot + // and is silently lost via drop(). + this.streams.drop(sessionID) + this.postMessage({ type: "messagesLoaded", sessionID, messages }) // Recover any prompts missed while the webview was loading or during an SSE reconnection. this.recoverPendingPrompts() } catch (error) { @@ -1426,12 +1423,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.connectionService.recordMessageSessionId(message.id, message.sessionID) } - this.postMessage({ - type: "messagesLoaded", - sessionID, - messages, - }) - + // Snapshot supersedes any queued deltas (see handleLoadMessages for the + // snapshot-freshness assumption that governs drop() here). + this.streams.drop(sessionID) + this.postMessage({ type: "messagesLoaded", sessionID, messages }) // Recover any prompts emitted by the child before we started tracking it. this.recoverPendingPrompts() } catch (err) { @@ -1524,11 +1519,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const workspaceDir = this.getWorkspaceDirectory(sessionID) await this.client.session.delete({ sessionID, directory: workspaceDir }, { throwOnError: true }) this.trackedSessionIds.delete(sessionID) + this.streams.drop(sessionID) this.syncedChildSessions.delete(sessionID) this.sessionDirectories.delete(sessionID) this.connectionService.pruneSession(sessionID) if (this.currentSession?.id === sessionID) { this.currentSession = null + this.focusSession(undefined) } this.postMessage({ type: "sessionDeleted", sessionID }) } catch (error) { @@ -2916,7 +2913,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const sid = event.properties.sessionID this.sessionStatusMap.set(sid, event.properties.status.type) const msg = mapSSEEventToWebviewMessage(event, sid) - if (msg) this.postMessage(msg) + if (msg) { + this.streams.flush(sid) + this.postMessage(msg) + } return } @@ -2992,16 +2992,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper handleNetworkEvent(event.type as string, event.properties as any, this.client, (s) => this.getWorkspaceDirectory(s)) const msg = mapSSEEventToWebviewMessage(event, sessionID) - if (msg) { - if (msg.type === "partUpdated") { - this.postMessage({ - ...msg, - part: this.slimPart(msg.part), - }) - return - } - this.postMessage(msg) + if (!msg) return + if (msg.type === "partUpdated") { + this.streams.push({ ...msg, part: this.slimPart(msg.part) }) + return } + this.streams.flush(sessionID) + this.postMessage(msg) } /** @@ -3335,6 +3332,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.viewStateDisposable?.dispose() this.visibilityDisposable?.dispose() this.webviewMessageDisposable?.dispose() + this.streams.dispose() this.isWebviewReady = false this.promptRecoveryQueued = false clearNetworkWaits(this.trackedSessionIds) diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index 1f9bf18fd3f..2599fde8cf9 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -1,5 +1,8 @@ import type { Session, Agent, Event, ProviderListResponse } from "@kilocode/sdk/v2/client" import type { CloudSessionMessage } from "./services/cli-backend/types" +import type { PartBatch, PartUpdate } from "./kilo-provider/session-stream-scheduler" + +export { SessionStreamScheduler } from "./kilo-provider/session-stream-scheduler" /** A single provider entry as returned by the /provider list endpoint. */ export type ProviderInfo = ProviderListResponse["all"][number] @@ -281,13 +284,8 @@ export function resolveContextDirectory(input: { } export type WebviewMessage = - | { - type: "partUpdated" - sessionID: string - messageID: string - part: unknown - delta?: { type: "text-delta"; textDelta: string } - } + | PartUpdate + | PartBatch | { type: "messageCreated" message: Record diff --git a/packages/kilo-vscode/src/kilo-provider/session-stream-scheduler.ts b/packages/kilo-vscode/src/kilo-provider/session-stream-scheduler.ts new file mode 100644 index 00000000000..1f3c91e8bae --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/session-stream-scheduler.ts @@ -0,0 +1,327 @@ +/** + * Session-aware streaming scheduler. + * + * Sits between SSE events and the webview `postMessage` path. Coalesces + * repeated `partUpdated` events for the same `(sessionID, messageID, partID)` + * tuple, prioritizes the focused session, and throttles background sessions + * so multi-agent streaming doesn't saturate the renderer main thread. + * + * See the tuning comment on the default constants below for rationale. + */ + +import type { PartBatch, PartUpdate } from "../shared/stream-messages" +export type { PartBatch, PartUpdate } from "../shared/stream-messages" + +export type StreamSchedulerStats = { + received: number + emitted: number + batches: number + active: number + background: number +} + +export type StreamSchedulerOptions = { + /** Flush cadence for the focused/active session. Defaults to 16ms. */ + activeMs?: number + /** Base background cadence. Defaults to 150ms. */ + backgroundBaseMs?: number + /** + * Additional ms per background session above the first 2 (adaptive throttle). + * 10 background sessions → base + 8 * step. Defaults to 20ms. + */ + backgroundStepMs?: number + /** Hard cap for the background cadence. Defaults to 400ms. */ + backgroundMaxMs?: number +} + +// Scheduler tuning — rationale: +// +// These defaults balance perceived streaming smoothness against renderer pressure. +// The scheduler sits between SSE (dozens to hundreds of deltas per second per session) +// and the webview message loop, which applies updates through Solid `batch()` and +// triggers DOM / style / layout work on the renderer main thread. +// +// - DEFAULT_ACTIVE_MS = 16 +// One 60Hz animation frame. The focused session should feel indistinguishable +// from immediate streaming, so we coalesce within a single paint window but no +// longer. Raising this to 32ms visibly stutters live text; lowering below ~8ms +// stops coalescing meaningfully because SSE delta arrival is already ~10-20ms +// apart at typical model rates. +// +// - DEFAULT_BG_BASE_MS = 150 +// Background (non-focused) sessions don't need frame-perfect updates — the user +// can't see their content. 150ms keeps tab-status signals (spinner motion, +// token counts via related events) feeling alive while collapsing most +// per-token deltas into a single batched emission. Under 100ms the coalescing +// win shrinks; over ~250ms users start perceiving lag when switching tabs +// mid-stream (though `focus()` also immediately flushes, so this is mostly a +// concern for users watching tab-level indicators). +// +// - DEFAULT_BG_STEP_MS = 20 +// Per-extra-background-session backoff beyond the first 2. Each additional +// streaming agent adds ~20ms to the background interval so total background +// message throughput stays roughly flat as the agent count grows. Without this, +// 10 concurrent agents would put the same ~7 msg/sec pressure per-session on +// the renderer as 1 agent does (~70 msg/sec total background). +// +// - DEFAULT_BG_MAX_MS = 400 +// Ceiling for the adaptive backoff. Even with 20+ agents streaming we never +// stall background emissions longer than 400ms, which keeps tab indicators +// recognizably "live" and keeps the `drop()`-on-delete path timely. Above +// ~500ms the UI starts feeling disconnected; below 300ms the many-agent +// backoff stops providing meaningful throttling. +const DEFAULT_ACTIVE_MS = 16 +const DEFAULT_BG_BASE_MS = 150 +const DEFAULT_BG_STEP_MS = 20 +const DEFAULT_BG_MAX_MS = 400 + +function partField(part: unknown, key: string): unknown { + if (!part || typeof part !== "object") return undefined + return (part as Record)[key] +} + +function appendPart(part: unknown, text: string): unknown { + if (!part || typeof part !== "object") return part + const item = part as Record + if ((item.type !== "text" && item.type !== "reasoning") || typeof item.text !== "string") return part + return { ...item, text: item.text + text } +} + +function partUpdateKey(msg: PartUpdate): string | undefined { + const id = partField(msg.part, "id") + const mid = msg.messageID || partField(msg.part, "messageID") + if (typeof id !== "string" || !id) return undefined + if (typeof mid !== "string" || !mid) return undefined + return `${msg.sessionID}:${mid}:${id}` +} + +function mergePartUpdate(prev: PartUpdate | undefined, msg: PartUpdate): PartUpdate { + if (!prev) return msg + const text = msg.delta?.textDelta + if (!text) return msg.delta ? prev : msg + if (!prev.delta) return { ...prev, part: appendPart(prev.part, text) } + return { + ...prev, + part: appendPart(prev.part, text), + delta: { type: "text-delta", textDelta: `${prev.delta.textDelta}${text}` }, + } +} + +export class SessionStreamScheduler { + private active: string | undefined + private atimer: ReturnType | null = null + private btimer: ReturnType | null = null + private bgFirstQueuedAt = 0 + private readonly queues = new Map>() + private readonly activeMs: number + private readonly bgBase: number + private readonly bgStep: number + private readonly bgMax: number + private readonly counters: StreamSchedulerStats = { + received: 0, + emitted: 0, + batches: 0, + active: 0, + background: 0, + } + + constructor( + private readonly send: (msg: PartUpdate | PartBatch) => void, + opts?: StreamSchedulerOptions, + ) { + this.activeMs = opts?.activeMs ?? DEFAULT_ACTIVE_MS + this.bgBase = opts?.backgroundBaseMs ?? DEFAULT_BG_BASE_MS + this.bgStep = opts?.backgroundStepMs ?? DEFAULT_BG_STEP_MS + this.bgMax = opts?.backgroundMaxMs ?? DEFAULT_BG_MAX_MS + } + + focus(sessionID?: string): void { + if (this.active === sessionID) return + const prev = this.active + if (this.atimer) { + clearTimeout(this.atimer) + this.atimer = null + } + this.active = sessionID + if (prev && this.queues.get(prev)?.size) this.scheduleBackground() + if (sessionID) this.flush(sessionID) + } + + push(msg: PartUpdate): void { + this.counters.received++ + const key = partUpdateKey(msg) + if (!key) { + // Non-keyable updates can't be merged. Flush pending first to preserve order. + this.flush(msg.sessionID) + this.emitOne(msg) + return + } + + const queue = this.ensureQueue(msg.sessionID) + const prev = queue.get(key) + // A full-part replacement after buffered deltas would lose information; flush first. + if (prev?.delta && !msg.delta) { + this.flush(msg.sessionID) + this.ensureQueue(msg.sessionID).set(key, msg) + } else { + queue.set(key, mergePartUpdate(prev, msg)) + } + this.schedule(msg.sessionID) + } + + flush(sessionID?: string): void { + if (!sessionID) { + this.clearTimers() + this.emit(this.takeAll()) + return + } + + if (this.active === sessionID && this.atimer) { + clearTimeout(this.atimer) + this.atimer = null + } + + this.emit(this.take(sessionID)) + + if (this.btimer && !this.hasBackground()) { + clearTimeout(this.btimer) + this.btimer = null + } + } + + /** + * Discard any queued updates for a session without emitting them. + * + * Called when an authoritative snapshot supersedes buffered deltas + * (messagesLoaded fetch) or when a session is deleted. Does NOT alter focus + * state — callers that also want to clear focus should call `focus(undefined)` + * themselves. A pending active-lane timer is left to fire harmlessly + * (`take()` returns `[]` for the emptied queue). + */ + drop(sessionID: string): void { + this.queues.delete(sessionID) + if (this.btimer && !this.hasBackground()) { + clearTimeout(this.btimer) + this.btimer = null + } + } + + dispose(): void { + this.clearTimers() + this.queues.clear() + } + + stats(): Readonly { + return this.counters + } + + private ensureQueue(sid: string): Map { + const existing = this.queues.get(sid) + if (existing) return existing + const queue = new Map() + this.queues.set(sid, queue) + return queue + } + + private schedule(sessionID: string): void { + if (!this.queues.get(sessionID)?.size) return + if (this.active === sessionID) { + if (this.atimer) return + this.atimer = setTimeout(() => this.flushActive(), this.activeMs) + return + } + this.scheduleBackground() + } + + private scheduleBackground(): void { + const count = this.backgroundCount() + if (count === 0) return + const now = Date.now() + if (!this.btimer) this.bgFirstQueuedAt = now + const elapsed = now - this.bgFirstQueuedAt + const extra = Math.max(0, count - 2) * this.bgStep + const target = Math.min(this.bgMax, this.bgBase + extra) + // Never defer past backgroundMaxMs from when the first update landed. + // Each push can extend the timer up to that hard cap as the session count grows. + const remaining = Math.max(0, Math.min(target - elapsed, this.bgMax - elapsed)) + if (this.btimer) clearTimeout(this.btimer) + this.btimer = setTimeout(() => this.flushBackground(), remaining) + } + + private flushActive(): void { + this.atimer = null + if (this.active) this.emit(this.take(this.active)) + } + + private flushBackground(): void { + this.btimer = null + this.bgFirstQueuedAt = 0 + this.emit(this.takeBackground()) + } + + private take(sessionID: string): PartUpdate[] { + const queue = this.queues.get(sessionID) + if (!queue) return [] + this.queues.delete(sessionID) + return [...queue.values()] + } + + private takeAll(): PartUpdate[] { + const updates = [...this.queues.values()].flatMap((queue) => [...queue.values()]) + this.queues.clear() + return updates + } + + private takeBackground(): PartUpdate[] { + const updates: PartUpdate[] = [] + for (const [sid, queue] of this.queues) { + if (sid === this.active) continue + updates.push(...queue.values()) + this.queues.delete(sid) + } + return updates + } + + private backgroundCount(): number { + let n = 0 + for (const [sid, queue] of this.queues) { + if (sid !== this.active && queue.size > 0) n++ + } + return n + } + + private hasBackground(): boolean { + return this.backgroundCount() > 0 + } + + private emit(updates: PartUpdate[]): void { + if (updates.length === 0) return + if (updates.length === 1) { + this.emitOne(updates[0]!) + return + } + this.counters.emitted += updates.length + this.counters.batches++ + this.countLane(updates[0]!.sessionID) + this.send({ type: "partsUpdated", updates }) + } + + private emitOne(msg: PartUpdate): void { + this.counters.emitted++ + this.counters.batches++ + this.countLane(msg.sessionID) + this.send(msg) + } + + private countLane(sessionID: string): void { + if (sessionID === this.active) this.counters.active++ + else this.counters.background++ + } + + private clearTimers(): void { + if (this.atimer) clearTimeout(this.atimer) + this.atimer = null + if (this.btimer) clearTimeout(this.btimer) + this.btimer = null + } +} diff --git a/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts b/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts index 2b54ac11bb9..b3cc547164c 100644 --- a/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts +++ b/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts @@ -21,10 +21,8 @@ export type SSEStateHandler = (state: "connecting" | "connected" | "disconnected * * NOTE on event coalescing: * The app batches rapid events into 16 ms windows before flushing to the UI. - * We don't do that here because `postMessage()` to the webview already acts as - * an implicit async buffer. If profiling shows the webview is overwhelmed by - * high-frequency events, adding a similar coalescing queue here would be a - * straightforward improvement. + * This adapter preserves raw SSE order for all subscribers; webview providers + * coalesce high-frequency part updates before calling `postMessage()`. */ export class SdkSSEAdapter { private readonly handlers = new Set() diff --git a/packages/kilo-vscode/src/shared/stream-messages.ts b/packages/kilo-vscode/src/shared/stream-messages.ts new file mode 100644 index 00000000000..8134f96b55d --- /dev/null +++ b/packages/kilo-vscode/src/shared/stream-messages.ts @@ -0,0 +1,24 @@ +/** + * Wire types for the streaming part-update pipeline. + * + * Single source of truth shared by the extension-side scheduler + * (`session-stream-scheduler.ts`) and the webview-side message types + * (`webview-ui/src/types/messages.ts`). The generic `P` lets the webview + * narrow `part` to its concrete `Part` union while the scheduler stays + * payload-agnostic. + */ + +export type PartTextDelta = { type: "text-delta"; textDelta: string } + +export type PartUpdate

= { + type: "partUpdated" + sessionID: string + messageID: string + part: P + delta?: PartTextDelta +} + +export type PartBatch

= { + type: "partsUpdated" + updates: PartUpdate

[] +} diff --git a/packages/kilo-vscode/tests/unit/message-contract.test.ts b/packages/kilo-vscode/tests/unit/message-contract.test.ts index c26c830c85e..ed7a93cc350 100644 --- a/packages/kilo-vscode/tests/unit/message-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/message-contract.test.ts @@ -17,11 +17,18 @@ const ROOT = path.resolve(import.meta.dir, "../..") const MESSAGES_FILE = path.join(ROOT, "webview-ui/src/types/messages.ts") const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") const KILO_PROVIDER_UTILS_FILE = path.join(ROOT, "src/kilo-provider-utils.ts") +// Some wire types (partUpdated, partsUpdated) live in a file shared by the +// extension and webview; the contract checks must include it. +const SHARED_STREAM_MESSAGES_FILE = path.join(ROOT, "src/shared/stream-messages.ts") function readFile(filePath: string): string { return fs.readFileSync(filePath, "utf-8") } +function readMessageTypeSources(): string { + return readFile(MESSAGES_FILE) + "\n" + readFile(SHARED_STREAM_MESSAGES_FILE) +} + describe("ExtensionMessage type members", () => { it("all members of ExtensionMessage union are defined as interfaces/types in messages.ts", () => { const content = readFile(MESSAGES_FILE) @@ -38,8 +45,9 @@ describe("ExtensionMessage type members", () => { const unionBody = unionMatch[1]! const memberNames = [...unionBody.matchAll(/\|\s*([A-Z]\w+)\b/g)].map((m) => m[1]!) + const defined = readMessageTypeSources() const missing = memberNames.filter((name) => { - return !new RegExp(`(interface|type)\\s+${name}\\b`).test(content) + return !new RegExp(`(interface|type)\\s+${name}\\b`).test(defined) }) expect(missing, `ExtensionMessage members without definitions: ${missing.join(", ")}`).toEqual([]) @@ -57,8 +65,9 @@ describe("ExtensionMessage type members", () => { const unionBody = unionMatch[1]! const memberNames = [...unionBody.matchAll(/\|\s*([A-Z]\w+)\b/g)].map((m) => m[1]!) + const defined = readMessageTypeSources() const missing = memberNames.filter((name) => { - return !new RegExp(`(interface|type)\\s+${name}\\b`).test(content) + return !new RegExp(`(interface|type)\\s+${name}\\b`).test(defined) }) expect(missing, `WebviewMessage members without definitions: ${missing.join(", ")}`).toEqual([]) @@ -68,7 +77,7 @@ describe("ExtensionMessage type members", () => { describe("KiloProvider message handler coverage", () => { it("all WebviewMessage switch cases in KiloProvider exist in WebviewMessage union", () => { const providerContent = readFile(KILO_PROVIDER_FILE) - const messagesContent = readFile(MESSAGES_FILE) + const messagesContent = readMessageTypeSources() // Extract case labels from handleWebviewMessage switch const caseMatches = [...providerContent.matchAll(/case "([a-zA-Z]+)":/g)].map((m) => m[1]!) @@ -89,12 +98,12 @@ describe("KiloProvider message handler coverage", () => { describe("mapSSEEventToWebviewMessage output types", () => { it("all output types from mapSSEEventToWebviewMessage exist in ExtensionMessage", () => { const utilsContent = readFile(KILO_PROVIDER_UTILS_FILE) - const messagesContent = readFile(MESSAGES_FILE) + const messagesContent = readMessageTypeSources() // Extract type literals used in the return values of mapSSEEventToWebviewMessage const typeMatches = [...utilsContent.matchAll(/type:\s*"([a-zA-Z]+)"/g)].map((m) => m[1]!) - // Get all type values defined in messages.ts + // Get all type values defined across the wire-type sources. const allTypes = [...messagesContent.matchAll(/type:\s*"([a-zA-Z]+)"/g)].map((m) => m[1]!) const typeSet = new Set(allTypes) diff --git a/packages/kilo-vscode/tests/unit/session-stream-scheduler.test.ts b/packages/kilo-vscode/tests/unit/session-stream-scheduler.test.ts new file mode 100644 index 00000000000..209d8c77079 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-stream-scheduler.test.ts @@ -0,0 +1,406 @@ +import { describe, it, expect } from "bun:test" +import { + SessionStreamScheduler, + type PartBatch, + type PartUpdate, +} from "../../src/kilo-provider/session-stream-scheduler" + +type Item = PartUpdate +type Sent = PartUpdate | PartBatch + +function update(text: string, delta?: string, sid = "sess-1", partID = "p1") { + const msg: Item = { + type: "partUpdated", + sessionID: sid, + messageID: "m1", + part: { id: partID, type: "text", messageID: "m1", text }, + } + if (delta === undefined) return msg + return { ...msg, delta: { type: "text-delta", textDelta: delta } } as Item +} + +function items(sent: Sent[]): Item[] { + return sent.flatMap((msg) => (msg.type === "partsUpdated" ? msg.updates : [msg])) +} + +function flushSync(...msgs: Item[]): Sent[] { + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg)) + for (const msg of msgs) queue.push(msg) + queue.flush() + return sent +} + +function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)) +} + +function partText(msg: Item): string { + return (msg.part as { text: string }).text +} + +describe("SessionStreamScheduler / coalescing", () => { + it("merges repeated text deltas", () => { + const sent = items(flushSync(update("a", "a"), update("b", "b"))) + expect(sent).toHaveLength(1) + expect(partText(sent[0]!)).toBe("ab") + expect(sent[0]!.delta?.textDelta).toBe("ab") + }) + + it("uses part messageID when messageID is blank", () => { + const sent = items(flushSync({ ...update("a", "a"), messageID: "" }, { ...update("b", "b"), messageID: "" })) + expect(sent).toHaveLength(1) + expect(partText(sent[0]!)).toBe("ab") + }) + + it("appends deltas onto queued full parts", () => { + const sent = items(flushSync(update("hello"), update(" world", " world"))) + expect(partText(sent[0]!)).toBe("hello world") + expect(sent[0]!.delta).toBeUndefined() + }) + + it("keeps the latest full part", () => { + const sent = items(flushSync(update("old"), update("new"))) + expect(partText(sent[0]!)).toBe("new") + }) + + it("flushes deltas before later full updates", () => { + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg)) + queue.push(update("a", "a")) + queue.push(update("done")) + queue.flush() + const flat = items(sent) + expect(flat).toHaveLength(2) + expect(partText(flat[0]!)).toBe("a") + expect(partText(flat[1]!)).toBe("done") + }) + + it("emits non-keyable updates immediately and flushes pending first", () => { + // A part without an id can't be coalesced. We must flush any queued deltas + // for the same session first to preserve ordering, then emit directly. + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg)) + queue.push(update("queued", "queued")) + queue.push({ + type: "partUpdated", + sessionID: "sess-1", + messageID: "m1", + part: { type: "text", messageID: "m1", text: "noId" }, + }) + queue.flush() + const flat = items(sent) + expect(flat).toHaveLength(2) + expect(partText(flat[0]!)).toBe("queued") + expect(partText(flat[1]!)).toBe("noId") + }) +}) + +describe("SessionStreamScheduler / session isolation", () => { + it("batches multiple sessions into one partsUpdated message", () => { + const sent = flushSync(update("a", "a", "sess-1"), update("b", "b", "sess-2")) + expect(sent).toHaveLength(1) + expect(sent[0]!.type).toBe("partsUpdated") + expect(items(sent)).toHaveLength(2) + }) + + it("never merges across sessions, messages, or parts", () => { + // Identity invariants: provider caching and session isolation depend on + // sessionID / messageID / partID never being swapped or combined. + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg)) + queue.push({ + ...update("a", "a", "sess-1"), + messageID: "m1", + part: { id: "p1", type: "text", messageID: "m1", text: "a" }, + }) + queue.push({ + ...update("b", "b", "sess-2"), + messageID: "m1", + part: { id: "p1", type: "text", messageID: "m1", text: "b" }, + }) + queue.push({ + ...update("c", "c", "sess-1"), + messageID: "m2", + part: { id: "p1", type: "text", messageID: "m2", text: "c" }, + }) + queue.push({ + ...update("d", "d", "sess-1"), + messageID: "m1", + part: { id: "p2", type: "text", messageID: "m1", text: "d" }, + }) + queue.flush() + const flat = items(sent) + expect(flat).toHaveLength(4) + const keys = flat.map((msg) => `${msg.sessionID}:${msg.messageID}:${(msg.part as { id: string }).id}`) + expect(new Set(keys).size).toBe(4) + expect(keys).toEqual(expect.arrayContaining(["sess-1:m1:p1", "sess-2:m1:p1", "sess-1:m2:p1", "sess-1:m1:p2"])) + }) + + it("preserves all part fields and concatenates deltas in arrival order", () => { + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg)) + queue.push({ + type: "partUpdated", + sessionID: "sess-1", + messageID: "m1", + part: { id: "p1", type: "text", messageID: "m1", text: "hi", extraMetadata: { tokens: 42 } }, + }) + queue.push({ + type: "partUpdated", + sessionID: "sess-1", + messageID: "m1", + part: { id: "p1", type: "text", messageID: "m1", text: "hi world" }, + delta: { type: "text-delta", textDelta: " world" }, + }) + queue.push({ + type: "partUpdated", + sessionID: "sess-1", + messageID: "m1", + part: { id: "p1", type: "text", messageID: "m1", text: "hi world!" }, + delta: { type: "text-delta", textDelta: "!" }, + }) + queue.flush() + const flat = items(sent) + expect(flat).toHaveLength(1) + const part = flat[0]!.part as { text: string; extraMetadata?: { tokens: number } } + expect(part.extraMetadata?.tokens).toBe(42) + expect(part.text).toBe("hi world!") + // When deltas fold into a queued full part, emit as a full-part replacement + // so the webview does an authoritative replace rather than appending. + expect(flat[0]!.delta).toBeUndefined() + }) +}) + +describe("SessionStreamScheduler / focus and lifecycle", () => { + it("flushes a newly focused session immediately", () => { + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg)) + queue.push(update("a", "a", "sess-2")) + queue.focus("sess-2") + expect(partText(items(sent)[0]!)).toBe("a") + }) + + it("drop() discards queued updates for the session", () => { + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg)) + queue.focus("sess-1") + queue.push(update("a", "a", "sess-2")) + queue.drop("sess-2") + queue.drop("sess-1") + queue.flush() + expect(sent).toHaveLength(0) + }) + + it("drop() before an authoritative snapshot prevents queued delta duplication", async () => { + // Race: a text-delta is queued while the caller is fetching a messages + // snapshot. If the snapshot already reflects the delta (server-authoritative), + // emitting the queued delta after messagesLoaded would duplicate streamed text + // in the webview. drop() must prevent any further emission of queued work. + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg), { + activeMs: 5, + backgroundBaseMs: 5, + backgroundStepMs: 0, + backgroundMaxMs: 5, + }) + queue.focus("sess-1") + queue.push(update("hello", "hello", "sess-1")) + queue.push(update(" world", " world", "sess-1")) + // Snapshot arrives; caller discards queued deltas before posting messagesLoaded. + queue.drop("sess-1") + await sleep(25) + // No queued updates should leak out after the drop. + expect(sent).toHaveLength(0) + queue.dispose() + }) + + it("drop() on the active session keeps focus intact", async () => { + // drop() is used when an authoritative snapshot supersedes buffered deltas + // (e.g. messagesLoaded after a fetch). We just focused this session for the + // user and need the NEXT pushes to keep using the low-latency active lane. + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg), { + activeMs: 5, + backgroundBaseMs: 500, + backgroundStepMs: 0, + backgroundMaxMs: 500, + }) + queue.focus("sess-1") + queue.push(update("a", "a", "sess-1")) + queue.drop("sess-1") + // Pending delta discarded; snapshot would now be posted by the caller. + // A subsequent push for the same session still lands on the active lane. + queue.push(update("b", "b", "sess-1")) + await sleep(25) + const flat = items(sent) + expect(flat).toHaveLength(1) + expect(partText(flat[0]!)).toBe("b") + expect(queue.stats().active).toBe(1) + expect(queue.stats().background).toBe(0) + queue.dispose() + }) + + it("focus(undefined) clears the active session", () => { + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg)) + queue.focus("sess-1") + queue.focus(undefined) + queue.push(update("a", "a", "sess-1")) + queue.flush() + // With no active session, the update lands on the background path but flush + // drains everything synchronously. + const stats = queue.stats() + expect(stats.background).toBe(1) + expect(stats.active).toBe(0) + }) + + it("dispose() stops further emissions from queued work", async () => { + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg), { + activeMs: 5, + backgroundBaseMs: 5, + backgroundStepMs: 0, + backgroundMaxMs: 5, + }) + queue.focus("sess-1") + queue.push(update("a", "a", "sess-1")) + queue.push(update("b", "b", "sess-2")) + queue.dispose() + await sleep(25) + expect(sent).toHaveLength(0) + }) + + it("flush() with no argument drains everything across sessions", () => { + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg)) + queue.push(update("a", "a", "sess-1")) + queue.push(update("b", "b", "sess-2")) + queue.push(update("c", "c", "sess-3")) + queue.flush() + expect(items(sent)).toHaveLength(3) + }) + + it("flush(sid) on an empty session is a no-op", () => { + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg)) + queue.flush("sess-does-not-exist") + expect(sent).toHaveLength(0) + }) +}) + +describe("SessionStreamScheduler / adaptive scheduling", () => { + it("active session flushes on its cadence", async () => { + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg), { + activeMs: 5, + backgroundBaseMs: 1000, + backgroundStepMs: 0, + backgroundMaxMs: 1000, + }) + queue.focus("sess-1") + queue.push(update("a", "a", "sess-1")) + queue.push(update("b", "b", "sess-1")) + await sleep(25) + expect(partText(items(sent)[0]!)).toBe("ab") + expect(queue.stats().active).toBe(1) + expect(queue.stats().background).toBe(0) + queue.dispose() + }) + + it("background lane throttles independently of active", async () => { + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg), { + activeMs: 5, + backgroundBaseMs: 20, + backgroundStepMs: 0, + backgroundMaxMs: 20, + }) + queue.focus("sess-1") + queue.push(update("a", "a", "sess-1")) + queue.push(update("x", "x", "sess-2")) + queue.push(update("y", "y", "sess-3")) + await sleep(15) + expect(sent).toHaveLength(1) + await sleep(25) + expect(sent).toHaveLength(2) + expect(queue.stats().active).toBe(1) + expect(queue.stats().background).toBe(1) + queue.dispose() + }) + + it("focus(A→B) flushes B immediately and schedules A on background", async () => { + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg), { + activeMs: 50, + backgroundBaseMs: 10, + backgroundStepMs: 0, + backgroundMaxMs: 10, + }) + queue.focus("sess-1") + queue.push(update("a", "a", "sess-1")) + queue.push(update("b", "b", "sess-2")) + queue.focus("sess-2") + expect(items(sent).some((msg) => partText(msg) === "b")).toBe(true) + await sleep(25) + expect(items(sent).some((msg) => partText(msg) === "a")).toBe(true) + queue.dispose() + }) + + it("adaptive backoff lengthens background interval with many sessions", async () => { + // Base 10ms + 20ms per extra background session beyond 2, capped at 200ms. + // 10 background sessions → 10 + 8 * 20 = 170ms. + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg), { + activeMs: 5, + backgroundBaseMs: 10, + backgroundStepMs: 20, + backgroundMaxMs: 200, + }) + for (let i = 1; i <= 10; i++) queue.push(update(`t${i}`, `t${i}`, `sess-${i}`)) + await sleep(60) + // With 10 bg sessions, interval is 170ms — nothing should have flushed at 60ms. + expect(sent).toHaveLength(0) + await sleep(180) + // One batch emitted for all 10 sessions. + expect(sent).toHaveLength(1) + expect(items(sent)).toHaveLength(10) + queue.dispose() + }) + + it("backoff is capped at backgroundMaxMs", async () => { + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg), { + activeMs: 5, + backgroundBaseMs: 10, + backgroundStepMs: 100, + backgroundMaxMs: 50, + }) + // Without the cap, 5 bg sessions would schedule 10 + 3*100 = 310ms. + // Cap forces it to 50ms. + for (let i = 1; i <= 5; i++) queue.push(update(`t${i}`, `t${i}`, `sess-${i}`)) + await sleep(80) + expect(sent).toHaveLength(1) + queue.dispose() + }) +}) + +describe("SessionStreamScheduler / stats", () => { + it("tracks received / emitted / batches / lane counters", () => { + const sent: Sent[] = [] + const queue = new SessionStreamScheduler((msg) => sent.push(msg)) + queue.focus("sess-1") + queue.push(update("a", "a", "sess-1")) + queue.push(update("b", "b", "sess-1")) + queue.push(update("x", "x", "sess-2")) + queue.flush() + const stats = queue.stats() + expect(stats.received).toBe(3) + // Two deltas merged in sess-1, one update in sess-2 → 2 unique updates emitted. + expect(stats.emitted).toBe(2) + // Single flush produces one batch message. + expect(stats.batches).toBe(1) + // Lane counters are incremented once per emission (batch or single). + expect(stats.active + stats.background).toBe(stats.batches) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 7ca84260513..fbd1712b839 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -641,123 +641,139 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "toggleFavorite", action, providerID, modelID }) } + function handleStreamMessage(message: ExtensionMessage): boolean { + if (message.type === "partUpdated") { + handlePartUpdated(message.sessionID, message.messageID, message.part, message.delta) + return true + } + + if (message.type === "partsUpdated") { + batch(() => { + for (const update of message.updates) { + handlePartUpdated(update.sessionID, update.messageID, update.part, update.delta) + } + }) + return true + } + + return false + } + + function handleExtensionMessage(message: ExtensionMessage): void { + if (handleStreamMessage(message)) return + switch (message.type) { + case "sessionCreated": + handleSessionCreated(message.session, message.draftID) + break + + case "messagesLoaded": + handleMessagesLoaded(message.sessionID, message.messages) + break + + case "messageCreated": + handleMessageCreated(message.message) + break + + case "sessionStatus": + handleSessionStatus(message.sessionID, message.status, message.attempt, message.message, message.next) + break + + case "todoUpdated": + handleTodoUpdated(message.sessionID, message.items) + break + + case "questionRequest": + handleQuestionRequest(message.question) + break + + case "questionResolved": + handleQuestionResolved(message.requestID) + break + + case "questionError": + handleQuestionError(message.requestID) + break + + case "clearPendingPrompts": + setPermissions([]) + setQuestions([]) + setRespondingPermissions(new Set()) + break + + case "sessionsLoaded": + handleSessionsLoaded(message.sessions, message.preserveSessionIds) + break + + case "sessionUpdated": + setStore("sessions", message.session.id, message.session) + break + + case "sessionDeleted": + handleSessionDeleted(message.sessionID) + break + + case "messageRemoved": + handleMessageRemoved(message.sessionID, message.messageID) + break + + case "sessionError": { + if (message.error?.name === "MessageAbortedError") break + const sid = message.sessionID ?? currentSessionID() + if (!sid) break + // Find the last user message in this session to use as parentID + const msgs = store.messages[sid] ?? [] + const parent = [...msgs].reverse().find((m) => m.role === "user") + const errorMsg: Message = { + id: Identifier.ascending("message"), + sessionID: sid, + role: "assistant", + createdAt: new Date().toISOString(), + parentID: parent?.id, + error: message.error, + } + handleMessageCreated(errorMsg) + break + } + + case "error": + // Only clear loading if the error is for the current session + // (or has no sessionID for backwards compatibility) + if (!message.sessionID || message.sessionID === currentSessionID()) setLoading(false) + break + + case "sendMessageFailed": + handleSendMessageFailed(message as unknown as SendMessageFailedMessage) + break + + case "cloudSessionDataLoaded": + handleCloudSessionDataLoaded(message.cloudSessionId, message.title, message.messages) + break + + case "cloudSessionImported": + handleCloudSessionImported(message.cloudSessionId, message.session) + break + + case "cloudSessionImportFailed": + setCloudPreviewId(null) + setCurrentSessionID(undefined) + setLoading(false) + showToast({ + variant: "error", + title: language.t("session.cloud.import.failed") ?? "Failed to import cloud session", + description: message.error, + }) + console.error("[Kilo New] Cloud session import failed:", message.error) + break + + case "worktreeStatsLoaded": + setWorktreeStats({ files: message.files, additions: message.additions, deletions: message.deletions }) + break + } + } + // Handle messages from extension onMount(() => { - const unsubscribe = vscode.onMessage((message: ExtensionMessage) => { - switch (message.type) { - case "sessionCreated": - handleSessionCreated(message.session, message.draftID) - break - - case "messagesLoaded": - handleMessagesLoaded(message.sessionID, message.messages) - break - - case "messageCreated": - handleMessageCreated(message.message) - break - - case "partUpdated": - handlePartUpdated(message.sessionID, message.messageID, message.part, message.delta) - break - - case "sessionStatus": - handleSessionStatus(message.sessionID, message.status, message.attempt, message.message, message.next) - break - - case "todoUpdated": - handleTodoUpdated(message.sessionID, message.items) - break - - case "questionRequest": - handleQuestionRequest(message.question) - break - - case "questionResolved": - handleQuestionResolved(message.requestID) - break - - case "questionError": - handleQuestionError(message.requestID) - break - - case "clearPendingPrompts": - setPermissions([]) - setQuestions([]) - setRespondingPermissions(new Set()) - break - - case "sessionsLoaded": - handleSessionsLoaded(message.sessions, message.preserveSessionIds) - break - - case "sessionUpdated": - setStore("sessions", message.session.id, message.session) - break - - case "sessionDeleted": - handleSessionDeleted(message.sessionID) - break - - case "messageRemoved": - handleMessageRemoved(message.sessionID, message.messageID) - break - - case "sessionError": { - if (message.error?.name === "MessageAbortedError") break - const sid = message.sessionID ?? currentSessionID() - if (!sid) break - // Find the last user message in this session to use as parentID - const msgs = store.messages[sid] ?? [] - const parent = [...msgs].reverse().find((m) => m.role === "user") - const errorMsg: Message = { - id: Identifier.ascending("message"), - sessionID: sid, - role: "assistant", - createdAt: new Date().toISOString(), - parentID: parent?.id, - error: message.error, - } - handleMessageCreated(errorMsg) - break - } - - case "error": - // Only clear loading if the error is for the current session - // (or has no sessionID for backwards compatibility) - if (!message.sessionID || message.sessionID === currentSessionID()) setLoading(false) - break - - case "sendMessageFailed": - handleSendMessageFailed(message as unknown as SendMessageFailedMessage) - break - - case "cloudSessionDataLoaded": - handleCloudSessionDataLoaded(message.cloudSessionId, message.title, message.messages) - break - - case "cloudSessionImported": - handleCloudSessionImported(message.cloudSessionId, message.session) - break - - case "cloudSessionImportFailed": - setCloudPreviewId(null) - setCurrentSessionID(undefined) - setLoading(false) - showToast({ - variant: "error", - title: language.t("session.cloud.import.failed") ?? "Failed to import cloud session", - description: message.error, - }) - console.error("[Kilo New] Cloud session import failed:", message.error) - break - - case "worktreeStatsLoaded": - setWorktreeStats({ files: message.files, additions: message.additions, deletions: message.deletions }) - break - } - }) - + const unsubscribe = vscode.onMessage(handleExtensionMessage) onCleanup(unsubscribe) }) diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 5ca600e7ef1..7d3e0e9ec7d 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -3,6 +3,7 @@ */ import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@kilocode/sdk/v2/client" +import type { PartBatch, PartUpdate } from "../../../src/shared/stream-messages" // Connection states export type ConnectionState = "connecting" | "connected" | "disconnected" | "error" @@ -505,13 +506,10 @@ export interface SendMessageFailedMessage { files?: FileAttachment[] } -export interface PartUpdatedMessage { - type: "partUpdated" - sessionID?: string - messageID?: string - part: Part - delta?: PartDelta -} +// Wire shape lives in src/shared/stream-messages.ts; narrow `part` to the +// webview's concrete union. +export type PartUpdatedMessage = PartUpdate +export type PartsUpdatedMessage = PartBatch export interface SessionStatusMessage { type: "sessionStatus" @@ -1463,6 +1461,7 @@ export type ExtensionMessage = | ErrorMessage | SendMessageFailedMessage | PartUpdatedMessage + | PartsUpdatedMessage | SessionStatusMessage | SessionErrorMessage | PermissionRequestMessage From 233a4569f400039af3f1798222bc181de57aca74 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 12:05:53 +0300 Subject: [PATCH 14/28] 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 15/28] 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 16/28] 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 17/28] 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 18/28] 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 19/28] 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 20/28] 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 21/28] 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 22/28] 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 23/28] 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 24/28] 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 25/28] 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 26/28] 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 27/28] 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 9749cc178d999f96669cc815709a7cdf3129aefd Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 17 Apr 2026 13:24:35 +0200 Subject: [PATCH 28/28] 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": "輸入自己的答案",