diff --git a/.changeset/custom-provider-image-modality.md b/.changeset/custom-provider-image-modality.md new file mode 100644 index 0000000000..4f53009402 --- /dev/null +++ b/.changeset/custom-provider-image-modality.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Support marking custom provider models as image-capable in VS Code settings. diff --git a/.changeset/defer-branch-naming.md b/.changeset/defer-branch-naming.md new file mode 100644 index 0000000000..43544b6dc3 --- /dev/null +++ b/.changeset/defer-branch-naming.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Defer Agent Manager automatic branch naming until the conversation shows a durable task. The first user message no longer renames the branch; naming waits for a second message (up to four) or for the worktree to contain changes, and renames only run while the session is idle. Read-only verification questions (for example "is X fixed?") no longer claim the branch name. diff --git a/.changeset/image-generation.md b/.changeset/image-generation.md new file mode 100644 index 0000000000..4c0c22bd28 --- /dev/null +++ b/.changeset/image-generation.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add experimental AI image generation tool. Enable via `experimental.image_generation` in config. Supports text-to-image generation and image editing through the Kilo Gateway or a BYO OpenRouter API key. diff --git a/.changeset/show-dismissed-question-content.md b/.changeset/show-dismissed-question-content.md new file mode 100644 index 0000000000..3672c1fc22 --- /dev/null +++ b/.changeset/show-dismissed-question-content.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fixed dismissed question tool content not showing in chat history. Dismissed questions now render with a "Dismissed" label and "N dismissed" subtitle instead of being invisible. diff --git a/packages/kilo-docs/lychee.toml b/packages/kilo-docs/lychee.toml index b4f1c000e7..9ede3aa94e 100644 --- a/packages/kilo-docs/lychee.toml +++ b/packages/kilo-docs/lychee.toml @@ -33,6 +33,8 @@ exclude = [ '^https?://api\.voyageai\.com/v1/embeddings/?$', '^https?://inference\.do-ai\.run/v1/?$', '^https?://generativelanguage\.googleapis\.com/v1beta/openai/?$', + # OpenRouter chat-completions is a POST-only endpoint; GET probes return 404 + '^https?://openrouter\.ai/api/v1/chat/completions/?$', '^https?://search\.parallel\.ai/mcp/?$', # xAI API and OAuth endpoints require request parameters or reject plain link checks. '^https?://api\.x\.ai/v1/?$', diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-dismissed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-dismissed-chromium-linux.png index 52aaff1f7d..b403a28540 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-dismissed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-dismissed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cba6f84ec6138fc59be088cdf84d2ebe57a55f8514df58eed693a84969ff7a90 -size 4794 +oid sha256:eecccfc0bbd53bd52261ff8cfc02c444c6d97f6438e6693d8cd998c36909708a +size 5178 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png index 3ef81e61c1..3eeefc28ec 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c94c54757e88700b5c48a10a60d8626d8d5ed76a29879f06059e5f6457382a5f -size 614004 +oid sha256:8bbe2c598818c82fa3d0911060bc98a88d11c71d943de2a6a4f0b236cfb7bd40 +size 630314 diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 7ac44b13cf..e2dee6036e 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -160,6 +160,8 @@ - +- + - - diff --git a/packages/kilo-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index 5ee3a63352..5c1d4b24f9 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -90,6 +90,75 @@ export async function fetchKiloModels(options?: { kilocodeOrganizationId?: string baseURL?: string }): Promise { + const raw = await fetchRawKiloModels(options) + if (raw.error) return { models: {}, error: raw.error } + + // Transform models to ModelsDev.Model format + const models: Record = {} + + for (const model of raw.data) { + // Skip image generation models + if (model.architecture?.output_modalities?.includes("image")) { + 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 + } + + return { models } +} + +export type KiloImageModel = { + id: string + name: string + description?: string +} + +export type KiloImageModelsResult = { + models: KiloImageModel[] + error?: { kind: "unauthorized" | "network" | "schema" | "http"; status?: number } +} + +/** + * Fetch image-capable models from Kilo API (OpenRouter-compatible endpoint). + * Uses the same raw fetch as {@link fetchKiloModels} but keeps only models + * whose `output_modalities` include `"image"`. + */ +export async function fetchKiloImageModels(options?: { + kilocodeToken?: string + kilocodeOrganizationId?: string + baseURL?: string +}): Promise { + const raw = await fetchRawKiloModels(options) + if (raw.error) return { models: [], error: raw.error } + + const models: KiloImageModel[] = [] + + for (const model of raw.data) { + if (model.architecture?.output_modalities?.includes("image")) { + models.push({ id: model.id, name: model.name, description: model.description }) + } + } + + return { models } +} + +/** + * Shared raw fetch + validate used by both {@link fetchKiloModels} and {@link fetchKiloImageModels}. + */ +async function fetchRawKiloModels(options?: { + kilocodeToken?: string + kilocodeOrganizationId?: string + baseURL?: string +}): Promise< + { data: OpenRouterModel[]; error?: undefined } | { data?: undefined; error: NonNullable } +> { const token = options?.kilocodeToken const organizationId = options?.kilocodeOrganizationId @@ -114,50 +183,32 @@ export async function fetchKiloModels(options?: { }).catch((err: unknown) => err as Error) if (response instanceof Error) { - return { models: {}, error: { kind: "network" } } + return { error: { kind: "network" } } } if (!response.ok) { // 401 with auth credentials: fall back to unauthenticated public endpoint if (response.status === 401 && (token || organizationId)) { - return fetchKiloModels({}) + return fetchRawKiloModels({}) } const kind = response.status === 401 || response.status === 403 ? "unauthorized" : "http" - return { models: {}, error: { kind, status: response.status } } + return { error: { kind, status: response.status } } } const json = await response.json().catch(() => null) if (json === null) { - return { models: {}, error: { kind: "schema" } } + return { error: { kind: "schema" } } } // Validate response schema const result = openRouterModelsResponseSchema.safeParse(json) if (!result.success) { - return { models: {}, error: { kind: "schema" } } + return { error: { kind: "schema" } } } - // Transform models to ModelsDev.Model format - const models: Record = {} - - for (const model of result.data.data) { - // Skip image generation models - if (model.architecture?.output_modalities?.includes("image")) { - 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 - } - - return { models } + return { data: result.data.data } } /** diff --git a/packages/kilo-gateway/src/index.ts b/packages/kilo-gateway/src/index.ts index 4f62a6ceff..d0b80b027c 100644 --- a/packages/kilo-gateway/src/index.ts +++ b/packages/kilo-gateway/src/index.ts @@ -35,7 +35,13 @@ export { promptOrganizationSelection, } from "./api/profile.js" export { fetchKiloPassState } from "./api/kilo-pass.js" -export { fetchKiloModels, type KiloModelsResult } from "./api/models.js" +export { + fetchKiloModels, + type KiloModelsResult, + fetchKiloImageModels, + type KiloImageModel, + type KiloImageModelsResult, +} from "./api/models.js" export { EMPTY_KILO_EMBEDDING_MODEL_CATALOG, fetchKiloEmbeddingModelCatalog, diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 177674cd18..6774a7c945 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -6,6 +6,7 @@ */ import { fetchKilocodeNotifications, KilocodeNotificationSchema } from "../api/notifications.js" +import { fetchKiloImageModels } from "../api/models.js" import { fetchOrganizationModes, clearModesCache } from "../api/modes.js" import { KILO_API_BASE, HEADER_FEATURE, HEADER_ORGANIZATIONID } from "../api/constants.js" import { buildKiloHeaders } from "../headers.js" @@ -445,6 +446,95 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { }) }, ) + .get( + "/models/images", + describeRoute({ + summary: "Image generation models", + description: "List image-capable models from the Kilo Gateway OpenRouter passthrough", + operationId: "kilo.models.images", + responses: { + 200: { + description: "Image model list", + content: { + "application/json": { + schema: resolver( + z.array(z.object({ id: z.string(), name: z.string(), description: z.string().optional() })), + ), + }, + }, + }, + ...errors(400, 401), + }, + }), + async (c: any) => { + try { + const proxy = await getProxyAuth() + if (!proxy.auth || !proxy.token) throw new UnauthorizedError() + + const result = await fetchKiloImageModels({ + kilocodeToken: proxy.token, + kilocodeOrganizationId: proxy.organizationId, + }) + if (result.error) { + if (result.error.kind === "unauthorized") throw new UnauthorizedError() + throw new Error(`Failed to fetch image models: ${result.error.kind}`) + } + return c.json(result.models) + } catch (err) { + if (!(err instanceof UnauthorizedError)) throw err + return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) + } + }, + ) + .post( + "/image/generations", + describeRoute({ + summary: "Image generation", + description: + "Proxy an image generation request (chat-completions with modalities) to the Kilo Gateway OpenRouter passthrough", + operationId: "kilo.image.generations", + responses: { + 200: { + description: "Image generation response", + content: { + "application/json": { + schema: resolver(z.unknown()), + }, + }, + }, + ...errors(400, 401), + }, + }), + validator("json", z.object({ body: z.unknown() }).passthrough()), + async (c: any) => { + const proxy = await getProxyAuth() + if (!proxy.auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) + if (!proxy.token) return c.json({ error: "No valid token found" }, 401) + + const payload = c.req.valid("json") + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${proxy.token}`, + ...buildKiloHeaders(undefined, { kilocodeOrganizationId: proxy.organizationId }), + [HEADER_FEATURE]: "vscode-extension", + } + + const response = await fetch(`${KILO_API_BASE}/api/openrouter/chat/completions`, { + method: "POST", + headers, + signal: c.req.raw.signal, + body: JSON.stringify(payload.body ?? payload), + }) + + const text = await response.text() + return new Response(text, { + status: response.status, + headers: { + "Content-Type": response.headers.get("Content-Type") ?? "application/json", + }, + }) + }, + ) .get( "/notifications", describeRoute({ diff --git a/packages/kilo-memory/src/capture/diff.ts b/packages/kilo-memory/src/capture/diff.ts index 4639b4bbf7..9ec1ebfe65 100644 --- a/packages/kilo-memory/src/capture/diff.ts +++ b/packages/kilo-memory/src/capture/diff.ts @@ -5,20 +5,29 @@ export type CaptureDiff = { deletions: number } -const durable = - /(^|\/)(AGENTS\.md|README(?:\.[^/]*)?|docs?\/.+|package\.json|bun\.lock|pnpm-lock\.yaml|package-lock\.json|turbo\.json|tsconfig[^/]*\.json|vite\.config|eslint|biome|prettier|kilo\.json|\.kilo\/.+|[^/]*(test|spec|config|command|agent|workflow)[^/]*\.(ts|tsx|js|json|md|yml|yaml))$/i +// Build/generated output: machine-produced files that are never a user edit. const generated = /(^|\/)(dist|build|out|coverage|node_modules|\.next|target|vendor|generated|gen|__snapshots__)(\/|$)|(^|\/)[^/]*\.(min|gen)\.[^/]+$|\.map$/i -export function hasDurableDiff(diffs: Pick[]) { +/** Any non-generated file change. Presence-based: numstat only lists changed files, and binary + * edits report 0/0, so churn must not be required. */ +export function hasUserEdit(diffs: Pick[]) { + return diffs.some((item) => { + const file = item.file ?? "" + if (!file) return false + return !generated.test(file) + }) +} + +/** A change big enough to consolidate immediately instead of waiting for the interval throttle. + * Churn-only, so every language/ecosystem is treated the same; build output is excluded. Text edits + * (human or agent) always carry real +/- counts — only binary files are 0/0, so a binary edit is + * never substantial here, but still counts as work via hasUserEdit. */ +export function hasSubstantialDiff(diffs: Pick[]) { return diffs.some((item) => { const file = item.file ?? "" if (!file) return false - // Generated output wins over the durable allowlist: a copied dist/package.json or a vendored doc - // is build output, not a user edit. if (generated.test(file)) return false - if (durable.test(file)) return true - // Fall back to churn size so any language counts, not just files matching the pattern above. return item.additions + item.deletions >= 20 }) } diff --git a/packages/kilo-memory/src/capture/plan.ts b/packages/kilo-memory/src/capture/plan.ts index e8ad12fc28..8cefd46b08 100644 --- a/packages/kilo-memory/src/capture/plan.ts +++ b/packages/kilo-memory/src/capture/plan.ts @@ -13,7 +13,8 @@ export function capturePlan(input: { reason?: CaptureReason summary: string echo: boolean - durable: boolean + substantial: boolean + edited: boolean priorTime: number now: number minIntervalMs: number @@ -27,19 +28,19 @@ export function capturePlan(input: { const session = base && !input.echo // Typed capture trusts the prompt as the content filter and remains bounded by the interval throttle. const typedSession = base - const trivial = Boolean(input.summary) && !input.durable && input.summary.length < 80 + const trivial = Boolean(input.summary) && !input.edited && input.summary.length < 80 const digestDue = session && !trivial && (!input.priorTime || !Number.isFinite(input.priorTime) || input.now - input.priorTime >= input.minIntervalMs || - input.durable) + input.substantial) const interval = Boolean( !input.bypassInterval && input.lastTypedConsolidationAt && input.now - input.lastTypedConsolidationAt < input.minIntervalMs && - !input.durable, + !input.substantial, ) const typed = typedCapture({ reason: input.reason, interval }) const typedCall = input.autoConsolidate && typed.call && typedSession diff --git a/packages/kilo-memory/src/effect/capture.ts b/packages/kilo-memory/src/effect/capture.ts index 538ac5508e..fda14a1a19 100644 --- a/packages/kilo-memory/src/effect/capture.ts +++ b/packages/kilo-memory/src/effect/capture.ts @@ -10,7 +10,8 @@ import { evidence, fallbackDigest, guardReason, - hasDurableDiff, + hasSubstantialDiff, + hasUserEdit, mergeOps, notice, parseDigest, @@ -149,11 +150,12 @@ export namespace MemoryCapture { const summary = summarize({ user, assistant, max: state.limits.maxSessionLineChars }) const diffs = view.diffs const changed = summarizeDiffs(diffs) - const durable = hasDurableDiff(diffs) + const substantial = hasSubstantialDiff(diffs) + const edited = hasUserEdit(diffs) const completed = !input.reason || input.reason === "completed" // Echo = short lookup answered from memory with no file changes. Long recall-assisted answers // (research, investigations) carry new content and must still be digested. - const echo = !durable && assistant.length < 1200 && view.recalledMemory + const echo = !edited && assistant.length < 1200 && view.recalledMemory // Echo gates the digest only. Typed capture is bounded by the interval throttle, and the typed // prompt is the language-agnostic content filter for lookup/correction turns. const sourced = provenance({ assistant }) && !editsInstructionDocs(diffs) @@ -168,7 +170,8 @@ export namespace MemoryCapture { reason: input.reason, summary, echo, - durable, + substantial, + edited, priorTime, now, minIntervalMs: state.capture.minIntervalMs, diff --git a/packages/kilo-memory/test/capture.test.ts b/packages/kilo-memory/test/capture.test.ts index aa3267bf8d..01d81da699 100644 --- a/packages/kilo-memory/test/capture.test.ts +++ b/packages/kilo-memory/test/capture.test.ts @@ -5,7 +5,8 @@ import { duplicateOps, fallbackDigest, guardReason, - hasDurableDiff, + hasSubstantialDiff, + hasUserEdit, mergeOps, notice, parseJson, @@ -303,7 +304,8 @@ describe("memory capture parsing", () => { const base = { summary: "User: continue implementing digest robustness Result: updated capture and storage behavior", echo: false, - durable: false, + substantial: false, + edited: false, priorTime: 0, now: 1_000, minIntervalMs: 500, @@ -332,13 +334,13 @@ describe("memory capture parsing", () => { expected: { session: false, digestDue: false, typedCall: true, typedWork: true, skipReason: undefined }, }, { - name: "expected work: recall-assisted durable answer is modeled as non-echo by caller", - input: { ...base, durable: true }, + name: "expected work: recall-assisted substantial answer is modeled as non-echo by caller", + input: { ...base, substantial: true }, expected: { session: true, digestDue: true, typedCall: true, skipReason: undefined }, }, { name: "expected skip: interrupted turn still schedules a non-LLM fallback digest", - input: { ...base, reason: "interrupted" as const, durable: true }, + input: { ...base, reason: "interrupted" as const, substantial: true }, expected: { completed: false, session: false, @@ -375,6 +377,16 @@ describe("memory capture parsing", () => { input: { ...base, summary: "User: test Result: ok", lastTypedConsolidationAt: 900 }, expected: { digestDue: false, typedCall: false, fallbackDigest: false, skipReason: "trivial" }, }, + { + name: "expected skip: short edited turn is interval-gated instead of trivial", + input: { ...base, summary: "User: test Result: ok", edited: true, priorTime: 900, lastTypedConsolidationAt: 900 }, + expected: { digestDue: false, typedCall: false, fallbackDigest: false, skipReason: "interval" }, + }, + { + name: "expected skip: short unedited turn is trivial", + input: { ...base, summary: "User: test Result: ok", edited: false, lastTypedConsolidationAt: 900 }, + expected: { digestDue: false, typedCall: false, fallbackDigest: false, skipReason: "trivial" }, + }, ] for (const item of cases) { @@ -388,22 +400,26 @@ describe("memory capture parsing", () => { { file: "README.md", status: "modified", additions: 1, deletions: 0 }, ] - expect(hasDurableDiff(diffs)).toBe(true) - expect(hasDurableDiff([{ file: "docs/setup.md", additions: 1, deletions: 0 }])).toBe(true) - expect(hasDurableDiff([{ file: ".kilo/rules.md", additions: 1, deletions: 0 }])).toBe(true) - expect(hasDurableDiff([{ file: "src/plain.ts", additions: 1, deletions: 0 }])).toBe(false) - // P1.5: a substantial edit is durable regardless of language — no JS/TS extension allowlist. - expect(hasDurableDiff([{ file: "src/service.py", additions: 20, deletions: 0 }])).toBe(true) - // Generated output never counts, even with heavy churn or a durable-looking basename... - expect(hasDurableDiff([{ file: "dist/service.py", additions: 200, deletions: 0 }])).toBe(false) - expect(hasDurableDiff([{ file: "src/generated/client.ts", additions: 200, deletions: 0 }])).toBe(false) - expect(hasDurableDiff([{ file: "sdk/src/gen/types.gen.ts", additions: 200, deletions: 0 }])).toBe(false) - expect(hasDurableDiff([{ file: "dist/package.json", additions: 1, deletions: 0 }])).toBe(false) - expect(hasDurableDiff([{ file: "vendor/docs/readme.md", additions: 30, deletions: 0 }])).toBe(false) - // ...while the durable allowlist still wins over churn size elsewhere (lockfiles are a real dep-change signal). - expect(hasDurableDiff([{ file: "packages/app/package.json", additions: 1, deletions: 0 }])).toBe(true) - expect(hasDurableDiff([{ file: "internal/server/main.go", additions: 12, deletions: 10 }])).toBe(true) - expect(hasDurableDiff([{ file: "src/lib.rs", additions: 5, deletions: 5 }])).toBe(false) + // Identical churn yields the identical verdict across every kind of path, so no ecosystem + // (manifest, doc, config, or source language) is treated specially. + for (const file of ["src/app.ts", "src/app.py", "src/app.go", "src/app.rb", "package.json", "docs/x.md", "config.yaml"]) { + expect(hasSubstantialDiff([{ file, additions: 1, deletions: 0 }]), `${file} small`).toBe(false) + expect(hasSubstantialDiff([{ file, additions: 20, deletions: 0 }]), `${file} large`).toBe(true) + } + // A split edit still counts by total churn. + expect(hasSubstantialDiff([{ file: "internal/server/main", additions: 12, deletions: 10 }])).toBe(true) + // Build output never counts, even with heavy churn. + expect(hasSubstantialDiff([{ file: "dist/bundle.js", additions: 200, deletions: 0 }])).toBe(false) + expect(hasSubstantialDiff([{ file: "src/generated/client.ts", additions: 200, deletions: 0 }])).toBe(false) + expect(hasSubstantialDiff([{ file: "sdk/src/gen/types.gen.ts", additions: 200, deletions: 0 }])).toBe(false) + // Binary edits report 0/0 churn, so they are never substantial (but still count as work below). + expect(hasSubstantialDiff([{ file: "assets/logo.png", additions: 0, deletions: 0 }])).toBe(false) + // hasUserEdit: any non-generated file changed counts as work, in any language; presence, not churn. + expect(hasUserEdit([])).toBe(false) + expect(hasUserEdit([{ additions: 1, deletions: 0 }])).toBe(false) + expect(hasUserEdit([{ file: "src/app.ts", additions: 1, deletions: 0 }])).toBe(true) + expect(hasUserEdit([{ file: "dist/bundle.js", additions: 300, deletions: 0 }])).toBe(false) + expect(hasUserEdit([{ file: "assets/logo.png", additions: 0, deletions: 0 }])).toBe(true) expect(summarizeDiffs(diffs)).toContain("modified README.md +1 -0") expect(fallbackDigest({ prior: "Earlier state.", summary: "New state.", max: 80 })).toContain("Latest: New state.") expect(parseDigest({ topic: "", summary: "User: x Result: y." }, "", 120).topic).not.toBe("User") diff --git a/packages/kilo-memory/test/effect-capture.test.ts b/packages/kilo-memory/test/effect-capture.test.ts index 3ab09d7888..b6cebda5aa 100644 --- a/packages/kilo-memory/test/effect-capture.test.ts +++ b/packages/kilo-memory/test/effect-capture.test.ts @@ -312,6 +312,40 @@ describe("MemoryCapture (fake ports)", () => { } }) + test("small file edit with recalled memory still records digest (edit defeats echo, any file type)", async () => { + const t = await tmp() + try { + await KiloMemory.enable({ root: t.root }) + await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } }) + + let runs = 0 + const result = await run({ + root: t.root, + session: session( + view({ + assistant: "Fixed the parser.", + recalledMemory: true, + diffs: [{ file: "src/parser", additions: 4, deletions: 0 }], + }), + ), + model: model({ + digest: '{"topic":"parser","summary":"Fixed the parser in src/parser."}', + typed: '{"operations":[],"skipped":[]}', + onRun: (system) => { + if (system === digestPrompt) runs++ + }, + }), + }) + + expect(result).toMatchObject({ skipped: false }) + expect(runs).toBe(1) + const saved = await MemoryFiles.readSession(t.root, { sessionID: "ses_effect", max: 480 }) + expect(saved?.summary).toContain("Fixed the parser") + } finally { + await t.done() + } + }) + test("interrupted close records a non-LLM fallback digest tagged with the reason", async () => { const t = await tmp() try { diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index b73a35a530..78a0a7c52e 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -1159,6 +1159,12 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const i18n = useI18n() const part = props.part as ToolPart const hideQuestion = createMemo(() => part.tool === "question" && busy(part.state.status)) + const isDismissedQuestionError = createMemo(() => { + if (part.tool !== "question") return false + if (part.state.status !== "error" || !part.state.error) return false + const errStr = typeof part.state.error === "string" ? part.state.error : "" + return errStr.includes("dismissed this question") + }) const emptyInput: Record = {} const emptyMetadata: Record = {} @@ -1177,13 +1183,24 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { {(error) => { const cleaned = error().replace("Error: ", "") - if (part.tool === "question" && cleaned.includes("dismissed this question")) { + if (isDismissedQuestionError()) { return ( -
- - {i18n.t("ui.messagePart.questions.dismissed")} - -
+ ) } const hint = @@ -2827,12 +2844,15 @@ ToolRegistry.register({ const i18n = useI18n() const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[]) const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[]) + const dismissed = createMemo(() => props.metadata.dismissed === true || props.status === "error") const completed = createMemo(() => answers().length > 0) const pending = createMemo(() => busy(props.status)) + const hasContent = createMemo(() => completed() || dismissed()) const subtitle = createMemo(() => { const count = questions().length if (count === 0) return "" + if (dismissed()) return i18n.t("ui.question.subtitle.dismissed", { count }) if (completed()) return i18n.t("ui.question.subtitle.answered", { count }) return `${count} ${i18n.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}` }) @@ -2851,15 +2871,19 @@ ToolRegistry.register({ /> } > - -
+ +
{(q, i) => { const answer = () => answers()[i()] ?? [] + const answerText = () => { + if (dismissed()) return i18n.t("ui.question.answer.dismissed") + return answer().join(", ") || i18n.t("ui.question.answer.none") + } return (
{q.question}
-
{answer().join(", ") || i18n.t("ui.question.answer.none")}
+
{answerText()}
) }} diff --git a/packages/kilo-ui/src/stories/message-part.stories.tsx b/packages/kilo-ui/src/stories/message-part.stories.tsx index 2a48c6060b..6b4f848406 100644 --- a/packages/kilo-ui/src/stories/message-part.stories.tsx +++ b/packages/kilo-ui/src/stories/message-part.stories.tsx @@ -529,6 +529,86 @@ const hintErrors: ToolPart[] = [ const mockDataHintErrors = createMockData(hintErrors) +// --- Question tool: answered (reference) --- + +const questionAnsweredPart: ToolPart = { + id: "part-question-answered", + sessionID: SESSION_ID, + messageID: ASST_MSG_ID, + type: "tool", + callID: "call-question-answered", + tool: "question", + state: { + status: "completed", + input: { + questions: [ + { + question: "Should I continue with this approach?", + header: "Continue?", + options: [ + { label: "Yes", description: "Proceed with the current plan" }, + { label: "No", description: "Stop and reconsider" }, + ], + }, + { + question: "Which library should I use for date formatting?", + header: "Library", + options: [ + { label: "date-fns", description: "Lightweight, tree-shakeable" }, + { label: "luxon", description: "Full-featured DateTime library" }, + { label: "dayjs", description: "Moment.js compatible, 2kB" }, + ], + }, + ], + }, + output: 'User answered: "Should I continue?"="Yes", "Which library?"="date-fns"', + title: "Asked 2 questions", + metadata: { answers: [["Yes"], ["date-fns"]] }, + time: { start: now - 8000, end: now - 7000 }, + }, +} + +// --- Question tool: dismissed (exercises the fix) --- + +const questionDismissedPart: ToolPart = { + id: "part-question-dismissed", + sessionID: SESSION_ID, + messageID: ASST_MSG_ID, + type: "tool", + callID: "call-question-dismissed", + tool: "question", + state: { + status: "completed", + input: { + questions: [ + { + question: "Should I continue with this approach?", + header: "Continue?", + options: [ + { label: "Yes", description: "Proceed with the current plan" }, + { label: "No", description: "Stop and reconsider" }, + ], + }, + { + question: "Which library should I use for date formatting?", + header: "Library", + options: [ + { label: "date-fns", description: "Lightweight, tree-shakeable" }, + { label: "luxon", description: "Full-featured DateTime library" }, + ], + }, + ], + }, + output: "User dismissed the question.", + title: "Question dismissed", + metadata: { answers: [], dismissed: true }, + time: { start: now - 8000, end: now - 7000 }, + }, +} + +const mockDataQuestionAnswered = createMockData([questionAnsweredPart, textPart]) +const mockDataQuestionDismissed = createMockData([questionDismissedPart, textPart]) + export const ToolHintErrors: Story = { render: () => ( @@ -536,3 +616,59 @@ export const ToolHintErrors: Story = { ), } + +// --- Question tool: answered (collapsed) --- + +export const QuestionAnswered: Story = { + name: "QuestionAnswered", + render: () => ( + + + + ), +} + +// --- Question tool: answered (expanded) --- + +export const QuestionAnsweredExpanded: Story = { + name: "QuestionAnswered (expanded)", + render: () => ( + + + + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const trigger = canvasElement + .querySelector('[data-slot="basic-tool-tool-title"]') + ?.closest("button") + if (trigger) trigger.click() + }, +} + +// --- Question tool: dismissed (collapsed — "2 dismissed" subtitle) --- + +export const QuestionDismissed: Story = { + name: "QuestionDismissed", + render: () => ( + + + + ), +} + +// --- Question tool: dismissed (expanded — shows questions with "Dismissed" labels) --- + +export const QuestionDismissedExpanded: Story = { + name: "QuestionDismissed (expanded)", + render: () => ( + + + + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const trigger = canvasElement + .querySelector('[data-slot="basic-tool-tool-title"]') + ?.closest("button") + if (trigger) trigger.click() + }, +} diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 21939cc65f..a144a952a1 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -161,6 +161,7 @@ import { configFeatures } from "./features" import { createAutoApproveBridge } from "./kilo-provider/auto-approve" import type { KiloProviderOptions } from "./kilo-provider/options" import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway" +import { fetchImageModels } from "./image-generation/models" import { stopSessionProcesses } from "./kilo-provider/background-process" import { sandboxDefault, sandboxSessionMetadata } from "./shared/sandbox-session" import { @@ -354,6 +355,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private cachedIndexingStatusMessage: unknown = null /** Cached kiloEmbeddingModelsLoaded payload so requestKiloEmbeddingModels is resilient offline. */ private cachedKiloEmbeddingModelsMessage: unknown = null + /** Cached imageModelsLoaded payload so requestImageModels is resilient offline. */ + private cachedImageModelsMessage: unknown = null /** Cached mcpStatusLoaded payload so requestMcpStatus can be served before client is ready */ private cachedMcpStatusMessage: unknown = null /** Ref-count of in-flight handleUpdateConfig calls; prevents fetchAndSendConfig from sending stale data */ @@ -1228,6 +1231,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper console.error("[Kilo New] fetchAndSendKiloEmbeddingModels failed:", e), ) break + case "requestImageModels": + this.fetchAndSendImageModels().catch((e) => console.error("[Kilo New] fetchAndSendImageModels failed:", e)) + break case "updateConfig": await this.handleUpdateConfig( message.config, @@ -2500,6 +2506,20 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage(message) } + private async fetchAndSendImageModels(): Promise { + const dir = this.getWorkspaceDirectory() + const result = await fetchImageModels(this.connectionService, dir) + if (!result.ok) { + if (this.cachedImageModelsMessage) { + this.postMessage(this.cachedImageModelsMessage) + } + return + } + const message = { type: "imageModelsLoaded" as const, models: result.models } + this.cachedImageModelsMessage = message + this.postMessage(message) + } + /** * Seed sessionStatusMap with current session statuses on connect. * Without this, the Settings panel (which has no tracked sessions) would see diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 837af5ae74..95d2dd67db 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -78,6 +78,7 @@ export class AgentManagerProvider implements Disposable { private cachedWorktreeStats: { type: "agentManager.worktreeStats"; stats: WorktreeStats[] } | undefined private cachedLocalStats: { type: "agentManager.localStats"; stats: LocalStats } | undefined private unsubTool: (() => void) | undefined + private unsubStatus: (() => void) | undefined private unsubFont: (() => void) | undefined private closing: Promise | undefined private onVisibilityChange: ((visible: boolean) => void) | undefined @@ -184,6 +185,19 @@ export class AgentManagerProvider implements Disposable { (event) => (event as { type?: string }).type === "kilocode.agent_manager.start", (event, directory) => this.onToolEvent(event, directory), ) + this.unsubStatus = this.connectionService.onEventFiltered( + (event) => (event as { type?: string }).type === "session.status", + (event) => this.onSessionStatus(event), + ) + } + + private onSessionStatus(event: unknown): void { + const props = (event as { properties?: { sessionID?: string; status?: { type?: string } } }).properties + const sid = props?.sessionID + const type = props?.status?.type + if (!sid || !type) return + if (type === "idle") this.naming.idle(sid) + else this.naming.busy(sid) } private log(...args: unknown[]) { @@ -1062,6 +1076,7 @@ export class AgentManagerProvider implements Disposable { this.statsPoller.skipWorktree(worktreeId) this.prBridge.remove(worktreeId) this.run.remove(worktreeId) + this.naming.forget(worktreeId) const orphaned = state.removeWorktree(worktreeId) if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) { this.diffs.stop() @@ -1095,6 +1110,7 @@ export class AgentManagerProvider implements Disposable { return null } + this.naming.forget(worktreeId) const orphaned = state.removeWorktree(worktreeId) if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) { this.diffs.stop() @@ -1959,6 +1975,7 @@ export class AgentManagerProvider implements Disposable { await this.stateReady?.catch((err) => this.log("dispose: stateReady rejected:", err)) await this.state?.flush().catch((err) => this.log("dispose: state flush failed:", err)) this.unsubTool?.() + this.unsubStatus?.() this.unsubFont?.() this.connectionService.unregisterFocused("agent-manager") this.connectionService.registerOpen("agent-manager", []) diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index e8a7b84161..3e7caaab27 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -158,6 +158,26 @@ export class WorktreeManager { return this.withGitLock(() => this.renameBranchImpl(worktreePath, current, requested)) } + /** Whether the worktree has uncommitted changes or commits ahead of base. + * Used to defer automatic branch naming until the branch carries real work. */ + async hasWork(worktreePath: string, base: string): Promise { + if (!this.isManagedPath(worktreePath)) return false + return this.withGitLock(async () => { + const git = simpleGit(worktreePath) + const status = await git.status() + if (status.files.length > 0) return true + return git + .raw(["rev-list", "--count", `${base}..HEAD`]) + .then((count) => parseInt(count.trim(), 10) > 0) + .catch((error) => { + // An unresolvable base ref means no work to compare; other git + // failures also fail safe to "no work", keeping the placeholder name. + this.log(`hasWork rev-list failed: ${error}`) + return false + }) + }) + } + private async ensureGitAvailable(): Promise { try { await execWithShellEnv("git", ["--version"]) diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index 697e6fdbb5..cca28e5c3e 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -38,6 +38,8 @@ export interface Worktree { branchOwned?: boolean /** Initial session whose prompts may name this placeholder branch once. */ autoNameSessionId?: string + /** Number of prompts observed for the armed session; bounds rename attempts. */ + autoNamePromptCount?: number /** Section this worktree belongs to, or undefined for ungrouped. */ sectionId?: string } @@ -234,6 +236,7 @@ export class WorktreeStateManager { this.log(`Updated worktree ${id} branch: ${wt.branch} → ${branch}`) wt.branch = branch wt.autoNameSessionId = undefined + wt.autoNamePromptCount = undefined void this.save() return true } @@ -242,6 +245,7 @@ export class WorktreeStateManager { const wt = this.worktrees.get(id) if (!wt || wt.branchOwned !== true) return wt.autoNameSessionId = sessionId + wt.autoNamePromptCount = 0 void this.save() } @@ -249,15 +253,27 @@ export class WorktreeStateManager { const wt = this.worktrees.get(id) if (!wt?.autoNameSessionId) return wt.autoNameSessionId = undefined + wt.autoNamePromptCount = undefined void this.save() } + /** Increment the prompt counter for an armed worktree and return the new + * count, or undefined when the worktree is no longer armed. */ + incrementAutoNameCount(id: string): number | undefined { + const wt = this.worktrees.get(id) + if (!wt?.autoNameSessionId) return undefined + wt.autoNamePromptCount = (wt.autoNamePromptCount ?? 0) + 1 + void this.save() + return wt.autoNamePromptCount + } + renameOwnedBranch(id: string, current: string, branch: string): boolean { const wt = this.worktrees.get(id) if (!wt || wt.branch !== current || wt.branchOwned !== true) return false wt.branch = branch wt.originalBranch = undefined wt.autoNameSessionId = undefined + wt.autoNamePromptCount = undefined this.log(`Automatically renamed worktree ${id} branch: ${current} → ${branch}`) void this.save() return true @@ -310,6 +326,7 @@ export class WorktreeStateManager { const worktree = worktreeId ? this.worktrees.get(worktreeId) : undefined if (worktree?.autoNameSessionId && worktreeId && this.getSessions(worktreeId).length > 1) { worktree.autoNameSessionId = undefined + worktree.autoNamePromptCount = undefined } this.log(`Added session ${sessionId} to worktree ${worktreeId ?? "local"}`) void this.save() @@ -321,10 +338,16 @@ export class WorktreeStateManager { const session = this.sessions.get(sessionId) if (!session) return const previous = session.worktreeId ? this.worktrees.get(session.worktreeId) : undefined - if (previous?.autoNameSessionId === sessionId) previous.autoNameSessionId = undefined + if (previous?.autoNameSessionId === sessionId) { + previous.autoNameSessionId = undefined + previous.autoNamePromptCount = undefined + } session.worktreeId = worktreeId const worktree = worktreeId ? this.worktrees.get(worktreeId) : undefined - if (worktree?.autoNameSessionId) worktree.autoNameSessionId = undefined + if (worktree?.autoNameSessionId) { + worktree.autoNameSessionId = undefined + worktree.autoNamePromptCount = undefined + } this.log(`Moved session ${sessionId} to ${worktreeId ?? "local"}`) void this.save() } diff --git a/packages/kilo-vscode/src/agent-manager/branch-naming.ts b/packages/kilo-vscode/src/agent-manager/branch-naming.ts index 016a16da09..6ed5ec88f8 100644 --- a/packages/kilo-vscode/src/agent-manager/branch-naming.ts +++ b/packages/kilo-vscode/src/agent-manager/branch-naming.ts @@ -1,5 +1,8 @@ import { semanticBranchName } from "./branch-name" -import type { WorktreeStateManager } from "./WorktreeStateManager" +import { remoteRef, type WorktreeStateManager } from "./WorktreeStateManager" + +/** Maximum prompts considered for automatic naming before disarming. */ +const MAX_PROMPTS = 4 interface Prompt { sessionID: string @@ -25,6 +28,7 @@ interface Client { interface Manager { renameBranch: (path: string, current: string, branch: string) => Promise + hasWork: (worktreePath: string, base: string) => Promise } interface Deps { @@ -36,34 +40,126 @@ interface Deps { log: (msg: string) => void } +interface Pending { + sessionID: string + branch: string +} + export class BranchNamingController { private readonly requests = new Map() + private readonly busySessions = new Set() + private readonly pending = new Map() + private readonly idleAttempted = new Set() + private readonly model = new Map() constructor(private readonly deps: Deps) {} + /** Called for every outgoing user message. Defers naming until intent is clear: + * the first message only arms, messages 2-4 may name, after that disarm. */ prompt(input: Prompt): void { - const state = this.deps.state() - const session = state?.getSession(input.sessionID) - const worktree = session?.worktreeId ? state?.getWorktree(session.worktreeId) : undefined + const { state, worktree } = this.resolve(input.sessionID) if (!state || !worktree || worktree.autoNameSessionId !== input.sessionID) return if (!this.deps.settings().enabled) { - state.clearAutoName(worktree.id) + this.disarm(worktree.id) return } if (state.getSessions(worktree.id).length !== 1 || worktree.prNumber || worktree.prUrl) { - state.clearAutoName(worktree.id) + this.disarm(worktree.id) return } - if (this.requests.has(worktree.id)) return + this.model.set(worktree.id, { providerID: input.providerID, modelID: input.modelID }) + this.idleAttempted.delete(worktree.id) + const count = state.incrementAutoNameCount(worktree.id) + if (count === undefined) return + if (count > MAX_PROMPTS) { + this.disarm(worktree.id) + return + } + if (count < 2) return + if (this.requests.has(worktree.id) || this.pending.has(worktree.id)) return + this.dispatch(worktree.id, input) + } - const request = new AbortController() - this.requests.set(worktree.id, request) - void this.generate(worktree.id, input, request) + /** Mark a session busy so the rename is deferred to the next idle transition. + * Only armed sessions are tracked to keep the set bounded. */ + busy(sessionID: string): void { + const { worktree } = this.resolve(sessionID) + if (worktree?.autoNameSessionId !== sessionID) return + this.busySessions.add(sessionID) + } + + /** Called when a session becomes idle. Triggers generation once when the + * worktree already has changes (covering a single detailed first prompt), + * and applies any rename that was held while the session was busy. */ + idle(sessionID: string): void { + this.busySessions.delete(sessionID) + const { state, worktree } = this.resolve(sessionID) + if (state && worktree && worktree.autoNameSessionId === sessionID) { + const count = worktree.autoNamePromptCount ?? 0 + if (count === 1 && !this.idleAttempted.has(worktree.id) && !this.requests.has(worktree.id)) { + this.idleAttempted.add(worktree.id) + void this.generateOnIdle(worktree.id, sessionID) + } + } + this.applyPending(sessionID) } dispose(): void { for (const request of this.requests.values()) request.abort() this.requests.clear() + this.pending.clear() + } + + private resolve(sessionID: string) { + const state = this.deps.state() + const session = state?.getSession(sessionID) + const worktree = session?.worktreeId ? state?.getWorktree(session.worktreeId) : undefined + return { state, worktree } + } + + /** Clear persisted arming plus the controller's in-memory bookkeeping. */ + private disarm(id: string): void { + this.deps.state()?.clearAutoName(id) + this.forget(id) + } + + /** Drop in-memory bookkeeping for a worktree whose arming ended without a + * rename (worktree removed, session moved/deleted). Also clears the armed + * session from the busy set. Call before the worktree is removed from state + * so the session is still resolvable. Safe to call for any id; no-ops when + * nothing is held. */ + forget(id: string): void { + const worktree = this.deps.state()?.getWorktree(id) + if (worktree?.autoNameSessionId) this.busySessions.delete(worktree.autoNameSessionId) + this.pending.delete(id) + this.model.delete(id) + this.idleAttempted.delete(id) + } + + private dispatch(id: string, input: Prompt): void { + const request = new AbortController() + this.requests.set(id, request) + void this.generate(id, input, request) + } + + private async generateOnIdle(id: string, sessionID: string): Promise { + const state = this.deps.state() + const manager = this.deps.manager() + const worktree = state?.getWorktree(id) + if (!state || !manager || !worktree || worktree.autoNameSessionId !== sessionID) return + if (!this.deps.settings().enabled) { + this.disarm(id) + return + } + if (state.getSessions(id).length !== 1 || worktree.prNumber || worktree.prUrl) { + this.disarm(id) + return + } + const ref = this.model.get(id) + const has = await manager.hasWork(worktree.path, remoteRef(worktree)).catch(() => false) + if (!has) return + if (this.requests.has(id)) return + this.dispatch(id, { sessionID, text: "", providerID: ref?.providerID, modelID: ref?.modelID }) } private async generate(id: string, input: Prompt, request: AbortController): Promise { @@ -83,7 +179,11 @@ export class BranchNamingController { { throwOnError: true, signal: request.signal }, ) if (!data.branch || request.signal.aborted) return - await this.rename(id, input.sessionID, data.branch) + // Hold the name: if busy, stash it as pending (the request slot frees + // immediately, but prompt() refuses to dispatch while a rename is + // pending); otherwise apply it now, keeping the slot occupied until it + // settles so a fast next prompt does not dispatch a redundant generation. + await this.queueRename(id, input.sessionID, data.branch) } catch (error) { if (request.signal.aborted) return this.deps.log(`Skipped automatic branch naming: ${error}`) @@ -92,7 +192,24 @@ export class BranchNamingController { } } - private async rename(id: string, sessionID: string, generated: string): Promise { + private async queueRename(id: string, sessionID: string, generated: string): Promise { + if (this.busySessions.has(sessionID)) { + this.pending.set(id, { sessionID, branch: generated }) + return + } + await this.applyRename(id, sessionID, generated) + } + + private applyPending(sessionID: string): void { + const { worktree } = this.resolve(sessionID) + if (!worktree) return + const pending = this.pending.get(worktree.id) + if (!pending) return + this.pending.delete(worktree.id) + void this.applyRename(worktree.id, pending.sessionID, pending.branch) + } + + private async applyRename(id: string, sessionID: string, generated: string): Promise { const state = this.deps.state() const manager = this.deps.manager() const worktree = state?.getWorktree(id) @@ -104,8 +221,15 @@ export class BranchNamingController { const branch = semanticBranchName(generated, cfg.prefix) if (!branch) return const current = worktree.branch - const renamed = await manager.renameBranch(worktree.path, current, branch) + // Called fire-and-forget: swallow rename failures into the log instead of + // an unhandled rejection, and stay armed so a later message can retry. + const renamed = await manager.renameBranch(worktree.path, current, branch).catch((error) => { + this.deps.log(`Skipped automatic branch naming: ${error}`) + return undefined + }) + if (!renamed) return if (!state.renameOwnedBranch(id, current, renamed)) return + this.forget(id) this.deps.push() this.deps.log(`Automatically named branch from session ${sessionID}: ${renamed}`) } diff --git a/packages/kilo-vscode/src/image-generation/models.ts b/packages/kilo-vscode/src/image-generation/models.ts new file mode 100644 index 0000000000..0fdefc76d0 --- /dev/null +++ b/packages/kilo-vscode/src/image-generation/models.ts @@ -0,0 +1,41 @@ +import type { KiloConnectionService } from "../services/cli-backend/connection-service" +import { getErrorMessage } from "../kilo-provider-utils" + +const PATH = "/kilo/models/images" + +export type ImageModel = { + id: string + name: string + description?: string +} + +export type ImageModelsResult = { ok: true; models: ImageModel[] } | { ok: false; error: string } + +export async function fetchImageModels( + connection: KiloConnectionService, + dir: string, + signal?: AbortSignal, +): Promise { + const cfg = connection.getServerConfig() + if (!cfg) return { ok: false, error: "Not connected to the Kilo backend" } + + const auth = Buffer.from(`kilo:${cfg.password}`).toString("base64") + const url = new URL(PATH, cfg.baseUrl) + if (dir) url.searchParams.set("directory", dir) + + try { + const res = await fetch(url, { + signal, + headers: { Authorization: `Basic ${auth}` }, + }) + + if (!res.ok) { + return { ok: false, error: `Failed to fetch image models (HTTP ${res.status})` } + } + + const models = (await res.json()) as ImageModel[] + return { ok: true, models } + } catch (err) { + return { ok: false, error: getErrorMessage(err) } + } +} diff --git a/packages/kilo-vscode/src/shared/custom-provider.ts b/packages/kilo-vscode/src/shared/custom-provider.ts index 8cadf7d07a..757897d1c1 100644 --- a/packages/kilo-vscode/src/shared/custom-provider.ts +++ b/packages/kilo-vscode/src/shared/custom-provider.ts @@ -23,6 +23,16 @@ const VariantConfigSchema = z.object({ export type VariantConfig = z.infer +// Mirror the CLI provider schema so the UI preserves hand-written configs. +const ModalitySchema = z.enum(["text", "audio", "image", "video", "pdf"]) + +const ModelModalitiesSchema = z.object({ + input: z.array(ModalitySchema).optional(), + output: z.array(ModalitySchema).optional(), +}) + +export type ModelModalities = z.infer + export const CustomProviderConfigSchema = z .object({ npm: z.enum(CUSTOM_PROVIDER_PACKAGES).default(CUSTOM_PROVIDER_PACKAGE), @@ -47,6 +57,7 @@ export const CustomProviderConfigSchema = z .object({ name: z.string().trim().min(1).max(200), reasoning: z.boolean().optional(), + modalities: ModelModalitiesSchema.optional(), variants: z.record(z.string().trim().min(1), VariantConfigSchema).optional(), }) .strict(), @@ -63,7 +74,10 @@ export type SanitizedProviderConfig = { baseURL: string headers?: Record } - models: Record }> + models: Record< + string, + { name: string; reasoning?: true; modalities?: ModelModalities; variants?: Record } + > } export type CustomProviderAuthChange = { mode: "preserve" } | { mode: "clear" } | { mode: "set"; key: string } @@ -134,6 +148,7 @@ export function normalizeCustomProviderConfig( { name: model.name.trim(), ...(model.reasoning ? { reasoning: true as const } : {}), + ...(model.modalities ? { modalities: model.modalities } : {}), ...(model.variants && Object.keys(model.variants).length > 0 ? { variants: model.variants } : {}), }, ]), @@ -159,6 +174,7 @@ type ProviderPatch = Omit & { null | { name: string reasoning?: true | null + modalities?: ModelModalities | null variants?: Record } > @@ -208,6 +224,7 @@ export function withCustomProviderDeletions(existing: unknown, next: SanitizedPr ...newModel, ...(variants ? { variants } : {}), ...(oldModel.reasoning !== undefined && newModel.reasoning === undefined ? { reasoning: null } : {}), + ...(oldModel.modalities !== undefined && newModel.modalities === undefined ? { modalities: null } : {}), } } diff --git a/packages/kilo-vscode/tests/unit/branch-naming.test.ts b/packages/kilo-vscode/tests/unit/branch-naming.test.ts index 8212678516..f6b0d3452d 100644 --- a/packages/kilo-vscode/tests/unit/branch-naming.test.ts +++ b/packages/kilo-vscode/tests/unit/branch-naming.test.ts @@ -14,6 +14,56 @@ async function settle() { await new Promise((resolve) => setTimeout(resolve, 20)) } +function makeNaming( + state: WorktreeStateManager, + deps: { + generate?: (input: { + directory: string + sessionID: string + prompt: string + providerID?: string + modelID?: string + }) => Promise<{ data: { branch: string | null } }> + rename?: (branch: string) => Promise + hasWork?: () => Promise + } = {}, +) { + const renamed: string[] = [] + const prompts: string[] = [] + const requests = { value: 0 } + const generate = deps.generate ?? (() => Promise.resolve({ data: { branch: null } })) + const naming = new BranchNamingController({ + state: () => state, + manager: () => ({ + renameBranch: async (_p: string, _c: string, branch: string) => { + renamed.push(branch) + return deps.rename ? await deps.rename(branch) : branch + }, + hasWork: async () => (deps.hasWork ? await deps.hasWork() : false), + }), + client: async () => ({ + branchName: { + generate: async (input) => { + requests.value += 1 + prompts.push(input.prompt) + return generate(input) + }, + }, + }), + settings: () => ({ enabled: true, prefix: "" }), + push: () => {}, + log: () => {}, + }) + return { naming, renamed, prompts, requests } +} + +function armed(state: WorktreeStateManager, branch = "quiet-river") { + const wt = state.addWorktree({ branch, path: "/tmp/" + branch, parentBranch: "main", branchOwned: true }) + state.addSession("session-1", wt.id) + state.armAutoName(wt.id, "session-1") + return wt +} + describe("BranchNamingController", () => { let root: string let state: WorktreeStateManager @@ -29,62 +79,50 @@ describe("BranchNamingController", () => { fs.rmSync(root, { recursive: true, force: true }) }) - it("retries on a later message when the first attempt is not clear yet", async () => { - const wt = state.addWorktree({ - branch: "quiet-river", - path: "/tmp/quiet-river", - parentBranch: "main", - branchOwned: true, - }) - state.addSession("session-1", wt.id) - state.armAutoName(wt.id, "session-1") - const renamed: string[] = [] - let requests = 0 - const naming = new BranchNamingController({ - state: () => state, - manager: () => ({ - renameBranch: async (_path, _current, branch) => { - renamed.push(branch) - return branch - }, - }), - client: async () => ({ - branchName: { - generate: async () => { - requests += 1 - return { data: { branch: requests === 1 ? null : "fix-final-task" } } - }, - }, - }), - settings: () => ({ enabled: true, prefix: "" }), - push: () => {}, - log: () => {}, + it("skips the first prompt and names on the second", async () => { + const wt = armed(state) + const { naming, renamed, prompts, requests } = makeNaming(state, { + generate: async () => ({ data: { branch: "fix-final-task" } }), }) naming.prompt({ sessionID: "session-1", text: "hi" }) await settle() expect(state.getWorktree(wt.id)?.autoNameSessionId).toBe("session-1") + expect(requests.value).toBe(0) + naming.prompt({ sessionID: "session-1", text: "Fix the task" }) await settle() - expect(requests).toBe(2) + expect(requests.value).toBe(1) + expect(prompts).toEqual(["Fix the task"]) expect(renamed).toEqual(["fix-final-task"]) expect(state.getWorktree(wt.id)?.autoNameSessionId).toBeUndefined() }) - it("renames once and applies the user prefix", async () => { - const wt = state.addWorktree({ - branch: "quiet-river", - path: "/tmp/quiet-river", - parentBranch: "main", - branchOwned: true, + it("names on the first prompt once the worktree has work, via idle", async () => { + const wt = armed(state) + const { naming, renamed, requests } = makeNaming(state, { + generate: async () => ({ data: { branch: "fix-token-refresh-race" } }), + hasWork: async () => true, }) - state.addSession("session-1", wt.id) - state.armAutoName(wt.id, "session-1") + + naming.prompt({ sessionID: "session-1", text: "Fix the token refresh race" }) + await settle() + expect(requests.value).toBe(0) + naming.idle("session-1") + await settle() + + expect(requests.value).toBe(1) + expect(renamed).toEqual(["fix-token-refresh-race"]) + expect(state.getWorktree(wt.id)?.autoNameSessionId).toBeUndefined() + }) + + it("applies the user prefix", async () => { + const wt = armed(state) const prompts: string[] = [] const naming = new BranchNamingController({ state: () => state, - manager: () => ({ renameBranch: async (_path, _current, branch) => branch }), + manager: () => ({ renameBranch: async (_p, _c, branch) => branch, hasWork: async () => true }), client: async () => ({ branchName: { generate: async (input) => { @@ -99,9 +137,10 @@ describe("BranchNamingController", () => { }) naming.prompt({ sessionID: "session-1", text: "Fix the token refresh race" }) + naming.idle("session-1") await settle() - expect(prompts).toEqual(["Fix the token refresh race"]) + expect(prompts).toEqual([""]) expect(state.getWorktree(wt.id)).toMatchObject({ branch: "marius/features/fix-token-refresh-race", autoNameSessionId: undefined, @@ -119,14 +158,9 @@ describe("BranchNamingController", () => { let requests = 0 const naming = new BranchNamingController({ state: () => state, - manager: () => ({ renameBranch: async (_path, _current, branch) => branch }), + manager: () => ({ renameBranch: async (_p, _c, branch) => branch, hasWork: async () => true }), client: async () => ({ - branchName: { - generate: async () => { - requests += 1 - return { data: { branch: "replace-custom-name" } } - }, - }, + branchName: { generate: async () => ({ data: { branch: ((requests += 1), "replace-custom-name") } }) }, }), settings: () => ({ enabled: true, prefix: "" }), push: () => {}, @@ -134,6 +168,7 @@ describe("BranchNamingController", () => { }) naming.prompt({ sessionID: "session-1", text: "Implement auth" }) + naming.idle("session-1") await settle() expect(requests).toBe(0) @@ -141,24 +176,18 @@ describe("BranchNamingController", () => { }) it("does not start another request while naming is pending", async () => { - const wt = state.addWorktree({ - branch: "quiet-river", - path: "/tmp/quiet-river", - parentBranch: "main", - branchOwned: true, - }) - state.addSession("session-1", wt.id) - state.armAutoName(wt.id, "session-1") + armed(state) const first = deferred<{ data: { branch: string | null } }>() const renamed: string[] = [] let requests = 0 const naming = new BranchNamingController({ state: () => state, manager: () => ({ - renameBranch: async (_path, _current, branch) => { + renameBranch: async (_p, _c, branch) => { renamed.push(branch) return branch }, + hasWork: async () => false, }), client: async () => ({ branchName: { @@ -174,7 +203,6 @@ describe("BranchNamingController", () => { }) naming.prompt({ sessionID: "session-1", text: "Explore some options" }) - await Promise.resolve() naming.prompt({ sessionID: "session-1", text: "Fix the final task" }) await settle() first.resolve({ data: { branch: "explore-options" } }) @@ -183,4 +211,130 @@ describe("BranchNamingController", () => { expect(requests).toBe(1) expect(renamed).toEqual(["explore-options"]) }) + + it("disarms after the maximum number of prompts without a rename", async () => { + const wt = armed(state) + const { naming, requests } = makeNaming(state, { + generate: async () => ({ data: { branch: null } }), + }) + + for (let i = 0; i < 6; i++) naming.prompt({ sessionID: "session-1", text: `vague ${i}` }) + await settle() + + expect(state.getWorktree(wt.id)?.autoNameSessionId).toBeUndefined() + expect(requests.value).toBeLessThanOrEqual(4) + }) + + it("holds the rename while busy and applies it on idle", async () => { + const wt = armed(state) + const { naming, renamed } = makeNaming(state, { + generate: async () => ({ data: { branch: "fix-thing" } }), + }) + + naming.busy("session-1") + naming.prompt({ sessionID: "session-1", text: "first" }) + naming.prompt({ sessionID: "session-1", text: "fix the thing" }) + await settle() + expect(renamed).toEqual([]) + expect(state.getWorktree(wt.id)?.branch).toBe("quiet-river") + + naming.idle("session-1") + await settle() + expect(renamed).toEqual(["fix-thing"]) + expect(state.getWorktree(wt.id)?.autoNameSessionId).toBeUndefined() + }) + + it("logs a failed rename and stays armed for a retry", async () => { + const wt = armed(state) + const logs: string[] = [] + const naming = new BranchNamingController({ + state: () => state, + manager: () => ({ + renameBranch: async () => { + throw new Error("Branch already has an upstream") + }, + hasWork: async () => false, + }), + client: async () => ({ + branchName: { generate: async () => ({ data: { branch: "fix-thing" } }) }, + }), + settings: () => ({ enabled: true, prefix: "" }), + push: () => {}, + log: (msg) => logs.push(msg), + }) + + naming.prompt({ sessionID: "session-1", text: "first" }) + naming.prompt({ sessionID: "session-1", text: "fix the thing" }) + await settle() + + expect(logs.some((msg) => msg.includes("Branch already has an upstream"))).toBe(true) + expect(state.getWorktree(wt.id)).toMatchObject({ + branch: "quiet-river", + autoNameSessionId: "session-1", + }) + }) + + it("does not generate on idle before any prompt", async () => { + armed(state) + const { naming, requests } = makeNaming(state, { hasWork: async () => true }) + + naming.idle("session-1") + await settle() + + expect(requests.value).toBe(0) + }) + + it("generates on prompts two to four and disarms on the fifth", async () => { + const wt = armed(state) + const { naming, requests } = makeNaming(state, { + generate: async () => ({ data: { branch: null } }), + }) + + const counts: number[] = [] + for (let i = 1; i <= 5; i++) { + naming.prompt({ sessionID: "session-1", text: `message ${i}` }) + await settle() + counts.push(requests.value) + } + + expect(counts).toEqual([0, 1, 2, 3, 3]) + expect(state.getWorktree(wt.id)?.autoNameSessionId).toBeUndefined() + }) + + it("holds a rename that resolves while busy and applies it on idle", async () => { + const wt = armed(state) + const response = deferred<{ data: { branch: string | null } }>() + const { naming, renamed } = makeNaming(state, { generate: () => response.promise }) + + naming.prompt({ sessionID: "session-1", text: "first" }) + naming.prompt({ sessionID: "session-1", text: "fix the thing" }) + naming.busy("session-1") + response.resolve({ data: { branch: "fix-thing" } }) + await settle() + expect(renamed).toEqual([]) + expect(state.getWorktree(wt.id)?.branch).toBe("quiet-river") + + naming.idle("session-1") + await settle() + expect(renamed).toEqual(["fix-thing"]) + expect(state.getWorktree(wt.id)?.autoNameSessionId).toBeUndefined() + }) + + it("disarms when the setting is disabled", async () => { + const wt = armed(state) + const naming = new BranchNamingController({ + state: () => state, + manager: () => ({ renameBranch: async (_p, _c, branch) => branch, hasWork: async () => true }), + client: async () => ({ branchName: { generate: async () => ({ data: { branch: "fix-thing" } }) } }), + settings: () => ({ enabled: false, prefix: "" }), + push: () => {}, + log: () => {}, + }) + + naming.prompt({ sessionID: "session-1", text: "Fix the thing" }) + await settle() + + expect(state.getWorktree(wt.id)?.autoNameSessionId).toBeUndefined() + expect(state.getWorktree(wt.id)?.branch).toBe("quiet-river") + }) }) diff --git a/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts b/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts index d9adf1c4bb..be1f69c050 100644 --- a/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts +++ b/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts @@ -12,7 +12,9 @@ function base(): FormState { npm: "@ai-sdk/openai-compatible", baseURL: "https://example.com/v1", apiKey: "", - models: [{ id: "model-1", name: "Model One", reasoning: false, variants: [] }], + models: [ + { id: "model-1", name: "Model One", reasoning: false, supportsImages: false, modalities: {}, variants: [] }, + ], headers: [], saving: false, } @@ -203,4 +205,63 @@ describe("validateCustomProvider – variant name validation", () => { }, }) }) + + it("serializes image modality when supportsImages is set", () => { + const form = base() + form.models[0].supportsImages = true + const out = validateCustomProvider(args(form)) + expect(out.result).toBeDefined() + const saved = out.result!.config.models["model-1"] as Record + expect(saved.modalities).toEqual({ input: ["text", "image"] }) + }) + + it("omits modalities when supportsImages is not set on a text-only model", () => { + const form = base() + const out = validateCustomProvider(args(form)) + expect(out.result).toBeDefined() + const saved = out.result!.config.models["model-1"] as Record + expect(saved.modalities).toBeUndefined() + }) + + it("preserves an existing image-only input when saving", () => { + const form = base() + form.models[0].modalities = { input: ["image"] } + form.models[0].supportsImages = true + const out = validateCustomProvider(args(form)) + expect(out.result).toBeDefined() + const saved = out.result!.config.models["model-1"] as Record + expect(saved.modalities).toEqual({ input: ["image"] }) + }) + + it("omits an empty input when image support is removed from an image-only model", () => { + const form = base() + form.models[0].modalities = { input: ["image"] } + form.models[0].supportsImages = false + const out = validateCustomProvider(args(form)) + expect(out.result).toBeDefined() + const saved = out.result!.config.models["model-1"] as Record + expect(saved.modalities).toBeUndefined() + }) + + it("preserves output-only modalities when saving", () => { + const form = base() + form.models[0].modalities = { output: ["audio"] } + const out = validateCustomProvider(args(form)) + expect(out.result).toBeDefined() + const saved = out.result!.config.models["model-1"] as Record + expect(saved.modalities).toEqual({ output: ["audio"] }) + }) + + it("preserves unsupported UI modalities when toggling image support", () => { + const form = base() + form.models[0].modalities = { + input: ["text", "audio", "image", "video", "pdf"], + output: ["text", "audio"], + } + form.models[0].supportsImages = false + const out = validateCustomProvider(args(form)) + expect(out.result).toBeDefined() + const saved = out.result!.config.models["model-1"] as Record + expect(saved.modalities).toEqual({ input: ["text", "audio", "video", "pdf"], output: ["text", "audio"] }) + }) }) diff --git a/packages/kilo-vscode/tests/unit/custom-provider.test.ts b/packages/kilo-vscode/tests/unit/custom-provider.test.ts index b915337695..b4869706e4 100644 --- a/packages/kilo-vscode/tests/unit/custom-provider.test.ts +++ b/packages/kilo-vscode/tests/unit/custom-provider.test.ts @@ -162,6 +162,39 @@ describe("sanitizeCustomProviderConfig", () => { }) }) + it("preserves core custom model modalities", () => { + const result = sanitizeCustomProviderConfig({ + name: "Media Provider", + options: { baseURL: "https://example.com/v1" }, + models: { + "model-1": { + name: "Model One", + modalities: { + input: ["text", "audio", "image", "video", "pdf"], + output: ["text", "audio"], + }, + }, + }, + }) + + expect(result).toEqual({ + value: { + npm: "@ai-sdk/openai-compatible", + name: "Media Provider", + options: { baseURL: "https://example.com/v1" }, + models: { + "model-1": { + name: "Model One", + modalities: { + input: ["text", "audio", "image", "video", "pdf"], + output: ["text", "audio"], + }, + }, + }, + }, + }) + }) + it("rejects unknown fields", () => { const result = sanitizeCustomProviderConfig({ name: "Bad Provider", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index b85a96390d..d41092970c 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 { ProviderProvider } from "../src/context/provider" import { ConfigProvider } from "../src/context/config" import { DisplayProvider } from "../src/context/display" import { KiloEmbeddingModelsProvider } from "../src/context/kilo-embedding-models" +import { ImageModelsProvider } from "../src/context/image-models" import { NotificationsProvider } from "../src/context/notifications" import { FeedbackProvider } from "../src/context/feedback" import { MemoryProvider } from "../src/context/memory" @@ -3138,21 +3139,23 @@ export const AgentManagerApp: Component = () => { - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 64e4b94eb3..80a9169ce3 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -39,6 +39,7 @@ import { MigrationWizard } from "./components/migration" // legacy-migration import { NotificationsProvider } from "./context/notifications" import { FeedbackProvider } from "./context/feedback" import { KiloEmbeddingModelsProvider } from "./context/kilo-embedding-models" +import { ImageModelsProvider } from "./context/image-models" import type { Message as SDKMessage, Part as SDKPart } from "@kilocode/sdk/v2" import "./styles/chat.css" @@ -416,19 +417,21 @@ const App: Component = () => { - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx index f4d09b4365..1041664e41 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx @@ -25,6 +25,8 @@ import { ModelCard } from "./CustomProviderModelCard" import type { ChatTemplateArgsValue, EnableThinkingValue, + Modalities, + Modality, ModelEntry, OutputEffortValue, ReasoningEffortValue, @@ -54,7 +56,35 @@ function fuzzy(query: string, target: string) { } type FetchedModel = { id: string; name: string } -type RawModel = { name?: string; reasoning?: boolean; variants?: Record> } +type RawModel = { + name?: string + reasoning?: boolean + modalities?: { input?: unknown; output?: unknown } + variants?: Record> +} + +// Keep this aligned with the CLI provider schema; the UI only exposes image. +const MODES = new Set(["text", "audio", "image", "video", "pdf"]) + +function list(raw: unknown): Modality[] | undefined { + if (!Array.isArray(raw)) return + const set = new Set() + raw.forEach((item) => { + if (typeof item === "string" && MODES.has(item as Modality)) set.add(item as Modality) + }) + return set.size ? [...set] : undefined +} + +function modes(raw: unknown): Modalities { + if (!raw || typeof raw !== "object") return {} + const obj = raw as { input?: unknown; output?: unknown } + const input = list(obj.input) + const output = list(obj.output) + return { + ...(input ? { input } : {}), + ...(output ? { output } : {}), + } +} function parseVariant([name, cfg]: [string, Record]): VariantEntry { return { @@ -76,15 +106,20 @@ function parseVariant([name, cfg]: [string, Record]): VariantEn } function initModels(cfg: ProviderConfig | undefined): ModelEntry[] { - if (!cfg?.models || typeof cfg.models !== "object") return [{ id: "", name: "", reasoning: false, variants: [] }] + const empty = { id: "", name: "", reasoning: false, supportsImages: false, modalities: {}, variants: [] } + if (!cfg?.models || typeof cfg.models !== "object") return [{ ...empty }] const entries = Object.entries(cfg.models) - if (entries.length === 0) return [{ id: "", name: "", reasoning: false, variants: [] }] + if (entries.length === 0) return [{ ...empty }] return entries.map(([id, model]) => { const raw = model as RawModel + const modalities = modes(raw.modalities) + const input = modalities.input ?? [] return { id, name: raw.name ?? id, reasoning: raw.reasoning ?? false, + supportsImages: input.includes("image"), + modalities, variants: Object.entries(raw.variants ?? {}).map(parseVariant), } }) @@ -327,7 +362,6 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { // Replace the single empty row or append const row = form.models[0] const empty = form.models.length === 1 && !!row && !row.id.trim() && !row.name.trim() - // Dedup against models already in the form (trimmed, case-insensitive). The // picker is built from a fetch-time snapshot, so a model the user typed // manually after fetching hasn't been filtered out yet. @@ -341,7 +375,13 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { return true }) - const defaults = (m: FetchedModel): ModelEntry => ({ ...m, reasoning: false, variants: [] }) + const defaults = (m: FetchedModel): ModelEntry => ({ + ...m, + reasoning: false, + supportsImages: false, + modalities: {}, + variants: [], + }) const merged = empty ? toAdd.map(defaults) : [...form.models, ...toAdd.map(defaults)] if (toAdd.length > 0) { @@ -396,7 +436,10 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { } function addModel() { - setForm("models", (v) => [...v, { id: "", name: "", reasoning: false, variants: [] }]) + setForm("models", (v) => [ + ...v, + { id: "", name: "", reasoning: false, supportsImages: false, modalities: {}, variants: [] }, + ]) setErrors("models", (v) => [...v, { variants: [] }]) } @@ -637,6 +680,7 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { onChangeId={(v) => setForm("models", i(), "id", v)} onChangeName={(v) => setForm("models", i(), "name", v)} onChangeReasoning={(v) => setForm("models", i(), "reasoning", v)} + onChangeSupportsImages={(v) => setForm("models", i(), "supportsImages", v)} onRemove={() => removeModel(i())} onAddVariant={() => addVariant(i())} onRemoveVariant={(vi) => removeVariant(i(), vi)} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx index 1af17b3288..d1b4ea13f3 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx @@ -14,6 +14,12 @@ export type SplitReasoningValue = undefined | boolean export type ReasoningEffortValue = undefined | "none" | "minimal" | "low" | "medium" | "high" | "xhigh" export type OutputEffortValue = undefined | "low" | "medium" | "high" | "xhigh" | "max" export type ChatTemplateArgsValue = undefined | boolean +export type Modality = "text" | "audio" | "image" | "video" | "pdf" + +export type Modalities = { + input?: Modality[] + output?: Modality[] +} export type VariantEntry = { name: string @@ -29,6 +35,8 @@ export type ModelEntry = { id: string name: string reasoning: boolean + supportsImages: boolean + modalities: Modalities variants: VariantEntry[] } @@ -296,6 +304,7 @@ type ModelCardProps = { onChangeId: (val: string) => void onChangeName: (val: string) => void onChangeReasoning: (val: boolean) => void + onChangeSupportsImages: (val: boolean) => void onRemove: () => void onAddVariant: () => void onRemoveVariant: (vi: number) => void @@ -372,6 +381,24 @@ export function ModelCard(props: ModelCardProps) { {props.t("provider.custom.models.reasoning.label")} + + {/* Variants — only available when reasoning is enabled */} 0}> diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts index b9fac865bb..2331d29543 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts @@ -1,5 +1,5 @@ import type { CustomProviderPackage } from "../../../../src/shared/provider-model" -import type { ModelEntry, VariantEntry } from "./CustomProviderModelCard" +import type { Modalities, ModelEntry, VariantEntry } from "./CustomProviderModelCard" type Translator = (key: string, params?: Record) => string @@ -115,10 +115,34 @@ function serializeVariant(v: VariantEntry): [string, Record] { return [v.name.trim(), cfg] } +function modalities(m: ModelEntry): Modalities | undefined { + const input = new Set(m.modalities.input ?? []) + const existing = input.size > 0 || (m.modalities.output?.length ?? 0) > 0 + if (!existing && !m.supportsImages) return + + const image = input.has("image") + const changed = image !== m.supportsImages + if (m.supportsImages && !image) { + input.add("text") + input.add("image") + } + if (!m.supportsImages) input.delete("image") + + const include = input.size > 0 || (m.modalities.input !== undefined && !changed) + if (!include && !m.modalities.output?.length) return + + return { + ...(include ? { input: [...input] } : {}), + ...(m.modalities.output?.length ? { output: m.modalities.output } : {}), + } +} + function serializeModel(m: ModelEntry): [string, Record] { const ventries = m.reasoning ? m.variants.filter((v) => v.name.trim()).map(serializeVariant) : [] const entry: Record = { name: m.name.trim() } + const modes = modalities(m) if (m.reasoning) entry.reasoning = true + if (modes) entry.modalities = modes if (ventries.length > 0) entry.variants = Object.fromEntries(ventries) return [m.id.trim(), entry] } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx index fbb24b2e2f..bdabb16fe1 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx @@ -6,6 +6,7 @@ import { Card } from "@kilocode/kilo-ui/card" import { useConfig } from "../../context/config" import { useLanguage } from "../../context/language" import { useVSCode } from "../../context/vscode" +import { useImageModels } from "../../context/image-models" import type { ExtensionMessage } from "../../types/messages" import { parseModelString } from "../../../../src/shared/provider-model" import { ModelSelectorBase } from "../shared/ModelSelector" @@ -25,6 +26,7 @@ const SHARE_OPTIONS: ShareOption[] = [ const ExperimentalTab: Component = () => { const { config, features, updateConfig } = useConfig() const language = useLanguage() + const imageModels = useImageModels() const vscode = useVSCode() const [active, setActive] = createSignal(false) @@ -156,6 +158,41 @@ const ExperimentalTab: Component = () => { + + updateExperimental("image_generation", checked)} + hideLabel + > + {language.t("settings.experimental.imageGeneration.title")} + + + + + +