Merge remote-tracking branch 'origin/dark-shield' into dark-shield

This commit is contained in:
kirillk
2026-07-08 13:52:41 -04:00
102 changed files with 2226 additions and 203 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Support marking custom provider models as image-capable in VS Code settings.
+6
View File
@@ -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.
+5
View File
@@ -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.
@@ -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.
+2
View File
@@ -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/?$',
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cba6f84ec6138fc59be088cdf84d2ebe57a55f8514df58eed693a84969ff7a90
size 4794
oid sha256:eecccfc0bbd53bd52261ff8cfc02c444c6d97f6438e6693d8cd998c36909708a
size 5178
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c94c54757e88700b5c48a10a60d8626d8d5ed76a29879f06059e5f6457382a5f
size 614004
oid sha256:8bbe2c598818c82fa3d0911060bc98a88d11c71d943de2a6a4f0b236cfb7bd40
size 630314
+2
View File
@@ -160,6 +160,8 @@
<!-- packages/opencode/src/kilocode/server/httpapi/public.ts -->
- <https://opencode.ai/zen>
<!-- packages/kilo-vscode/webview-ui/src/i18n/en.ts -->
- <https://openrouter.ai/api/v1/chat/completions>
<!-- packages/opencode/src/kilocode/tool/generate-image.ts -->
- <https://openrouter.ai/docs/cookbook/administration/usage-accounting>
<!-- packages/opencode/src/kilocode/session/index.ts -->
- <https://opncd.ai>
+75 -24
View File
@@ -90,6 +90,75 @@ export async function fetchKiloModels(options?: {
kilocodeOrganizationId?: string
baseURL?: string
}): Promise<KiloModelsResult> {
const raw = await fetchRawKiloModels(options)
if (raw.error) return { models: {}, error: raw.error }
// Transform models to ModelsDev.Model format
const models: Record<string, any> = {}
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<KiloImageModelsResult> {
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<KiloModelsResult["error"]> }
> {
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<string, any> = {}
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 }
}
/**
+7 -1
View File
@@ -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,
@@ -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({
+16 -7
View File
@@ -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<CaptureDiff, "file" | "additions" | "deletions">[]) {
/** 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<CaptureDiff, "file">[]) {
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<CaptureDiff, "file" | "additions" | "deletions">[]) {
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
})
}
+5 -4
View File
@@ -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
+7 -4
View File
@@ -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,
+37 -21
View File
@@ -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")
@@ -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 {
@@ -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<string, any> = {}
const emptyMetadata: Record<string, any> = {}
@@ -1177,13 +1183,24 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
<Match when={part.state.status === "error" && part.state.error}>
{(error) => {
const cleaned = error().replace("Error: ", "")
if (part.tool === "question" && cleaned.includes("dismissed this question")) {
if (isDismissedQuestionError()) {
return (
<div style="width: 100%; display: flex; justify-content: flex-end;">
<span class="text-13-regular text-text-weak cursor-default">
{i18n.t("ui.messagePart.questions.dismissed")}
</span>
</div>
<Dynamic
component={render()}
input={input()}
tool={part.tool}
partID={part.id}
callID={part.callID}
metadata={meta()}
partMetadata={top()}
// @ts-expect-error
output={part.state.output}
status={part.state.status}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
animate
reveal={props.animate}
/>
)
}
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({
/>
}
>
<Show when={completed()}>
<div data-component="question-answers">
<Show when={hasContent()}>
<div data-component="question-answers" data-dismissed={dismissed() ? "" : undefined}>
<For each={questions()}>
{(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 (
<div data-slot="question-answer-item">
<div data-slot="question-text">{q.question}</div>
<div data-slot="answer-text">{answer().join(", ") || i18n.t("ui.question.answer.none")}</div>
<div data-slot="answer-text">{answerText()}</div>
</div>
)
}}
@@ -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: () => (
<AllProviders data={mockDataHintErrors}>
@@ -536,3 +616,59 @@ export const ToolHintErrors: Story = {
</AllProviders>
),
}
// --- Question tool: answered (collapsed) ---
export const QuestionAnswered: Story = {
name: "QuestionAnswered",
render: () => (
<AllProviders data={mockDataQuestionAnswered}>
<AssistantParts messages={[mockAssistantMessage]} />
</AllProviders>
),
}
// --- Question tool: answered (expanded) ---
export const QuestionAnsweredExpanded: Story = {
name: "QuestionAnswered (expanded)",
render: () => (
<AllProviders data={mockDataQuestionAnswered}>
<AssistantParts messages={[mockAssistantMessage]} />
</AllProviders>
),
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: () => (
<AllProviders data={mockDataQuestionDismissed}>
<AssistantParts messages={[mockAssistantMessage]} />
</AllProviders>
),
}
// --- Question tool: dismissed (expanded — shows questions with "Dismissed" labels) ---
export const QuestionDismissedExpanded: Story = {
name: "QuestionDismissed (expanded)",
render: () => (
<AllProviders data={mockDataQuestionDismissed}>
<AssistantParts messages={[mockAssistantMessage]} />
</AllProviders>
),
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
const trigger = canvasElement
.querySelector('[data-slot="basic-tool-tool-title"]')
?.closest("button")
if (trigger) trigger.click()
},
}
+20
View File
@@ -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<void> {
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
@@ -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<void> | 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", [])
@@ -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<boolean> {
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<void> {
try {
await execWithShellEnv("git", ["--version"])
@@ -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()
}
@@ -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<string>
hasWork: (worktreePath: string, base: string) => Promise<boolean>
}
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<string, AbortController>()
private readonly busySessions = new Set<string>()
private readonly pending = new Map<string, Pending>()
private readonly idleAttempted = new Set<string>()
private readonly model = new Map<string, { providerID?: string; modelID?: string }>()
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<void> {
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<void> {
@@ -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<void> {
private async queueRename(id: string, sessionID: string, generated: string): Promise<void> {
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<void> {
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}`)
}
@@ -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<ImageModelsResult> {
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) }
}
}
@@ -23,6 +23,16 @@ const VariantConfigSchema = z.object({
export type VariantConfig = z.infer<typeof VariantConfigSchema>
// 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<typeof ModelModalitiesSchema>
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<string, string>
}
models: Record<string, { name: string; reasoning?: true; variants?: Record<string, VariantConfig> }>
models: Record<
string,
{ name: string; reasoning?: true; modalities?: ModelModalities; variants?: Record<string, VariantConfig> }
>
}
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<SanitizedProviderConfig, "models"> & {
null | {
name: string
reasoning?: true | null
modalities?: ModelModalities | null
variants?: Record<string, VariantConfig | VariantPatch | null>
}
>
@@ -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 } : {}),
}
}
@@ -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<string>
hasWork?: () => Promise<boolean>
} = {},
) {
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")
})
})
@@ -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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
expect(saved.modalities).toEqual({ input: ["text", "audio", "video", "pdf"], output: ["text", "audio"] })
})
})
@@ -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",
@@ -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 = () => {
<DisplayProvider>
<IndexingProvider>
<KiloEmbeddingModelsProvider>
<NotificationsProvider>
<SessionProvider>
<AgentRequirementsProvider>
<MemoryProvider>
<FeedbackProvider>
<WorktreeModeProvider>
<DataBridge>
<AgentManagerContent />
</DataBridge>
</WorktreeModeProvider>
</FeedbackProvider>
</MemoryProvider>
</AgentRequirementsProvider>
</SessionProvider>
</NotificationsProvider>
<ImageModelsProvider>
<NotificationsProvider>
<SessionProvider>
<AgentRequirementsProvider>
<MemoryProvider>
<FeedbackProvider>
<WorktreeModeProvider>
<DataBridge>
<AgentManagerContent />
</DataBridge>
</WorktreeModeProvider>
</FeedbackProvider>
</MemoryProvider>
</AgentRequirementsProvider>
</SessionProvider>
</NotificationsProvider>
</ImageModelsProvider>
</KiloEmbeddingModelsProvider>
</IndexingProvider>
</DisplayProvider>
+16 -13
View File
@@ -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 = () => {
<WorkStyleProvider>
<IndexingProvider>
<KiloEmbeddingModelsProvider>
<NotificationsProvider>
<SessionProvider>
<AgentRequirementsProvider>
<MemoryProvider>
<FeedbackProvider>
<DataBridge>
<AppContent />
</DataBridge>
</FeedbackProvider>
</MemoryProvider>
</AgentRequirementsProvider>
</SessionProvider>
</NotificationsProvider>
<ImageModelsProvider>
<NotificationsProvider>
<SessionProvider>
<AgentRequirementsProvider>
<MemoryProvider>
<FeedbackProvider>
<DataBridge>
<AppContent />
</DataBridge>
</FeedbackProvider>
</MemoryProvider>
</AgentRequirementsProvider>
</SessionProvider>
</NotificationsProvider>
</ImageModelsProvider>
</KiloEmbeddingModelsProvider>
</IndexingProvider>
</WorkStyleProvider>
@@ -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<string, Record<string, unknown>> }
type RawModel = {
name?: string
reasoning?: boolean
modalities?: { input?: unknown; output?: unknown }
variants?: Record<string, Record<string, unknown>>
}
// Keep this aligned with the CLI provider schema; the UI only exposes image.
const MODES = new Set<Modality>(["text", "audio", "image", "video", "pdf"])
function list(raw: unknown): Modality[] | undefined {
if (!Array.isArray(raw)) return
const set = new Set<Modality>()
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<string, unknown>]): VariantEntry {
return {
@@ -76,15 +106,20 @@ function parseVariant([name, cfg]: [string, Record<string, unknown>]): 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)}
@@ -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")}
</label>
<label
style={{
display: "flex",
"align-items": "center",
gap: "8px",
cursor: "pointer",
"font-size": "var(--kilo-font-size-13)",
color: "var(--vscode-foreground)",
}}
>
<input
type="checkbox"
checked={props.m.supportsImages}
onChange={(e) => props.onChangeSupportsImages(e.currentTarget.checked)}
/>
{props.t("provider.custom.models.modalities.image")}
</label>
{/* Variants — only available when reasoning is enabled */}
<Show when={props.m.reasoning}>
<Show when={props.m.variants.length > 0}>
@@ -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, string>) => string
@@ -115,10 +115,34 @@ function serializeVariant(v: VariantEntry): [string, Record<string, unknown>] {
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<string, unknown>] {
const ventries = m.reasoning ? m.variants.filter((v) => v.name.trim()).map(serializeVariant) : []
const entry: Record<string, unknown> = { 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]
}
@@ -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 = () => {
</Switch>
</SettingsRow>
<SettingsRow
title={language.t("settings.experimental.imageGeneration.title")}
description={language.t("settings.experimental.imageGeneration.description")}
>
<Switch
checked={experimental().image_generation ?? false}
onChange={(checked) => updateExperimental("image_generation", checked)}
hideLabel
>
{language.t("settings.experimental.imageGeneration.title")}
</Switch>
</SettingsRow>
<Show when={experimental().image_generation}>
<SettingsRow
title={language.t("settings.experimental.imageGenerationModel.title")}
description={language.t("settings.experimental.imageGenerationModel.description")}
>
<Select
options={imageModels.models().map((m) => ({ value: m.id, label: m.name }))}
current={imageModels
.models()
.map((m) => ({ value: m.id, label: m.name }))
.find((m) => m.value === experimental().image_generation_model)}
value={(item) => item.value}
label={(item) => item.label}
onSelect={(item) => updateExperimental("image_generation_model", item?.value ?? undefined)}
variant="secondary"
size="small"
triggerVariant="settings"
placeholder={language.t("settings.experimental.imageGenerationModel.placeholder")}
/>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("settings.experimental.nativeNotebookTools.title")}
description={language.t("settings.experimental.nativeNotebookTools.description")}
@@ -0,0 +1,45 @@
import { createContext, createSignal, onCleanup, useContext, type Accessor, type ParentComponent } from "solid-js"
import { useVSCode } from "./vscode"
import type { ExtensionMessage } from "../types/messages"
export type ImageModel = {
id: string
name: string
description?: string
}
type ImageModelsContextValue = {
models: Accessor<ImageModel[]>
}
export const ImageModelsContext = createContext<ImageModelsContextValue>()
export const ImageModelsProvider: ParentComponent = (props) => {
const vscode = useVSCode()
const [models, setModels] = createSignal<ImageModel[]>([])
const request = () => vscode.postMessage({ type: "requestImageModels" })
const unsubscribe = vscode.onMessage((message: ExtensionMessage) => {
if (message.type !== "imageModelsLoaded") return
setModels(message.models)
})
request()
// Retry once after a delay in case the backend wasn't ready for the initial request.
const retry = setTimeout(request, 3000)
onCleanup(() => clearTimeout(retry))
onCleanup(unsubscribe)
return <ImageModelsContext.Provider value={{ models }}>{props.children}</ImageModelsContext.Provider>
}
export function useImageModels(): ImageModelsContextValue {
const context = useContext(ImageModelsContext)
if (!context) {
throw new Error("useImageModels must be used within an ImageModelsProvider")
}
return context
}
+7
View File
@@ -940,6 +940,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": "إزالة المتغير",
@@ -1398,6 +1399,12 @@ export const dict = {
"settings.experimental.batch.description": "تمكين المعالجة الدفعية لاستدعاءات الأدوات",
"settings.experimental.codebaseSearch.title": "بحث في قاعدة الكود",
"settings.experimental.codebaseSearch.description": "تمكين البحث بالذكاء الاصطناعي باللغة الطبيعية عبر قاعدة الكود",
"settings.experimental.imageGeneration.title": "توليد الصور",
"settings.experimental.imageGeneration.description": "تمكين توليد الصور بالذكاء الاصطناعي",
"settings.experimental.imageGenerationModel.title": "نموذج الصور",
"settings.experimental.imageGenerationModel.description": "نموذج توليد الصور",
"settings.experimental.imageGenerationModel.placeholder": "افتراضي (Auto Router)",
"settings.experimental.speechToText.title": "تحويل الصوت إلى نص",
"settings.experimental.speechToText.description":
"تمكين الإدخال الصوتي في حقول المطالبة باستخدام حساب Kilo الخاص بك من خلال Kilo Gateway.",
+7
View File
@@ -956,6 +956,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",
@@ -1433,6 +1434,12 @@ export const dict = {
"settings.experimental.codebaseSearch.title": "Pesquisa de código",
"settings.experimental.codebaseSearch.description":
"Ativar pesquisa por linguagem natural com IA em toda a base de código",
"settings.experimental.imageGeneration.title": "Geração de imagens",
"settings.experimental.imageGeneration.description": "Ativar geração de imagens por IA",
"settings.experimental.imageGenerationModel.title": "Modelo de imagem",
"settings.experimental.imageGenerationModel.description": "Modelo de geração de imagens",
"settings.experimental.imageGenerationModel.placeholder": "Padrão (Auto Router)",
"settings.experimental.speechToText.title": "Fala para texto",
"settings.experimental.speechToText.description":
"Ative a entrada de voz nos campos de prompt usando sua conta do Kilo por meio do Kilo Gateway.",
+7
View File
@@ -999,6 +999,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",
@@ -1430,6 +1431,12 @@ export const dict = {
"settings.experimental.batch.description": "Omogući batch obradu poziva alata",
"settings.experimental.codebaseSearch.title": "Pretraga koda",
"settings.experimental.codebaseSearch.description": "Omogući AI pretragu prirodnim jezikom kroz bazu koda",
"settings.experimental.imageGeneration.title": "Generisanje slika",
"settings.experimental.imageGeneration.description": "Omogući AI generisanje slika",
"settings.experimental.imageGenerationModel.title": "Model slike",
"settings.experimental.imageGenerationModel.description": "Model za generisanje slika",
"settings.experimental.imageGenerationModel.placeholder": "Zadano (Auto Router)",
"settings.experimental.speechToText.title": "Govor u tekst",
"settings.experimental.speechToText.description":
"Omogućite glasovni unos u poljima za promptove koristeći vaš Kilo račun preko Kilo Gateway.",
+7
View File
@@ -992,6 +992,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",
@@ -1424,6 +1425,12 @@ export const dict = {
"settings.experimental.batch.description": "Aktiver batchbehandling af flere værktøjskald",
"settings.experimental.codebaseSearch.title": "Kodesøgning",
"settings.experimental.codebaseSearch.description": "Aktiver AI-drevet naturlig sprogsøgning på tværs af kodebasen",
"settings.experimental.imageGeneration.title": "Billedgenerering",
"settings.experimental.imageGeneration.description": "Aktiver AI-billedgenerering",
"settings.experimental.imageGenerationModel.title": "Billedmodel",
"settings.experimental.imageGenerationModel.description": "Billedgenereringsmodel",
"settings.experimental.imageGenerationModel.placeholder": "Standard (Auto Router)",
"settings.experimental.speechToText.title": "Tale til tekst",
"settings.experimental.speechToText.description":
"Aktivér stemmeinput i prompt-felter ved hjælp af din Kilo-konto gennem Kilo Gateway.",
@@ -1010,6 +1010,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",
@@ -1452,6 +1453,12 @@ export const dict = {
"settings.experimental.codebaseSearch.title": "Codebase-Suche",
"settings.experimental.codebaseSearch.description":
"KI-gestützte Suche in natürlicher Sprache über die gesamte Codebasis aktivieren",
"settings.experimental.imageGeneration.title": "Bildgenerierung",
"settings.experimental.imageGeneration.description": "KI-Bildgenerierung aktivieren",
"settings.experimental.imageGenerationModel.title": "Bildmodell",
"settings.experimental.imageGenerationModel.description": "Bildgenerierungsmodell",
"settings.experimental.imageGenerationModel.placeholder": "Standard (Auto Router)",
"settings.experimental.speechToText.title": "Sprache zu Text",
"settings.experimental.speechToText.description":
"Aktivieren Sie die Spracheingabe in Prompt-Feldern mit Ihrem Kilo-Konto über Kilo Gateway.",
@@ -913,6 +913,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",
@@ -1406,6 +1407,12 @@ export const dict = {
"settings.experimental.batch.description": "Enable batching of multiple tool calls",
"settings.experimental.codebaseSearch.title": "Codebase Search",
"settings.experimental.codebaseSearch.description": "Enable AI-powered natural language search across your codebase",
"settings.experimental.imageGeneration.title": "Image Generation",
"settings.experimental.imageGeneration.description": "Enable AI image generation",
"settings.experimental.imageGenerationModel.title": "Image Model",
"settings.experimental.imageGenerationModel.description": "Image Generation Model",
"settings.experimental.imageGenerationModel.placeholder": "Default (Auto Router)",
"settings.experimental.speechToText.title": "Speech to Text",
"settings.experimental.speechToText.description":
"Enable voice input in prompt fields using your Kilo account through Kilo Gateway.",
+7
View File
@@ -1002,6 +1002,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",
@@ -1441,6 +1442,12 @@ export const dict = {
"settings.experimental.codebaseSearch.title": "Búsqueda de código",
"settings.experimental.codebaseSearch.description":
"Habilitar búsqueda por lenguaje natural con IA en toda la base de código",
"settings.experimental.imageGeneration.title": "Generación de imágenes",
"settings.experimental.imageGeneration.description": "Habilitar generación de imágenes con IA",
"settings.experimental.imageGenerationModel.title": "Modelo de imagen",
"settings.experimental.imageGenerationModel.description": "Modelo de generación de imágenes",
"settings.experimental.imageGenerationModel.placeholder": "Predeterminado (Auto Router)",
"settings.experimental.speechToText.title": "Voz a texto",
"settings.experimental.speechToText.description":
"Habilita la entrada de voz en los campos de prompt usando tu cuenta de Kilo a través de Kilo Gateway.",
+7
View File
@@ -1008,6 +1008,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",
@@ -1456,6 +1457,12 @@ export const dict = {
"settings.experimental.codebaseSearch.title": "Recherche de code",
"settings.experimental.codebaseSearch.description":
"Activer la recherche en langage naturel par IA dans toute la base de code",
"settings.experimental.imageGeneration.title": "Génération d'images",
"settings.experimental.imageGeneration.description": "Activer la génération d'images par IA",
"settings.experimental.imageGenerationModel.title": "Modèle d'image",
"settings.experimental.imageGenerationModel.description": "Modèle de génération d'images",
"settings.experimental.imageGenerationModel.placeholder": "Par défaut (Auto Router)",
"settings.experimental.speechToText.title": "Transcription vocale",
"settings.experimental.speechToText.description":
"Activez la saisie vocale dans les champs de prompt en utilisant votre compte Kilo via Kilo Gateway.",
+7
View File
@@ -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",
@@ -1222,6 +1223,12 @@ export const dict = {
"Abilita l'indicizzazione semantica del codebase e il tool semantic_search. Richiede configurazione indicizzazione.",
"settings.experimental.codebaseSearch.title": "Ricerca codebase",
"settings.experimental.codebaseSearch.description": "Abilita ricerca in linguaggio naturale con AI nel codebase",
"settings.experimental.imageGeneration.title": "Generazione di immagini",
"settings.experimental.imageGeneration.description": "Abilita la generazione di immagini con AI",
"settings.experimental.imageGenerationModel.title": "Modello di immagine",
"settings.experimental.imageGenerationModel.description": "Modello di generazione di immagini",
"settings.experimental.imageGenerationModel.placeholder": "Predefinito (Auto Router)",
"settings.experimental.nativeNotebookTools.title": "Strumenti nativi per notebook",
"settings.experimental.nativeNotebookTools.description":
"Abilita strumenti sperimentali per leggere, modificare ed eseguire i notebook di VS Code",
+7
View File
@@ -989,6 +989,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": "バリアントを削除",
@@ -1419,6 +1420,12 @@ export const dict = {
"settings.experimental.batch.description": "複数のツール呼び出しのバッチ処理を有効にする",
"settings.experimental.codebaseSearch.title": "コードベース検索",
"settings.experimental.codebaseSearch.description": "コードベース全体でAIによる自然言語検索を有効にする",
"settings.experimental.imageGeneration.title": "画像生成",
"settings.experimental.imageGeneration.description": "AI画像生成を有効にする",
"settings.experimental.imageGenerationModel.title": "画像モデル",
"settings.experimental.imageGenerationModel.description": "画像生成モデル",
"settings.experimental.imageGenerationModel.placeholder": "デフォルト (Auto Router)",
"settings.experimental.speechToText.title": "音声認識",
"settings.experimental.speechToText.description":
"Kilo Gateway経由でKiloアカウントを使用して、プロンプトフィールドでの音声入力を有効にします。",
+7
View File
@@ -947,6 +947,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": "변형 제거",
@@ -1411,6 +1412,12 @@ export const dict = {
"settings.experimental.batch.description": "여러 도구 호출의 배치 처리 활성화",
"settings.experimental.codebaseSearch.title": "코드베이스 검색",
"settings.experimental.codebaseSearch.description": "코드베이스 전체에서 AI 기반 자연어 검색 활성화",
"settings.experimental.imageGeneration.title": "이미지 생성",
"settings.experimental.imageGeneration.description": "AI 이미지 생성 활성화",
"settings.experimental.imageGenerationModel.title": "이미지 모델",
"settings.experimental.imageGenerationModel.description": "이미지 생성 모델",
"settings.experimental.imageGenerationModel.placeholder": "기본값 (Auto Router)",
"settings.experimental.speechToText.title": "음성 텍스트 변환",
"settings.experimental.speechToText.description":
"Kilo Gateway를 통해 Kilo 계정을 사용하여 프롬프트 필드에서 음성 입력을 활성화합니다.",
+7
View File
@@ -950,6 +950,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",
@@ -1427,6 +1428,12 @@ export const dict = {
"settings.experimental.codebaseSearch.title": "Codebase Zoeken",
"settings.experimental.codebaseSearch.description":
"Schakel AI-aangedreven zoeken in natuurlijke taal door je codebase in",
"settings.experimental.imageGeneration.title": "Afbeeldingsgeneratie",
"settings.experimental.imageGeneration.description": "AI-afbeeldingsgeneratie inschakelen",
"settings.experimental.imageGenerationModel.title": "Afbeeldingsmodel",
"settings.experimental.imageGenerationModel.description": "Afbeeldingsgeneratiemodel",
"settings.experimental.imageGenerationModel.placeholder": "Standaard (Auto Router)",
"settings.experimental.speechToText.title": "Spraak naar tekst",
"settings.experimental.speechToText.description":
"Schakel spraakinvoer in promptvelden in met uw Kilo-account via Kilo Gateway.",
+7
View File
@@ -957,6 +957,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",
@@ -1387,6 +1388,12 @@ export const dict = {
"settings.experimental.batch.description": "Aktiver batchbehandling av verktøykall",
"settings.experimental.codebaseSearch.title": "Kodesøk",
"settings.experimental.codebaseSearch.description": "Aktiver AI-drevet naturlig språksøk på tvers av kodebasen",
"settings.experimental.imageGeneration.title": "Bildegenerering",
"settings.experimental.imageGeneration.description": "Aktiver AI-bildegenerering",
"settings.experimental.imageGenerationModel.title": "Bildemodell",
"settings.experimental.imageGenerationModel.description": "Bildegenereringsmodell",
"settings.experimental.imageGenerationModel.placeholder": "Standard (Auto Router)",
"settings.experimental.speechToText.title": "Tale til tekst",
"settings.experimental.speechToText.description":
"Aktiver taleinndata i prompt-felt ved å bruke din Kilo-konto gjennom Kilo Gateway.",
+7
View File
@@ -955,6 +955,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",
@@ -1386,6 +1387,12 @@ export const dict = {
"settings.experimental.batch.description": "Włącz przetwarzanie wsadowe wywołań narzędzi",
"settings.experimental.codebaseSearch.title": "Wyszukiwanie kodu",
"settings.experimental.codebaseSearch.description": "Włącz wyszukiwanie w języku naturalnym z AI w całej bazie kodu",
"settings.experimental.imageGeneration.title": "Generowanie obrazów",
"settings.experimental.imageGeneration.description": "Włącz generowanie obrazów przez AI",
"settings.experimental.imageGenerationModel.title": "Model obrazu",
"settings.experimental.imageGenerationModel.description": "Model generowania obrazów",
"settings.experimental.imageGenerationModel.placeholder": "Domyślny (Auto Router)",
"settings.experimental.speechToText.title": "Mowa na tekst",
"settings.experimental.speechToText.description":
"Włącz wprowadzanie głosowe w polach promptów przy użyciu konta Kilo za pośrednictwem Kilo Gateway.",
+7
View File
@@ -996,6 +996,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": "Удалить вариант",
@@ -1428,6 +1429,12 @@ export const dict = {
"settings.experimental.batch.description": "Включить пакетную обработку вызовов инструментов",
"settings.experimental.codebaseSearch.title": "Поиск по коду",
"settings.experimental.codebaseSearch.description": "Включить поиск на естественном языке с ИИ по всей кодовой базе",
"settings.experimental.imageGeneration.title": "Генерация изображений",
"settings.experimental.imageGeneration.description": "Включить генерацию изображений с помощью ИИ",
"settings.experimental.imageGenerationModel.title": "Модель изображений",
"settings.experimental.imageGenerationModel.description": "Модель генерации изображений",
"settings.experimental.imageGenerationModel.placeholder": "По умолчанию (Auto Router)",
"settings.experimental.speechToText.title": "Речь в текст",
"settings.experimental.speechToText.description":
"Включите голосовой ввод в полях запросов, используя вашу учетную запись Kilo через Kilo Gateway.",
+7
View File
@@ -982,6 +982,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": "ลบรูปแบบ",
@@ -1407,6 +1408,12 @@ export const dict = {
"settings.experimental.batch.description": "เปิดใช้งานการประมวลผลแบทช์ของการเรียกเครื่องมือ",
"settings.experimental.codebaseSearch.title": "ค้นหาโค้ดเบส",
"settings.experimental.codebaseSearch.description": "เปิดใช้งานการค้นหาด้วยภาษาธรรมชาติโดย AI ทั่วทั้งโค้ดเบส",
"settings.experimental.imageGeneration.title": "การสร้างภาพ",
"settings.experimental.imageGeneration.description": "เปิดใช้งานการสร้างภาพด้วย AI",
"settings.experimental.imageGenerationModel.title": "โมเดลภาพ",
"settings.experimental.imageGenerationModel.description": "โมเดลการสร้างภาพ",
"settings.experimental.imageGenerationModel.placeholder": "ค่าเริ่มต้น (Auto Router)",
"settings.experimental.speechToText.title": "แปลงเสียงเป็นข้อความ",
"settings.experimental.speechToText.description":
"เปิดใช้งานการป้อนข้อมูลด้วยเสียงในช่องพรอมต์โดยใช้บัญชี Kilo ของคุณผ่าน Kilo Gateway",
+7
View File
@@ -947,6 +947,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",
@@ -1418,6 +1419,12 @@ export const dict = {
"settings.experimental.codebaseSearch.title": "Kod Tabanı Araması",
"settings.experimental.codebaseSearch.description":
"Kod tabanınız genelinde yapay zeka destekli doğal dil aramasını etkinleştir",
"settings.experimental.imageGeneration.title": "Görüntü oluşturma",
"settings.experimental.imageGeneration.description": "AI görüntü oluşturmayı etkinleştir",
"settings.experimental.imageGenerationModel.title": "Görüntü modeli",
"settings.experimental.imageGenerationModel.description": "Görüntü oluşturma modeli",
"settings.experimental.imageGenerationModel.placeholder": "Varsayılan (Auto Router)",
"settings.experimental.speechToText.title": "Sesten metne",
"settings.experimental.speechToText.description":
"Kilo Gateway üzerinden Kilo hesabınızı kullanarak komut alanlarında sesli girişi etkinleştirin.",
+7
View File
@@ -947,6 +947,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": "Видалити варіант",
@@ -1416,6 +1417,12 @@ export const dict = {
"settings.experimental.codebaseSearch.title": "Пошук по кодовій базі",
"settings.experimental.codebaseSearch.description":
"Увімкнути пошук природною мовою на основі ШІ по всій кодовій базі",
"settings.experimental.imageGeneration.title": "Генерація зображень",
"settings.experimental.imageGeneration.description": "Увімкнути генерацію зображень за допомогою ШІ",
"settings.experimental.imageGenerationModel.title": "Модель зображень",
"settings.experimental.imageGenerationModel.description": "Модель генерації зображень",
"settings.experimental.imageGenerationModel.placeholder": "За замовчуванням (Auto Router)",
"settings.experimental.speechToText.title": "Мовлення в текст",
"settings.experimental.speechToText.description":
"Увімкніть голосове введення в полях запитів, використовуючи ваш обліковий запис Kilo через Kilo Gateway.",
+7
View File
@@ -964,6 +964,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": "移除变体",
@@ -1383,6 +1384,12 @@ export const dict = {
"settings.experimental.batch.description": "启用多个工具调用的批处理",
"settings.experimental.codebaseSearch.title": "代码库搜索",
"settings.experimental.codebaseSearch.description": "启用 AI 驱动的自然语言代码库搜索",
"settings.experimental.imageGeneration.title": "图像生成",
"settings.experimental.imageGeneration.description": "启用 AI 图像生成",
"settings.experimental.imageGenerationModel.title": "图像模型",
"settings.experimental.imageGenerationModel.description": "图像生成模型",
"settings.experimental.imageGenerationModel.placeholder": "默认 (Auto Router)",
"settings.experimental.speechToText.title": "语音转文本",
"settings.experimental.speechToText.description": "通过 Kilo Gateway 使用您的 Kilo 帐户在提示词字段中启用语音输入。",
"settings.models.speechToText.disabledDescription":
+7
View File
@@ -928,6 +928,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": "移除變體",
@@ -1347,6 +1348,12 @@ export const dict = {
"settings.experimental.batch.description": "啟用多個工具呼叫的批次處理",
"settings.experimental.codebaseSearch.title": "程式碼庫搜尋",
"settings.experimental.codebaseSearch.description": "啟用 AI 驅動的自然語言程式碼庫搜尋",
"settings.experimental.imageGeneration.title": "圖像生成",
"settings.experimental.imageGeneration.description": "啟用 AI 圖像生成",
"settings.experimental.imageGenerationModel.title": "圖像模型",
"settings.experimental.imageGenerationModel.description": "圖像生成模型",
"settings.experimental.imageGenerationModel.placeholder": "預設 (Auto Router)",
"settings.experimental.speechToText.title": "語音轉文字",
"settings.experimental.speechToText.description": "透過 Kilo Gateway 使用您的 Kilo 帳戶在提示詞欄位中啟用語音輸入。",
"settings.models.speechToText.disabledDescription":
@@ -40,6 +40,8 @@ export interface WatcherConfig {
export interface ExperimentalConfig {
batch_tool?: boolean
codebase_search?: boolean
image_generation?: boolean
image_generation_model?: string
agent_requirements?: boolean
native_notebook_tools?: boolean
speech_to_text_model?: string
@@ -341,6 +341,11 @@ export interface KiloEmbeddingModelsLoadedMessage {
catalog: KiloEmbeddingModelCatalog
}
export interface ImageModelsLoadedMessage {
type: "imageModelsLoaded"
models: Array<{ id: string; name: string; description?: string }>
}
export interface ProvidersLoadedMessage {
type: "providersLoaded"
providers: Record<string, Provider>
@@ -1098,6 +1103,7 @@ export type ExtensionMessage =
| IndexingStatusLoadedMessage
| IndexingSettingsLoadedMessage
| KiloEmbeddingModelsLoadedMessage
| ImageModelsLoadedMessage
| ProvidersLoadedMessage
| AgentsLoadedMessage
| SkillsLoadedMessage
@@ -476,6 +476,10 @@ export interface RequestKiloEmbeddingModelsMessage {
type: "requestKiloEmbeddingModels"
}
export interface RequestImageModelsMessage {
type: "requestImageModels"
}
export interface OpenSettingsTabRequest {
type: "openSettingsTab"
tab: string
@@ -1395,6 +1399,7 @@ export type WebviewMessage =
| AgentManagerTerminalCreateRequest
| AgentManagerTerminalCloseRequest
| AgentManagerTerminalResizeRequest
| RequestImageModelsMessage
// ============================================
// VS Code API type
@@ -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 (
<Switch>
<Match when={answers().length > 0}>
<BlockTool title="# Questions" part={props.part}>
{/* kilocode_change start - gate on dismissed or answers so dismissed/answered render, pending falls through to Asking... */}
<Match when={dismissed() || answers().length > 0}>
<BlockTool title={title()} part={props.part}>
<box gap={1}>
<For each={questions()}>
{(question, index) => (
<box>
<text fg={theme.textMuted}>{stringValue(question.question)}</text>
<text fg={theme.text}>{formatAnswer(answers()[index()])}</text>
<text fg={theme.text}>{format(answers()[index()])}</text>
</box>
)}
</For>
</box>
</BlockTool>
</Match>
{/* kilocode_change end */}
<Match when={true}>
<InlineTool icon="→" pending="Asking questions..." complete={questions().length} part={props.part}>
Asked {questions().length} question{questions().length === 1 ? "" : "s"}
@@ -2815,28 +2815,64 @@ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
function Question(props: ToolProps<typeof QuestionTool>) {
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<string>) {
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 (
<Switch>
<Match when={props.metadata.answers}>
<BlockTool title="# Questions" part={props.part}>
<box gap={1}>
<For each={props.input.questions ?? []}>
{(q, i) => (
<box flexDirection="column">
<text fg={theme.textMuted}>{q.question}</text>
<text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
</box>
)}
</For>
</box>
</BlockTool>
{/* kilocode_change start - toggle between one-liner and full block */}
<Match when={count() > 0}>
<Show
when={expanded()}
fallback={
<InlineTool
icon="→"
complete={count()}
pending="Asking questions..."
part={props.part}
onClick={() => setExpanded(true)}
>
{subtitle()}
</InlineTool>
}
>
<BlockTool title={title()} part={props.part} onClick={() => setExpanded(false)}>
<box gap={1}>
<For each={props.input.questions ?? []}>
{(q, i) => (
<box flexDirection="column">
<text fg={theme.textMuted}>{q.question}</text>
<text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
</box>
)}
</For>
</box>
</BlockTool>
</Show>
</Match>
{/* kilocode_change end */}
<Match when={true}>
<InlineTool icon="→" pending="Asking questions..." complete={count()} part={props.part}>
Asked {count()} question{count() !== 1 ? "s" : ""}
+4
View File
@@ -392,6 +392,10 @@ export const Info = Schema.Struct({
batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }),
// kilocode_change start
codebase_search: Schema.optional(Schema.Boolean).annotate({ description: "Enable AI-powered codebase search" }),
image_generation: Schema.optional(Schema.Boolean).annotate({ description: "Enable AI image generation" }),
image_generation_model: Schema.optional(Schema.String).annotate({
description: "Model ID to use for image generation (default: openrouter/auto)",
}),
agent_requirements: Schema.optional(Schema.Boolean).annotate({
description: "Require declared agent skills, MCPs, and VS Code extensions before VS Code prompts can run",
}),
@@ -18,6 +18,7 @@ Return exactly one line:
- null when there is not yet a clear, stable workstream
Return null for greetings, acknowledgements, capability questions, casual conversation, vague requests, unresolved brainstorming, or messages that only select an option without enough preceding context.
Return null when the messages only ask a question or check a status and do not describe work to perform (for example "is X fixed?", "check whether ...").
A concrete implementation, investigation, planning, documentation, or research task is a valid workstream.
Name the durable goal or outcome, not a tentative implementation detail. Prefer an action and object, such as fix-token-refresh-race or research-branch-naming.
If the user asks for a specific branch name, prefer that name.
@@ -207,6 +207,12 @@ export const TranscriptionResponse = Schema.Struct({
usage: Schema.optional(Schema.Unknown),
})
export const ImageModel = Schema.Struct({
id: Schema.String,
name: Schema.String,
description: Schema.optional(Schema.String),
})
const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown)
export const CloudMessage = Schema.StructWithRest(
@@ -260,6 +266,7 @@ export const KiloGatewayPaths = {
fim: `${root}/fim`,
edit: `${root}/edit`,
audioTranscriptions: `${root}/audio/transcriptions`,
imageModels: `${root}/models/images`,
notifications: `${root}/notifications`,
organization: `${root}/organization`,
clawStatus: `${root}/claw/status`,
@@ -343,6 +350,17 @@ export const KiloGatewayApi = HttpApi.make("kilo")
description: "Proxy an audio transcription request to the Kilo Gateway",
}),
),
HttpApiEndpoint.get("imageModels", KiloGatewayPaths.imageModels, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(ImageModel), "Image-capable model list"),
error: [HttpApiError.BadRequest, HttpApiError.Unauthorized],
}).annotateMerge(
OpenApi.annotations({
identifier: "kilo.models.images",
summary: "Image generation models",
description: "List image-capable models from the Kilo Gateway OpenRouter passthrough",
}),
),
HttpApiEndpoint.get("notifications", KiloGatewayPaths.notifications, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Notification), "Notifications list"),
@@ -2,6 +2,7 @@ import {
GatewayError,
fetchCloudSession,
fetchCloudSessionForImport,
fetchKiloImageModels,
getCloudSessions,
getOrganizationId,
getToken,
@@ -527,6 +528,29 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo",
})
})
const imageModels = Effect.fn("KiloGatewayHttpApi.imageModels")(function* () {
const info = yield* proxyAuth()
if (!info.auth) return yield* Effect.fail(new HttpApiError.Unauthorized({}))
if (!info.token) return yield* Effect.fail(new HttpApiError.Unauthorized({}))
const result = yield* Effect.tryPromise({
try: () =>
fetchKiloImageModels({
kilocodeToken: info.token,
kilocodeOrganizationId: info.organizationId,
}),
catch: () => new HttpApiError.BadRequest({}),
})
if (result.error) {
const err =
result.error.kind === "unauthorized" ? new HttpApiError.Unauthorized({}) : new HttpApiError.BadRequest({})
return yield* Effect.fail(err)
}
return result.models
})
return handlers
.handle("profile", profile)
.handle("authStatus", authStatus)
@@ -534,6 +558,7 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo",
.handle("fim", fim)
.handle("edit", edit)
.handle("audioTranscriptions", audioTranscriptions)
.handle("imageModels", imageModels)
.handle("notifications", notifications)
.handle("organization", organization)
.handle("clawStatus", clawStatus)
@@ -0,0 +1,263 @@
// kilocode_change - new file
import { Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import * as path from "path"
import { readFile } from "fs/promises"
import * as Tool from "../../tool/tool"
import * as Auth from "../../auth"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { InstanceState } from "@/effect/instance-state"
import * as Log from "@opencode-ai/core/util/log"
import { assertExternalDirectoryEffect } from "../../tool/external-directory"
import { Config } from "@/config/config"
import { KILO_OPENROUTER_BASE } from "@kilocode/kilo-gateway"
import DESCRIPTION from "./generate-image.txt"
const log = Log.create({ service: "tool.generate_image" })
const KILO_OPENROUTER_URL = `${KILO_OPENROUTER_BASE}/chat/completions`
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
/** Fallback catalog used when the gateway is unreachable or the user is offline. */
export const FALLBACK_IMAGE_MODELS = [
{ value: "openrouter/auto", label: "Auto Router" },
{ value: "google/gemini-2.5-flash-image", label: "Gemini 2.5 Flash Image" },
{ value: "google/gemini-3-pro-image-preview", label: "Gemini 3 Pro Image Preview" },
{ value: "openai/gpt-5-image", label: "GPT-5 Image" },
{ value: "openai/gpt-5-image-mini", label: "GPT-5 Image Mini" },
{ value: "black-forest-labs/flux.2-flex", label: "Black Forest Labs FLUX.2 Flex" },
{ value: "black-forest-labs/flux.2-pro", label: "Black Forest Labs FLUX.2 Pro" },
] as const
export const DEFAULT_MODEL = "openrouter/auto"
/** Kept for test compatibility. */
export const IMAGE_MODELS = FALLBACK_IMAGE_MODELS
export type ImageFormat = "png" | "jpeg"
const DATA_URL_RE = /^data:image\/(png|jpeg|jpg);base64,(.+)$/
export function parseImageResponse(body: string): { format: ImageFormat; base64: string } | null {
let json: unknown
try {
json = JSON.parse(body)
} catch {
return null
}
const choices = (json as any)?.choices
const url = choices?.[0]?.message?.images?.[0]?.image_url?.url
if (typeof url !== "string") return null
const m = url.match(DATA_URL_RE)
if (!m) return null
const format = (m[1] === "jpg" ? "jpeg" : m[1]) as ImageFormat
return { format, base64: m[2] }
}
export type AuthInput = {
type: "oauth" | "api"
access?: string
key?: string
accountId?: string
}
export type ResolvedProvider = {
url: string
token: string
provider: "kilo" | "openrouter"
organizationId?: string
}
export function resolveProvider(
auth: AuthInput | undefined,
openRouterKey: string | undefined,
): ResolvedProvider | null {
const token = auth?.type === "oauth" ? auth.access : auth?.type === "api" ? auth.key : undefined
if (token) {
return {
url: KILO_OPENROUTER_URL,
token,
provider: "kilo",
...(auth?.type === "oauth" && auth.accountId ? { organizationId: auth.accountId } : {}),
}
}
if (openRouterKey) {
return { url: OPENROUTER_URL, token: openRouterKey, provider: "openrouter" }
}
return null
}
export function ensureExtension(relPath: string, format: ImageFormat): string {
const ext = format === "jpeg" ? "jpg" : format
const match = relPath.match(/\.([a-z]+)$/i)
if (!match) return `${relPath}.${ext}`
const existing = match[1].toLowerCase()
const imageExts = ["png", "jpg", "jpeg"]
if (!imageExts.includes(existing)) return `${relPath}.${ext}`
const matches = ext === "jpg" ? ["jpg", "jpeg"] : ["png"]
if (matches.includes(existing)) return relPath
return `${relPath.slice(0, -match[0].length)}.${ext}`
}
type ResolvedRequest = { url: string; headers: Record<string, string>; body: string }
function buildRequest(resolved: ResolvedProvider, prompt: string, model: string, inputImage?: string): ResolvedRequest {
const headers: Record<string, string> = {
Authorization: `Bearer ${resolved.token}`,
"Content-Type": "application/json",
}
if (resolved.organizationId) headers["X-KILOCODE-ORGANIZATIONID"] = resolved.organizationId
const content = inputImage
? [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: inputImage } },
]
: prompt
return {
url: resolved.url,
headers,
body: JSON.stringify({
model,
messages: [{ role: "user", content }],
modalities: ["image", "text"],
}),
}
}
const Parameters = Schema.Struct({
prompt: Schema.String.annotate({ description: "Text description of the image to generate or the edits to apply" }),
path: Schema.String.annotate({
description: "Filesystem path (relative to the workspace) where the resulting image should be saved",
}),
image: Schema.optional(Schema.String).annotate({
description:
"Optional path (relative to the workspace) to an existing image to edit; supports PNG, JPG, JPEG, GIF, and WEBP",
}),
model: Schema.optional(Schema.String).annotate({
description: "Model ID to use for image generation. Omit to use the configured default.",
}),
})
type Meta = {
format?: ImageFormat
filepath?: string
provider?: "kilo" | "openrouter"
error?: string
}
export const GenerateImageTool = Tool.define(
"generate_image",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const authSvc = yield* Auth.Service
const configSvc = yield* Config.Service
const http = yield* HttpClient.HttpClient
return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const instance = yield* InstanceState.context
const auth = yield* authSvc.get("kilo")
const authInput: AuthInput | undefined = auth
? {
type: auth.type === "api" ? "api" : "oauth",
...(auth.type === "api" ? { key: auth.key } : {}),
...(auth.type === "oauth" ? { access: auth.access } : {}),
...(auth.type === "oauth" && auth.accountId ? { accountId: auth.accountId } : {}),
}
: undefined
const resolved = resolveProvider(authInput, process.env["OPENROUTER_API_KEY"])
if (!resolved) {
return {
title: "Image generation unavailable",
output:
"No image generation provider available. Log in to Kilo or set OPENROUTER_API_KEY, then try again.",
metadata: { error: "no-provider" } as Meta,
}
}
yield* ctx.metadata({
title: `Generate image "${params.prompt.slice(0, 60)}"`,
metadata: { provider: resolved.provider },
})
let inputImage: string | undefined
if (params.image) {
const imgPath = path.isAbsolute(params.image) ? params.image : path.join(instance.directory, params.image)
yield* assertExternalDirectoryEffect(ctx, imgPath)
const buf = yield* Effect.tryPromise(() => readFile(imgPath))
const ext = path.extname(imgPath).slice(1).toLowerCase() || "png"
const mime = ext === "jpg" ? "jpeg" : ext
inputImage = `data:image/${mime};base64,${buf.toString("base64")}`
}
const cfg = yield* configSvc.get()
const model = params.model ?? cfg.experimental?.image_generation_model ?? DEFAULT_MODEL
const req = buildRequest(resolved, params.prompt, model, inputImage)
const response = yield* http.execute(
HttpClientRequest.post(req.url).pipe(
HttpClientRequest.setHeaders(req.headers),
HttpClientRequest.bodyText(req.body, "application/json"),
),
)
const status = response.status
if (status < 200 || status >= 300) {
const errText = yield* response.text
log.warn("image generation failed", { status, errText: errText.slice(0, 200) })
return {
title: "Image generation failed",
output: `Image generation request failed (HTTP ${status}).`,
metadata: { provider: resolved.provider, error: "http-error" } as Meta,
}
}
const text = yield* response.text
const parsed = parseImageResponse(text)
if (!parsed) {
return {
title: "Image generation produced no image",
output: "The model did not return an image. Try a different prompt or model.",
metadata: { provider: resolved.provider, error: "no-image" } as Meta,
}
}
const finalPath = ensureExtension(params.path, parsed.format)
const absPath = path.isAbsolute(finalPath) ? finalPath : path.join(instance.directory, finalPath)
yield* assertExternalDirectoryEffect(ctx, absPath)
yield* ctx.ask({
permission: "write",
patterns: [path.relative(instance.worktree, absPath)],
always: ["*"],
metadata: { filepath: absPath },
})
const buf = Buffer.from(parsed.base64, "base64")
yield* fs.writeWithDirs(absPath, buf)
return {
title: path.relative(instance.worktree, absPath),
output: `Image saved to ${finalPath}.`,
metadata: {
format: parsed.format,
filepath: absPath,
provider: resolved.provider,
} as Meta,
attachments: [
{
type: "file" as const,
mime: `image/${parsed.format}`,
url: `file://${absPath}`,
filename: path.basename(absPath),
},
],
}
}).pipe(Effect.orDie),
}
}),
)
@@ -0,0 +1,7 @@
Generate a new image from a text prompt or edit an existing image using AI models through the Kilo Gateway or OpenRouter.
Usage notes:
- Provide a `prompt` describing what to generate or how to edit
- Provide a `path` (relative to the workspace) where the resulting image should be saved — the extension is auto-appended (.png/.jpg) if missing
- Optionally provide an `image` path to an existing image to edit or transform (supports PNG, JPG, JPEG, GIF, WEBP)
- The tool writes the image to disk and returns it inline in the chat
@@ -4,6 +4,7 @@ import { RecallTool } from "../../tool/recall"
import { AgentManagerModelsTool } from "./agent-manager-models"
import { AgentManagerTool } from "./agent-manager"
import { BackgroundProcessTool } from "./background-process"
import { GenerateImageTool } from "./generate-image"
import { InteractiveTerminalTool } from "./interactive-terminal"
import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "./notebook-host"
import { MemoryRecallTool } from "./memory-recall"
@@ -60,14 +61,15 @@ export namespace KiloToolRegistry {
const save = yield* MemorySaveTool
const manager = yield* AgentManagerTool
const process = yield* BackgroundProcessTool
const image = yield* GenerateImageTool
const terminal = yield* InteractiveTerminalTool
if (!notebook) return { codebase, recall, managerModels, memory, save, manager, process, terminal }
if (!notebook) return { codebase, recall, managerModels, memory, save, manager, process, image, terminal }
const tools = yield* Effect.all({
notebookRead: NotebookReadTool,
notebookEdit: NotebookEditTool,
notebookExecute: NotebookExecuteTool,
}).pipe(Effect.provideService(Notebook.Service, notebook))
return { codebase, recall, managerModels, memory, save, manager, process, terminal, ...tools }
return { codebase, recall, managerModels, memory, save, manager, process, image, terminal, ...tools }
})
}
@@ -82,6 +84,7 @@ export namespace KiloToolRegistry {
save: Tool.Info
manager: Tool.Info
process: Tool.Info
image: Tool.Info
terminal?: Tool.Info
notebookRead?: Tool.Info
notebookEdit?: Tool.Info
@@ -99,6 +102,7 @@ export namespace KiloToolRegistry {
save: Tool.init(tools.save),
manager: Tool.init(tools.manager),
process: Tool.init(tools.process),
image: Tool.init(tools.image),
})
const terminal = tools.terminal ? yield* Tool.init(tools.terminal) : undefined
const notebooks =
@@ -168,15 +172,17 @@ export namespace KiloToolRegistry {
save: Tool.Def
manager: Tool.Def
process: Tool.Def
image: Tool.Def
terminal?: Tool.Def
notebookRead?: Tool.Def
notebookEdit?: Tool.Def
notebookExecute?: Tool.Def
},
cfg: { experimental?: { codebase_search?: boolean; native_notebook_tools?: boolean } },
cfg: { experimental?: { codebase_search?: boolean; image_generation?: boolean; native_notebook_tools?: boolean } },
): Tool.Def[] {
return [
...(cfg.experimental?.codebase_search === true ? [tools.codebase] : []),
...(cfg.experimental?.image_generation === true ? [tools.image] : []),
...(tools.semantic ? [tools.semantic] : []),
tools.memory,
tools.save,
+3
View File
@@ -31,6 +31,7 @@ import { Notebook } from "@/kilocode/notebook/service" // kilocode_change
import { RepoCloneTool } from "./repo_clone"
import { RepoOverviewTool } from "./repo_overview"
import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change
import { Auth } from "@/auth" // kilocode_change
import { RepositoryCache } from "@/reference/repository-cache"
import * as Log from "@opencode-ai/core/util/log"
import { LspTool } from "./lsp"
@@ -128,6 +129,7 @@ export const layer: Layer.Layer<
| Command.Service
// kilocode_change end
| RuntimeFlags.Service
| Auth.Service // kilocode_change - required by generate-image tool
> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -461,6 +463,7 @@ export const defaultLayer = Layer.suspend(
Layer.provide(Notebook.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),
Layer.provide(Auth.defaultLayer),
),
// kilocode_change end
)
@@ -368,6 +368,7 @@ export const kiloScenarios: Scenario[] = [
}))
.status(401),
http.protected.get("/kilo/notifications", "kilo.notifications").json(200, array),
http.protected.get("/kilo/models/images", "kilo.models.images").probe({ path: "/path" }).status(401),
http.protected
.post("/kilo/organization", "kilo.organization.set")
.at((ctx) => ({ path: "/kilo/organization", headers: ctx.headers(), body: { organizationId: null } }))
@@ -11,6 +11,7 @@ import { Agent as AgentSvc } from "../../src/agent/agent"
import { BackgroundJob } from "../../src/background/job"
import { Bus } from "../../src/bus"
import { Command } from "../../src/command"
import { Auth } from "../../src/auth" // kilocode_change
import { Config } from "../../src/config/config"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { EventV2Bridge } from "../../src/event-v2-bridge"
@@ -165,6 +166,7 @@ function makeHttp() {
Layer.provide(Format.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Command.defaultLayer),
Layer.provide(Auth.defaultLayer), // kilocode_change
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),
@@ -10,6 +10,7 @@ import { Agent as AgentSvc } from "../../src/agent/agent"
import { BackgroundJob } from "../../src/background/job"
import { Bus } from "../../src/bus"
import { Command } from "../../src/command"
import { Auth } from "../../src/auth" // kilocode_change
import { Config } from "../../src/config/config"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { EventV2Bridge } from "../../src/event-v2-bridge"
@@ -159,6 +160,7 @@ function makeHttp() {
Layer.provide(Git.defaultLayer),
Layer.provide(Reference.defaultLayer),
Layer.provide(Command.defaultLayer),
Layer.provide(Auth.defaultLayer), // kilocode_change
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),
@@ -10,6 +10,7 @@ import { Agent as AgentSvc } from "../../src/agent/agent"
import { BackgroundJob } from "../../src/background/job"
import { Bus } from "../../src/bus"
import { Command } from "../../src/command"
import { Auth } from "../../src/auth" // kilocode_change
import { Config } from "../../src/config/config"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { EventV2Bridge } from "../../src/event-v2-bridge"
@@ -155,6 +156,7 @@ function makeHttp() {
Layer.provide(Git.defaultLayer),
Layer.provide(Reference.defaultLayer),
Layer.provide(Command.defaultLayer),
Layer.provide(Auth.defaultLayer), // kilocode_change
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),
@@ -42,6 +42,7 @@ function infos() {
save: info("kilo_memory_save"),
manager: info("agent_manager"),
process: info("background_process"),
image: info("generate_image"),
notebookRead: info("notebook_read"),
notebookEdit: info("notebook_edit"),
notebookExecute: info("notebook_execute"),
@@ -337,6 +337,7 @@ describe("kilocode tool registry indexing", () => {
save: def("kilo_memory_save"),
manager: def("agent_manager"),
process: def("background_process"),
image: def("generate_image"),
terminal: def("interactive_terminal"),
notebookRead: def("notebook_read"),
notebookEdit: def("notebook_edit"),
@@ -364,6 +365,21 @@ describe("kilocode tool registry indexing", () => {
"interactive_terminal",
],
)
expect(
KiloToolRegistry.extra(tools, { experimental: { codebase_search: true, image_generation: true } }).map(
(tool) => tool.id,
),
).toEqual([
"codebase_search",
"generate_image",
"semantic_search",
"kilo_memory_recall",
"kilo_memory_save",
"recall",
"background_process",
"interactive_terminal",
])
process.env["KILO_CLIENT"] = "vscode"
expect(KiloToolRegistry.extra(tools, { experimental: { codebase_search: true } }).map((tool) => tool.id)).toEqual(
[
@@ -54,6 +54,7 @@ function infos() {
save: info("kilo_memory_save"),
manager: info("agent_manager"),
process: info("background_process"),
image: info("generate_image"),
notebookRead: info("notebook_read"),
notebookEdit: info("notebook_edit"),
notebookExecute: info("notebook_execute"),
@@ -0,0 +1,159 @@
// kilocode_change - new file
import { describe, expect, test } from "bun:test"
import {
parseImageResponse,
resolveProvider,
ensureExtension,
IMAGE_MODELS,
DEFAULT_MODEL,
} from "../../../src/kilocode/tool/generate-image"
describe("generate-image response parser", () => {
test("extracts PNG from data URL in choices[0].message.images[0]", () => {
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const body = JSON.stringify({
choices: [
{
message: {
images: [{ image_url: { url: `data:image/png;base64,${base64}` } }],
},
},
],
})
const result = parseImageResponse(body)
expect(result).not.toBeNull()
expect(result!.format).toBe("png")
expect(result!.base64).toBe(base64)
})
test("extracts JPEG format", () => {
const base64 = "/9j/4AAQSkZJRgABAQAAAQABAAD"
const body = JSON.stringify({
choices: [{ message: { images: [{ image_url: { url: `data:image/jpeg;base64,${base64}` } }] } }],
})
const result = parseImageResponse(body)
expect(result!.format).toBe("jpeg")
expect(result!.base64).toBe(base64)
})
test("returns null when choices array is empty", () => {
expect(parseImageResponse(JSON.stringify({ choices: [] }))).toBeNull()
})
test("returns null when images array is missing", () => {
const body = JSON.stringify({ choices: [{ message: {} }] })
expect(parseImageResponse(body)).toBeNull()
})
test("returns null on malformed JSON", () => {
expect(parseImageResponse("not json")).toBeNull()
})
test("returns null when data URL prefix is invalid", () => {
const body = JSON.stringify({
choices: [{ message: { images: [{ image_url: { url: "https://example.com/image.png" } }] } }],
})
expect(parseImageResponse(body)).toBeNull()
})
})
describe("generate-image provider resolver", () => {
test("uses Kilo cloud when Kilo auth is present", () => {
const result = resolveProvider({ type: "oauth", access: "kilo-token", accountId: "org-123" }, undefined)
expect(result).not.toBeNull()
expect(result!.token).toBe("kilo-token")
expect(result!.organizationId).toBe("org-123")
expect(result!.provider).toBe("kilo")
expect(result!.url).toContain("openrouter")
})
test("uses Kilo cloud with API key auth", () => {
const result = resolveProvider({ type: "api", key: "kilo-api-key" }, undefined)
expect(result!.token).toBe("kilo-api-key")
expect(result!.provider).toBe("kilo")
})
test("falls back to OpenRouter with BYO key when no Kilo auth", () => {
const result = resolveProvider(undefined, "or-key-123")
expect(result!.provider).toBe("openrouter")
expect(result!.token).toBe("or-key-123")
expect(result!.url).toContain("openrouter.ai")
})
test("returns null when no auth source is available", () => {
expect(resolveProvider(undefined, undefined)).toBeNull()
})
test("prefers Kilo auth over OpenRouter key", () => {
const result = resolveProvider({ type: "oauth", access: "kilo-token" }, "or-key")
expect(result!.provider).toBe("kilo")
expect(result!.token).toBe("kilo-token")
})
})
describe("generate-image response parser MIME normalization", () => {
test("normalizes jpg data URL to jpeg format", () => {
const base64 = "/9j/4AAQSkZJRgABAQAAAQABAAD"
const body = JSON.stringify({
choices: [{ message: { images: [{ image_url: { url: `data:image/jpg;base64,${base64}` } }] } }],
})
const result = parseImageResponse(body)
expect(result!.format).toBe("jpeg")
expect(result!.base64).toBe(base64)
})
})
describe("generate-image path extension", () => {
test("appends .png when no extension", () => {
expect(ensureExtension("output/logo", "png")).toBe("output/logo.png")
})
test("appends .jpg for jpeg format", () => {
expect(ensureExtension("output/photo", "jpeg")).toBe("output/photo.jpg")
})
test("keeps existing .png extension when format is png", () => {
expect(ensureExtension("output/logo.png", "png")).toBe("output/logo.png")
})
test("keeps existing .jpg extension when format is jpeg", () => {
expect(ensureExtension("output/photo.jpg", "jpeg")).toBe("output/photo.jpg")
})
test("keeps existing .jpeg extension when format is jpeg", () => {
expect(ensureExtension("output/photo.jpeg", "jpeg")).toBe("output/photo.jpeg")
})
test("replaces mismatched image extension when format differs", () => {
expect(ensureExtension("output/photo.jpg", "png")).toBe("output/photo.png")
expect(ensureExtension("output/photo.jpeg", "png")).toBe("output/photo.png")
expect(ensureExtension("output/logo.png", "jpeg")).toBe("output/logo.jpg")
})
test("keeps uppercase .PNG extension when format is png", () => {
expect(ensureExtension("output/logo.PNG", "png")).toBe("output/logo.PNG")
})
test("appends when path has a dot that is not an image extension", () => {
expect(ensureExtension("assets/logo.final", "png")).toBe("assets/logo.final.png")
})
})
describe("generate-image model catalog", () => {
test("has a non-empty model list", () => {
expect(IMAGE_MODELS.length).toBeGreaterThan(0)
})
test("includes the default model", () => {
expect(IMAGE_MODELS.some((m) => m.value === DEFAULT_MODEL)).toBe(true)
})
test("every model has value and label", () => {
for (const m of IMAGE_MODELS) {
expect(typeof m.value).toBe("string")
expect(m.value.length).toBeGreaterThan(0)
expect(typeof m.label).toBe("string")
expect(m.label.length).toBeGreaterThan(0)
}
})
})
@@ -13,6 +13,7 @@ import { Agent as AgentSvc } from "../../src/agent/agent"
import { BackgroundJob } from "@/background/job"
import { Bus } from "../../src/bus"
import { Command } from "../../src/command"
import { Auth } from "../../src/auth" // kilocode_change
import { Config } from "@/config/config"
import { LSP } from "@/lsp/lsp"
import { MCP } from "../../src/mcp"
@@ -226,6 +227,7 @@ function makePrompt(input?: { processor?: "blocking" }) {
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
Layer.provide(Auth.defaultLayer), // kilocode_change
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),
@@ -34,6 +34,7 @@ import { BackgroundJob } from "@/background/job"
import { Git } from "../../src/git"
import { Bus } from "../../src/bus"
import { Command } from "../../src/command"
import { Auth } from "../../src/auth" // kilocode_change
import { Config } from "@/config/config"
import { LSP } from "@/lsp/lsp"
import { MCP } from "../../src/mcp"
@@ -147,6 +148,7 @@ function makeHttp() {
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
Layer.provide(Auth.defaultLayer), // kilocode_change
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),
@@ -35,6 +35,7 @@ import { ToolJsonSchema } from "@/tool/json-schema"
import { MessageID, SessionID } from "@/session/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Command } from "@/command" // kilocode_change
import { Auth } from "@/auth" // kilocode_change
import * as SandboxNetwork from "@/kilocode/sandbox/network" // kilocode_change
import { run as runSandbox, type Profile } from "@kilocode/sandbox" // kilocode_change
import { MemoryService } from "@kilocode/kilo-memory/effect/service" // kilocode_change
@@ -76,6 +77,7 @@ const registryLayer = (opts: RegistryLayerOptions = {}) =>
.pipe(
Layer.provide(RuntimeFlags.layer(opts.flags ?? {})),
Layer.provide(Command.defaultLayer), // kilocode_change
Layer.provide(Auth.defaultLayer), // kilocode_change
Layer.provide(MemoryService.layer), // kilocode_change
)
+39
View File
@@ -190,6 +190,8 @@ import type {
KiloEditResponses,
KiloFimErrors,
KiloFimResponses,
KiloModelsImagesErrors,
KiloModelsImagesResponses,
KiloModesErrors,
KiloModesResponses,
KiloNotificationsErrors,
@@ -6780,6 +6782,38 @@ export class Audio extends HeyApiClient {
}
}
export class Models extends HeyApiClient {
/**
* Image generation models
*
* List image-capable models from the Kilo Gateway OpenRouter passthrough
*/
public images<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<KiloModelsImagesResponses, KiloModelsImagesErrors, ThrowOnError>({
url: "/kilo/models/images",
...options,
...params,
})
}
}
export class Organization extends HeyApiClient {
/**
* Update Kilo Gateway organization
@@ -7238,6 +7272,11 @@ export class Kilo extends HeyApiClient {
return (this._audio ??= new Audio({ client: this.client }))
}
private _models?: Models
get models(): Models {
return (this._models ??= new Models({ client: this.client }))
}
private _organization?: Organization
get organization(): Organization {
return (this._organization ??= new Organization({ client: this.client }))
+38
View File
@@ -1685,6 +1685,8 @@ export type Config = {
disable_paste_summary?: boolean
batch_tool?: boolean
codebase_search?: boolean
image_generation?: boolean
image_generation_model?: string
agent_requirements?: boolean
native_notebook_tools?: boolean
speech_to_text_model?: string
@@ -11076,6 +11078,42 @@ export type KiloAudioTranscriptionsResponses = {
export type KiloAudioTranscriptionsResponse = KiloAudioTranscriptionsResponses[keyof KiloAudioTranscriptionsResponses]
export type KiloModelsImagesData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/kilo/models/images"
}
export type KiloModelsImagesErrors = {
/**
* BadRequest | InvalidRequestError
*/
400: EffectHttpApiErrorBadRequest | InvalidRequestError
/**
* Unauthorized
*/
401: EffectHttpApiErrorUnauthorized
}
export type KiloModelsImagesError = KiloModelsImagesErrors[keyof KiloModelsImagesErrors]
export type KiloModelsImagesResponses = {
/**
* Image-capable model list
*/
200: Array<{
id: string
name: string
description?: string
}>
}
export type KiloModelsImagesResponse = KiloModelsImagesResponses[keyof KiloModelsImagesResponses]
export type KiloNotificationsData = {
body?: never
path?: never
+94
View File
@@ -14321,6 +14321,94 @@
]
}
},
"/kilo/models/images": {
"get": {
"tags": ["kilo"],
"operationId": "kilo.models.images",
"parameters": [
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"200": {
"description": "Image-capable model list",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": ["id", "name"],
"additionalProperties": false
},
"description": "Image-capable model list"
}
}
}
},
"400": {
"description": "BadRequest | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
},
{
"$ref": "#/components/schemas/InvalidRequestError"
}
]
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/effect_HttpApiError_Unauthorized"
}
}
}
}
},
"description": "List image-capable models from the Kilo Gateway OpenRouter passthrough",
"summary": "Image generation models",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilo.models.images({\n ...\n})"
}
]
}
},
"/kilo/notifications": {
"get": {
"tags": ["kilo"],
@@ -25151,6 +25239,12 @@
"codebase_search": {
"type": "boolean"
},
"image_generation": {
"type": "boolean"
},
"image_generation_model": {
"type": "string"
},
"agent_requirements": {
"type": "boolean"
},
+2
View File
@@ -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": "حدد إجابة واحدة",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -185,7 +185,9 @@ export const dict: Record<string, string> = {
"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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -187,7 +187,9 @@ export const dict: Record<string, string> = {
"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",
+2
View File
@@ -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 つ選択",
+2
View File
@@ -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": "하나의 답변을 선택",
+2
View File
@@ -190,7 +190,9 @@ export const dict: Record<string, string> = {
"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",
+2
View File
@@ -175,7 +175,9 @@ export const dict: Record<Keys, string> = {
"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",
+2
View File
@@ -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ź",
+2
View File
@@ -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": "Выберите один ответ",
+2
View File
@@ -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": "เลือกหนึ่งคำตอบ",
+2
View File
@@ -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",
+2
View File
@@ -198,7 +198,9 @@ export const dict: Record<string, string> = {
"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": "Виберіть одну відповідь",

Some files were not shown because too many files have changed in this diff Show More