mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge remote-tracking branch 'origin/dark-shield' into dark-shield
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Support marking custom provider models as image-capable in VS Code settings.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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/?$',
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cba6f84ec6138fc59be088cdf84d2ebe57a55f8514df58eed693a84969ff7a90
|
||||
size 4794
|
||||
oid sha256:eecccfc0bbd53bd52261ff8cfc02c444c6d97f6438e6693d8cd998c36909708a
|
||||
size 5178
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c94c54757e88700b5c48a10a60d8626d8d5ed76a29879f06059e5f6457382a5f
|
||||
size 614004
|
||||
oid sha256:8bbe2c598818c82fa3d0911060bc98a88d11c71d943de2a6a4f0b236cfb7bd40
|
||||
size 630314
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
+25
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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" : ""}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+2
@@ -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": "حدد إجابة واحدة",
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
Generated
+2
@@ -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 つ選択",
|
||||
|
||||
Generated
+2
@@ -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": "하나의 답변을 선택",
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
Generated
+2
@@ -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ź",
|
||||
|
||||
Generated
+2
@@ -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": "Выберите один ответ",
|
||||
|
||||
Generated
+2
@@ -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": "เลือกหนึ่งคำตอบ",
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
Generated
+2
@@ -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
Reference in New Issue
Block a user