From 0b784691f271fac4ca983468656bac248aa98f3f Mon Sep 17 00:00:00 2001 From: "jackson.zhou" Date: Tue, 30 Jun 2026 17:28:08 +0800 Subject: [PATCH 1/8] fix(vscode): expose custom model image modality --- .changeset/custom-provider-image-modality.md | 5 ++ .../kilo-vscode/src/shared/custom-provider.ts | 19 ++++++- .../custom-provider-dialog-validate.test.ts | 34 ++++++++++- .../tests/unit/custom-provider.test.ts | 33 +++++++++++ .../settings/CustomProviderDialog.tsx | 56 +++++++++++++++++-- .../settings/CustomProviderModelCard.tsx | 27 +++++++++ .../settings/CustomProviderValidation.ts | 19 ++++++- .../kilo-vscode/webview-ui/src/i18n/ar.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/it.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 1 + 27 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 .changeset/custom-provider-image-modality.md 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/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/custom-provider-dialog-validate.test.ts b/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts index d9adf1c4bb..bf7f857de7 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,34 @@ 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 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/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..3b9d46e230 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,27 @@ 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 + + input.add("text") + if (m.supportsImages) input.add("image") + else input.delete("image") + + return { + 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/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index a87c8ac9b6..14d1c20953 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -939,6 +939,7 @@ export const dict = { "provider.custom.models.name.label": "الاسم", "provider.custom.models.name.placeholder": "الاسم المعروض", "provider.custom.models.reasoning.label": "الاستدلال", + "provider.custom.models.modalities.image": "صورة", "provider.custom.models.variants.label": "المتغيرات", "provider.custom.models.variants.add": "إضافة متغير", "provider.custom.models.variants.remove": "إزالة المتغير", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 5daf082020..d6544c0e70 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -955,6 +955,7 @@ export const dict = { "provider.custom.models.name.label": "Nome", "provider.custom.models.name.placeholder": "Nome de Exibição", "provider.custom.models.reasoning.label": "Raciocínio", + "provider.custom.models.modalities.image": "Imagem", "provider.custom.models.variants.label": "Variantes", "provider.custom.models.variants.add": "Adicionar variante", "provider.custom.models.variants.remove": "Remover variante", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index a5a894c7f0..98eb0dd130 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -998,6 +998,7 @@ export const dict = { "provider.custom.models.name.label": "Naziv", "provider.custom.models.name.placeholder": "Naziv za prikaz", "provider.custom.models.reasoning.label": "Zaključivanje", + "provider.custom.models.modalities.image": "Slika", "provider.custom.models.variants.label": "Varijante", "provider.custom.models.variants.add": "Dodaj varijantu", "provider.custom.models.variants.remove": "Ukloni varijantu", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 14b21e2ab8..791b6c7565 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -991,6 +991,7 @@ export const dict = { "provider.custom.models.name.label": "Navn", "provider.custom.models.name.placeholder": "Visningsnavn", "provider.custom.models.reasoning.label": "Ræsonnement", + "provider.custom.models.modalities.image": "Billede", "provider.custom.models.variants.label": "Varianter", "provider.custom.models.variants.add": "Tilføj variant", "provider.custom.models.variants.remove": "Fjern variant", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 7c3ebe2962..d0afa01ae7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1009,6 +1009,7 @@ export const dict = { "provider.custom.models.name.label": "Name", "provider.custom.models.name.placeholder": "Anzeigename", "provider.custom.models.reasoning.label": "Schlussfolgerung", + "provider.custom.models.modalities.image": "Bild", "provider.custom.models.variants.label": "Varianten", "provider.custom.models.variants.add": "Variante hinzufügen", "provider.custom.models.variants.remove": "Variante entfernen", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index d4435bcf03..b9c5748c9c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -912,6 +912,7 @@ export const dict = { "provider.custom.models.name.label": "Name", "provider.custom.models.name.placeholder": "Display Name", "provider.custom.models.reasoning.label": "Reasoning", + "provider.custom.models.modalities.image": "Image", "provider.custom.models.variants.label": "Variants", "provider.custom.models.variants.add": "Add variant", "provider.custom.models.variants.remove": "Remove variant", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index a5bf4b39bd..d577c5515a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1001,6 +1001,7 @@ export const dict = { "provider.custom.models.name.label": "Nombre", "provider.custom.models.name.placeholder": "Nombre para mostrar", "provider.custom.models.reasoning.label": "Razonamiento", + "provider.custom.models.modalities.image": "Imagen", "provider.custom.models.variants.label": "Variantes", "provider.custom.models.variants.add": "Añadir variante", "provider.custom.models.variants.remove": "Eliminar variante", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index e793315324..3c61a404e9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1007,6 +1007,7 @@ export const dict = { "provider.custom.models.name.label": "Nom", "provider.custom.models.name.placeholder": "Nom d'affichage", "provider.custom.models.reasoning.label": "Raisonnement", + "provider.custom.models.modalities.image": "Image", "provider.custom.models.variants.label": "Variantes", "provider.custom.models.variants.add": "Ajouter une variante", "provider.custom.models.variants.remove": "Supprimer la variante", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 5e1079426a..4369103621 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -767,6 +767,7 @@ export const dict = { "provider.custom.models.name.label": "Nome", "provider.custom.models.name.placeholder": "Nome visualizzato", "provider.custom.models.reasoning.label": "Reasoning", + "provider.custom.models.modalities.image": "Immagine", "provider.custom.models.variants.label": "Variants", "provider.custom.models.variants.add": "Aggiungi variante", "provider.custom.models.variants.remove": "Rimuovi variante", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 5483784d61..70f5f41899 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -988,6 +988,7 @@ export const dict = { "provider.custom.models.name.label": "名前", "provider.custom.models.name.placeholder": "表示名", "provider.custom.models.reasoning.label": "推論", + "provider.custom.models.modalities.image": "画像", "provider.custom.models.variants.label": "バリアント", "provider.custom.models.variants.add": "バリアントを追加", "provider.custom.models.variants.remove": "バリアントを削除", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 1c68d5d743..22a8734d0e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -946,6 +946,7 @@ export const dict = { "provider.custom.models.name.label": "이름", "provider.custom.models.name.placeholder": "표시 이름", "provider.custom.models.reasoning.label": "추론", + "provider.custom.models.modalities.image": "이미지", "provider.custom.models.variants.label": "변형", "provider.custom.models.variants.add": "변형 추가", "provider.custom.models.variants.remove": "변형 제거", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 845b5d5a16..fda9ab06ad 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -949,6 +949,7 @@ export const dict = { "provider.custom.models.name.label": "Naam", "provider.custom.models.name.placeholder": "Weergavenaam", "provider.custom.models.reasoning.label": "Redeneren", + "provider.custom.models.modalities.image": "Afbeelding", "provider.custom.models.variants.label": "Varianten", "provider.custom.models.variants.add": "Variant toevoegen", "provider.custom.models.variants.remove": "Variant verwijderen", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 775d9dc7e6..a44d6d7525 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -956,6 +956,7 @@ export const dict = { "provider.custom.models.name.label": "Navn", "provider.custom.models.name.placeholder": "Visningsnavn", "provider.custom.models.reasoning.label": "Resonnering", + "provider.custom.models.modalities.image": "Bilde", "provider.custom.models.variants.label": "Varianter", "provider.custom.models.variants.add": "Legg til variant", "provider.custom.models.variants.remove": "Fjern variant", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index d3fa876fb7..8e5daf4c1a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -954,6 +954,7 @@ export const dict = { "provider.custom.models.name.label": "Nazwa", "provider.custom.models.name.placeholder": "Nazwa wyświetlana", "provider.custom.models.reasoning.label": "Rozumowanie", + "provider.custom.models.modalities.image": "Obraz", "provider.custom.models.variants.label": "Warianty", "provider.custom.models.variants.add": "Dodaj wariant", "provider.custom.models.variants.remove": "Usuń wariant", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 6f616a81d8..224026d8b4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -995,6 +995,7 @@ export const dict = { "provider.custom.models.name.label": "Название", "provider.custom.models.name.placeholder": "Отображаемое имя", "provider.custom.models.reasoning.label": "Рассуждение", + "provider.custom.models.modalities.image": "Изображение", "provider.custom.models.variants.label": "Варианты", "provider.custom.models.variants.add": "Добавить вариант", "provider.custom.models.variants.remove": "Удалить вариант", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index e5ec7f48ed..4551ce2949 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -981,6 +981,7 @@ export const dict = { "provider.custom.models.name.label": "ชื่อ", "provider.custom.models.name.placeholder": "ชื่อที่แสดง", "provider.custom.models.reasoning.label": "การใช้เหตุผล", + "provider.custom.models.modalities.image": "รูปภาพ", "provider.custom.models.variants.label": "รูปแบบ", "provider.custom.models.variants.add": "เพิ่มรูปแบบ", "provider.custom.models.variants.remove": "ลบรูปแบบ", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index e57d018906..493a921365 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -946,6 +946,7 @@ export const dict = { "provider.custom.models.name.label": "Ad", "provider.custom.models.name.placeholder": "Görünen Ad", "provider.custom.models.reasoning.label": "Akıl Yürütme", + "provider.custom.models.modalities.image": "Görüntü", "provider.custom.models.variants.label": "Varyantlar", "provider.custom.models.variants.add": "Varyant ekle", "provider.custom.models.variants.remove": "Varyantı kaldır", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 06c0f00dfa..e995b663fa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -946,6 +946,7 @@ export const dict = { "provider.custom.models.name.label": "Назва", "provider.custom.models.name.placeholder": "Відображувана назва", "provider.custom.models.reasoning.label": "Міркування", + "provider.custom.models.modalities.image": "Зображення", "provider.custom.models.variants.label": "Варіанти", "provider.custom.models.variants.add": "Додати варіант", "provider.custom.models.variants.remove": "Видалити варіант", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 3f0eafa95e..9d2eb7cca7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -963,6 +963,7 @@ export const dict = { "provider.custom.models.name.label": "名称", "provider.custom.models.name.placeholder": "显示名称", "provider.custom.models.reasoning.label": "推理", + "provider.custom.models.modalities.image": "图片", "provider.custom.models.variants.label": "变体", "provider.custom.models.variants.add": "添加变体", "provider.custom.models.variants.remove": "移除变体", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index d50689f1f7..1bce76917d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -927,6 +927,7 @@ export const dict = { "provider.custom.models.name.label": "名稱", "provider.custom.models.name.placeholder": "顯示名稱", "provider.custom.models.reasoning.label": "推理", + "provider.custom.models.modalities.image": "圖片", "provider.custom.models.variants.label": "變體", "provider.custom.models.variants.add": "新增變體", "provider.custom.models.variants.remove": "移除變體", From b46a429f0cb00507fc7894af1e5656fc670a6f88 Mon Sep 17 00:00:00 2001 From: "jackson.zhou" Date: Thu, 2 Jul 2026 10:16:15 +0800 Subject: [PATCH 2/8] fix(vscode): preserve existing model modalities --- .../custom-provider-dialog-validate.test.ts | 19 +++++++++++++++++++ .../settings/CustomProviderValidation.ts | 11 +++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) 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 bf7f857de7..73410ead71 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 @@ -223,6 +223,25 @@ describe("validateCustomProvider – variant name validation", () => { 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("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 = { 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 3b9d46e230..62b04d0552 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts @@ -120,12 +120,15 @@ function modalities(m: ModelEntry): Modalities | undefined { const existing = input.size > 0 || (m.modalities.output?.length ?? 0) > 0 if (!existing && !m.supportsImages) return - input.add("text") - if (m.supportsImages) input.add("image") - else input.delete("image") + const image = input.has("image") + if (m.supportsImages && !image) { + input.add("text") + input.add("image") + } + if (!m.supportsImages) input.delete("image") return { - input: [...input], + ...(m.modalities.input !== undefined || m.supportsImages ? { input: [...input] } : {}), ...(m.modalities.output?.length ? { output: m.modalities.output } : {}), } } From d4ca4332e93d3a3860eeaf4b382021ba82f70de2 Mon Sep 17 00:00:00 2001 From: "jackson.zhou" Date: Thu, 2 Jul 2026 10:29:28 +0800 Subject: [PATCH 3/8] fix(vscode): omit empty model input modalities --- .../tests/unit/custom-provider-dialog-validate.test.ts | 10 ++++++++++ .../components/settings/CustomProviderValidation.ts | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) 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 73410ead71..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 @@ -233,6 +233,16 @@ describe("validateCustomProvider – variant name validation", () => { 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"] } 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 62b04d0552..2331d29543 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts @@ -121,14 +121,18 @@ function modalities(m: ModelEntry): Modalities | undefined { 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 { - ...(m.modalities.input !== undefined || m.supportsImages ? { input: [...input] } : {}), + ...(include ? { input: [...input] } : {}), ...(m.modalities.output?.length ? { output: m.modalities.output } : {}), } } From 8ff2a163affffa52a69fabd04ac4f542113b4488 Mon Sep 17 00:00:00 2001 From: Thomas Brugman Date: Wed, 8 Jul 2026 16:46:46 +0200 Subject: [PATCH 4/8] fix: show dismissed question content in chat history (#10361) --- .changeset/show-dismissed-question-content.md | 5 + .../kilo-ui/src/components/message-part.tsx | 42 ++++-- .../src/stories/message-part.stories.tsx | 136 ++++++++++++++++++ .../tui/feature-plugins/system/session-v2.tsx | 24 +++- .../src/cli/cmd/tui/routes/session/index.tsx | 62 ++++++-- 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/it.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 + 25 files changed, 284 insertions(+), 25 deletions(-) create mode 100644 .changeset/show-dismissed-question-content.md 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-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/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 7de7d4ace7..6e00d8446c 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -1010,22 +1010,40 @@ function Question(props: ToolProps) { arrayValue(props.input.questions).flatMap((item) => (isRecord(item) ? [item] : [])), ) const answers = createMemo(() => arrayValue(props.metadata.answers)) + // kilocode_change start - show dismissed question content; use questions() + // presence (not answers) so dismissed/answered/error states all render content. + const dismissed = createMemo( + () => + props.metadata.dismissed === true || + (props.part.state.status === "error" && String(props.part.state.error?.message ?? "").includes("dismissed")), + ) + + function format(answer: unknown) { + if (dismissed()) return "Dismissed" + return formatAnswer(answer) + } + + const title = createMemo(() => (dismissed() ? "# Questions (dismissed)" : "# Questions")) + // kilocode_change end + return ( - 0}> - + {/* kilocode_change start - gate on dismissed or answers so dismissed/answered render, pending falls through to Asking... */} + 0}> + {(question, index) => ( {stringValue(question.question)} - {formatAnswer(answers()[index()])} + {format(answers()[index()])} )} + {/* kilocode_change end */} Asked {questions().length} question{questions().length === 1 ? "" : "s"} 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 9b3c279fea..bc062a24a0 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -2815,28 +2815,64 @@ function TodoWrite(props: ToolProps) { function Question(props: ToolProps) { const { theme } = useTheme() const count = createMemo(() => props.input.questions?.length ?? 0) + // kilocode_change start - show dismissed question content with toggle; + // use input.questions presence (not metadata) so dismissed/answered/error + // states all render content. Clicking the one-liner expands to the full + // block; clicking the block title collapses back. + const dismissed = createMemo( + () => + props.metadata.dismissed === true || + (props.part.state.status === "error" && String(props.part.state.error ?? "").includes("dismissed")), + ) + const [expanded, setExpanded] = createSignal(false) function format(answer?: ReadonlyArray) { + if (dismissed()) return "Dismissed" if (!answer?.length) return "(no answer)" return answer.join(", ") } + const title = createMemo(() => (dismissed() ? "# Questions (dismissed)" : "# Questions")) + const subtitle = createMemo(() => { + if (dismissed()) return `${count()} dismissed` + if ((props.metadata.answers?.length ?? 0) > 0) return `${count()} answered` + return `${count()} question${count() !== 1 ? "s" : ""}` + }) + // kilocode_change end + return ( - - - - - {(q, i) => ( - - {q.question} - {format(props.metadata.answers?.[i()])} - - )} - - - + {/* kilocode_change start - toggle between one-liner and full block */} + 0}> + setExpanded(true)} + > + {subtitle()} + + } + > + setExpanded(false)}> + + + {(q, i) => ( + + {q.question} + {format(props.metadata.answers?.[i()])} + + )} + + + + + {/* kilocode_change end */} Asked {count()} question{count() !== 1 ? "s" : ""} diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 868bf3d70e..f9b9ecb55b 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -172,7 +172,9 @@ export const dict = { "ui.patch.action.patched": "مصحح", "ui.question.subtitle.answered": "{{count}} أجيب", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(لا توجد إجابة)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(لم يتم الرد)", "ui.question.multiHint": "حدد كل ما ينطبق", "ui.question.singleHint": "حدد إجابة واحدة", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index f09b6d52f3..014068f94c 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -172,7 +172,9 @@ export const dict = { "ui.patch.action.patched": "Patch aplicado", "ui.question.subtitle.answered": "{{count}} respondidas", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(sem resposta)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(não respondida)", "ui.question.multiHint": "Selecione todas que se aplicam", "ui.question.singleHint": "Selecione uma resposta", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index cf3f1a2a9c..3e1edd7982 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -176,7 +176,9 @@ export const dict = { "ui.patch.action.patched": "Primijenjeno", "ui.question.subtitle.answered": "{{count}} odgovoreno", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(nema odgovora)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(nije odgovoreno)", "ui.question.multiHint": "Odaberi sve što važi", "ui.question.singleHint": "Odaberi jedan odgovor", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 2587a192e4..97357a8b54 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -171,7 +171,9 @@ export const dict = { "ui.patch.action.patched": "Patchet", "ui.question.subtitle.answered": "{{count}} besvaret", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(intet svar)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(ikke besvaret)", "ui.question.multiHint": "Vælg alle der gælder", "ui.question.singleHint": "Vælg ét svar", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index c47501afd3..fb396a1c92 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -177,7 +177,9 @@ export const dict = { "ui.patch.action.patched": "Gepatched", "ui.question.subtitle.answered": "{{count}} beantwortet", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(keine Antwort)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(nicht beantwortet)", "ui.question.multiHint": "Alle zutreffenden auswählen", "ui.question.singleHint": "Eine Antwort auswählen", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index d10d1a1b86..418d48942d 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -185,7 +185,9 @@ export const dict: Record = { "ui.patch.action.patched": "Patched", "ui.question.subtitle.answered": "{{count}} answered", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(no answer)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(not answered)", "ui.question.multiHint": "Select all answers that apply", "ui.question.singleHint": "Select one answer", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index f0dd59fbaa..7ba1f28af5 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -172,7 +172,9 @@ export const dict = { "ui.patch.action.patched": "Parcheado", "ui.question.subtitle.answered": "{{count}} respondidas", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(sin respuesta)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(no respondida)", "ui.question.multiHint": "Selecciona todas las que correspondan", "ui.question.singleHint": "Selecciona una respuesta", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index ab057725da..91d527b392 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -172,7 +172,9 @@ export const dict = { "ui.patch.action.patched": "Corrigé", "ui.question.subtitle.answered": "{{count}} répondu(s)", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(pas de réponse)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(non répondu)", "ui.question.multiHint": "Sélectionnez tout ce qui s'applique", "ui.question.singleHint": "Sélectionnez une réponse", diff --git a/packages/ui/src/i18n/it.ts b/packages/ui/src/i18n/it.ts index 41973bbdc4..d91be9ef57 100644 --- a/packages/ui/src/i18n/it.ts +++ b/packages/ui/src/i18n/it.ts @@ -187,7 +187,9 @@ export const dict: Record = { "ui.patch.action.patched": "Patch applicata", "ui.question.subtitle.answered": "{{count}} risposte", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(nessuna risposta)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(senza risposta)", "ui.question.multiHint": "Seleziona tutte le risposte applicabili", "ui.question.singleHint": "Seleziona una risposta", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 49f124a0ff..b1936d8cc0 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -171,7 +171,9 @@ export const dict = { "ui.patch.action.patched": "パッチ適用済み", "ui.question.subtitle.answered": "{{count}}件回答済み", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(回答なし)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(未回答)", "ui.question.multiHint": "該当するものをすべて選択", "ui.question.singleHint": "1 つ選択", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index e087064375..28b86bec23 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -172,7 +172,9 @@ export const dict = { "ui.patch.action.patched": "패치됨", "ui.question.subtitle.answered": "{{count}}개 답변됨", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(답변 없음)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(답변되지 않음)", "ui.question.multiHint": "해당하는 항목 모두 선택", "ui.question.singleHint": "하나의 답변을 선택", diff --git a/packages/ui/src/i18n/nl.ts b/packages/ui/src/i18n/nl.ts index 334af1ada9..1f03ae55fa 100644 --- a/packages/ui/src/i18n/nl.ts +++ b/packages/ui/src/i18n/nl.ts @@ -190,7 +190,9 @@ export const dict: Record = { "ui.patch.action.patched": "Gepatcht", "ui.question.subtitle.answered": "{{count}} beantwoord", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(geen antwoord)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(niet beantwoord)", "ui.question.multiHint": "Selecteer alle antwoorden die van toepassing zijn", "ui.question.singleHint": "Selecteer één antwoord", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index 043eb298ba..24817e1c38 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -175,7 +175,9 @@ export const dict: Record = { "ui.patch.action.patched": "Oppdatert", "ui.question.subtitle.answered": "{{count}} besvart", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(ingen svar)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(ikke besvart)", "ui.question.multiHint": "Velg alle som gjelder", "ui.question.singleHint": "Velg ett svar", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index 47b49754f1..1676674aa1 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -171,7 +171,9 @@ export const dict = { "ui.patch.action.patched": "Załatano", "ui.question.subtitle.answered": "{{count}} odpowiedzi", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(brak odpowiedzi)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(bez odpowiedzi)", "ui.question.multiHint": "Zaznacz wszystkie pasujące", "ui.question.singleHint": "Wybierz jedną odpowiedź", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 3bbe1a1d27..c82e71c3d3 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -171,7 +171,9 @@ export const dict = { "ui.patch.action.patched": "Изменено", "ui.question.subtitle.answered": "{{count}} отвечено", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(нет ответа)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(не отвечено)", "ui.question.multiHint": "Выберите все подходящие", "ui.question.singleHint": "Выберите один ответ", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index dcc0dec766..c6f0a170d1 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -173,7 +173,9 @@ export const dict = { "ui.patch.action.patched": "แพตช์", "ui.question.subtitle.answered": "{{count}} ตอบแล้ว", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(ไม่มีคำตอบ)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(ไม่ได้ตอบ)", "ui.question.multiHint": "เลือกทั้งหมดที่ใช้", "ui.question.singleHint": "เลือกหนึ่งคำตอบ", diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index e66437a4ac..aed757726f 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -178,7 +178,9 @@ export const dict = { "ui.patch.action.patched": "Yamalandı", "ui.question.subtitle.answered": "{{count}} cevaplandı", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(cevap yok)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(cevaplanmadı)", "ui.question.multiHint": "Geçerli tüm cevapları seçin", "ui.question.singleHint": "Bir cevap seçin", diff --git a/packages/ui/src/i18n/uk.ts b/packages/ui/src/i18n/uk.ts index dfeece39ea..7db2d3e537 100644 --- a/packages/ui/src/i18n/uk.ts +++ b/packages/ui/src/i18n/uk.ts @@ -198,7 +198,9 @@ export const dict: Record = { "ui.patch.action.patched": "Застосовано патч", // kilocode_change "ui.question.subtitle.answered": "{{count}} відповідей", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(немає відповіді)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(не відповіли)", "ui.question.multiHint": "Виберіть усі відповідні варіанти", "ui.question.singleHint": "Виберіть одну відповідь", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index f8f99c3ec1..b3579bd4d4 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -175,7 +175,9 @@ export const dict = { "ui.patch.action.patched": "已应用补丁", "ui.question.subtitle.answered": "{{count}} 已回答", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(无答案)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(未回答)", "ui.question.multiHint": "可多选", "ui.question.singleHint": "选择一个答案", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index ae01cb1fdc..07b938d027 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -175,7 +175,9 @@ export const dict = { "ui.patch.action.patched": "已套用修補", "ui.question.subtitle.answered": "{{count}} 已回答", + "ui.question.subtitle.dismissed": "{{count}} dismissed", // kilocode_change "ui.question.answer.none": "(無答案)", + "ui.question.answer.dismissed": "Dismissed", // kilocode_change "ui.question.review.notAnswered": "(未回答)", "ui.question.multiHint": "可多選", "ui.question.singleHint": "選擇一個答案", From bdb71652d50a6010e70769ad2d7c36ef9ef3f7bf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 8 Jul 2026 14:52:13 +0000 Subject: [PATCH 5/8] chore: update kilo-vscode visual regression baselines --- .../composite-webview/question-dismissed-chromium-linux.png | 4 ++-- .../labs-tool-call-lab/search-previews-chromium-linux.png | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 From dfa712d98979680479cb10cbe34a23f7be244726 Mon Sep 17 00:00:00 2001 From: V Keerthi Vikram Date: Wed, 8 Jul 2026 21:31:01 +0530 Subject: [PATCH 6/8] feat: add AI image generation tool (#11826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add AI image generation tool Port the legacy generate_image tool to the opencode-based CLI as a Kilo-owned tool gated by experimental.image_generation config flag. - New generate_image tool with prompt/path/image/model params - Routes through Kilo Gateway (zero-config) or BYO OpenRouter key - Supports text-to-image generation and image editing - Dynamic model discovery via GET /kilo/models/images endpoint - VS Code settings toggle + live model dropdown in Experimental tab - Writes image to disk and returns inline FilePart attachment - Fallback model catalog for offline resilience * refactor: change default image model to openrouter/auto * fix: address bot review feedback - Remove unused fetchKiloImageModels import in tool - Normalize jpg→jpeg MIME in parser, input image, and attachment - Replace mismatched extensions in ensureExtension (not just append) - Add assertExternalDirectoryEffect for output path traversal guard - Map unauthorized errors to 401 (not 400) in image models handler - Keep last known model list on fetch failure (don't overwrite with empty) - Fix tool description (remove false web search claim, fix grammar) - Remove duplicated provider resolver tests * fix: add retry for image models request to handle backend startup race * fix(image-generation): address kilo bot review comments - ensureExtension replaces mismatched image extensions instead of appending (photo.jpg + PNG -> photo.png, not photo.jpg.png) - Gateway /models/images normalizes errors to 400/401 matching every other gateway route (was leaking undeclared upstream statuses) - Add 401 response to openapi.json + SDK types for /kilo/models/images to match the gateway's errors(400, 401) declaration * fix(gateway): align /models/images error handling with other gateway routes * refactor: switch image generation to effect HttpClient * test: cover kilo models images endpoint in httpapi exercise scenarios * Exclude POST-only and parameterized API endpoints from link checker * Add ImageModelsProvider to agent manager context tree * chore(deps): bump @openrouter/ai-sdk-provider to 2.10.0 Switches imageModel() to OpenRouter's POST /api/v1/images endpoint for proper image usage/billing and image-specific params. * fix: address image generation PR review feedback - revert @openrouter/ai-sdk-provider 2.9.0->2.10.0 bump (image tool uses raw HTTP, not the SDK) - translate image generation settings strings across all locales - use central KILO_OPENROUTER_BASE instead of hardcoded URL fallback - remove completed plan file * chore: refresh source-links.md after URL refactor --- .changeset/image-generation.md | 5 + packages/kilo-docs/lychee.toml | 2 + packages/kilo-docs/source-links.md | 2 + packages/kilo-gateway/src/api/models.ts | 99 +++++-- packages/kilo-gateway/src/index.ts | 8 +- packages/kilo-gateway/src/server/routes.ts | 90 ++++++ packages/kilo-vscode/src/KiloProvider.ts | 20 ++ .../src/image-generation/models.ts | 41 +++ .../agent-manager/AgentManagerApp.tsx | 33 ++- packages/kilo-vscode/webview-ui/src/App.tsx | 29 +- .../components/settings/ExperimentalTab.tsx | 37 +++ .../webview-ui/src/context/image-models.tsx | 45 +++ .../kilo-vscode/webview-ui/src/i18n/ar.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/it.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 6 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 6 + .../webview-ui/src/types/messages/config.ts | 2 + .../src/types/messages/extension-messages.ts | 6 + .../src/types/messages/webview-messages.ts | 5 + packages/opencode/src/config/config.ts | 4 + .../server/httpapi/groups/kilo-gateway.ts | 18 ++ .../server/httpapi/handlers/kilo-gateway.ts | 25 ++ .../src/kilocode/tool/generate-image.ts | 263 ++++++++++++++++++ .../src/kilocode/tool/generate-image.txt | 7 + .../opencode/src/kilocode/tool/registry.ts | 12 +- packages/opencode/src/tool/registry.ts | 3 + .../server/httpapi-exercise-scenarios.ts | 1 + .../kilocode/session-compaction-cap.test.ts | 2 + .../session-prompt-compaction-safety.test.ts | 2 + .../session-prompt-permission-refresh.test.ts | 2 + ...l-registry-indexing-import-failure.test.ts | 1 + .../kilocode/tool-registry-indexing.test.ts | 16 ++ ...l-registry-semantic-import-failure.test.ts | 1 + .../test/kilocode/tool/generate-image.test.ts | 159 +++++++++++ packages/opencode/test/session/prompt.test.ts | 2 + .../test/session/snapshot-tool-race.test.ts | 2 + packages/opencode/test/tool/registry.test.ts | 2 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 39 +++ packages/sdk/js/src/v2/gen/types.gen.ts | 38 +++ packages/sdk/openapi.json | 94 +++++++ 56 files changed, 1181 insertions(+), 56 deletions(-) create mode 100644 .changeset/image-generation.md create mode 100644 packages/kilo-vscode/src/image-generation/models.ts create mode 100644 packages/kilo-vscode/webview-ui/src/context/image-models.tsx create mode 100644 packages/opencode/src/kilocode/tool/generate-image.ts create mode 100644 packages/opencode/src/kilocode/tool/generate-image.txt create mode 100644 packages/opencode/test/kilocode/tool/generate-image.test.ts 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/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/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-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/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/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/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")} + + + + + +