diff --git a/.changeset/custom-provider-edit-screen-improvements.md b/.changeset/custom-provider-edit-screen-improvements.md new file mode 100644 index 00000000000..b4b383ff25f --- /dev/null +++ b/.changeset/custom-provider-edit-screen-improvements.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/kilo-ui": patch +--- + +Improve custom provider edit dialog layout, make advanced configuration action prominent, and add bulk toggle buttons for reasoning and image modalities across all models. diff --git a/.changeset/exclude-chatgpt-from-prompt-cache-breakpoint.md b/.changeset/exclude-chatgpt-from-prompt-cache-breakpoint.md new file mode 100644 index 00000000000..6362bdd7ff1 --- /dev/null +++ b/.changeset/exclude-chatgpt-from-prompt-cache-breakpoint.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Exclude ChatGPT subscriptions from explicit prompt cache breakpoints. diff --git a/.changeset/fix-mcp-env-header-expansion.md b/.changeset/fix-mcp-env-header-expansion.md new file mode 100644 index 00000000000..051924f1dfb --- /dev/null +++ b/.changeset/fix-mcp-env-header-expansion.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent project MCP configs from resolving variable-backed headers or inheriting trusted headers when changing endpoints, while preserving unaffected servers. diff --git a/.changeset/fix-memory-model-timeout-warnings.md b/.changeset/fix-memory-model-timeout-warnings.md new file mode 100644 index 00000000000..5a0c8700162 --- /dev/null +++ b/.changeset/fix-memory-model-timeout-warnings.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-memory": patch +"kilo-code": patch +--- + +Reduce noisy memory timeout warnings and retry transient background consolidation failures once. diff --git a/.changeset/ignore-negative-model-prices.md b/.changeset/ignore-negative-model-prices.md new file mode 100644 index 00000000000..66bcf01a6dd --- /dev/null +++ b/.changeset/ignore-negative-model-prices.md @@ -0,0 +1,8 @@ +--- +"@kilocode/kilo-gateway": patch +"@kilocode/cli": patch +"kilo-code": patch +"@kilocode/kilo-jetbrains": patch +--- + +Ignore negative pricing entries from model catalogs and handle unpriced models gracefully in UI price formatting. diff --git a/.changeset/mermaid-copy-clipboard-images.md b/.changeset/mermaid-copy-clipboard-images.md new file mode 100644 index 00000000000..c9c0a405f9c --- /dev/null +++ b/.changeset/mermaid-copy-clipboard-images.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix Mermaid Copy PNG/SVG in VS Code webviews so they put images on the clipboard instead of failing or copying SVG markup. diff --git a/.changeset/show-vscode-session-errors.md b/.changeset/show-vscode-session-errors.md new file mode 100644 index 00000000000..5302a13a707 --- /dev/null +++ b/.changeset/show-vscode-session-errors.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep model and provider errors visible in VS Code when chat history refreshes. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png index 4720473ebb2..25035a43351 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f2349f8c0d7049811702e5b8cbeb5fc2f402bdda8630d1a5210a970a13284197 -size 3694 +oid sha256:33ee6eb7070237487d1931f32ac77a2549e18e664b993895e9a9d6619527d0aa +size 4063 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png index 4720473ebb2..25035a43351 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f2349f8c0d7049811702e5b8cbeb5fc2f402bdda8630d1a5210a970a13284197 -size 3694 +oid sha256:33ee6eb7070237487d1931f32ac77a2549e18e664b993895e9a9d6619527d0aa +size 4063 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/custom-provider-create-dialog-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/custom-provider-create-dialog-chromium-linux.png new file mode 100644 index 00000000000..038643cd74a --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/custom-provider-create-dialog-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72a0a206e978250d41b42f3c0585691a78cbe2635c12d320bcefe61cd938ced5 +size 280 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/custom-provider-edit-dialog-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/custom-provider-edit-dialog-chromium-linux.png new file mode 100644 index 00000000000..038643cd74a --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/custom-provider-edit-dialog-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72a0a206e978250d41b42f3c0585691a78cbe2635c12d320bcefe61cd938ced5 +size 280 diff --git a/packages/kilo-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index 8188c1f825b..3ccb4ff2534 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -76,7 +76,7 @@ type OpenRouterModel = z.infer function parseApiPrice(price: string | null | undefined): number | undefined { if (!price) return undefined const parsed = parseFloat(price) - if (isNaN(parsed)) return undefined + if (isNaN(parsed) || parsed < 0) return undefined return parsed * 1_000_000 // Convert $/token → $/M tokens } diff --git a/packages/kilo-gateway/test/api/models.test.ts b/packages/kilo-gateway/test/api/models.test.ts index 0833ec11f30..7d73b3d1b5e 100644 --- a/packages/kilo-gateway/test/api/models.test.ts +++ b/packages/kilo-gateway/test/api/models.test.ts @@ -383,3 +383,66 @@ test("keeps organization catalog errors from silently falling back to personal m expect(calls[0]).toContain("/api/gateway/transcription-models") expect(headers[0]?.get("X-KILOCODE-ORGANIZATIONID")).toBe("org-1") }) + +test("omits cost when pricing contains negative values (dynamic/auto-routed pricing)", async () => { + const orig = globalThis.fetch + stubFetch( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: "openrouter/auto", + name: "Auto Router", + context_length: 128000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text"], + output_modalities: ["text"], + }, + supported_parameters: ["tools"], + pricing: { + prompt: "-1", + completion: "-1", + }, + }, + { + id: "test/fixed-price", + name: "Fixed Price Model", + context_length: 128000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text"], + output_modalities: ["text"], + }, + supported_parameters: ["tools"], + pricing: { + prompt: "0.000003", + completion: "0.000015", + input_cache_read: "0.0000003", + input_cache_write: "-1", + }, + }, + ], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.error).toBeUndefined() + expect(result.models["openrouter/auto"]).toBeDefined() + expect(result.models["openrouter/auto"].cost).toBeUndefined() + expect(result.models["test/fixed-price"].cost).toEqual({ + input: 3, + output: 15, + cache_read: 0.3, + }) +}) + diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt index eb83cf748e6..b6541f267a6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt @@ -157,14 +157,14 @@ internal class ModelDetailsPanel( return buildList { item.releaseDate?.let { add(KiloBundle.message("model.picker.details.released") to date(it)) } if (!item.free) { - item.cost?.let { cost -> + item.cost?.takeIf { it.input > 0.0 || it.output > 0.0 }?.let { cost -> add(KiloBundle.message("model.picker.details.input") to price(cost.input)) add(KiloBundle.message("model.picker.details.output") to price(cost.output)) add(KiloBundle.message("model.picker.details.cached") to cached(cost.input, cost.cache?.read)) add(KiloBundle.message("model.picker.details.average") to price(average(cost.input, cost.output, cost.cache?.read))) } ?: run { - item.inputPrice?.let { add(KiloBundle.message("model.picker.details.input") to price(it)) } - item.outputPrice?.let { add(KiloBundle.message("model.picker.details.output") to price(it)) } + item.inputPrice?.takeIf { it > 0.0 }?.let { add(KiloBundle.message("model.picker.details.input") to price(it)) } + item.outputPrice?.takeIf { it > 0.0 }?.let { add(KiloBundle.message("model.picker.details.output") to price(it)) } } } ctx?.let { add(KiloBundle.message("model.picker.details.context") to context(it)) } diff --git a/packages/kilo-memory/src/capture/operations.ts b/packages/kilo-memory/src/capture/operations.ts index f4703fdd160..53a1dcf345f 100644 --- a/packages/kilo-memory/src/capture/operations.ts +++ b/packages/kilo-memory/src/capture/operations.ts @@ -126,7 +126,7 @@ export namespace MemoryOperations { const adds = input.ops .filter((item): item is Add => item.action === "add") // Redact rejected text too: this filter runs before the secret one, so a rejected op never - // reaches it, and skips flow into the persistent decisions audit (/memory/show, TUI). + // reaches it, and skips remain visible in the operation result. .filter((op) => { const item = reject(op) if (!item) return true @@ -314,7 +314,7 @@ export namespace MemoryOperations { } } - async function persist(input: { root: string; state: MemorySchema.State; count: number; removed: number }) { + async function persist(input: { root: string; state: MemorySchema.State; count: number }) { const index = await MemoryIndexer.rebuild({ root: input.root, state: input.state }) await MemoryFiles.writeState(input.root, { ...input.state, @@ -323,7 +323,6 @@ export namespace MemoryOperations { lastOperationCount: input.count, }, }) - await MemoryFiles.append(input.root, `apply ops=${input.count} removed=${input.removed}`) return index } @@ -365,9 +364,9 @@ export namespace MemoryOperations { const prepared = prepare({ state, ops: input.ops, max: state.limits.maxLineChars }) const removes = input.ops.filter((item): item is Remove => item.action === "remove") const plan = planOps({ docs, inventory, removes, adds: prepared.adds, now: Date.now() }) - // Commit (IO): write changed documents, then rebuild the index, persist state, and audit. + // Commit (IO): write changed documents, then rebuild the index and persist state. await writeDocs({ root: input.root, plan }) - const index = await persist({ root: input.root, state, count: plan.count, removed: plan.removed }) + const index = await persist({ root: input.root, state, count: plan.count }) return { operationCount: plan.count, added: plan.added, diff --git a/packages/kilo-memory/src/capture/outcome.ts b/packages/kilo-memory/src/capture/outcome.ts index 9ee4be803d7..ce65e172862 100644 --- a/packages/kilo-memory/src/capture/outcome.ts +++ b/packages/kilo-memory/src/capture/outcome.ts @@ -1,9 +1,12 @@ import { MemoryOperations } from "./operations" import { MemoryRedact } from "./redact" import { MemoryShared } from "../recall/shared" +import { TRANSIENT } from "../schema" import type { MemoryFiles } from "../storage/store" import type { CaptureSkip } from "./parse" +export { TRANSIENT } + export type CaptureSourceItem = { id: string text: string @@ -69,14 +72,75 @@ export function errorReason(err: unknown) { return MemoryShared.brief(MemoryRedact.text(parts.join(" ")), 500) } -export function guardReason(input: string) { - const value = input.toLowerCase() - if (/\b(429|rate[_ -]?limit|too many requests)\b/.test(value)) return "rate_limit_guard" - if (/\b(insufficient[_ -]?quota|quota exceeded|exceeded your quota|billing|credits?|credit balance)\b/.test(value)) +const RATE_TEXT = /\b(429|rate[_ -]?limit|too many requests)\b/ +const QUOTA_TEXT = /\b(insufficient[_ -]?quota|quota exceeded|exceeded your quota|billing|credits?|credit balance)\b/ +const TIMEOUT_TEXT = + /\b(timeouterror|etimedout|deadline[ _-]?exceeded|timed out|(connect|headers|body|gateway)[ _-]?time[ -]?out)\b|^timeout$/ +const TIMEOUT_CODES = new Set(["ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]) + +/** Structural timeout detection over an error's name/status/code, its message text, and its nested + * cause/errors chains (cycle-safe). Message matching is limited to `message` — never the serialized + * body/data blobs — so response payloads that merely mention "timeout" cannot classify as transient. */ +function timedOut(item: unknown, seen = new WeakSet()): boolean { + if (!item || typeof item !== "object" || seen.has(item)) return false + seen.add(item) + const err = item as { + name?: unknown + code?: unknown + status?: unknown + statusCode?: unknown + message?: unknown + cause?: unknown + errors?: unknown + } + if (err.name === "TimeoutError") return true + if (err.status === 504 || err.statusCode === 504) return true + if (typeof err.code === "string" && TIMEOUT_CODES.has(err.code)) return true + if (typeof err.message === "string" && TIMEOUT_TEXT.test(err.message.toLowerCase())) return true + if (timedOut(err.cause, seen)) return true + return Array.isArray(err.errors) && err.errors.some((entry) => timedOut(entry, seen)) +} + +function rateGuarded(item: unknown, seen = new WeakSet()): boolean { + if (!item || typeof item !== "object" || seen.has(item)) return false + seen.add(item) + const err = item as { + status?: unknown + statusCode?: unknown + message?: unknown + cause?: unknown + errors?: unknown + } + if (err.status === 429 || err.statusCode === 429) return true + if (typeof err.message === "string" && RATE_TEXT.test(err.message.toLowerCase())) return true + if (rateGuarded(err.cause, seen)) return true + return Array.isArray(err.errors) && err.errors.some((entry) => rateGuarded(entry, seen)) +} + +function quotaGuarded(item: unknown, seen = new WeakSet()): boolean { + if (!item || typeof item !== "object" || seen.has(item)) return false + seen.add(item) + const err = item as { + message?: unknown + cause?: unknown + errors?: unknown + } + if (typeof err.message === "string" && QUOTA_TEXT.test(err.message.toLowerCase())) return true + if (quotaGuarded(err.cause, seen)) return true + return Array.isArray(err.errors) && err.errors.some((entry) => quotaGuarded(entry, seen)) +} + +export function guardReason(input: unknown) { + const value = (typeof input === "string" ? input : errorReason(input)).toLowerCase() + if (typeof input === "string" ? RATE_TEXT.test(value) : rateGuarded(input) || RATE_TEXT.test(value)) + return "rate_limit_guard" + if (typeof input === "string" ? QUOTA_TEXT.test(value) : quotaGuarded(input) || QUOTA_TEXT.test(value)) return "quota_guard" + if (typeof input === "string" ? TIMEOUT_TEXT.test(value) : timedOut(input)) return TRANSIENT return undefined } +/** @deprecated Memory audit persistence was removed. */ export function skipped(input: { sessionID: string; reason: string }): MemoryFiles.Decision { return { kind: "typed", @@ -94,6 +158,7 @@ export function skipped(input: { sessionID: string; reason: string }): MemoryFil } } +/** @deprecated Memory audit persistence was removed. */ export function auditOps(ops: MemoryOperations.Op[]) { return MemoryShared.audit(ops) } diff --git a/packages/kilo-memory/src/effect/capture.ts b/packages/kilo-memory/src/effect/capture.ts index fda14a1a19a..cd0e1308d20 100644 --- a/packages/kilo-memory/src/effect/capture.ts +++ b/packages/kilo-memory/src/effect/capture.ts @@ -1,6 +1,5 @@ import { Cause, Effect } from "effect" import { - auditOps, cap, capturePlan, digestPrompt, @@ -18,9 +17,9 @@ import { parseJson, parseOps, salvageTyped, - skipped, summarize, summarizeDiffs, + TRANSIENT, typedPrompt, usage, verifySkips, @@ -105,11 +104,16 @@ export namespace MemoryCapture { yield* memory.prepare({ root }) const state = yield* memory.state({ root }) const reported = new Set() - const fail = (reason: string) => + const fail = (reason: string, detail?: string) => Effect.promise(async () => { const safe = MemoryRedact.text(reason) if (reported.has(safe)) return reported.add(safe) + if (reason === TRANSIENT) + MemoryLog.warn("memory capture transient failure", { + reason: safe, + detail: MemoryShared.brief(MemoryRedact.text(detail ?? safe), 160), + }) await MemoryEvents.publish({ event: "error", payload: MemoryEvents.status({ @@ -123,7 +127,6 @@ export namespace MemoryCapture { }) const skip = (reason: string, opts?: { idleFlush?: boolean }) => Effect.gen(function* () { - if (state.enabled) yield* memory.decide({ root, decision: skipped({ sessionID: input.sessionID, reason }) }) yield* Effect.promise(() => MemoryEvents.publish({ event: "status", @@ -203,23 +206,6 @@ export namespace MemoryCapture { tokens: 0, fallback: true, }) - yield* memory.decide({ - root, - decision: { - kind: "digest", - trigger: "turn-close", - sessionID: input.sessionID, - result: "fallback", - llm: false, - parsed: false, - fallback: true, - reason: input.reason, - tokens: 0, - operationCount: 1, - skippedCount: 0, - summary: `session digest fallback on ${input.reason ?? "close"}`, - }, - }) } return yield* skip(plan.skipReason, plan.idleFlush ? { idleFlush: true } : undefined) } @@ -232,22 +218,12 @@ export namespace MemoryCapture { const model = digestDue || typedCall - ? yield* Effect.gen(function* () { - const resolution = yield* input.model.resolve({ + ? ( + yield* input.model.resolve({ configured: input.memoryModel, session: view.sessionModel, }) - if (resolution.fallback) { - yield* memory.append({ - root, - text: `memory_model_config reason=${MemoryShared.brief( - MemoryRedact.text(resolution.fallback.reason), - 160, - )} fallback=1`, - }) - } - return resolution.handle - }) + ).handle : undefined const digestEffect = digestDue ? Effect.gen(function* () { @@ -277,9 +253,8 @@ export namespace MemoryCapture { Effect.gen(function* () { if (signal.aborted) return { ok: false as const, reason: "cancelled" } const raw = errorReason(err) - const reason = MemoryRedact.text(guardReason(raw) ?? raw) - yield* fail(reason) - yield* memory.append({ root, text: `digest error=${MemoryShared.brief(reason, 160)} fallback=1` }) + const reason = MemoryRedact.text(guardReason(err) ?? raw) + yield* fail(reason, raw) return { ok: false as const, reason } }), ), @@ -296,14 +271,9 @@ export namespace MemoryCapture { try: () => parseJson(digestSchema, result.result.text), catch: (error) => error, }).pipe( - Effect.catch((err: unknown) => + Effect.catch(() => Effect.gen(function* () { - const reason = MemoryRedact.text(errorReason(err)) yield* fail("digest parse_error") - yield* memory.append({ - root, - text: `digest parse_error=${MemoryShared.brief(reason, 160)} fallback=1`, - }) return undefined }), ), @@ -402,9 +372,8 @@ export namespace MemoryCapture { Effect.gen(function* () { if (signal.aborted) return { ok: false as const, reason: "cancelled" } const raw = errorReason(err) - const reason = MemoryRedact.text(guardReason(raw) ?? raw) - yield* fail(reason) - yield* memory.append({ root, text: `consolidate error=${MemoryShared.brief(reason, 160)}` }) + const reason = MemoryRedact.text(guardReason(err) ?? raw) + yield* fail(reason, raw) return { ok: false as const, reason } }), ), @@ -423,11 +392,9 @@ export namespace MemoryCapture { try: () => salvageTyped(result.result.text), catch: (error) => error, }).pipe( - Effect.catch((err: unknown) => + Effect.catch(() => Effect.gen(function* () { - const reason = MemoryRedact.text(errorReason(err)) yield* fail("consolidate parse_error") - yield* memory.append({ root, text: `consolidate parse_error=${MemoryShared.brief(reason, 160)}` }) return undefined }), ), @@ -475,30 +442,6 @@ export namespace MemoryCapture { fallback: Boolean(digest.reason), }) } - if (digestDue) { - yield* memory.decide({ - root, - decision: { - kind: "digest", - trigger: "turn-close", - sessionID: input.sessionID, - result: digest.reason ? "fallback" : digest.summary ? "saved" : "skipped", - llm: true, - parsed: Boolean(digest.summary && !digest.reason), - fallback: Boolean(digest.reason), - reason: digest.reason, - tokens: digest.tokens, - operationCount: digest.summary ? 1 : 0, - skippedCount: digest.summary ? 0 : 1, - summary: digest.reason - ? `session digest used fallback after ${digest.reason}` - : digest.summary - ? "session digest saved" - : "session digest skipped", - }, - }) - } - // Apply adds only: a same-key add supersedes/updates an existing fact in place. reconcile also // surfaces exact-key auto-removes, but V0 keeps hard removes explicit-only — auto-capture never // deletes memory it merely paraphrased (or wrongly flags), so reconciled.removes is not applied. @@ -506,39 +449,11 @@ export namespace MemoryCapture { const ops = reconciled.ops.slice(0, state.capture.maxOpsPerRun) const project = ops.length > 0 ? yield* memory.apply({ root, ops, trigger: "turn-close", tokens: generated.tokens }) : undefined - // Apply-time skips (content gate + secret, both redacted at creation) surface in the typed audit - // record alongside the model's own declared skips. const applied: CaptureSkip[] = [...generated.skipped, ...(project?.skipped ?? [])] const count = project?.operationCount ?? 0 - if (typedCall) { - yield* memory.decide({ - root, - decision: { - kind: "typed", - trigger: "turn-close", - sessionID: input.sessionID, - result: generated.fallback ? "fallback" : count > 0 ? "saved" : "skipped", - llm: true, - parsed: !generated.fallback, - fallback: generated.fallback, - reason: generated.reason, - tokens: generated.tokens, - operationCount: count, - skippedCount: applied.length, - skipped: applied, - operations: auditOps(ops), - files: [...new Set(ops.flatMap((item) => (item.action === "add" && item.file ? [item.file] : [])))], - summary: generated.fallback - ? `typed consolidation skipped after ${generated.reason ?? "model failure"}` - : count > 0 - ? `typed consolidation saved ${count} ops` - : `typed consolidation skipped ${applied.length} candidates`, - }, - }) - } const tokens = digest.tokens + generated.tokens if (!digest.summary && !typedCall && count === 0) return yield* skip("no_ops") - if ((digestDue || typedCall || count > 0) && (!typedCall || !generated.fallback)) { + if (digestDue || typedCall || count > 0) { yield* memory.commit({ root, now, @@ -546,8 +461,7 @@ export namespace MemoryCapture { tokens, count, digest: Boolean(digest.summary), - typed: typedCall, - skipped: applied, + typed: typedCall && !generated.fallback, }) } const updated = yield* memory.state({ root }) diff --git a/packages/kilo-memory/src/effect/service.ts b/packages/kilo-memory/src/effect/service.ts index 9f705499360..026553a61a3 100644 --- a/packages/kilo-memory/src/effect/service.ts +++ b/packages/kilo-memory/src/effect/service.ts @@ -1,5 +1,4 @@ import { Context, Effect, Layer, Semaphore } from "effect" -import { skipLine, type CaptureSkip } from "../capture/capture" import type { Memory } from "../memory" import type { MemoryOperations } from "../capture/operations" import { MemoryRecall } from "../recall/recall" @@ -104,7 +103,6 @@ type CommitInput = RootInput & { // shared typed-interval clock (lastTypedConsolidationAt); a digest-only commit must leave it untouched so a // digest in one session cannot throttle another session's typed capture. typed: boolean - skipped: CaptureSkip[] cost?: number } @@ -156,10 +154,12 @@ export namespace MemoryService { readonly recent: ( input: RecentInput, ) => Effect.Effect>, Failure> + /** @deprecated Memory audit persistence was removed. */ readonly append: (input: AppendInput) => Effect.Effect readonly index: (input: RootInput) => Effect.Effect readonly commit: (input: CommitInput) => Effect.Effect readonly recordRecall: (input: RecordRecallInput) => Effect.Effect + /** @deprecated Memory audit persistence was removed. */ readonly decide: (input: DecideInput) => Effect.Effect readonly readSource: (input: ReadSourceInput) => Effect.Effect readonly turnLock: (sessionID: SessionID) => Semaphore.Semaphore @@ -200,7 +200,7 @@ export namespace MemoryService { return Object.fromEntries(entries) as Sources }), recent: (input) => bridge(() => MemoryFiles.recentSessions(input.root, input.limit, input.max)), - append: (input) => bridge(() => MemoryFiles.append(input.root, input.text)), + append: () => Effect.void, index: (input) => bridge(async () => { const text = await MemoryFiles.readIndex(input.root) @@ -219,20 +219,11 @@ export namespace MemoryService { lastSessionSavedAt: input.digest ? input.now : state.stats.lastSessionSavedAt, lastConsolidatedMessageID: input.messageID, lastConsolidationCost: input.cost ?? state.stats.lastConsolidationCost, - lastConsolidationTokens: input.tokens, - lastOperationCount: input.count, + lastConsolidationTokens: + input.typed || input.digest ? input.tokens : state.stats.lastConsolidationTokens, + lastOperationCount: input.typed ? input.count : state.stats.lastOperationCount, }, }) - const skip = skipLine(input.skipped) - await MemoryFiles.append( - input.root, - [ - `consolidate trigger=turn-close digest=${input.digest ? 1 : 0} ops=${input.count} tokens=${input.tokens}`, - skip, - ] - .filter(Boolean) - .join(" "), - ) }), ), recordRecall: (input) => @@ -266,7 +257,7 @@ export namespace MemoryService { }), }) }), - decide: (input) => bridge(() => MemoryFiles.decide(input.root, input.decision)), + decide: () => Effect.void, readSource: (input) => bridge(() => MemoryFiles.readSource(input.root, input.file)), // Ref-counted so every acquirer — in-flight or queued behind `withPermits` — shares one // semaphore. Each call must be balanced by exactly one `dropLock`. diff --git a/packages/kilo-memory/src/memory-notice.ts b/packages/kilo-memory/src/memory-notice.ts index f96bca8eec7..983b4613879 100644 --- a/packages/kilo-memory/src/memory-notice.ts +++ b/packages/kilo-memory/src/memory-notice.ts @@ -1,23 +1,12 @@ import { MemoryShared } from "./recall/shared" import type { MemoryOperations } from "./capture/operations" -import { MemoryRedact } from "./capture/redact" -/** Human-facing messages and audit views describing an explicit apply result. */ +/** Human-facing messages describing an explicit apply result. */ export namespace MemoryNotice { export function saved(input: { added: number; removed: number }) { return input.removed > 0 || input.added > 0 } - export function summary(input: { added: number; removed: number; count: number }) { - if (input.added > 0 && input.removed > 0) { - return `explicit memory operation saved ${input.added} and removed ${input.removed}` - } - if (input.added > 0) return `explicit memory operation saved ${input.added} ops` - if (input.removed > 0) return `explicit memory operation removed ${input.removed} entries` - if (input.count > 0) return "explicit memory operation matched no source memory" - return "explicit memory operation had no accepted ops" - } - export function message(input: { ops: MemoryOperations.Op[]; added: number; removed: number; count: number }) { const refs = MemoryShared.refs(input.ops) if (input.added > 0 && input.removed > 0) return `Memory updated · ${input.added} saved, ${input.removed} removed` @@ -25,15 +14,4 @@ export namespace MemoryNotice { if (input.removed > 0) return `Memory updated · ${input.removed} removed` return `Memory unchanged · ${input.count} ops` } - - export function skip(input: MemoryOperations.Rejection[]) { - return input.map((item) => (item.reason === "out_of_scope" ? { reason: item.reason } : item)) - } - - export function ops(input: { ops: MemoryOperations.Op[]; skipped: MemoryOperations.Rejection[] }) { - const blocked = new Set(input.skipped.filter((item) => item.reason === "out_of_scope").map((item) => item.text)) - return MemoryShared.audit( - input.ops.filter((item) => item.action !== "add" || !blocked.has(MemoryRedact.text(item.text))), - ) - } } diff --git a/packages/kilo-memory/src/memory.ts b/packages/kilo-memory/src/memory.ts index 8bd14dbf421..267b92093af 100644 --- a/packages/kilo-memory/src/memory.ts +++ b/packages/kilo-memory/src/memory.ts @@ -8,7 +8,6 @@ import { MemorySchema } from "./schema" import { MemoryShared } from "./recall/shared" import { MemoryToken } from "./recall/token" import { MemorySlug } from "./slug" -import { MemoryRedact } from "./capture/redact" /** Root-bound package facade. External Kilo surfaces should derive root from workspace context first. */ export namespace Memory { @@ -97,7 +96,6 @@ export namespace Memory { const state = await MemoryFiles.readState(input.root) const next = { ...state, enabled: false } await MemoryFiles.writeState(input.root, next) - await MemoryFiles.append(input.root, `disable ${next.scope} source=command`) return { root: input.root, state: next } }) } @@ -124,16 +122,6 @@ export namespace Memory { ...(input.settings.verbose === undefined ? {} : { verbose: input.settings.verbose }), } await MemoryFiles.writeState(input.root, next) - await MemoryFiles.append( - input.root, - [ - `settings ${next.scope}`, - input.settings.autoConsolidate === undefined ? "" : `autoConsolidate=${next.autoConsolidate}`, - input.settings.verbose === undefined ? "" : `verbose=${next.verbose}`, - ] - .filter(Boolean) - .join(" "), - ) return { root: input.root, state: next } }) } @@ -216,24 +204,6 @@ export namespace Memory { } const state = await MemoryFiles.readState(input.root) const ok = MemoryNotice.saved({ added: result.added, removed: result.removed }) - if (trigger === "explicit") { - await MemoryFiles.decide(input.root, { - kind: "typed", - trigger, - sessionID: input.sessionID, - result: ok ? "saved" : "skipped", - llm: false, - parsed: true, - fallback: false, - tokens: input.tokens ?? 0, - operationCount: result.operationCount, - skippedCount: result.skipped.length || (ok ? 0 : 1), - skipped: MemoryNotice.skip(result.skipped), - operations: MemoryNotice.ops({ ops: accepted, skipped: result.skipped }), - files: MemoryShared.files(accepted), - summary: MemoryNotice.summary({ added: result.added, removed: result.removed, count: result.operationCount }), - }) - } return { root: input.root, state, @@ -318,31 +288,6 @@ export namespace Memory { const hits = result?.hits ?? [] const files = [...new Set(hits.map((hit) => hit.source))] const topics = [...new Set(hits.flatMap((hit) => (hit.topics?.length ? hit.topics : [hit.kind])))] - await MemoryFiles.decide(input.root, { - kind: "recall", - trigger: "targeted-recall", - sessionID: input.sessionID, - result: result ? "recalled" : "skipped", - llm: false, - parsed: false, - fallback: false, - reason: result ? undefined : "no_matches", - query: MemoryShared.brief(MemoryRedact.text(input.query), 240), - topics, - files, - tokens: result?.tokens ?? 0, - operationCount: hits.length, - skippedCount: result ? 0 : 1, - summary: result ? `targeted recall matched ${hits.length} memories` : "targeted recall found no matches", - }) - if (result) { - await MemoryFiles.queue(input.root, async () => { - await MemoryFiles.append( - input.root, - `recall session=${input.sessionID ?? ""} hits=${result.hits.length} tokens=${result.tokens} files=${files.join(",")}`, - ) - }) - } return { root: input.root, state, result, hits, files, topics } } @@ -368,10 +313,6 @@ export namespace Memory { }) await MemoryFiles.pruneSessions(input.root, state.limits.maxSessionFiles) const index = await MemoryIndexer.rebuild({ root: input.root, state }) - await MemoryFiles.append( - input.root, - `session digest session=${input.sessionID} tokens=${input.tokens ?? 0} indexTokens=${index.tokens}`, - ) return { root: input.root, state, skipped: false as const, index } }) } diff --git a/packages/kilo-memory/src/recall/indexer.ts b/packages/kilo-memory/src/recall/indexer.ts index e4c219cbb01..1f906d2867b 100644 --- a/packages/kilo-memory/src/recall/indexer.ts +++ b/packages/kilo-memory/src/recall/indexer.ts @@ -226,7 +226,6 @@ export namespace MemoryIndexer { return MemoryFiles.queue(input.root, async () => { const result = await build(input) await MemoryFiles.writeIndex(input.root, result.text) - await MemoryFiles.append(input.root, `regenerate index.kmem bytes=${result.bytes} tokens=${result.tokens}`) return result }) } diff --git a/packages/kilo-memory/src/recall/shared.ts b/packages/kilo-memory/src/recall/shared.ts index b24829b2f46..b38f215e6ac 100644 --- a/packages/kilo-memory/src/recall/shared.ts +++ b/packages/kilo-memory/src/recall/shared.ts @@ -94,6 +94,7 @@ export namespace MemoryShared { ] } + /** @deprecated Memory audit persistence was removed. */ export function audit(ops: MemoryOperations.Op[]) { return ops.map((item) => item.action === "add" diff --git a/packages/kilo-memory/src/schema.ts b/packages/kilo-memory/src/schema.ts index 59436a8c144..0ca36e7616e 100644 --- a/packages/kilo-memory/src/schema.ts +++ b/packages/kilo-memory/src/schema.ts @@ -1,3 +1,9 @@ +/** Wire contract for suppressible background-capture failures: guardReason emits this as the + * `memory.error` reason, and every client surface (TUI toast filter, VS Code provider) matches + * against this same constant rather than a local literal. Lives here (dependency-free module) + * so clients can import it without pulling the capture pipeline into their bundles. */ +export const TRANSIENT = "transient" as const + export namespace MemorySchema { export const VERSION = 1 export const maxStoredDigestSummary = 4_000 diff --git a/packages/kilo-memory/src/storage/audit.ts b/packages/kilo-memory/src/storage/audit.ts index 70c7c42bf3a..58fe9f496b7 100644 --- a/packages/kilo-memory/src/storage/audit.ts +++ b/packages/kilo-memory/src/storage/audit.ts @@ -1,15 +1,5 @@ -import z from "zod" -import { MemoryFs } from "./fs" - +/** Compatibility facade for the removed memory audit log. */ export namespace MemoryAudit { - const Log = z - .object({ - kind: z.literal("log"), - summary: z.string(), - time: z.string().optional(), - }) - .passthrough() - export type Decision = | { kind: "log" @@ -43,18 +33,14 @@ export namespace MemoryAudit { }[] } - function audit(root: string, input: Decision) { - void root - void input - return Promise.resolve() - } - export async function append(root: string, text: string) { - await audit(root, { kind: "log", result: "logged", summary: text }) + void root + void text } export async function decide(root: string, input: Decision) { - await audit(root, input) + void root + void input } export async function readDecisions(root: string) { @@ -62,24 +48,8 @@ export namespace MemoryAudit { return "" } - function record(input: string) { - try { - const data = JSON.parse(input) - const parsed = Log.safeParse(data) - return parsed.success ? parsed.data : undefined - } catch (error) { - if (MemoryFs.parse(error)) return undefined - throw error - } - } - export async function readChanges(root: string) { - const lines = (await readDecisions(root)).split("\n").flatMap((line) => { - const data = record(line) - if (!data) return [] - const time = data.time ?? "" - return [`${time} ${data.summary}`.trim()] - }) - return lines.join("\n") + void root + return "" } } diff --git a/packages/kilo-memory/src/storage/state.ts b/packages/kilo-memory/src/storage/state.ts index bb373e24610..136a83a3e5e 100644 --- a/packages/kilo-memory/src/storage/state.ts +++ b/packages/kilo-memory/src/storage/state.ts @@ -1,6 +1,5 @@ import { readdir, rm } from "fs/promises" import path from "path" -import { MemoryAudit } from "./audit" import { MemoryFs } from "./fs" import { MemoryMarkdown } from "./markdown" import { MemoryPaths } from "./paths" @@ -19,13 +18,10 @@ export namespace MemoryState { "corrections.md": "# Corrective Memory\n\n## Corrections\n", } - async function recover(root: string, file: string, error: unknown) { + async function recover(root: string, file: string) { await MemoryFs.backup(file) const state = MemorySchema.missing() await writeState(root, state) - await MemoryAudit.append(root, `recover state.json error=${MemoryFs.brief(error)}`).catch((err: unknown) => - MemoryFs.warn("failed to audit memory state recovery", { err, root }), - ) return state } @@ -33,14 +29,14 @@ export namespace MemoryState { const file = MemoryPaths.files(root).state const data = await MemoryFs.json(file).catch(async (error: unknown) => { if (MemoryFs.miss(error)) return undefined - if (MemoryFs.parse(error)) return recover(root, file, error) + if (MemoryFs.parse(error)) return recover(root, file) throw error }) if (data === undefined) return MemorySchema.missing() return Promise.resolve() .then(() => MemorySchema.parse(data)) .catch((error: unknown) => { - if (MemoryFs.parse(error)) return recover(root, file, error) + if (MemoryFs.parse(error)) return recover(root, file) throw error }) } @@ -199,7 +195,6 @@ export namespace MemoryState { ? { ...(await readState(root)), enabled: true, autoInject: true } : { ...MemorySchema.create(), enabled: true } await writeState(root, state) - await MemoryAudit.append(root, "enable project source=command") return state } diff --git a/packages/kilo-memory/src/tool.ts b/packages/kilo-memory/src/tool.ts index e87fb85d0f5..cef71179d35 100644 --- a/packages/kilo-memory/src/tool.ts +++ b/packages/kilo-memory/src/tool.ts @@ -4,7 +4,6 @@ import { MemoryError, type MemoryError as Failure } from "./effect/errors" import { MemoryPaths } from "./effect/paths" import { MemoryService } from "./effect/service" import { MemoryRecall } from "./recall/recall" -import { MemoryToken } from "./recall/token" import { MemorySchema } from "./schema" import recallDescription from "./prompts/tool-memory-recall.txt" import saveDescription from "./prompts/tool-memory-save.txt" @@ -115,50 +114,6 @@ export namespace MemoryTool { } } - function audit( - memory: MemoryService.Interface, - input: { - root: string - params: RecallParams - current: string - hits: MemoryRecall.Hit[] - skipped?: string - output: string - }, - ) { - const files = [...new Set(input.hits.map((hit) => hit.source))] - const topics = [...new Set(input.hits.flatMap((hit) => (hit.topics?.length ? hit.topics : [hit.kind])))] - const query = - input.params.query ?? - (input.params.sessionID - ? `sessionID=${input.params.sessionID}` - : input.params.mode === "digest" - ? "recent digests" - : undefined) - return memory.decide({ - root: input.root, - decision: { - kind: "recall", - trigger: "targeted-recall", - sessionID: input.current, - result: input.hits.length ? "recalled" : "skipped", - llm: false, - parsed: false, - fallback: false, - reason: input.skipped, - query, - topics, - files, - tokens: MemoryToken.estimate(input.output), - operationCount: input.hits.length, - skippedCount: input.hits.length ? 0 : 1, - summary: input.hits.length - ? `memory recall returned ${input.hits.length} ${input.params.mode} hits` - : `memory recall found no ${input.params.mode} hits`, - }, - }) - } - function miss(input: { params: RecallParams; current: string }) { const self = input.params.sessionID === input.current if (self && input.params.mode === "digest") { @@ -239,35 +194,6 @@ export namespace MemoryTool { }) } - function catalogAudit( - memory: MemoryService.Interface, - input: { - root: string - current: string - query: string - result: { output: string; count: number; files: string[] } - }, - ) { - return memory.decide({ - root: input.root, - decision: { - kind: "recall", - trigger: "targeted-recall", - sessionID: input.current, - result: input.result.count ? "recalled" : "skipped", - llm: false, - parsed: false, - fallback: false, - query: input.query || "all keys", - files: input.result.files, - tokens: MemoryToken.estimate(input.result.output), - operationCount: input.result.count, - skippedCount: input.result.count ? 0 : 1, - summary: `memory catalog listed ${input.result.count} entries`, - }, - }) - } - function approvalRecall(input: Recall) { return input.ask({ permission: "kilo_memory_recall", @@ -285,7 +211,6 @@ export namespace MemoryTool { return Effect.gen(function* () { const result = yield* catalog(input.memory, { root: live.root, query }) const safe = { ...result, output: block(result.output) } - yield* catalogAudit(input.memory, { root: live.root, current: live.current, query, result: safe }) yield* input.memory.recordRecall({ root: live.root, sessionID: live.current, now: Date.now(), count: result.count }) return { title: `Kilo memory catalog: ${result.count} entr${result.count === 1 ? "y" : "ies"}`, @@ -296,22 +221,13 @@ export namespace MemoryTool { } function recallQuery(input: Recall, live: Live) { - return Effect.gen(function* () { - const output = "Provide a topic query for typed/search memory recall." - yield* audit(input.memory, { - root: live.root, - params: input.params, - current: live.current, - hits: [], - skipped: "missing_query", - output, - }) - return { + return Effect.succeed( + { title: `Kilo memory ${input.params.mode}: no query`, - output, + output: "Provide a topic query for typed/search memory recall.", metadata: { sources: [], count: 0 }, - } satisfies Result - }) + } satisfies Result, + ) } function recallSearch(input: Recall, live: Live, query: string, mode: MemoryRecall.Mode) { @@ -327,15 +243,7 @@ export namespace MemoryTool { limit, }) const hits = result?.hits ?? [] - const self = input.params.sessionID === live.current - const skipped = - input.params.sessionID && input.params.mode === "digest" && hits.length === 0 - ? self - ? "current_session_digest" - : "missing_session_digest" - : undefined const output = hits.length ? result!.block : miss({ params: input.params, current: live.current }) - yield* audit(input.memory, { root: live.root, params: input.params, current: live.current, hits, skipped, output }) yield* input.memory.recordRecall({ root: live.root, sessionID: live.current, now: Date.now(), count: hits.length }) if (hits.length === 0) { @@ -440,29 +348,8 @@ export namespace MemoryTool { return saved(input) } - function skip(input: Base & { params: SaveParams }, root: string) { - return Effect.gen(function* () { - const reason = input.params.reason ?? "out_of_scope" - yield* input.memory.decide({ - root, - decision: { - kind: "typed", - trigger: "explicit", - sessionID: input.sessionID, - result: "skipped", - llm: false, - parsed: true, - fallback: false, - reason, - tokens: 0, - operationCount: 0, - skippedCount: 1, - skipped: [{ reason }], - summary: `explicit memory save skipped: ${reason}`, - }, - }) - return skipped({ reason }) - }) + function skip(input: Base & { params: SaveParams }) { + return Effect.succeed(skipped({ reason: input.params.reason ?? "out_of_scope" })) } function forget(input: Save, root: string) { @@ -497,7 +384,7 @@ export namespace MemoryTool { const state = yield* input.memory.state({ root }) if (!state.enabled) return disabled() if (input.params.action === "forget") return yield* forget(input, root) - if (input.params.action === "skip") return yield* skip(input, root) + if (input.params.action === "skip") return yield* skip(input) return yield* write(input, root) }) } diff --git a/packages/kilo-memory/test/capture.test.ts b/packages/kilo-memory/test/capture.test.ts index a1dcdc609c2..516ed3a65d3 100644 --- a/packages/kilo-memory/test/capture.test.ts +++ b/packages/kilo-memory/test/capture.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" import { - auditOps, capturePlan, duplicateOps, + errorReason, fallbackDigest, guardReason, hasSubstantialDiff, @@ -128,7 +128,7 @@ describe("memory capture parsing", () => { expect(() => salvageTyped(`{"op":"upsert_project_fact","key":"good_one","value":"Keep this fact."}`)).toThrow() }) - test("redacts secrets in salvaged unsupported ops before they reach the audit", () => { + test("redacts secrets in salvaged unsupported ops before they reach callers", () => { const parsed = salvageTyped( `{"operations":[{"op":"not_a_real_op","key":"leak","value":"key is sk-abcdefghijklmnopqrstuvwxyz"}],"skipped":[]}`, ) @@ -169,17 +169,6 @@ describe("memory capture parsing", () => { expect(JSON.stringify(parsed.skipped)).not.toContain(secret.slice(0, 20)) }) - test("redacts remove audit queries before truncating", () => { - const secret = "sk-" + "a".repeat(40) - const query = "x".repeat(100) + secret - const audit = auditOps([{ action: "remove", query }]) - const text = JSON.stringify(audit) - - expect(text).toContain("[redacted]") - expect(text).not.toContain(secret) - expect(text).not.toContain(secret.slice(0, 20)) - }) - test("truncates typed batches beyond the op cap instead of failing", () => { const ops = Array.from( { length: 20 }, @@ -332,6 +321,11 @@ describe("memory capture parsing", () => { input: base, expected: { session: true, digestDue: true, typedCall: true, typedWork: true, skipReason: undefined }, }, + { + name: "expected work: a persisted null typed clock does not throttle capture", + input: { ...base, lastTypedConsolidationAt: null }, + expected: { session: true, digestDue: true, typedCall: true, typedWork: true, skipReason: undefined }, + }, { name: "expected idle flush: completed turn inside interval skips now", input: { ...base, priorTime: 900, lastTypedConsolidationAt: 900 }, @@ -650,6 +644,70 @@ describe("memory capture parsing", () => { ) expect(guardReason("429 too many requests")).toBe("rate_limit_guard") expect(guardReason("billing credits exhausted")).toBe("quota_guard") + expect(guardReason("request timed out")).toBe("transient") + expect(guardReason("deadline exceeded")).toBe("transient") + expect(guardReason("DeadlineExceeded")).toBe("transient") + expect(guardReason('cause={"code":"ETIMEDOUT"}')).toBe("transient") + expect(guardReason("504 Gateway Timeout")).toBe("transient") + expect(guardReason("Connect Timeout Error")).toBe("transient") + expect(guardReason("Headers Timeout Error")).toBe("transient") + expect(guardReason("Body Timeout Error")).toBe("transient") + expect(guardReason("connect_timeout")).toBe("transient") + expect(guardReason('status=400 body={"error":"invalid parameter: timeout"}')).toBeUndefined() + expect(guardReason("set request timeout to 30000")).toBeUndefined() + const timeout = new Error("request aborted") + timeout.name = "TimeoutError" + expect(guardReason(timeout)).toBe("transient") + expect(errorReason(timeout)).toBe("request aborted") + expect(guardReason(Object.assign(new Error("request failed"), { status: 504 }))).toBe("transient") + expect( + guardReason(Object.assign(new Error("request failed"), { cause: { code: "UND_ERR_CONNECT_TIMEOUT" } })), + ).toBe("transient") + expect( + guardReason(Object.assign(new Error("request failed"), { cause: { code: "UND_ERR_HEADERS_TIMEOUT" } })), + ).toBe("transient") + expect(guardReason(Object.assign(new Error("request failed"), { cause: { code: "ETIMEDOUT" } }))).toBe( + "transient", + ) + expect( + guardReason(Object.assign(new Error("failed after 2 attempts"), { errors: [{ statusCode: 504 }] })), + ).toBe("transient") + expect(guardReason(new Error("set request timeout to 30000"))).toBeUndefined() + expect(guardReason(new Error("request timed out"))).toBe("transient") + expect(guardReason(Object.assign(new Error("request failed"), { cause: new Error("connect timeout") }))).toBe( + "transient", + ) + expect( + guardReason( + Object.assign(new Error("failed after 2 attempts"), { + errors: [Object.assign(new Error("rate limit reached"), { status: 429 }), timeout], + }), + ), + ).toBe("rate_limit_guard") + expect( + guardReason( + Object.assign(new Error("failed after 2 attempts"), { + errors: [new Error("insufficient quota available"), timeout], + }), + ), + ).toBe("quota_guard") + expect( + guardReason( + Object.assign(new Error("request failed"), { + cause: Object.assign(new Error("rate limit reached"), { status: 429 }), + }), + ), + ).toBe("rate_limit_guard") + expect( + guardReason( + Object.assign(new Error("timed out"), { + name: "TimeoutError", + cause: new Error("billing credit balance exhausted"), + }), + ), + ).toBe("quota_guard") + expect(guardReason("timeout after 429 too many requests")).toBe("rate_limit_guard") + expect(guardReason("timeout after quota exceeded")).toBe("quota_guard") }) test("redacts common secret token shapes", () => { diff --git a/packages/kilo-memory/test/core.test.ts b/packages/kilo-memory/test/core.test.ts index 04b52a03624..9c029e6e211 100644 --- a/packages/kilo-memory/test/core.test.ts +++ b/packages/kilo-memory/test/core.test.ts @@ -50,6 +50,22 @@ describe("memory core package", () => { }) }) + test("legacy audit compatibility APIs remain no-op", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await MemoryFiles.append(t.root, "provider error with sensitive detail") + await MemoryFiles.decide(t.root, { + kind: "typed", + result: "error", + reason: "provider error with sensitive detail", + }) + + expect(await MemoryFiles.readChanges(t.root)).toBe("") + expect(await MemoryFiles.readDecisions(t.root)).toBe("") + expect(await Bun.file(path.join(t.root, "decisions.jsonl")).exists()).toBe(false) + }) + }) + test("prepare removes legacy decisions once from owned memory roots", async () => { await use(async (t) => { await Memory.enable({ root: t.root }) diff --git a/packages/kilo-memory/test/effect-capture.test.ts b/packages/kilo-memory/test/effect-capture.test.ts index 91e0ec84e04..50a86b0f1f4 100644 --- a/packages/kilo-memory/test/effect-capture.test.ts +++ b/packages/kilo-memory/test/effect-capture.test.ts @@ -7,6 +7,7 @@ import { digestPrompt, typedPrompt } from "../src/capture/capture" import { MemoryCapture } from "../src/effect/capture" import { MemoryEvents } from "../src/effect/events" import { KiloMemory } from "../src/effect/index" +import { MemoryLog } from "../src/effect/log" import type { MemoryPorts } from "../src/effect/ports" import { MemoryService } from "../src/effect/service" import { MemoryTimers } from "../src/effect/timers" @@ -49,11 +50,18 @@ function session(turn: MemoryPorts.TurnView | undefined): MemoryPorts.SessionPor /** Model port that answers digest/typed calls from canned JSON, keyed by system prompt so it is * order-independent (digest and typed run concurrently). */ -function model(input: { digest: string; typed: string; fallback?: string; onRun?: (system: string) => void }): MemoryPorts.ModelPort { +function model(input: { + digest: string + typed: string + fallback?: string + fail?: Error + onRun?: (system: string) => void +}): MemoryPorts.ModelPort { return { resolve: () => Effect.succeed({ handle: {}, ...(input.fallback ? { fallback: { reason: input.fallback } } : {}) }), run: async ({ system }) => { input.onRun?.(system) + if (system === typedPrompt && input.fail) throw input.fail const text = system === digestPrompt ? input.digest : system === typedPrompt ? input.typed : "{}" return { text, usage: USAGE } }, @@ -66,6 +74,7 @@ function run(input: { model: MemoryPorts.ModelPort memoryModel?: string reason?: "completed" | "interrupted" | "error" + bypassInterval?: boolean }) { return Effect.runPromise( MemoryCapture.turn({ @@ -75,6 +84,7 @@ function run(input: { model: input.model, memoryModel: input.memoryModel, reason: input.reason ?? "completed", + bypassInterval: input.bypassInterval, }).pipe(Effect.provideService(MemoryService.Service, MemoryService.make())), ) } @@ -107,6 +117,121 @@ describe("MemoryCapture (fake ports)", () => { } }) + test("typed timeout preserves digest progress without advancing the typed clock", async () => { + const t = await tmp() + const events: MemoryEvents.Status[] = [] + const logs: string[] = [] + try { + await KiloMemory.enable({ root: t.root }) + await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } }) + MemoryLog.setWarn((message, meta) => logs.push(`${message}:${meta?.reason}:${meta?.detail}`)) + MemoryEvents.setSink((input) => { + events.push(input.payload) + }) + + const result = await run({ + root: t.root, + session: session(view()), + model: model({ + digest: '{"topic":"repo setup","summary":"Explored repo setup commands. Next step: verify memory tests."}', + typed: "{}", + fail: new DOMException("memory model timed out", "TimeoutError"), + }), + }) + + expect(result).toMatchObject({ skipped: false, operationCount: 0 }) + const state = await MemoryFiles.readState(t.root) + expect(state.stats.lastTypedConsolidationAt).toBeNull() + expect(state.stats.lastSessionSavedAt).toEqual(expect.any(Number)) + expect(state.stats.lastConsolidatedMessageID).toBe("msg_assistant") + expect(events.find((item) => item.state === "error")?.reason).toBe("transient") + expect(logs).toEqual(["memory capture transient failure:transient:memory model timed out"]) + + const calls: string[] = [] + const retry = await run({ + root: t.root, + session: session(view()), + model: model({ + digest: "{}", + typed: '{"operations":[],"skipped":[]}', + onRun: (system) => calls.push(system), + }), + bypassInterval: true, + }) + expect(retry).toMatchObject({ skipped: true, reason: "no_new_content" }) + expect(calls).toEqual([]) + + const next = await run({ + root: t.root, + session: session( + view({ + assistant: "Use bun install, then run the package tests and typecheck.", + lastAssistantID: "msg_assistant_next", + }), + ), + model: model({ + digest: "{}", + typed: '{"operations":[],"skipped":[]}', + onRun: (system) => calls.push(system), + }), + }) + expect(next).toMatchObject({ skipped: false, operationCount: 0 }) + expect(calls).toEqual([typedPrompt]) + const updated = await MemoryFiles.readState(t.root) + expect(updated.stats.lastTypedConsolidationAt).toEqual(expect.any(Number)) + expect(updated.stats.lastConsolidatedMessageID).toBe("msg_assistant_next") + } finally { + MemoryLog.setWarn(() => {}) + MemoryEvents.setSink(() => {}) + await t.done() + } + }) + + test("fallback commit preserves metrics from the prior successful typed consolidation", async () => { + const t = await tmp() + try { + await KiloMemory.enable({ root: t.root }) + await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } }) + + const first = await run({ + root: t.root, + session: session(view()), + model: model({ + digest: '{"topic":"repo setup","summary":"Explored repo setup."}', + typed: + '{"operations":[{"op":"upsert_environment_fact","section":"Commands","key":"test_cmd","value":"bun test"}],"skipped":[]}', + }), + }) + expect(first).toMatchObject({ skipped: false, operationCount: 1 }) + const initial = await MemoryFiles.readState(t.root) + expect(initial.stats.lastOperationCount).toBe(1) + expect(initial.stats.lastTypedConsolidationAt).toEqual(expect.any(Number)) + + const second = await run({ + root: t.root, + session: session( + view({ + assistant: "Investigated a timeout edge case.", + lastAssistantID: "msg_assistant_timeout", + }), + ), + model: model({ + digest: '{"topic":"investigation","summary":"Investigated timeouts."}', + typed: "{}", + fail: new DOMException("memory model timed out", "TimeoutError"), + }), + bypassInterval: true, + }) + expect(second).toMatchObject({ skipped: false, operationCount: 0 }) + const preserved = await MemoryFiles.readState(t.root) + expect(preserved.stats.lastOperationCount).toBe(1) + expect(preserved.stats.lastTypedConsolidationAt).toBe(initial.stats.lastTypedConsolidationAt) + expect(preserved.stats.lastConsolidatedMessageID).toBe("msg_assistant_timeout") + } finally { + await t.done() + } + }) + test("turn-close skips a secret-like op and applies the rest of the batch", async () => { const t = await tmp() const events: MemoryEvents.Status[] = [] diff --git a/packages/kilo-ui/src/components/dialog.css b/packages/kilo-ui/src/components/dialog.css index d80f2b0d117..fd9018f9dea 100644 --- a/packages/kilo-ui/src/components/dialog.css +++ b/packages/kilo-ui/src/components/dialog.css @@ -12,6 +12,24 @@ border: 1px solid var(--border-weak-base); } + &[data-size="large"] [data-slot="dialog-container"] { + width: min(calc(100vw - 32px), 800px); + height: min(calc(100vh - 32px), 720px); + } + + &[data-size="large"] [data-slot="dialog-content"] { + max-width: 800px; + } + + &[data-size="x-large"] [data-slot="dialog-container"] { + width: min(calc(100vw - 32px), 980px); + height: min(calc(100vh - 32px), 800px); + } + + &[data-size="x-large"] [data-slot="dialog-content"] { + max-width: 980px; + } + [data-slot="dialog-header"] { font-size: var(--kilo-font-size-14); } diff --git a/packages/kilo-ui/src/components/icon.tsx b/packages/kilo-ui/src/components/icon.tsx index 96e8621531e..f58623ade7d 100644 --- a/packages/kilo-ui/src/components/icon.tsx +++ b/packages/kilo-ui/src/components/icon.tsx @@ -2,6 +2,18 @@ import { Icon as Upstream, type IconProps as Props } from "@opencode-ai/ui/icon" import { splitProps } from "solid-js" const icons: Record = { + "circle-x-outline": { + viewBox: "0 0 20 20", + path: ``, + }, + "pull-request": { + viewBox: "0 0 20 20", + path: ``, + }, + refresh: { + viewBox: "0 0 20 20", + path: ``, + }, memory: { viewBox: "0 0 24 24", path: ``, diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 4660904c00a..918b0a0afc3 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1,6 +1,7 @@ import * as path from "path" import { existsSync } from "fs" import * as vscode from "vscode" +import { TRANSIENT as MEMORY_TRANSIENT } from "@kilocode/kilo-memory/schema" import type { KiloClient, Session, @@ -4307,8 +4308,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper for (const sid of trackedByDir) targets.add(sid) if (local && active) targets.add(active) if (targets.size === 0 && local) targets.add(undefined) - const detail = - props.detail && typeof props.detail === "object" + const transient = event.type === "memory.error" && props.reason === MEMORY_TRANSIENT + const detail = transient + ? undefined + : props.detail && typeof props.detail === "object" ? props.detail : event.type === "memory.error" && typeof props.reason === "string" ? { type: "error", message: props.reason, reason: props.reason } diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index 56ced904d57..d94a4511fc2 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -1,10 +1,12 @@ import type { ExecFileOptionsWithStringEncoding } from "child_process" import type { Worktree } from "./WorktreeStateManager" -import type { PRStatus, PRCheck, PRComment, CheckStatus, AggregateCheckStatus, PRState, ReviewDecision } from "./types" +import type { PRStatus, PRCheck, PRComment, PRReviewer, AggregateCheckStatus } from "./types" import { execWithShellEnv } from "./shell-env" import { execGhRead } from "./gh" import { classifyPRError } from "./git-import" import type { Semaphore } from "./semaphore" +import { parsePRResult, checkStatus, formatCheckDuration, parseComments, parseReviewers } from "./am-pr-utils" +import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./am-pr-types" interface PRStatusPollerOptions { getWorktrees: () => Worktree[] @@ -17,6 +19,7 @@ interface PRStatusPollerOptions { } const GH_PROBE_TTL = 300_000 // 5 minutes — gh installation state rarely changes at runtime +const GH_PROBE_FAILURE_TTL = 30_000 // 30 seconds — retry faster after a failed probe const MAX_BACKOFF = 120_000 // 2 minutes — cap for exponential backoff on repeated errors const BACKOFF_MULTIPLIER = 2 const PR_LOOKUP_TTL = 10_000 // 10 seconds — short TTL; only the active worktree polls so this stays cheap @@ -171,7 +174,8 @@ export class PRStatusPoller { private async probeGh(): Promise { const now = Date.now() - if (this.ghAvailable !== undefined && now - this.ghProbeTime < GH_PROBE_TTL) { + const ttl = this.ghAvailable === false ? GH_PROBE_FAILURE_TTL : GH_PROBE_TTL + if (this.ghAvailable !== undefined && now - this.ghProbeTime < ttl) { return this.ghAvailable } try { @@ -244,8 +248,9 @@ export class PRStatusPoller { return } - const [checks, comments] = await Promise.all([ + const [checks, reviewers, comments] = await Promise.all([ this.fetchChecks(pr.number, wt.path), + this.fetchReviewers(pr.number, wt.path), this.activeWorktreeId === worktreeId ? this.fetchComments(pr.number, wt.path) : undefined, ]) if (this.stale(generation)) return @@ -253,17 +258,22 @@ export class PRStatusPoller { const status: PRStatus = { number: pr.number, title: pr.title, + body: pr.body, url: pr.url, state: pr.state, review: pr.review, checks, - ...(comments && { comments }), + reviewers, + ...(comments && { + comments: { total: comments.total, unresolved: comments.unresolved, comments: comments.comments }, + }), additions: pr.additions, deletions: pr.deletions, files: pr.files, } - const hash = `${worktreeId}:${pr.number}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}` + const reviewersSig = reviewers.map((r) => `${r.login}:${r.state}`).join(",") + const hash = `${worktreeId}:${pr.number}:${pr.title}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${reviewersSig}:${pr.body ?? ""}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}` if (this.lastHash.get(worktreeId) === hash) return this.lastHash.set(worktreeId, hash) @@ -292,7 +302,7 @@ export class PRStatusPoller { } private static readonly PR_JSON_FIELDS = - "number,title,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid" + "number,title,body,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid" /** Return a cached PR lookup if still fresh, otherwise fetch and cache. * Keyed by branch name so multiple worktrees on the same branch share @@ -374,7 +384,7 @@ export class PRStatusPoller { passed: number failed: number pending: number - items: PRCheck[] + checks: PRCheck[] }> { try { const { stdout } = await this.gh( @@ -389,24 +399,24 @@ export class PRStatusPoller { completedAt?: string }> - const items: PRCheck[] = data.map((c) => ({ + const checks: PRCheck[] = data.map((c) => ({ name: c.name, - status: mapCheckStatus(c.state), + status: checkStatus(c.state), url: c.link, duration: formatCheckDuration(c.startedAt, c.completedAt), })) - const total = items.length - const passed = items.filter((c) => c.status === "success").length - const failed = items.filter((c) => c.status === "failure").length - const pending = items.filter((c) => c.status === "pending").length + const total = checks.filter((c) => c.status !== "skipped").length + const passed = checks.filter((c) => c.status === "success").length + const failed = checks.filter((c) => c.status === "failure").length + const pending = checks.filter((c) => c.status === "pending").length const status: AggregateCheckStatus = total === 0 ? "none" : failed > 0 ? "failure" : pending > 0 ? "pending" : "success" - return { status, total, passed, failed, pending, items } + return { status, total, passed, failed, pending, checks } } catch { - return { status: "none", total: 0, passed: 0, failed: 0, pending: 0, items: [] } + return { status: "none", total: 0, passed: 0, failed: 0, pending: 0, checks: [] } } } @@ -424,13 +434,53 @@ export class PRStatusPoller { return info } + private async fetchReviewers(prNumber: number, cwd: string): Promise { + try { + const repo = await this.getRepoInfo(cwd) + const query = `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewRequests(first: 20) { + nodes { requestedReviewer { ... on User { login avatarUrl } } } + } + reviews(last: 20, states: [APPROVED, CHANGES_REQUESTED, COMMENTED]) { + nodes { author { login avatarUrl } state } + } + } + } + }` + const { stdout } = await this.gh( + [ + "api", + "graphql", + "-f", + `query=${query}`, + "-F", + `owner=${repo.owner}`, + "-F", + `repo=${repo.name}`, + "-F", + `number=${prNumber}`, + ], + { cwd, timeout: 15_000 }, + ) + const pr = JSON.parse(stdout)?.data?.repository?.pullRequest + return parseReviewers( + (pr?.reviewRequests?.nodes ?? []) as GhReviewRequest[], + (pr?.reviews?.nodes ?? []) as GhReview[], + ) + } catch (err) { + this.options.log("Failed to fetch PR reviewers:", err) + return [] + } + } + private async fetchComments( prNumber: number, cwd: string, - ): Promise<{ total: number; unresolved: number; items: PRComment[] }> { + ): Promise<{ total: number; unresolved: number; comments: PRComment[] }> { try { const repo = await this.getRepoInfo(cwd) - const query = `query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { @@ -469,107 +519,16 @@ export class PRStatusPoller { ], { cwd, timeout: 15_000 }, ) - const result = JSON.parse(stdout) - const threads = result?.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [] - - const items: PRComment[] = [] - for (const thread of threads) { - const first = thread.comments?.nodes?.[0] - if (!first) continue - items.push({ - id: first.id, - author: first.author?.login ?? "unknown", - avatar: first.author?.avatarUrl, - body: first.body ?? "", - file: first.path, - line: first.line, - url: first.url, - resolved: thread.isResolved ?? false, - createdAt: first.createdAt ? new Date(first.createdAt).getTime() : undefined, - }) - } - - const total = items.length - const unresolved = items.filter((c) => !c.resolved).length - return { total, unresolved, items } + const pr = JSON.parse(stdout)?.data?.repository?.pullRequest + const comments = parseComments((pr?.reviewThreads?.nodes ?? []) as GhThread[]) + return { total: comments.length, unresolved: comments.filter((c) => !c.resolved).length, comments } } catch (err) { this.options.log("Failed to fetch PR comments:", err) - return { total: 0, unresolved: 0, items: [] } + return { total: 0, unresolved: 0, comments: [] } } } } -interface PRResult { - number: number - title: string - url: string - state: PRState - review: ReviewDecision | null - additions: number - deletions: number - files: number -} - -function parsePRResult(json: string): PRResult | null { - const data = JSON.parse(json) - if (!data.number) return null - return { - number: data.number, - title: data.title ?? "", - url: data.url ?? "", - state: parsePRState(data.isDraft, data.state), - review: parseReviewDecision(data.reviewDecision), - additions: data.additions ?? 0, - deletions: data.deletions ?? 0, - files: data.changedFiles ?? 0, - } -} - -function parsePRState(isDraft: boolean, ghState: string): PRState { - if (isDraft) return "draft" - if (ghState === "MERGED") return "merged" - if (ghState === "CLOSED") return "closed" - return "open" -} - -function parseReviewDecision(decision: string | undefined): ReviewDecision | null { - if (decision === "APPROVED") return "approved" - if (decision === "CHANGES_REQUESTED") return "changes_requested" - if (decision === "REVIEW_REQUIRED") return "pending" - return null -} - -function mapCheckStatus(state: string): CheckStatus { - switch (state.toUpperCase()) { - case "SUCCESS": - return "success" - case "FAILURE": - case "ERROR": - return "failure" - case "PENDING": - case "QUEUED": - case "IN_PROGRESS": - case "REQUESTED": - case "WAITING": - return "pending" - case "SKIPPED": - return "skipped" - case "CANCELLED": - case "TIMED_OUT": - case "STALE": - case "STARTUP_FAILURE": - return "cancelled" - default: - return "pending" - } -} - -function formatCheckDuration(startedAt?: string, completedAt?: string): string | undefined { - if (!startedAt || !completedAt) return undefined - const secs = Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000) - return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s` -} - /** Run async thunks with bounded concurrency, returning settled results. */ async function settled(thunks: (() => Promise)[], concurrency: number): Promise[]> { const results: PromiseSettledResult[] = new Array(thunks.length) diff --git a/packages/kilo-vscode/src/agent-manager/am-pr-types.ts b/packages/kilo-vscode/src/agent-manager/am-pr-types.ts new file mode 100644 index 00000000000..4cd818d7a09 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/am-pr-types.ts @@ -0,0 +1,40 @@ +import type { PRState, ReviewDecision } from "./types" + +// Raw shapes returned by `gh pr view --json` + +export interface GhAuthor { + login?: string + avatarUrl?: string +} +export interface GhComment { + id: string + author?: GhAuthor + body?: string + path?: string + line?: number + url?: string + createdAt?: string +} +export interface GhThread { + isResolved?: boolean + comments?: { nodes?: GhComment[] } +} +export interface GhReviewRequest { + requestedReviewer?: GhAuthor +} +export interface GhReview { + author?: GhAuthor + state?: string +} + +export interface PRResult { + number: number + title: string + body: string + url: string + state: PRState + review: ReviewDecision | null + additions: number + deletions: number + files: number +} diff --git a/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts b/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts new file mode 100644 index 00000000000..5fe6e0fad5a --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts @@ -0,0 +1,105 @@ +import type { CheckStatus, PRComment, PRReviewer, ReviewerState } from "./types" +import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./am-pr-types" + +export function parsePRResult(json: string): PRResult | null { + const data = JSON.parse(json) + if (!data.number) return null + const state = data.isDraft ? "draft" : (data.state?.toLowerCase() ?? "open") + const decision = data.reviewDecision as string | undefined + const review = + decision === "APPROVED" + ? "approved" + : decision === "CHANGES_REQUESTED" + ? "changes_requested" + : decision === "REVIEW_REQUIRED" + ? "pending" + : null + return { + number: data.number, + title: data.title ?? "", + body: data.body ?? "", + url: data.url ?? "", + state, + review, + additions: data.additions ?? 0, + deletions: data.deletions ?? 0, + files: data.changedFiles ?? 0, + } +} + +export function checkStatus(state: string): CheckStatus { + switch (state.toUpperCase()) { + case "SUCCESS": + case "NEUTRAL": + return "success" + case "FAILURE": + case "ERROR": + case "ACTION_REQUIRED": + return "failure" + case "PENDING": + case "QUEUED": + case "IN_PROGRESS": + case "REQUESTED": + case "WAITING": + return "pending" + case "SKIPPED": + return "skipped" + case "CANCELLED": + case "TIMED_OUT": + case "STALE": + case "STARTUP_FAILURE": + return "cancelled" + default: + return "pending" + } +} + +export function formatCheckDuration(startedAt?: string, completedAt?: string): string | undefined { + if (!startedAt || !completedAt) return undefined + const secs = Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000) + return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s` +} + +const REVIEWER_STATE: Record = { + APPROVED: "approved", + CHANGES_REQUESTED: "changes_requested", + COMMENTED: "commented", +} + +export function parseComments(threads: GhThread[]): PRComment[] { + const items: PRComment[] = [] + for (const thread of threads) { + const first = thread.comments?.nodes?.[0] + if (!first) continue + items.push({ + id: first.id, + author: first.author?.login ?? "unknown", + avatar: first.author?.avatarUrl, + body: first.body ?? "", + file: first.path, + line: first.line, + url: first.url, + resolved: thread.isResolved ?? false, + createdAt: first.createdAt ? new Date(first.createdAt).getTime() : undefined, + }) + } + return items +} + +export function parseReviewers(requests: GhReviewRequest[], reviews: GhReview[]): PRReviewer[] { + const map = new Map() + for (const node of requests) { + const user = node.requestedReviewer + if (!user?.login) continue + map.set(user.login, { login: user.login, avatar: user.avatarUrl, state: "pending" }) + } + for (const node of reviews) { + const login = node.author?.login + if (!login) continue + const state = REVIEWER_STATE[node.state ?? ""] ?? "pending" + if (!map.has(login) || state !== "commented") { + map.set(login, { login, avatar: node.author?.avatarUrl, state }) + } + } + return [...map.values()] +} diff --git a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts index cbbb1bffda8..42370a5e721 100644 --- a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts +++ b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts @@ -31,6 +31,7 @@ export class PRStatusBridge { readonly poller: PRStatusPoller private readonly cache = new Map() private readonly host: PRBridgeHost + private lastErrorNotified: "gh_missing" | "gh_auth" | "fetch_failed" | undefined constructor(host: PRBridgeHost) { this.host = host @@ -61,6 +62,8 @@ export class PRStatusBridge { /** Replay cached PR statuses to a freshly-connected webview. */ replay(): void { this.cache.forEach((msg) => this.host.postToWebview(msg)) + if (this.lastErrorNotified === "gh_auth" || this.lastErrorNotified === "gh_missing") + this.host.postToWebview({ type: "agentManager.prError", error: this.lastErrorNotified } as AgentManagerOutMessage) } snapshot(): Map { @@ -78,8 +81,8 @@ export class PRStatusBridge { return true } if (m.type === "agentManager.openPR") { - const wt = this.host.getWorktrees().find((w: Worktree) => w.id === m.worktreeId) - if (wt?.prUrl) this.host.openExternal(wt.prUrl) + const url = (m.url as string) ?? this.host.getWorktrees().find((w: Worktree) => w.id === m.worktreeId)?.prUrl + if (url) this.host.openExternal(url) return true } return false @@ -93,6 +96,13 @@ export class PRStatusBridge { reset(): void { this.poller.stop() this.cache.clear() + this.lastErrorNotified = undefined + } + + notifyError(err: "gh_missing" | "gh_auth" | "fetch_failed"): void { + if (this.lastErrorNotified === err) return + this.lastErrorNotified = err + this.host.postToWebview({ type: "agentManager.prError", error: err } as AgentManagerOutMessage) } } @@ -115,10 +125,15 @@ function bridgePollerOpts(bridge: PRStatusBridge, host: PRBridgeHost) { pr: null, error: err, } as AgentManagerOutMessage) + // Always forward auth/missing errors so the webview can show a toast, + // regardless of whether prior data exists. Deduplicate per error type + // so multiple failing worktrees don't produce multiple toasts. + if (err === "gh_auth" || err === "gh_missing") bridge.notifyError(err) return } const msg = { type: "agentManager.prStatus", worktreeId: id, pr, error: err } as AgentManagerOutMessage bridge["cache"].set(id, msg) + bridge["lastErrorNotified"] = undefined host.postToWebview(msg) host.updateWorktreePR(id, pr?.number, pr?.url, pr?.state) }, diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 7e6792f10b2..01c88ff1581 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -71,9 +71,18 @@ export interface PRComment { createdAt?: number } +export type ReviewerState = "approved" | "changes_requested" | "pending" | "commented" + +export interface PRReviewer { + login: string + avatar?: string + state: ReviewerState +} + export interface PRStatus { number: number title: string + body?: string url: string state: PRState review: ReviewDecision | null @@ -83,12 +92,13 @@ export interface PRStatus { passed: number failed: number pending: number - items: PRCheck[] + checks: PRCheck[] } + reviewers: PRReviewer[] comments?: { total: number unresolved: number - items: PRComment[] + comments: PRComment[] } additions: number deletions: number @@ -379,6 +389,11 @@ interface PRStatusOutMessage { error?: "gh_missing" | "gh_auth" | "fetch_failed" } +interface PRErrorOutMessage { + type: "agentManager.prError" + error: "gh_missing" | "gh_auth" | "fetch_failed" +} + interface ActionOutMessage { type: "action" action: string @@ -418,6 +433,7 @@ export type AgentManagerOutMessage = | RevertWorktreeFileResultMessage | DiffBranchesMessage | PRStatusOutMessage + | PRErrorOutMessage | ActionOutMessage | RunStatusMessage | TerminalCreatedMessage @@ -739,6 +755,7 @@ interface OpenPRIn { type: "agentManager.openPR" projectId?: string worktreeId: string + url?: string } interface OpenSessionsIn { diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index c4bd2f9f74e..c1284b1d481 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -450,7 +450,7 @@ export type WebviewMessage = | { type: "sessionUpdated"; session: ReturnType } | { type: "sessionDeleted"; sessionID: string } | { type: "messageRemoved"; sessionID: string; messageID: string } - | { type: "sessionError"; sessionID?: string; error?: unknown } + | { type: "sessionError"; eventID: string; sessionID?: string; error?: unknown } | { type: "sandboxStatus" sessionID: string @@ -620,6 +620,7 @@ export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: strin case "session.error": { return { type: "sessionError", + eventID: event.id, sessionID: event.properties.sessionID, error: event.properties.error, } diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 7d0b8b1c132..0486cc70822 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -38,6 +38,7 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/MultiModelSelector.tsx"), path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"), + path.join(ROOT, "webview-ui/agent-manager/pr/PRBadge.tsx"), path.join(ROOT, "webview-ui/agent-manager/SectionHeader.tsx"), path.join(ROOT, "webview-ui/agent-manager/SidebarSectionHeader.tsx"), path.join(ROOT, "webview-ui/agent-manager/SidebarSearchMenu.tsx"), diff --git a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts index 74197ac0492..11d92fda5c4 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts @@ -86,8 +86,8 @@ describe("Agent Manager orchestration domain", () => { url: "https://example.com/pr/42", state: "open", review: "approved", - checks: { status: "success", total: 1, passed: 1, failed: 0, pending: 0, items: [] }, - comments: { total: 2, unresolved: 1, items: [] }, + checks: { status: "success", total: 1, passed: 1, failed: 0, pending: 0, checks: [] }, + comments: { total: 2, unresolved: 1, comments: [], reviewers: [] }, additions: 10, deletions: 2, files: 1, diff --git a/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts b/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts new file mode 100644 index 00000000000..734b3128f6e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "bun:test" +import { PRStatusBridge } from "../../src/agent-manager/pr-status-bridge" +import type { AgentManagerOutMessage, PRStatus } from "../../src/agent-manager/types" + +const pr: PRStatus = { + number: 1, + title: "my PR", + url: "https://github.com/x/y/pull/1", + state: "open", + review: null, + checks: { status: "none", total: 0, passed: 0, failed: 0, pending: 0, checks: [] }, + additions: 0, + deletions: 0, + files: 0, +} + +function harness(opts: { hasPersisted?: boolean } = {}) { + const sent: AgentManagerOutMessage[] = [] + const worktrees: { id: string; prUrl?: string }[] = [] + const bridge = PRStatusBridge.create({ + getWorktrees: () => worktrees as never, + getWorkspaceRoot: () => "/repo", + postToWebview: (msg) => sent.push(msg), + updateWorktreePR: () => {}, + hasPersistedPR: () => opts.hasPersisted ?? false, + openExternal: () => {}, + log: () => {}, + }) + const onStatus = (bridge.poller as unknown as { options: { onStatus: (...a: unknown[]) => void } }).options.onStatus + return { bridge, sent, onStatus } +} + +// --- error deduplication --- + +describe("PRStatusBridge.notifyError", () => { + it("sends the first error notification", () => { + const { bridge, sent } = harness() + bridge.notifyError("gh_missing") + expect(sent).toHaveLength(1) + expect(sent[0]).toEqual(expect.objectContaining({ type: "agentManager.prError", error: "gh_missing" })) + }) + + it("deduplicates the same error type", () => { + const { bridge, sent } = harness() + bridge.notifyError("gh_auth") + bridge.notifyError("gh_auth") + expect(sent).toHaveLength(1) + }) + + it("sends again when error type changes", () => { + const { bridge, sent } = harness() + bridge.notifyError("gh_missing") + bridge.notifyError("gh_auth") + expect(sent).toHaveLength(2) + }) +}) + +// --- onStatus cache suppression --- + +describe("PRStatusBridge onStatus", () => { + it("forwards a successful status to the webview", () => { + const { sent, onStatus } = harness() + onStatus("wt1", pr) + expect(sent).toHaveLength(1) + expect(sent[0]).toEqual(expect.objectContaining({ type: "agentManager.prStatus", worktreeId: "wt1", pr })) + }) + + it("forwards pr:null error when no cache entry and no persisted PR", () => { + const { sent, onStatus } = harness() + onStatus("wt1", null, "fetch_failed") + expect(sent).toHaveLength(1) + expect(sent[0]).toEqual(expect.objectContaining({ type: "agentManager.prStatus", worktreeId: "wt1", pr: null })) + }) + + it("suppresses pr:null error when cache entry exists", () => { + const { sent, onStatus } = harness() + onStatus("wt1", pr) + sent.length = 0 + onStatus("wt1", null, "fetch_failed") + expect(sent).toHaveLength(0) + }) + + it("suppresses pr:null error when persisted PR exists", () => { + const { sent, onStatus } = harness({ hasPersisted: true }) + onStatus("wt1", null, "fetch_failed") + expect(sent).toHaveLength(0) + }) + + it("forwards gh_auth error even when cache entry exists", () => { + const { sent, onStatus } = harness() + onStatus("wt1", pr) + sent.length = 0 + onStatus("wt1", null, "gh_auth") + const errorMsg = sent.find((m) => m.type === "agentManager.prError") + expect(errorMsg).toEqual(expect.objectContaining({ error: "gh_auth" })) + }) + + it("forwards gh_missing error even when cache entry exists", () => { + const { sent, onStatus } = harness() + onStatus("wt1", pr) + sent.length = 0 + onStatus("wt1", null, "gh_missing") + const errorMsg = sent.find((m) => m.type === "agentManager.prError") + expect(errorMsg).toEqual(expect.objectContaining({ error: "gh_missing" })) + }) +}) + +// --- replay --- + +describe("PRStatusBridge.replay", () => { + it("replays cached status messages", () => { + const { bridge, sent, onStatus } = harness() + onStatus("wt1", pr) + sent.length = 0 + bridge.replay() + expect(sent).toHaveLength(1) + expect(sent[0]).toEqual(expect.objectContaining({ type: "agentManager.prStatus", worktreeId: "wt1" })) + }) + + it("replays the last auth error on reconnect", () => { + const { bridge, sent, onStatus } = harness() + onStatus("wt1", null, "gh_auth") + sent.length = 0 + bridge.replay() + expect( + sent.some((m) => m.type === "agentManager.prError" && (m as never as { error: string }).error === "gh_auth"), + ).toBe(true) + }) + + it("does not replay fetch_failed errors", () => { + const { bridge, sent, onStatus } = harness() + onStatus("wt1", null, "fetch_failed") + sent.length = 0 + bridge.replay() + expect(sent).toHaveLength(0) + }) +}) + +// --- snapshot --- + +describe("PRStatusBridge.snapshot", () => { + it("returns only entries with a non-null pr", () => { + const { bridge, onStatus } = harness() + onStatus("wt1", pr) + onStatus("wt2", pr) + expect(bridge.snapshot().size).toBe(2) + }) + + it("excludes entries where pr was null", () => { + const { bridge, onStatus } = harness({ hasPersisted: true }) + onStatus("wt1", null, "fetch_failed") + expect(bridge.snapshot().size).toBe(0) + }) +}) + +// --- remove / reset --- + +describe("PRStatusBridge.remove", () => { + it("removes a cached entry so it is no longer replayed", () => { + const { bridge, sent, onStatus } = harness() + onStatus("wt1", pr) + bridge.remove("wt1") + sent.length = 0 + bridge.replay() + expect(sent).toHaveLength(0) + }) +}) + +describe("PRStatusBridge.reset", () => { + it("clears cache and error state so replay sends nothing", () => { + const { bridge, sent, onStatus } = harness() + onStatus("wt1", pr) + bridge.notifyError("gh_auth") + bridge.reset() + sent.length = 0 + bridge.replay() + expect(sent).toHaveLength(0) + }) + + it("allows the same error to be sent again after reset", () => { + const { bridge, sent } = harness() + bridge.notifyError("gh_auth") + bridge.reset() + sent.length = 0 + bridge.notifyError("gh_auth") + expect(sent).toHaveLength(1) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts b/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts new file mode 100644 index 00000000000..ba2d8d80070 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it } from "bun:test" +import { + parsePRResult, + checkStatus, + formatCheckDuration, + parseComments, + parseReviewers, +} from "../../src/agent-manager/am-pr-utils" +import type { GhThread, GhReviewRequest, GhReview } from "../../src/agent-manager/am-pr-types" + +// --- parsePRResult --- + +describe("parsePRResult", () => { + it("returns null when number is missing", () => { + expect(parsePRResult(JSON.stringify({ title: "foo" }))).toBeNull() + }) + + it("parses an open PR", () => { + const raw = { + number: 42, + title: "my PR", + body: "desc", + url: "https://github.com/x/y/pull/42", + state: "OPEN", + isDraft: false, + reviewDecision: null, + additions: 10, + deletions: 3, + changedFiles: 2, + } + expect(parsePRResult(JSON.stringify(raw))).toEqual({ + number: 42, + title: "my PR", + body: "desc", + url: "https://github.com/x/y/pull/42", + state: "open", + review: null, + additions: 10, + deletions: 3, + files: 2, + }) + }) + + it("maps isDraft to draft state regardless of gh state field", () => { + const raw = { + number: 1, + title: "", + body: "", + url: "", + state: "OPEN", + isDraft: true, + reviewDecision: null, + additions: 0, + deletions: 0, + changedFiles: 0, + } + expect(parsePRResult(JSON.stringify(raw))?.state).toBe("draft") + }) + + it("maps MERGED state", () => { + const raw = { + number: 1, + title: "", + body: "", + url: "", + state: "MERGED", + isDraft: false, + reviewDecision: null, + additions: 0, + deletions: 0, + changedFiles: 0, + } + expect(parsePRResult(JSON.stringify(raw))?.state).toBe("merged") + }) + + it("maps CLOSED state", () => { + const raw = { + number: 1, + title: "", + body: "", + url: "", + state: "CLOSED", + isDraft: false, + reviewDecision: null, + additions: 0, + deletions: 0, + changedFiles: 0, + } + expect(parsePRResult(JSON.stringify(raw))?.state).toBe("closed") + }) + + it("maps APPROVED review decision", () => { + const raw = { + number: 1, + title: "", + body: "", + url: "", + state: "OPEN", + isDraft: false, + reviewDecision: "APPROVED", + additions: 0, + deletions: 0, + changedFiles: 0, + } + expect(parsePRResult(JSON.stringify(raw))?.review).toBe("approved") + }) + + it("maps CHANGES_REQUESTED review decision", () => { + const raw = { + number: 1, + title: "", + body: "", + url: "", + state: "OPEN", + isDraft: false, + reviewDecision: "CHANGES_REQUESTED", + additions: 0, + deletions: 0, + changedFiles: 0, + } + expect(parsePRResult(JSON.stringify(raw))?.review).toBe("changes_requested") + }) + + it("maps REVIEW_REQUIRED review decision to pending", () => { + const raw = { + number: 1, + title: "", + body: "", + url: "", + state: "OPEN", + isDraft: false, + reviewDecision: "REVIEW_REQUIRED", + additions: 0, + deletions: 0, + changedFiles: 0, + } + expect(parsePRResult(JSON.stringify(raw))?.review).toBe("pending") + }) + + it("returns null review for unknown decision", () => { + const raw = { + number: 1, + title: "", + body: "", + url: "", + state: "OPEN", + isDraft: false, + reviewDecision: "SOMETHING_ELSE", + additions: 0, + deletions: 0, + changedFiles: 0, + } + expect(parsePRResult(JSON.stringify(raw))?.review).toBeNull() + }) + + it("defaults missing fields to empty strings and zeros", () => { + const result = parsePRResult(JSON.stringify({ number: 5 })) + expect(result).toEqual( + expect.objectContaining({ title: "", body: "", url: "", additions: 0, deletions: 0, files: 0 }), + ) + }) +}) + +// --- checkStatus --- + +describe("checkStatus", () => { + it("maps SUCCESS", () => expect(checkStatus("SUCCESS")).toBe("success")) + it("maps FAILURE", () => expect(checkStatus("FAILURE")).toBe("failure")) + it("maps ERROR to failure", () => expect(checkStatus("ERROR")).toBe("failure")) + it("maps PENDING", () => expect(checkStatus("PENDING")).toBe("pending")) + it("maps QUEUED to pending", () => expect(checkStatus("QUEUED")).toBe("pending")) + it("maps IN_PROGRESS to pending", () => expect(checkStatus("IN_PROGRESS")).toBe("pending")) + it("maps REQUESTED to pending", () => expect(checkStatus("REQUESTED")).toBe("pending")) + it("maps WAITING to pending", () => expect(checkStatus("WAITING")).toBe("pending")) + it("maps SKIPPED", () => expect(checkStatus("SKIPPED")).toBe("skipped")) + it("maps CANCELLED", () => expect(checkStatus("CANCELLED")).toBe("cancelled")) + it("maps TIMED_OUT to cancelled", () => expect(checkStatus("TIMED_OUT")).toBe("cancelled")) + it("maps STALE to cancelled", () => expect(checkStatus("STALE")).toBe("cancelled")) + it("maps STARTUP_FAILURE to cancelled", () => expect(checkStatus("STARTUP_FAILURE")).toBe("cancelled")) + it("maps unknown state to pending", () => expect(checkStatus("WHATEVER")).toBe("pending")) + it("is case-insensitive", () => expect(checkStatus("success")).toBe("success")) +}) + +// --- formatCheckDuration --- + +describe("formatCheckDuration", () => { + it("returns undefined when startedAt is missing", () => { + expect(formatCheckDuration(undefined, "2024-01-01T00:01:00Z")).toBeUndefined() + }) + + it("returns undefined when completedAt is missing", () => { + expect(formatCheckDuration("2024-01-01T00:00:00Z", undefined)).toBeUndefined() + }) + + it("formats sub-minute durations in seconds", () => { + expect(formatCheckDuration("2024-01-01T00:00:00Z", "2024-01-01T00:00:45Z")).toBe("45s") + }) + + it("formats durations over a minute as m/s", () => { + expect(formatCheckDuration("2024-01-01T00:00:00Z", "2024-01-01T00:02:30Z")).toBe("2m 30s") + }) + + it("formats exactly 60 seconds as 1m 0s", () => { + expect(formatCheckDuration("2024-01-01T00:00:00Z", "2024-01-01T00:01:00Z")).toBe("1m 0s") + }) +}) + +// --- parseComments --- + +describe("parseComments", () => { + it("returns empty array for empty threads", () => { + expect(parseComments([])).toEqual([]) + }) + + it("skips threads with no comments", () => { + const threads: GhThread[] = [{ isResolved: false, comments: { nodes: [] } }] + expect(parseComments(threads)).toHaveLength(0) + }) + + it("parses a resolved thread", () => { + const threads: GhThread[] = [ + { + isResolved: true, + comments: { + nodes: [ + { + id: "c1", + author: { login: "alice", avatarUrl: "https://avatar" }, + body: "looks good", + path: "src/foo.ts", + line: 10, + url: "https://url", + createdAt: "2024-01-01T00:00:00Z", + }, + ], + }, + }, + ] + expect(parseComments(threads)).toEqual([ + { + id: "c1", + author: "alice", + avatar: "https://avatar", + body: "looks good", + file: "src/foo.ts", + line: 10, + url: "https://url", + resolved: true, + createdAt: new Date("2024-01-01T00:00:00Z").getTime(), + }, + ]) + }) + + it("defaults missing author to 'unknown'", () => { + const threads: GhThread[] = [{ isResolved: false, comments: { nodes: [{ id: "c2", body: "note" }] } }] + expect(parseComments(threads)[0]?.author).toBe("unknown") + }) + + it("only uses the first comment of each thread", () => { + const threads: GhThread[] = [ + { + isResolved: false, + comments: { + nodes: [ + { id: "first", body: "first comment" }, + { id: "second", body: "second comment" }, + ], + }, + }, + ] + const result = parseComments(threads) + expect(result).toHaveLength(1) + expect(result[0]?.id).toBe("first") + }) +}) + +// --- parseReviewers --- + +describe("parseReviewers", () => { + it("returns empty array with no requests or reviews", () => { + expect(parseReviewers([], [])).toEqual([]) + }) + + it("adds pending reviewer from request", () => { + const requests: GhReviewRequest[] = [{ requestedReviewer: { login: "alice", avatarUrl: "https://avatar" } }] + expect(parseReviewers(requests, [])).toEqual([{ login: "alice", avatar: "https://avatar", state: "pending" }]) + }) + + it("skips review requests without a login", () => { + const requests: GhReviewRequest[] = [{ requestedReviewer: {} }] + expect(parseReviewers(requests, [])).toHaveLength(0) + }) + + it("adds reviewer from review when not in requests", () => { + const reviews: GhReview[] = [{ author: { login: "bob" }, state: "APPROVED" }] + expect(parseReviewers([], reviews)).toEqual([{ login: "bob", avatar: undefined, state: "approved" }]) + }) + + it("upgrades pending request to approved when review arrives", () => { + const requests: GhReviewRequest[] = [{ requestedReviewer: { login: "alice" } }] + const reviews: GhReview[] = [{ author: { login: "alice" }, state: "APPROVED" }] + expect(parseReviewers(requests, reviews)).toEqual([{ login: "alice", avatar: undefined, state: "approved" }]) + }) + + it("does not downgrade approved to commented", () => { + const requests: GhReviewRequest[] = [{ requestedReviewer: { login: "alice" } }] + const reviews: GhReview[] = [ + { author: { login: "alice" }, state: "APPROVED" }, + { author: { login: "alice" }, state: "COMMENTED" }, + ] + expect(parseReviewers(requests, reviews)[0]?.state).toBe("approved") + }) + + it("does upgrade pending to changes_requested", () => { + const requests: GhReviewRequest[] = [{ requestedReviewer: { login: "alice" } }] + const reviews: GhReview[] = [{ author: { login: "alice" }, state: "CHANGES_REQUESTED" }] + expect(parseReviewers(requests, reviews)[0]?.state).toBe("changes_requested") + }) + + it("skips reviews without a login", () => { + const reviews: GhReview[] = [{ author: {}, state: "APPROVED" }] + expect(parseReviewers([], reviews)).toHaveLength(0) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts b/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts index 814b2147e0b..788fa6615ac 100644 --- a/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts +++ b/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts @@ -292,4 +292,20 @@ describe("validateCustomProvider – variant name validation", () => { const saved = out.result!.config.models["model-1"] as Record expect(saved.modalities).toEqual({ input: ["text", "audio", "video", "pdf"], output: ["text", "audio"] }) }) + + it("handles multiple models with reasoning and images toggled", () => { + const form = base() + form.models = [ + { id: "m1", name: "Model 1", reasoning: true, supportsImages: true, modalities: {}, variants: [] }, + { id: "m2", name: "Model 2", reasoning: true, supportsImages: false, modalities: {}, variants: [] }, + ] + const out = validateCustomProvider(args(form)) + expect(out.result).toBeDefined() + const m1 = out.result!.config.models["m1"] as Record + const m2 = out.result!.config.models["m2"] as Record + expect(m1.reasoning).toBe(true) + expect(m1.modalities).toEqual({ input: ["text", "image"] }) + expect(m2.reasoning).toBe(true) + expect(m2.modalities).toBeUndefined() + }) }) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts index 274cb8cc60d..815ab32f8bd 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts @@ -127,6 +127,43 @@ describe("KiloProvider memory events", () => { expect(calls).toEqual(["/repo", "/repo"]) }) + it("refreshes status without forwarding transient memory errors", async () => { + const calls: string[] = [] + const posts: unknown[] = [] + const client = { + memory: { + status: async (input: { directory: string }) => { + calls.push(input.directory) + return { data: status(input.directory) } + }, + }, + } as unknown as KiloClient + const provider = new KiloProvider( + {} as never, + { + getClient: () => client, + } as never, + ) + const item = provider as unknown as Internals + item.webview = { postMessage: async (message) => posts.push(message) } + item.currentSession = { id: "ses_active" } + item.trackedSessionIds.add("ses_active") + provider.setSessionDirectory("ses_active", "/repo") + + item.handleEvent( + { + type: "memory.error", + properties: { sessionID: "ses_active", reason: "transient" }, + }, + "/repo", + ) + await item.memory.idle() + + expect(posts).not.toContainEqual(expect.objectContaining({ type: "memoryEvent" })) + expect(posts).toContainEqual(expect.objectContaining({ type: "memoryLoaded", sessionID: "ses_active" })) + expect(calls).toEqual(["/repo"]) + }) + it("uses the project directory when toggling memory", async () => { const calls: unknown[] = [] const client = { diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index 3a9213d887f..75ed68671e5 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -21,6 +21,7 @@ import type { Event, EventSessionStatus, EventSessionTurnClose, + EventSessionError, EventSandboxStatusChanged, EventPermissionAsked, EventPermissionReplied, @@ -375,6 +376,30 @@ describe("mapSSEEventToWebviewMessage", () => { expect(msg).toEqual({ type: "sessionTurnClosed", sessionID: "sess-1", reason: "interrupted" }) }) + it("maps session errors with their event identity and message", () => { + const event: EventSessionError = { + id: "evt-error", + type: "session.error", + properties: { + sessionID: "sess-1", + error: { + name: "APIError", + data: { + message: "prompt_cache_breakpoint is not supported on this model", + isRetryable: false, + }, + }, + }, + } + + expect(mapSSEEventToWebviewMessage(event, "sess-1")).toEqual({ + type: "sessionError", + eventID: "evt-error", + sessionID: "sess-1", + error: event.properties.error, + }) + }) + it("maps permission.asked to permissionRequest", () => { const event: EventPermissionAsked = { type: "permission.asked", diff --git a/packages/kilo-vscode/tests/unit/model-price-format.test.ts b/packages/kilo-vscode/tests/unit/model-price-format.test.ts index d5aab2d6729..8a01b838094 100644 --- a/packages/kilo-vscode/tests/unit/model-price-format.test.ts +++ b/packages/kilo-vscode/tests/unit/model-price-format.test.ts @@ -18,6 +18,11 @@ describe("fmtPrice", () => { expect(fmtPrice(0)).toBe("Free") }) + it("returns dash for negative price", () => { + expect(fmtPrice(-1)).toBe("—") + expect(fmtPrice(-1000000)).toBe("—") + }) + it("uses 4 decimal places for sub-cent prices", () => { expect(fmtPrice(0.005)).toBe("$0.0050/1M") }) diff --git a/packages/kilo-vscode/tests/unit/session-errors.test.ts b/packages/kilo-vscode/tests/unit/session-errors.test.ts index c24ed0297a8..53eb01d8152 100644 --- a/packages/kilo-vscode/tests/unit/session-errors.test.ts +++ b/packages/kilo-vscode/tests/unit/session-errors.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "bun:test" -import { errorIDs, visibleError } from "../../webview-ui/src/context/session-errors" +import { + errorIDs, + preserveSessionErrors, + visibleError, + withoutResolvedSessionErrors, +} from "../../webview-ui/src/context/session-errors" import type { Message } from "../../webview-ui/src/types/messages" const base = { @@ -45,3 +50,32 @@ describe("visibleError", () => { expect(visibleError(messages, () => false)).toBeUndefined() }) }) + +describe("session error reconciliation", () => { + const error = { + name: "APIError", + data: { message: "prompt_cache_breakpoint is not supported on this model", isRetryable: false }, + } + + it("preserves transient errors across stale history replacements", () => { + const user = { ...base, id: "message_1", role: "user" as const } + const transient = { ...assistant("message_2", error), parentID: user.id, sessionErrorID: "event_1" } + + expect(preserveSessionErrors([user, transient], [user])).toEqual([user, transient]) + }) + + it("replaces a transient error with its persisted assistant message", () => { + const transient = { ...assistant("message_2", error), parentID: "message_1", sessionErrorID: "event_1" } + const persisted = { ...assistant("message_3", error), parentID: "message_1" } + + expect(preserveSessionErrors([transient], [persisted])).toEqual([persisted]) + expect(withoutResolvedSessionErrors([transient], [persisted])).toEqual([]) + }) + + it("deduplicates repeated delivery of the same session error event", () => { + const first = { ...assistant("message_2", error), parentID: "message_1", sessionErrorID: "event_1" } + const repeat = { ...assistant("message_3", error), parentID: "message_1", sessionErrorID: "event_1" } + + expect(withoutResolvedSessionErrors([first], [repeat])).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index dbc84af2e2b..80c6314e6c8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -36,6 +36,7 @@ import type { RunStatus, PRStatus, AgentManagerPRStatusMessage, + AgentManagerPRErrorMessage, AgentManagerProjectsMessage, AgentProjectSnapshot, ManagedSessionState, @@ -143,6 +144,7 @@ import { import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering" import { useTabScroll } from "./tab-scroll" import { DiffPanel } from "./DiffPanel" +import { PRPanel } from "./pr/PRPanel" import { createRevertFile } from "./revert-file" import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView" import { createApplyToLocal } from "./apply-to-local" @@ -152,6 +154,8 @@ import { clearReviewComposer, createReviewComposer } from "../diff-viewer/review import type { SidebarSearchMenuRef } from "./SidebarSearchMenu" import { createSidebarSearch, type SidebarSearchItem } from "./sidebar-search" import { randomColor } from "./section-colors" +import { createMarkdownRender } from "./review-preferences" +import { createSidebarCollapse } from "./sidebar-collapse" import { createNewTaskDrafts } from "./new-task-drafts" import { buildTopLevelItems, @@ -170,11 +174,9 @@ import { DiffScopeControls } from "../diff-viewer/DiffScopeControls" import { scopeCapabilities } from "./diff-scope-state" import { createDiffReviewScope } from "./diff-review-scope" import { initialMessage, seedInitialVariant } from "./initial-message" -import { createMarkdownRender } from "./review-preferences" -import { createSidebarCollapse } from "./sidebar-collapse" import { SidebarToggleButton } from "./SidebarToggleButton" import { setTabWidths } from "./tab-widths" -import { clampPanelWidth, createPanelResize, maxPanelWidth, minPanelWidth } from "./side-panel-layout" +import { clampPanelWidth, createPanelResize, maxPanelWidth, minPanelWidth, SidePanel } from "./side-panel-layout" import { buildShortcutCategories } from "./shortcuts" import { tracker } from "./telemetry" import { createChatFocus, createPromptFocus, hasQuestionOption } from "./focus" @@ -196,7 +198,7 @@ interface SetupState { /** Sidebar selection: LOCAL for local repo, worktree ID for a worktree, or null for an unassigned session. */ type SidebarSelection = typeof LOCAL | string | null -type SidePanel = "diff" | "pr" | "terminal" | null +export type SidePanelState = SidePanel | null const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) import { parseBindingTokens } from "./keybind-tokens" @@ -288,8 +290,16 @@ const AgentManagerContent: Component = () => { let pendingSidebarWidth: number | undefined const [history, setHistory] = createSignal(false) - const [sidePanel, setSidePanel] = createSignal(null) - const diffOpen = () => sidePanel() === "diff" + const [sidePanel, setSidePanel] = createSignal(null) + const diffOpen = () => sidePanel() === SidePanel.Diff + const prOpen = () => sidePanel() === SidePanel.PR + const activePR = createMemo(() => { + const selected = selection() + if (!selected || selected === LOCAL) return undefined + const pr = prStatuses()[selected] + if (!pr) return undefined + return { pr, selected, wt: worktrees().find((w) => w.id === selected) } + }) const diffs = createWorktreeDiffs(vscode) const diffDatas = diffs.diffDatas const diffLoading = diffs.diffLoading @@ -302,7 +312,7 @@ const AgentManagerContent: Component = () => { const showSideTerminal = () => { setHistory(false) setReviewActive(false) - setSidePanel("terminal") + setSidePanel(SidePanel.Terminal) } const [reviewOpenByContext, setReviewOpenByContext] = createSignal>({}) @@ -314,7 +324,6 @@ const AgentManagerContent: Component = () => { // Per-worktree git stats (diff additions/deletions, commits missing from origin) const worktreeStats = () => registry.active().worktreeStats() - // Per-worktree PR status data const prStatuses = () => registry.active().prStatuses() const runStatuses = () => registry.active().runStatuses() @@ -392,7 +401,7 @@ const AgentManagerContent: Component = () => { const rememberPromptFocus = (focused: boolean) => { if (focused) focusMemory.set(focusKey(), "prompt") } - const terminalVisible = () => sidePanel() === "terminal" && !history() && !reviewActive() + const terminalVisible = () => sidePanel() === SidePanel.Terminal && !history() && !reviewActive() const focusOnDraftChange = () => { const key = focusKey() const owner = focusMemory.get(key) @@ -461,6 +470,15 @@ const AgentManagerContent: Component = () => { } createEffect(on(selection, () => cancelPendingDelete(), { defer: true })) createEffect(on(selection, () => clearReviewComposer(reviewComposer), { defer: true })) + createEffect( + on( + selection, + () => { + if (!activePR()) setSidePanel((p) => (p === SidePanel.PR ? null : p)) + }, + { defer: true }, + ), + ) onCleanup(() => clearTimeout(pendingDeleteTimer)) // Per-context tab memory lives in the active project's store: maps sidebar @@ -518,11 +536,17 @@ const AgentManagerContent: Component = () => { } const openWindow = metrics.click("open_worktree_window", "tab_toolbar", openWorktreeDirectory) + const togglePRPanel = () => { + setHistory(false) + if (reviewActive()) closeReviewTab() + setSidePanel((prev) => (prev === SidePanel.PR ? null : SidePanel.PR)) + } + const openSelectedPR = () => { const sel = selection() if (!sel || sel === LOCAL || !prStatuses()[sel]) return metrics.track("open_pull_request", "keyboard_shortcut") - vscode.postMessage({ type: "agentManager.openPR", worktreeId: sel }) + togglePRPanel() } const runWorktree = (id: string, destination: TerminalDestination) => { @@ -1154,8 +1178,8 @@ const AgentManagerContent: Component = () => { } else if (msg.action === "toggleDiff") { if (reviewActive()) { closeReviewTab() - setSidePanel("diff") - } else setSidePanel((prev) => (prev === "diff" ? null : "diff")) + setSidePanel(SidePanel.Diff) + } else setSidePanel((prev) => (prev === SidePanel.Diff ? null : SidePanel.Diff)) } else if (msg.action === "newTab") handleNewTabForCurrentSelection() else if (msg.action === "closeTab") closeActiveTab() else if (msg.action === "newWorktree") showNewWorktreeDialog() @@ -1316,6 +1340,7 @@ const AgentManagerContent: Component = () => { showToast({ variant: "error", title: t("agentManager.terminal.errorTitle"), description: message }), postMessage: (message) => vscode.postMessage(message as never), onCreated: (contextKey, terminalId) => appendToTabOrder(contextKey, terminalId), + onSideClosed: (_contextKey, terminalId) => forgetTerminalFocus(terminalId), onScriptRunning: (contextKey, terminalId) => { if (terms.sideKey() !== contextKey) return @@ -1383,7 +1408,7 @@ const AgentManagerContent: Component = () => { if (!isActivePayload(ev.projectId)) return // Close diff/review panels — nothing to show during setup. // Terminal panels keep live setup output, so they stay open. - if (sidePanel() === "diff") setSidePanel(null) + if (sidePanel() === SidePanel.Diff) setSidePanel(null) setReviewActive(false) setSetup({ active: true, message: ev.message, branch: ev.branch, worktreeId: ev.worktreeId }) } @@ -1533,6 +1558,15 @@ const AgentManagerContent: Component = () => { managedSession: focusManagedSession, }) + if (msg.type === "agentManager.prError") { + const ev = msg as AgentManagerPRErrorMessage + showToast({ + variant: "error", + title: t(`agentManager.pr.error.${ev.error}.title`), + description: t(`agentManager.pr.error.${ev.error}.description`), + }) + } + if (projectLive.apply(msg)) return }) @@ -1982,7 +2016,7 @@ const AgentManagerContent: Component = () => { const sideCtl = createSideTerminal({ handlers: termHandlers, - visible: () => sidePanel() === "terminal" && !history() && !reviewActive(), + visible: () => sidePanel() === SidePanel.Terminal && !history() && !reviewActive(), focusedId: () => terms.sideFocusedId(), count: () => terms.sidesForContext(terms.sideKey()).length, isScript: terms.isScript, @@ -2115,7 +2149,7 @@ const AgentManagerContent: Component = () => { const closeActiveTab = () => { // A focused side terminal owns Cmd+W while its panel is visible. // Closing a chat tab out from under the user's cursor would be surprising. - if (sidePanel() === "terminal" && terms.sideFocusedId()) { + if (sidePanel() === SidePanel.Terminal && terms.sideFocusedId()) { if (sideCtl.close()) return } if (termHandlers.closeFocused()) { @@ -2171,10 +2205,10 @@ const AgentManagerContent: Component = () => { }) if (reviewActive()) { closeReviewTab() - setSidePanel("diff") + setSidePanel(SidePanel.Diff) return } - setSidePanel((prev) => (prev === "diff" ? null : "diff")) + setSidePanel((prev) => (prev === SidePanel.Diff ? null : SidePanel.Diff)) } const renderTabById = (id: string) => @@ -2370,8 +2404,11 @@ const AgentManagerContent: Component = () => { reviewActive={reviewActive} onToggleDiff={toggleDiffPanel} onToggleReview={metrics.click("fullscreen_review", "tab_toolbar", toggleReviewTab)} + prStatus={() => activePR()?.pr} + prOpen={prOpen} + onTogglePR={togglePRPanel} terminalDestination={sideCtl.destination} - terminalDestinationActive={() => sidePanel() === "terminal"} + terminalDestinationActive={() => sidePanel() === SidePanel.Terminal} terminalKeybind={() => kb().showTerminal ?? ""} onTerminalDestinationOpen={() => { cancelAmbientSetup() @@ -2559,7 +2596,7 @@ const AgentManagerContent: Component = () => { />
- + { activeTerminalId={terms.activeId()} /> + + {(() => { + const data = activePR()! + return ( + setSidePanel(null)} + onOpenExternal={() => + vscode.postMessage({ + type: "agentManager.openPR", + worktreeId: data.selected, + url: data.pr.url, + }) + } + /> + ) + })()} + sidePanel() === "terminal"} + visible={() => sidePanel() === SidePanel.Terminal} nextKeybind={kb().nextTerminal ?? ""} closeKeybind={kb().closeTab ?? ""} onFocusPrompt={prompt.focus} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx index 359ad99e4cb..3f8cba105e7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx @@ -13,7 +13,7 @@ import { closestCenter, type DragEvent, } from "@thisbeyond/solid-dnd" -import type { LocalGitStats, RunStatus, WorktreeGitStats } from "../src/types/messages" +import type { LocalGitStats, RunStatus, WorktreeGitStats, PRStatus } from "../src/types/messages" import type { LanguageContextValue } from "../src/context/language" import { LOCAL } from "./navigate" import { ConstrainDragYAxis } from "../src/components/chat/TabDnd" @@ -55,6 +55,9 @@ export interface TabBarProps { reviewActive: () => boolean onToggleDiff: () => void onToggleReview: () => void + prStatus: () => PRStatus | undefined + prOpen: () => boolean + onTogglePR: () => void terminalDestination: () => TerminalDestination terminalDestinationActive: () => boolean terminalKeybind: () => string @@ -215,6 +218,20 @@ export const TabBar: Component = (props) => ( ) })()} + + {(pr) => ( + + + + )} + = (props) => ( title={props.t("agentManager.diff.toggle")} > - - - 0}> - {stats()!.files}f + + {(pr) => ( + 0 || pr().deletions > 0}> + + 0}> + +{pr().additions} + + 0}> + −{pr().deletions} + + - +{stats()!.additions} - −{stats()!.deletions} - + )} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx index a7fc15a6cf4..c44b767cffd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx @@ -10,7 +10,8 @@ import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import { HoverCard } from "@kilocode/kilo-ui/hover-card" import { ContextMenu } from "@kilocode/kilo-ui/context-menu" import { Button } from "@kilocode/kilo-ui/button" -import type { WorktreeState, WorktreeGitStats, PRStatus, SectionState, RunStatus } from "../src/types/messages" +import type { WorktreeState, WorktreeGitStats, SectionState, RunStatus } from "../src/types/messages" +import type { PRStatus } from "../src/types/messages" import { colorCss } from "./section-colors" import { useLanguage } from "../src/context/language" import { formatRelativeDate } from "../src/utils/date" @@ -57,7 +58,7 @@ interface WorktreeItemProps { /** Keybinding string for the open-in-vscode action. */ openKeybind: string /** PR status for this worktree's branch, or null if no PR. */ - pr?: PRStatus | null + pr?: PRStatus runStatus?: RunStatus /** Callback when the PR badge is clicked. */ onOpenPR?: () => void @@ -94,14 +95,6 @@ const hasStats = (s: WorktreeGitStats | undefined): s is WorktreeGitStats => * and review results are conveyed by a separate status icon (see prBadgeIndicator) * so a failing check is not mistaken for a closed PR. */ -export function prAccentColor(pr: PRStatus): string { - if (pr.state === "draft") return "var(--text-weaker)" - if (pr.state === "merged") return "#a78bfa" - if (pr.state === "closed") return "#f87171" - if (pr.checks.status === "pending") return "#fbbf24" - return "#34d399" -} - /** True while an open PR's checks are still running — drives the pulsing amber badge. */ export function prChecksRunning(pr: PRStatus): boolean { return pr.state === "open" && pr.checks.status === "pending" @@ -349,18 +342,30 @@ export const WorktreeItem: Component = (props) => { } > {(pr) => { - const accent = () => prAccentColor(pr()) const indicator = () => prBadgeIndicator(pr()) return ( - }> + + #{pr().number} + - + @@ -374,7 +379,6 @@ export const WorktreeItem: Component = (props) => { /> - #{pr().number} ) }} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 88adf062704..5017d37a669 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -840,7 +840,7 @@ html[data-theme="kilo-vscode"] } .am-run-badge[data-run-state="stopping"] { - --am-run-accent: #fbbf24; + --am-run-accent: var(--vscode-charts-yellow, #fbbf24); } .am-run-badge[data-run-state="success"] { @@ -848,7 +848,7 @@ html[data-theme="kilo-vscode"] } .am-run-badge[data-run-state="error"] { - --am-run-accent: #f87171; + --am-run-accent: var(--vscode-testing-iconFailed, #f87171); } @keyframes am-run-pulse { @@ -898,24 +898,44 @@ html[data-theme="kilo-vscode"] } .am-stat-additions { - color: #34d399; + color: var(--vscode-testing-iconPassed, #34d399); } .am-stat-deletions { - color: #f87171; + color: var(--vscode-testing-iconFailed, #f87171); +} + +/* PR badge accent colors via classList */ +.am-pr-accent-open, +.am-pr-panel-badge.am-pr-accent-open { + --am-pr-accent: var(--vscode-testing-iconPassed, #34d399); +} +.am-pr-accent-pending, +.am-pr-panel-badge.am-pr-accent-pending { + --am-pr-accent: var(--vscode-charts-yellow, #fbbf24); +} +.am-pr-accent-draft, +.am-pr-panel-badge.am-pr-accent-draft { + --am-pr-accent: var(--text-weaker); +} +.am-pr-accent-merged, +.am-pr-panel-badge.am-pr-accent-merged { + --am-pr-accent: var(--vscode-charts-purple, var(--vscode-foreground)); +} +.am-pr-accent-closed, +.am-pr-panel-badge.am-pr-accent-closed { + --am-pr-accent: var(--vscode-testing-iconFailed, #f87171); } -/* PR pill badge — second row inside am-wt-row2. - Uses --pr-accent custom property (set inline) to derive all colors. */ .am-pr-badge { display: inline-flex; align-items: center; gap: 2px; - padding: 2px 7px 2px 4px; + padding: 2px 4px; border-radius: 6px; border: none; - background: color-mix(in srgb, var(--pr-accent) 12%, transparent); - color: var(--pr-accent); + background: color-mix(in srgb, var(--am-pr-accent) 12%, transparent); + color: var(--am-pr-accent); cursor: pointer; font-size: var(--kilo-font-size-11); font-weight: 500; @@ -924,34 +944,34 @@ html[data-theme="kilo-vscode"] margin-left: auto; } .am-pr-badge:hover { - background: color-mix(in srgb, var(--pr-accent) 25%, transparent); + background: color-mix(in srgb, var(--am-pr-accent) 25%, transparent); } .am-pr-badge [data-component="icon"] { width: 12px; height: 12px; - color: var(--pr-accent); + color: var(--am-pr-accent); } .am-pr-badge-number { line-height: 1; color: var(--text-muted); } .am-pr-badge:hover .am-pr-badge-number { - color: var(--pr-accent); + color: var(--am-pr-accent); } /* Status icon overlaid on the badge — colored by CI/review result, independent of the badge's PR-state accent color. */ .am-pr-badge-status[data-status="failure"] { - color: #ef4444; + color: var(--vscode-testing-iconFailed, #f87171); } .am-pr-badge-status[data-status="changes"] { - color: #fbbf24; + color: var(--vscode-charts-yellow, #fbbf24); } .am-pr-badge-status[data-status="approved"] { - color: #34d399; + color: var(--vscode-testing-iconPassed, #34d399); } /* Checks still running — pulse the whole amber badge. */ -.am-pr-badge[data-pending] { +.am-pr-badge.am-pr-badge-pending { animation: am-pr-pulse 1.5s ease-in-out infinite; } @keyframes am-pr-pulse { @@ -965,17 +985,17 @@ html[data-theme="kilo-vscode"] } @media (prefers-reduced-motion: reduce) { - .am-pr-badge[data-pending] { + .am-pr-badge.am-pr-badge-pending { animation: none; opacity: 0.9; } } .am-worktree-item-active .am-pr-badge { - background: color-mix(in srgb, var(--pr-accent) 18%, transparent); + background: color-mix(in srgb, var(--am-pr-accent) 18%, transparent); } .am-worktree-item-active .am-pr-badge:hover { - background: color-mix(in srgb, var(--pr-accent) 30%, transparent); + background: color-mix(in srgb, var(--am-pr-accent) 30%, transparent); } /* Clickable link icon in hover card to open PR on GitHub */ @@ -996,35 +1016,35 @@ html[data-theme="kilo-vscode"] } .am-worktree-item-active .am-stat-files { - color: rgba(255, 255, 255, 0.7); + color: var(--text-weak); } .am-worktree-item-active .am-stat-additions { - color: #6ee7b7; + color: var(--vscode-testing-iconPassed, #6ee7b7); } .am-worktree-item-active .am-stat-deletions { - color: #fca5a5; + color: var(--vscode-testing-iconFailed, #fca5a5); } .am-worktree-commits { - color: #34d399; + color: var(--vscode-testing-iconPassed, #34d399); } .am-worktree-behind { - color: #f87171; + color: var(--vscode-testing-iconFailed, #f87171); } .am-worktree-item-active .am-worktree-commits { - color: #6ee7b7; + color: var(--vscode-testing-iconPassed, #6ee7b7); } .am-worktree-item-active .am-worktree-behind { - color: #fca5a5; + color: var(--vscode-testing-iconFailed, #fca5a5); } .am-local-item-active .am-worktree-behind { - color: #fca5a5; + color: var(--vscode-testing-iconFailed, #fca5a5); } .am-local-item:hover .am-worktree-stats, @@ -1699,7 +1719,7 @@ body.am-wt-dragging-active * { } .am-sidebar-search-status { - color: #fbbf24; + color: var(--vscode-charts-yellow, #fbbf24); text-transform: uppercase; } @@ -1794,9 +1814,10 @@ body.am-wt-dragging-active * { color: var(--text-base); } -.am-diff-toggle-btn.am-tab-diff-btn-active { - background: var(--surface-interactive-base) !important; - color: var(--text-base) !important; +.am-diff-toggle-btn.am-tab-diff-btn-active, +.am-diff-toggle-btn.am-tab-diff-btn-active:hover { + background: var(--surface-interactive-base); + color: var(--text-on-interactive-base); } .am-diff-toggle-btn.am-diff-toggle-has-changes { @@ -1810,20 +1831,24 @@ body.am-wt-dragging-active * { } .am-diff-toggle-btn.am-tab-diff-btn-active .am-stat-files { - color: rgba(255, 255, 255, 0.7); + color: var(--text-weak); } .am-diff-toggle-btn.am-tab-diff-btn-active .am-stat-additions { - color: #6ee7b7; + color: var(--vscode-testing-iconPassed, #6ee7b7); } .am-diff-toggle-btn.am-tab-diff-btn-active .am-stat-deletions { - color: #fca5a5; + color: var(--vscode-testing-iconFailed, #fca5a5); } .am-tab-diff-btn-active { background: var(--surface-interactive-base) !important; - color: var(--text-base) !important; + color: var(--text-on-interactive-base) !important; +} + +.am-tab-diff-btn-active [data-slot="icon-svg"] { + color: var(--text-on-interactive-base) !important; } .am-detail-content { @@ -2947,9 +2972,10 @@ body.am-wt-dragging-active * { color: var(--text-base); } -.am-nv-pill-active { - background: var(--border-focus, #007fd4) !important; - color: #fff !important; +.am-nv-pill-active, +.am-nv-pill-active:hover { + background: var(--border-focus, #007fd4); + color: var(--text-on-interactive-base); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index 726e132d709..ae7aeceab7f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Worktree جديد", "agentManager.worktree.setupScript": "سكربت إعداد Worktree", "agentManager.worktree.delete": "حذف Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "حذف؟", "agentManager.worktree.stale": "قديم", "agentManager.worktree.staleTooltip": "مفقود على القرص أو لم يعد git worktree يتتبعه", "agentManager.worktree.removeStale": "إزالة Worktree القديم", @@ -104,8 +104,8 @@ export const dict = { "agentManager.run.options": "خيارات التشغيل", "agentManager.run.configure": "تكوين سكربت التشغيل", "agentManager.shortcuts.openAgentManager": "فتح Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "وضع الوكيل التالي", + "agentManager.shortcuts.cyclePreviousAgentMode": "وضع الوكيل السابق", "agentManager.shortcuts.showShortcuts": "إظهار اختصارات لوحة المفاتيح", "agentManager.dialog.removeStaleWorktree.title": "إزالة Worktree القديم", "agentManager.dialog.removeStaleWorktree.messagePre": "إزالة Worktree القديم ", @@ -211,4 +211,9 @@ export const dict = { "agentManager.import.noBranchesFound": "لم يتم العثور على فروع.", "agentManager.import.noBranchesHint": "الصق رابط PR أعلاه أو أنشئ Worktree جديدًا.", "agentManager.import.failed": "فشل الاستيراد", + + "agentManager.pr.error.gh_auth.title": "مصادقة GitHub مطلوبة", + "agentManager.pr.error.gh_auth.description": "شغّل 'gh auth login' في الطرفية لاستعادة حالة PR.", + "agentManager.pr.error.gh_missing.title": "لم يتم تثبيت GitHub CLI", + "agentManager.pr.error.gh_missing.description": "قم بتثبيت GitHub CLI لتفعيل حالة PR.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index 8ff91cfdff6..843a6c86e4b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Novo Worktree", "agentManager.worktree.setupScript": "Script de configuração do Worktree", "agentManager.worktree.delete": "Excluir Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "Excluir?", "agentManager.worktree.stale": "Obsoleto", "agentManager.worktree.staleTooltip": "Ausente no disco ou não é mais rastreado pelo git worktree", "agentManager.worktree.removeStale": "Remover Worktree obsoleto", @@ -106,8 +106,8 @@ export const dict = { "agentManager.run.options": "Opções de execução", "agentManager.run.configure": "Configurar script de execução", "agentManager.shortcuts.openAgentManager": "Abrir Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Próximo modo de agente", + "agentManager.shortcuts.cyclePreviousAgentMode": "Modo de agente anterior", "agentManager.shortcuts.showShortcuts": "Mostrar atalhos de teclado", "agentManager.dialog.removeStaleWorktree.title": "Remover Worktree obsoleto", "agentManager.dialog.removeStaleWorktree.messagePre": "Remover Worktree obsoleto ", @@ -215,4 +215,9 @@ export const dict = { "agentManager.import.noBranchesFound": "Nenhum branch encontrado.", "agentManager.import.noBranchesHint": "Cole uma URL de PR acima ou crie um novo Worktree.", "agentManager.import.failed": "Falha na importação", + + "agentManager.pr.error.gh_auth.title": "Autenticação do GitHub necessária", + "agentManager.pr.error.gh_auth.description": "Execute 'gh auth login' no terminal para restaurar o status do PR.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI não instalado", + "agentManager.pr.error.gh_missing.description": "Instale o GitHub CLI para habilitar o status do PR.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index 10cd591c258..7ff68e5a77b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Novi Worktree", "agentManager.worktree.setupScript": "Skripta za postavljanje Worktree-a", "agentManager.worktree.delete": "Obriši Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "Izbrisati?", "agentManager.worktree.stale": "Zastario", "agentManager.worktree.staleTooltip": "Nedostaje na disku ili ga git worktree više ne prati", "agentManager.worktree.removeStale": "Ukloni zastarjeli Worktree", @@ -106,8 +106,8 @@ export const dict = { "agentManager.run.options": "Opcije pokretanja", "agentManager.run.configure": "Konfiguriši skriptu za pokretanje", "agentManager.shortcuts.openAgentManager": "Otvori Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Sljedeći način rada agenta", + "agentManager.shortcuts.cyclePreviousAgentMode": "Prethodni način rada agenta", "agentManager.shortcuts.showShortcuts": "Prikaži prečice na tastaturi", "agentManager.dialog.removeStaleWorktree.title": "Ukloni zastarjeli Worktree", "agentManager.dialog.removeStaleWorktree.messagePre": "Ukloni zastarjeli Worktree ", @@ -203,7 +203,7 @@ export const dict = { "agentManager.review.imageUnavailable": "Pregled slike nije dostupan za ovaj snimak sesije.", "agentManager.review.endOfLongDiff": "Došli ste do kraja!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "Zahtjev za povlačenje", "agentManager.import.pastePrUrl": "Zalijepite PR URL...", "agentManager.import.open": "Otvori", "agentManager.import.branches": "Branchevi", @@ -214,4 +214,9 @@ export const dict = { "agentManager.import.noBranchesFound": "Nisu pronađeni branchevi.", "agentManager.import.noBranchesHint": "Zalijepite PR URL iznad ili kreirajte novi Worktree.", "agentManager.import.failed": "Uvoz neuspješan", + + "agentManager.pr.error.gh_auth.title": "Potrebna GitHub autentikacija", + "agentManager.pr.error.gh_auth.description": "Pokrenite 'gh auth login' u terminalu da vratite status PR-a.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI nije instaliran", + "agentManager.pr.error.gh_missing.description": "Instalirajte GitHub CLI da omogućite status PR-a.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index d715e408088..d89a28452a7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Nyt Worktree", "agentManager.worktree.setupScript": "Worktree-opsætningsscript", "agentManager.worktree.delete": "Slet Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "Slet?", "agentManager.worktree.stale": "Forældet", "agentManager.worktree.staleTooltip": "Mangler på disken eller spores ikke længere af git worktree", "agentManager.worktree.removeStale": "Fjern forældet Worktree", @@ -107,8 +107,8 @@ export const dict = { "agentManager.run.options": "Kørselsindstillinger", "agentManager.run.configure": "Konfigurer kørselsscript", "agentManager.shortcuts.openAgentManager": "Åbn Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Næste agenttilstand", + "agentManager.shortcuts.cyclePreviousAgentMode": "Forrige agenttilstand", "agentManager.shortcuts.showShortcuts": "Vis tastaturgenveje", "agentManager.dialog.removeStaleWorktree.title": "Fjern forældet Worktree", "agentManager.dialog.removeStaleWorktree.messagePre": "Fjern forældet Worktree ", @@ -205,10 +205,10 @@ export const dict = { "Forhåndsvisning af billedet er ikke tilgængelig for dette snapshot af sessionen.", "agentManager.review.endOfLongDiff": "Du nåede slutningen!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "Pull request", "agentManager.import.pastePrUrl": "Indsæt PR URL...", "agentManager.import.open": "Åbn", - "agentManager.import.branches": "Branches", + "agentManager.import.branches": "Grene", "agentManager.import.selectBranch": "Vælg branch...", "agentManager.import.loading": "Indlæser...", "agentManager.import.loadingBranches": "Indlæser branches...", @@ -216,4 +216,9 @@ export const dict = { "agentManager.import.noBranchesFound": "Ingen branches fundet.", "agentManager.import.noBranchesHint": "Indsæt en PR URL ovenfor eller opret et nyt Worktree.", "agentManager.import.failed": "Import mislykkedes", + + "agentManager.pr.error.gh_auth.title": "GitHub-godkendelse påkrævet", + "agentManager.pr.error.gh_auth.description": "Kør 'gh auth login' i din terminal for at gendanne PR-status.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI er ikke installeret", + "agentManager.pr.error.gh_missing.description": "Installer GitHub CLI for at aktivere PR-status.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index f60caf8070b..0875d75f794 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Neuer Worktree", "agentManager.worktree.setupScript": "Worktree-Einrichtungsskript", "agentManager.worktree.delete": "Worktree löschen", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "Löschen?", "agentManager.worktree.stale": "Veraltet", "agentManager.worktree.staleTooltip": "Fehlt auf der Festplatte oder wird nicht mehr von git worktree verfolgt", "agentManager.worktree.removeStale": "Veralteten Worktree entfernen", @@ -108,8 +108,8 @@ export const dict = { "agentManager.run.options": "Ausführungsoptionen", "agentManager.run.configure": "Ausführungsskript konfigurieren", "agentManager.shortcuts.openAgentManager": "Agent Manager öffnen", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Nächster Agentenmodus", + "agentManager.shortcuts.cyclePreviousAgentMode": "Vorheriger Agentenmodus", "agentManager.shortcuts.showShortcuts": "Tastenkürzel anzeigen", "agentManager.dialog.removeStaleWorktree.title": "Veralteten Worktree entfernen", "agentManager.dialog.removeStaleWorktree.messagePre": "Veralteten Worktree entfernen ", @@ -208,7 +208,7 @@ export const dict = { "agentManager.import.pullRequest": "Pull Request", "agentManager.import.pastePrUrl": "PR-URL einfügen...", "agentManager.import.open": "Öffnen", - "agentManager.import.branches": "Branches", + "agentManager.import.branches": "Zweige", "agentManager.import.selectBranch": "Branch auswählen...", "agentManager.import.loading": "Laden...", "agentManager.import.loadingBranches": "Branches werden geladen...", @@ -216,4 +216,10 @@ export const dict = { "agentManager.import.noBranchesFound": "Keine Branches gefunden.", "agentManager.import.noBranchesHint": "Fügen Sie oben eine PR-URL ein oder erstellen Sie einen neuen Worktree.", "agentManager.import.failed": "Import fehlgeschlagen", + + "agentManager.pr.error.gh_auth.title": "GitHub-Authentifizierung erforderlich", + "agentManager.pr.error.gh_auth.description": + "Führen Sie 'gh auth login' in Ihrem Terminal aus, um den PR-Status wiederherzustellen.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI nicht installiert", + "agentManager.pr.error.gh_missing.description": "Installieren Sie die GitHub CLI, um den PR-Status zu aktivieren.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index db68e9bd7dc..ca2526380c8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -217,4 +217,8 @@ export const dict = { "agentManager.import.noBranchesFound": "No branches found.", "agentManager.import.noBranchesHint": "Paste a PR URL above or create a new worktree.", "agentManager.import.failed": "Import failed", + "agentManager.pr.error.gh_auth.title": "GitHub authentication required", + "agentManager.pr.error.gh_auth.description": "Run 'gh auth login' in your terminal to restore PR status.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI not installed", + "agentManager.pr.error.gh_missing.description": "Install the GitHub CLI to enable PR status.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index f0173a673fd..6b17280918d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Nuevo Worktree", "agentManager.worktree.setupScript": "Script de configuración de Worktree", "agentManager.worktree.delete": "Eliminar Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "¿Eliminar?", "agentManager.worktree.stale": "Obsoleto", "agentManager.worktree.staleTooltip": "No está en disco o ya no está seguido por git worktree", "agentManager.worktree.removeStale": "Eliminar Worktree obsoleto", @@ -107,8 +107,8 @@ export const dict = { "agentManager.run.options": "Opciones de ejecución", "agentManager.run.configure": "Configurar script de ejecución", "agentManager.shortcuts.openAgentManager": "Abrir Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Siguiente modo de agente", + "agentManager.shortcuts.cyclePreviousAgentMode": "Modo de agente anterior", "agentManager.shortcuts.showShortcuts": "Mostrar atajos de teclado", "agentManager.dialog.removeStaleWorktree.title": "Eliminar Worktree obsoleto", "agentManager.dialog.removeStaleWorktree.messagePre": "¿Eliminar Worktree obsoleto ", @@ -208,7 +208,7 @@ export const dict = { "agentManager.import.pullRequest": "Pull Request", "agentManager.import.pastePrUrl": "Pegar URL del PR...", "agentManager.import.open": "Abrir", - "agentManager.import.branches": "Branches", + "agentManager.import.branches": "Ramas", "agentManager.import.selectBranch": "Seleccionar branch...", "agentManager.import.loading": "Cargando...", "agentManager.import.loadingBranches": "Cargando branches...", @@ -216,4 +216,10 @@ export const dict = { "agentManager.import.noBranchesFound": "No se encontraron branches.", "agentManager.import.noBranchesHint": "Pega una URL de PR arriba o crea un nuevo Worktree.", "agentManager.import.failed": "Error en la importación", + + "agentManager.pr.error.gh_auth.title": "Se requiere autenticación de GitHub", + "agentManager.pr.error.gh_auth.description": + "Ejecuta 'gh auth login' en tu terminal para restaurar el estado del PR.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI no está instalado", + "agentManager.pr.error.gh_missing.description": "Instala GitHub CLI para habilitar el estado del PR.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts index b5d50a2b430..01aa40d7c65 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts @@ -3,8 +3,8 @@ export const dict = { "agentManager.sidebar.collapse": "بستن نوار کناری", "agentManager.sidebar.expand": "نمایش نوار کناری", "agentManager.section.worktrees": "WORKTREES", - "agentManager.section.sessions": "SESSIONS", - "agentManager.projects": "PROJECTS", + "agentManager.section.sessions": "جلسات", + "agentManager.projects": "پروژه‌ها", "agentManager.project.add": "افزودن پروژه", "agentManager.project.remove": "حذف از Agent Manager", "agentManager.project.trust": "اعتماد", @@ -207,7 +207,7 @@ export const dict = { "agentManager.review.imageUnavailable": "پیش‌نمایش تصویر برای این نمونه جلسه در دسترس نیست.", "agentManager.review.endOfLongDiff": "به انتها رسیدید!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "درخواست ادغام", "agentManager.import.pastePrUrl": "URL درخواست PR را وارد کنید...", "agentManager.import.open": "باز کردن", "agentManager.import.branches": "شاخه‌ها", @@ -218,4 +218,9 @@ export const dict = { "agentManager.import.noBranchesFound": "هیچ شاخه‌ای یافت نشد.", "agentManager.import.noBranchesHint": "یک URL درخواست ادغام را در بالا جای‌گذاری کنید یا یک worktree جدید بسازید.", "agentManager.import.failed": "وارد کردن ناموفق بود", + + "agentManager.pr.error.gh_auth.title": "احراز هویت GitHub لازم است", + "agentManager.pr.error.gh_auth.description": "برای بازیابی وضعیت PR، دستور 'gh auth login' را در ترمینال اجرا کنید.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI نصب نشده است", + "agentManager.pr.error.gh_missing.description": "برای فعال کردن وضعیت PR، GitHub CLI را نصب کنید.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index 544b5007a2c..3d8f7279b9f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Nouveau Worktree", "agentManager.worktree.setupScript": "Script de configuration du Worktree", "agentManager.worktree.delete": "Supprimer le Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "Supprimer ?", "agentManager.worktree.stale": "Obsolète", "agentManager.worktree.staleTooltip": "Absent du disque ou n'est plus suivi par git worktree", "agentManager.worktree.removeStale": "Supprimer le Worktree obsolète", @@ -60,7 +60,7 @@ export const dict = { "agentManager.sidebarSearch.label": "Rechercher des Worktrees et des sessions", "agentManager.sidebarSearch.scope": "Recherche dans l'espace de travail local, les sessions locales, les Worktrees et leurs sessions", - "agentManager.sidebarSearch.contexts": "LOCAL & WORKTREES", + "agentManager.sidebarSearch.contexts": "LOCAL ET WORKTREES", "agentManager.terminal.new": "Nouvel onglet de terminal", "agentManager.terminal.ended": "terminal terminé — fermez l'onglet pour ignorer", @@ -107,8 +107,8 @@ export const dict = { "agentManager.run.options": "Options d'exécution", "agentManager.run.configure": "Configurer le script d'exécution", "agentManager.shortcuts.openAgentManager": "Ouvrir Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Mode d'agent suivant", + "agentManager.shortcuts.cyclePreviousAgentMode": "Mode d'agent précédent", "agentManager.shortcuts.showShortcuts": "Afficher les raccourcis clavier", "agentManager.dialog.removeStaleWorktree.title": "Supprimer le Worktree obsolète", "agentManager.dialog.removeStaleWorktree.messagePre": "Supprimer le Worktree obsolète ", @@ -216,4 +216,10 @@ export const dict = { "agentManager.import.noBranchesFound": "Aucune branche trouvée.", "agentManager.import.noBranchesHint": "Collez une URL de PR ci-dessus ou créez un nouveau Worktree.", "agentManager.import.failed": "Échec de l'importation", + + "agentManager.pr.error.gh_auth.title": "Authentification GitHub requise", + "agentManager.pr.error.gh_auth.description": + "Exécutez 'gh auth login' dans votre terminal pour restaurer le statut de la PR.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI non installé", + "agentManager.pr.error.gh_missing.description": "Installez GitHub CLI pour activer le statut de la PR.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts index 59f6ea340c6..684ea283f4f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -36,7 +36,7 @@ export const dict = { "agentManager.section.moveUp": "Sposta su", "agentManager.section.moveDown": "Sposta giù", - "agentManager.hoverCard.branch": "BRANCH", + "agentManager.hoverCard.branch": "Ramo", "agentManager.hoverCard.worktree": "Worktree", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessioni", @@ -222,4 +222,10 @@ export const dict = { "agentManager.import.noBranchesFound": "Nessun branch trovato.", "agentManager.import.noBranchesHint": "Incolla un URL PR sopra o crea un nuovo worktree.", "agentManager.import.failed": "Importazione non riuscita", + + "agentManager.pr.error.gh_auth.title": "Autenticazione GitHub richiesta", + "agentManager.pr.error.gh_auth.description": + "Esegui 'gh auth login' nel terminale per ripristinare lo stato della PR.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI non installato", + "agentManager.pr.error.gh_missing.description": "Installa GitHub CLI per abilitare lo stato della PR.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index 5e4dd806ef5..c6ef13f8cb0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "新しいWorktree", "agentManager.worktree.setupScript": "Worktreeセットアップスクリプト", "agentManager.worktree.delete": "Worktreeを削除", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "削除しますか?", "agentManager.worktree.stale": "無効", "agentManager.worktree.staleTooltip": "ディスク上に存在しないか、git worktree で追跡されていません", "agentManager.worktree.removeStale": "無効な Worktree を削除", @@ -107,8 +107,8 @@ export const dict = { "agentManager.run.options": "実行オプション", "agentManager.run.configure": "実行スクリプトを設定", "agentManager.shortcuts.openAgentManager": "Agent Managerを開く", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "次のエージェントモード", + "agentManager.shortcuts.cyclePreviousAgentMode": "前のエージェントモード", "agentManager.shortcuts.showShortcuts": "キーボードショートカットを表示", "agentManager.dialog.removeStaleWorktree.title": "無効な Worktree を削除", "agentManager.dialog.removeStaleWorktree.messagePre": "無効な Worktree を削除 ", @@ -204,7 +204,7 @@ export const dict = { "agentManager.review.imageUnavailable": "このセッションスナップショットでは画像プレビューを利用できません。", "agentManager.review.endOfLongDiff": "最後まで到達しました!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "プルリクエスト", "agentManager.import.pastePrUrl": "PR URLを貼り付け...", "agentManager.import.open": "開く", "agentManager.import.branches": "ブランチ", @@ -215,4 +215,11 @@ export const dict = { "agentManager.import.noBranchesFound": "ブランチが見つかりません。", "agentManager.import.noBranchesHint": "上にPR URLを貼り付けるか、新しいWorktreeを作成してください。", "agentManager.import.failed": "インポートに失敗しました", + + "agentManager.pr.error.gh_auth.title": "GitHub認証が必要です", + "agentManager.pr.error.gh_auth.description": + "PRステータスを復元するには、ターミナルで 'gh auth login' を実行してください。", + "agentManager.pr.error.gh_missing.title": "GitHub CLIがインストールされていません", + "agentManager.pr.error.gh_missing.description": + "PRステータスを有効にするには、GitHub CLIをインストールしてください。", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index b729493b1d3..e8fa9677984 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "새 Worktree", "agentManager.worktree.setupScript": "Worktree 설정 스크립트", "agentManager.worktree.delete": "Worktree 삭제", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "삭제하시겠습니까?", "agentManager.worktree.stale": "오래됨", "agentManager.worktree.staleTooltip": "디스크에 없거나 더 이상 git worktree에서 추적되지 않습니다", "agentManager.worktree.removeStale": "오래된 Worktree 제거", @@ -105,8 +105,8 @@ export const dict = { "agentManager.run.options": "실행 옵션", "agentManager.run.configure": "실행 스크립트 구성", "agentManager.shortcuts.openAgentManager": "Agent Manager 열기", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "다음 에이전트 모드", + "agentManager.shortcuts.cyclePreviousAgentMode": "이전 에이전트 모드", "agentManager.shortcuts.showShortcuts": "키보드 단축키 표시", "agentManager.dialog.removeStaleWorktree.title": "오래된 Worktree 제거", "agentManager.dialog.removeStaleWorktree.messagePre": "오래된 Worktree 제거 ", @@ -202,7 +202,7 @@ export const dict = { "agentManager.review.imageUnavailable": "이 세션 스냅샷에서는 이미지 미리보기를 사용할 수 없습니다.", "agentManager.review.endOfLongDiff": "끝까지 도달했습니다!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "풀 리퀘스트", "agentManager.import.pastePrUrl": "PR URL 붙여넣기...", "agentManager.import.open": "열기", "agentManager.import.branches": "브랜치", @@ -213,4 +213,9 @@ export const dict = { "agentManager.import.noBranchesFound": "브랜치를 찾을 수 없습니다.", "agentManager.import.noBranchesHint": "위에 PR URL을 붙여넣거나 새 Worktree를 만드세요.", "agentManager.import.failed": "가져오기 실패", + + "agentManager.pr.error.gh_auth.title": "GitHub 인증 필요", + "agentManager.pr.error.gh_auth.description": "PR 상태를 복원하려면 터미널에서 'gh auth login'을 실행하세요.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI가 설치되지 않음", + "agentManager.pr.error.gh_missing.description": "PR 상태를 활성화하려면 GitHub CLI를 설치하세요.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index 3ffc6c7dce5..89171ad1b81 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -138,7 +138,7 @@ export const dict = { "agentManager.dialog.baseBranch": "Basis branch", "agentManager.dialog.searchBranches": "Zoeken naar branches...", "agentManager.dialog.branchBadge.default": "standaard", - "agentManager.dialog.branchBadge.remote": "remote", + "agentManager.dialog.branchBadge.remote": "extern", "agentManager.dialog.versions": "Versies", "agentManager.dialog.versionHint": "{{count}} worktrees zullen parallel draaien", "agentManager.dialog.compareModels": "Modellen vergelijken", @@ -209,7 +209,7 @@ export const dict = { "agentManager.review.imageUnavailable": "Afbeeldingsvoorbeeld is niet beschikbaar voor deze sessiesnapshot.", "agentManager.review.endOfLongDiff": "Je hebt het einde bereikt!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "Pull request", "agentManager.import.pastePrUrl": "Plak PR URL...", "agentManager.import.open": "Openen", "agentManager.import.branches": "Branches", @@ -220,4 +220,9 @@ export const dict = { "agentManager.import.noBranchesFound": "Geen branches gevonden.", "agentManager.import.noBranchesHint": "Plak hierboven een PR URL of maak een nieuwe worktree.", "agentManager.import.failed": "Importeren mislukt", + + "agentManager.pr.error.gh_auth.title": "GitHub-authenticatie vereist", + "agentManager.pr.error.gh_auth.description": "Voer 'gh auth login' uit in je terminal om de PR-status te herstellen.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI niet geïnstalleerd", + "agentManager.pr.error.gh_missing.description": "Installeer de GitHub CLI om de PR-status in te schakelen.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index d5dfa02a817..5807661c9d3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Nytt Worktree", "agentManager.worktree.setupScript": "Worktree-oppsettskript", "agentManager.worktree.delete": "Slett Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "Slette?", "agentManager.worktree.stale": "Utdatert", "agentManager.worktree.staleTooltip": "Mangler på disken eller spores ikke lenger av git worktree", "agentManager.worktree.removeStale": "Fjern utdatert Worktree", @@ -105,8 +105,8 @@ export const dict = { "agentManager.run.options": "Kjøringsalternativer", "agentManager.run.configure": "Konfigurer kjøreskript", "agentManager.shortcuts.openAgentManager": "Åpne Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Neste agentmodus", + "agentManager.shortcuts.cyclePreviousAgentMode": "Forrige agentmodus", "agentManager.shortcuts.showShortcuts": "Vis tastatursnarveier", "agentManager.dialog.removeStaleWorktree.title": "Fjern utdatert Worktree", "agentManager.dialog.removeStaleWorktree.messagePre": "Fjern utdatert Worktree ", @@ -202,10 +202,10 @@ export const dict = { "Forhåndsvisning av bildet er ikke tilgjengelig for dette øyeblikksbildet av økten.", "agentManager.review.endOfLongDiff": "Du nådde slutten!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "Pull request", "agentManager.import.pastePrUrl": "Lim inn PR URL...", "agentManager.import.open": "Åpne", - "agentManager.import.branches": "Branches", + "agentManager.import.branches": "Grener", "agentManager.import.selectBranch": "Velg branch...", "agentManager.import.loading": "Laster...", "agentManager.import.loadingBranches": "Laster branches...", @@ -213,4 +213,9 @@ export const dict = { "agentManager.import.noBranchesFound": "Ingen branches funnet.", "agentManager.import.noBranchesHint": "Lim inn en PR URL ovenfor eller opprett et nytt Worktree.", "agentManager.import.failed": "Import mislyktes", + + "agentManager.pr.error.gh_auth.title": "GitHub-autentisering kreves", + "agentManager.pr.error.gh_auth.description": "Kjør 'gh auth login' i terminalen for å gjenopprette PR-status.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI er ikke installert", + "agentManager.pr.error.gh_missing.description": "Installer GitHub CLI for å aktivere PR-status.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index 9fb33a5c980..ddd54169991 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Nowy Worktree", "agentManager.worktree.setupScript": "Skrypt konfiguracji Worktree", "agentManager.worktree.delete": "Usuń Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "Usunąć?", "agentManager.worktree.stale": "Nieaktualny", "agentManager.worktree.staleTooltip": "Brak na dysku lub nie jest już śledzony przez git worktree", "agentManager.worktree.removeStale": "Usuń nieaktualny Worktree", @@ -107,8 +107,8 @@ export const dict = { "agentManager.run.options": "Opcje uruchamiania", "agentManager.run.configure": "Konfiguruj skrypt uruchamiania", "agentManager.shortcuts.openAgentManager": "Otwórz Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Następny tryb agenta", + "agentManager.shortcuts.cyclePreviousAgentMode": "Poprzedni tryb agenta", "agentManager.shortcuts.showShortcuts": "Pokaż skróty klawiszowe", "agentManager.dialog.removeStaleWorktree.title": "Usuń nieaktualny Worktree", "agentManager.dialog.removeStaleWorktree.messagePre": "Usunąć nieaktualny Worktree ", @@ -214,4 +214,9 @@ export const dict = { "agentManager.import.noBranchesFound": "Nie znaleziono branchy.", "agentManager.import.noBranchesHint": "Wklej URL PR powyżej lub utwórz nowy Worktree.", "agentManager.import.failed": "Import nie powiódł się", + + "agentManager.pr.error.gh_auth.title": "Wymagana autoryzacja GitHub", + "agentManager.pr.error.gh_auth.description": "Uruchom 'gh auth login' w terminalu, aby przywrócić status PR.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI nie jest zainstalowane", + "agentManager.pr.error.gh_missing.description": "Zainstaluj GitHub CLI, aby włączyć status PR.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index 74afb2c7aa3..90ae939cbdc 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Новый Worktree", "agentManager.worktree.setupScript": "Скрипт настройки Worktree", "agentManager.worktree.delete": "Удалить Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "Удалить?", "agentManager.worktree.stale": "Устаревший", "agentManager.worktree.staleTooltip": "Отсутствует на диске или больше не отслеживается git worktree", "agentManager.worktree.removeStale": "Удалить устаревший Worktree", @@ -107,8 +107,8 @@ export const dict = { "agentManager.run.options": "Параметры запуска", "agentManager.run.configure": "Настроить скрипт запуска", "agentManager.shortcuts.openAgentManager": "Открыть Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "Следующий режим агента", + "agentManager.shortcuts.cyclePreviousAgentMode": "Предыдущий режим агента", "agentManager.shortcuts.showShortcuts": "Показать сочетания клавиш", "agentManager.dialog.removeStaleWorktree.title": "Удалить устаревший Worktree", "agentManager.dialog.removeStaleWorktree.messagePre": "Удалить устаревший Worktree ", @@ -215,4 +215,9 @@ export const dict = { "agentManager.import.noBranchesFound": "Ветки не найдены.", "agentManager.import.noBranchesHint": "Вставьте URL PR выше или создайте новый Worktree.", "agentManager.import.failed": "Ошибка импорта", + + "agentManager.pr.error.gh_auth.title": "Требуется аутентификация GitHub", + "agentManager.pr.error.gh_auth.description": "Выполните 'gh auth login' в терминале, чтобы восстановить статус PR.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI не установлен", + "agentManager.pr.error.gh_missing.description": "Установите GitHub CLI, чтобы включить статус PR.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index b65655f053c..0a395b5ff8f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "Worktree ใหม่", "agentManager.worktree.setupScript": "สคริปต์ตั้งค่า Worktree", "agentManager.worktree.delete": "ลบ Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "ลบ?", "agentManager.worktree.stale": "ล้าสมัย", "agentManager.worktree.staleTooltip": "ไม่พบบนดิสก์หรือไม่ได้ถูกติดตามโดย git worktree อีกต่อไป", "agentManager.worktree.removeStale": "ลบ Worktree ที่ล้าสมัย", @@ -102,8 +102,8 @@ export const dict = { "agentManager.run.options": "ตัวเลือกการเรียกใช้", "agentManager.run.configure": "กำหนดค่าสคริปต์การเรียกใช้", "agentManager.shortcuts.openAgentManager": "เปิด Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "โหมดเอเจนต์ถัดไป", + "agentManager.shortcuts.cyclePreviousAgentMode": "โหมดเอเจนต์ก่อนหน้า", "agentManager.shortcuts.showShortcuts": "แสดงปุ่มลัดแป้นพิมพ์", "agentManager.dialog.removeStaleWorktree.title": "ลบ Worktree ที่ล้าสมัย", "agentManager.dialog.removeStaleWorktree.messagePre": "ลบ Worktree ที่ล้าสมัย ", @@ -198,10 +198,10 @@ export const dict = { "agentManager.review.imageUnavailable": "ไม่สามารถแสดงตัวอย่างรูปภาพสำหรับสแนปช็อตของเซสชันนี้ได้", "agentManager.review.endOfLongDiff": "คุณมาถึงท้ายสุดแล้ว!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "คำขอรวมโค้ด", "agentManager.import.pastePrUrl": "วาง URL ของ PR...", "agentManager.import.open": "เปิด", - "agentManager.import.branches": "Branches", + "agentManager.import.branches": "สาขา", "agentManager.import.selectBranch": "เลือก Branch...", "agentManager.import.loading": "กำลังโหลด...", "agentManager.import.loadingBranches": "กำลังโหลด Branches...", @@ -209,4 +209,9 @@ export const dict = { "agentManager.import.noBranchesFound": "ไม่พบ Branch", "agentManager.import.noBranchesHint": "วาง URL ของ PR ด้านบนหรือสร้าง Worktree ใหม่", "agentManager.import.failed": "นำเข้าล้มเหลว", + + "agentManager.pr.error.gh_auth.title": "ต้องยืนยันตัวตน GitHub", + "agentManager.pr.error.gh_auth.description": "รันคำสั่ง 'gh auth login' ในเทอร์มินัลเพื่อกู้คืนสถานะ PR", + "agentManager.pr.error.gh_missing.title": "ไม่ได้ติดตั้ง GitHub CLI", + "agentManager.pr.error.gh_missing.description": "ติดตั้ง GitHub CLI เพื่อเปิดใช้งานสถานะ PR", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index 43b033d32a9..ed3c98980c7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -211,7 +211,7 @@ export const dict = { "agentManager.review.imageUnavailable": "Bu oturum anlık görüntüsü için görsel önizlemesi kullanılamıyor.", "agentManager.review.endOfLongDiff": "Sonuna geldiniz!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "Çekme İsteği", "agentManager.import.pastePrUrl": "PR URL'sini yapıştırın...", "agentManager.import.open": "Aç", "agentManager.import.branches": "Dallar", @@ -222,4 +222,10 @@ export const dict = { "agentManager.import.noBranchesFound": "Hiçbir dal bulunamadı.", "agentManager.import.noBranchesHint": "Yukarıya bir PR URL'si yapıştırın veya yeni bir worktree oluşturun.", "agentManager.import.failed": "İçe aktarma başarısız oldu", + + "agentManager.pr.error.gh_auth.title": "GitHub kimlik doğrulaması gerekli", + "agentManager.pr.error.gh_auth.description": + "PR durumunu geri yüklemek için terminalinizde 'gh auth login' komutunu çalıştırın.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI yüklü değil", + "agentManager.pr.error.gh_missing.description": "PR durumunu etkinleştirmek için GitHub CLI'yi yükleyin.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index 0242498ddf5..a6c8f9dc53e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -212,7 +212,7 @@ export const dict = { "agentManager.review.imageUnavailable": "Попередній перегляд зображення недоступний для цього знімка сеансу.", "agentManager.review.endOfLongDiff": "Ви дійшли до кінця!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "Пул-реквест", "agentManager.import.pastePrUrl": "Вставте URL PR...", "agentManager.import.open": "Відкрити", "agentManager.import.branches": "Гілки", @@ -223,4 +223,9 @@ export const dict = { "agentManager.import.noBranchesFound": "Гілок не знайдено.", "agentManager.import.noBranchesHint": "Вставте URL PR вище або створіть нове робоче дерево.", "agentManager.import.failed": "Імпортування не вдалося", + + "agentManager.pr.error.gh_auth.title": "Потрібна автентифікація GitHub", + "agentManager.pr.error.gh_auth.description": "Виконайте 'gh auth login' у терміналі, щоб відновити статус PR.", + "agentManager.pr.error.gh_missing.title": "GitHub CLI не встановлено", + "agentManager.pr.error.gh_missing.description": "Встановіть GitHub CLI, щоб увімкнути статус PR.", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index 8bbcfd45263..8010f67b7d7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "新建 Worktree", "agentManager.worktree.setupScript": "Worktree 设置脚本", "agentManager.worktree.delete": "删除 Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "删除?", "agentManager.worktree.stale": "已失效", "agentManager.worktree.staleTooltip": "在磁盘上缺失,或不再由 git worktree 跟踪", "agentManager.worktree.removeStale": "移除失效 Worktree", @@ -102,8 +102,8 @@ export const dict = { "agentManager.run.options": "运行选项", "agentManager.run.configure": "配置运行脚本", "agentManager.shortcuts.openAgentManager": "打开 Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "下一个代理模式", + "agentManager.shortcuts.cyclePreviousAgentMode": "上一个代理模式", "agentManager.shortcuts.showShortcuts": "显示键盘快捷键", "agentManager.dialog.removeStaleWorktree.title": "移除失效 Worktree", "agentManager.dialog.removeStaleWorktree.messagePre": "移除失效 Worktree ", @@ -196,7 +196,7 @@ export const dict = { "agentManager.review.imageUnavailable": "此会话快照无法提供图片预览。", "agentManager.review.endOfLongDiff": "你已经到末尾了!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "拉取请求", "agentManager.import.pastePrUrl": "粘贴 PR URL...", "agentManager.import.open": "打开", "agentManager.import.branches": "分支", @@ -207,4 +207,9 @@ export const dict = { "agentManager.import.noBranchesFound": "未找到分支。", "agentManager.import.noBranchesHint": "在上方粘贴 PR URL 或创建新的 Worktree。", "agentManager.import.failed": "导入失败", + + "agentManager.pr.error.gh_auth.title": "需要 GitHub 身份验证", + "agentManager.pr.error.gh_auth.description": "在终端中运行 'gh auth login' 以恢复 PR 状态。", + "agentManager.pr.error.gh_missing.title": "未安装 GitHub CLI", + "agentManager.pr.error.gh_missing.description": "安装 GitHub CLI 以启用 PR 状态。", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index 0acadafdbfe..ddb93a8804a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -14,7 +14,7 @@ export const dict = { "agentManager.worktree.new": "新建 Worktree", "agentManager.worktree.setupScript": "Worktree 設定指令碼", "agentManager.worktree.delete": "刪除 Worktree", - "agentManager.worktree.confirmDelete": "Delete?", + "agentManager.worktree.confirmDelete": "刪除?", "agentManager.worktree.stale": "已失效", "agentManager.worktree.staleTooltip": "磁碟上不存在,或不再由 git worktree 追蹤", "agentManager.worktree.removeStale": "移除失效 Worktree", @@ -102,8 +102,8 @@ export const dict = { "agentManager.run.options": "執行選項", "agentManager.run.configure": "設定執行指令碼", "agentManager.shortcuts.openAgentManager": "開啟 Agent Manager", - "agentManager.shortcuts.cycleAgentMode": "Next agent mode", - "agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode", + "agentManager.shortcuts.cycleAgentMode": "下一個代理模式", + "agentManager.shortcuts.cyclePreviousAgentMode": "上一個代理模式", "agentManager.shortcuts.showShortcuts": "顯示鍵盤快捷鍵", "agentManager.dialog.removeStaleWorktree.title": "移除失效 Worktree", "agentManager.dialog.removeStaleWorktree.messagePre": "移除失效 Worktree ", @@ -196,7 +196,7 @@ export const dict = { "agentManager.review.imageUnavailable": "此工作階段快照無法預覽圖片。", "agentManager.review.endOfLongDiff": "你已經到最後了!", - "agentManager.import.pullRequest": "Pull Request", + "agentManager.import.pullRequest": "提取請求", "agentManager.import.pastePrUrl": "貼上 PR URL...", "agentManager.import.open": "開啟", "agentManager.import.branches": "分支", @@ -207,4 +207,9 @@ export const dict = { "agentManager.import.noBranchesFound": "找不到分支。", "agentManager.import.noBranchesHint": "在上方貼上 PR URL 或建立新的 Worktree。", "agentManager.import.failed": "匯入失敗", + + "agentManager.pr.error.gh_auth.title": "需要 GitHub 驗證", + "agentManager.pr.error.gh_auth.description": "在終端機中執行 'gh auth login' 以還原 PR 狀態。", + "agentManager.pr.error.gh_missing.title": "未安裝 GitHub CLI", + "agentManager.pr.error.gh_missing.description": "安裝 GitHub CLI 以啟用 PR 狀態。", } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/CopyButton.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/CopyButton.tsx new file mode 100644 index 00000000000..4a856df8d5b --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/CopyButton.tsx @@ -0,0 +1,22 @@ +/** @jsxImportSource solid-js */ +import { createSignal } from "solid-js" +import { IconButton } from "@kilocode/kilo-ui/icon-button" + +export function CopyButton(props: { text: string; label?: string; class?: string }) { + const [copied, setCopied] = createSignal(false) + const copy = () => { + navigator.clipboard.writeText(props.text) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + return ( + + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx new file mode 100644 index 00000000000..55bdea2165a --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRBadge.tsx @@ -0,0 +1,33 @@ +/** @jsxImportSource solid-js */ +import { Show } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import type { PRStatus } from "../../src/types/messages" +import { prBadgeIndicator, prChecksRunning } from "../WorktreeItem" + +const INDICATOR_ICON: Record = { + failure: "circle-x-outline", + changes: "warning", + approved: "circle-check", +} + +export function PRBadge(props: { pr: PRStatus }) { + const indicator = () => prBadgeIndicator(props.pr) + return ( + + + + + + + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx new file mode 100644 index 00000000000..57be374882f --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRChecks.tsx @@ -0,0 +1,62 @@ +/** @jsxImportSource solid-js */ +import { For, Show, createSignal } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import type { PRStatus } from "../../src/types/messages" +import type { PRCheck, CheckStatus } from "./pr-types" +import { SectionHeading } from "./SectionHeading" +import { useVSCode } from "../../src/context/vscode" + +const CHECK: Record = { + success: { icon: "circle-check", label: "Passed" }, + failure: { icon: "circle-x-outline", label: "Failed" }, + cancelled: { icon: "circle-x-outline", label: "Cancelled" }, + skipped: { icon: "circle-x-outline", label: "Skipped" }, + pending: { icon: "play", label: "Running" }, +} + +export function PRChecks(props: { checks: PRStatus["checks"] }) { + const vscode = useVSCode() + const [open, setOpen] = createSignal(true) + return ( + <> +
+
+ setOpen((v) => !v)} + count={`${props.checks.passed}/${props.checks.total} passed`} + countClass={`am-pr-checks-count-${props.checks.status}`} + /> + +
+ + {(check: PRCheck) => ( +
+ + {check.name} + {CHECK[check.status].label} + + {check.duration} + + + + + + +
+ )} +
+
+
+
+ + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx new file mode 100644 index 00000000000..b73c915f02d --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRComments.tsx @@ -0,0 +1,51 @@ +/** @jsxImportSource solid-js */ +import { For, Show, createSignal } from "solid-js" +import { Markdown } from "@kilocode/kilo-ui/markdown" +import type { PRStatus } from "../../src/types/messages" +import type { PRComment } from "./pr-types" +import { SectionHeading } from "./SectionHeading" +import { CopyButton } from "./CopyButton" + +export function PRComments(props: { comments: NonNullable }) { + const [open, setOpen] = createSignal(true) + return ( + <> +
+
+ setOpen((v) => !v)} + count={props.comments.unresolved > 0 ? `${props.comments.unresolved} unresolved` : undefined} + countClass="am-pr-panel-unresolved" + /> + +
+ + {(comment: PRComment) => ( +
+
+ {comment.author} + + + {comment.file} + {`:${comment.line}`} + + + + Resolved + + +
+
+ +
+
+ )} +
+
+
+
+ + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRDescription.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRDescription.tsx new file mode 100644 index 00000000000..524be50e2ec --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRDescription.tsx @@ -0,0 +1,21 @@ +/** @jsxImportSource solid-js */ +import { Show, createSignal } from "solid-js" +import { Markdown } from "@kilocode/kilo-ui/markdown" +import { SectionHeading } from "./SectionHeading" + +export function PRDescription(props: { body: string }) { + const [open, setOpen] = createSignal(true) + return ( + <> +
+
+ setOpen((v) => !v)} /> + +
+ +
+
+
+ + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PROverview.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PROverview.tsx new file mode 100644 index 00000000000..577dd4a8e7d --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PROverview.tsx @@ -0,0 +1,51 @@ +/** @jsxImportSource solid-js */ +import { Show } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import type { WorktreeState } from "../../src/types/messages" +import type { PRStatus } from "../../src/types/messages" + +const STATE_LABEL: Record = { + open: "Open", + draft: "Draft", + merged: "Merged", + closed: "Closed", +} + +const REVIEW_LABEL: Partial, string>> = { + approved: "Approved", + changes_requested: "Changes Requested", + pending: "Review Pending", +} + +export function PROverview(props: { pr: PRStatus; worktree?: WorktreeState }) { + return ( +
+ + {(wt) => ( +
+ Branch + + {wt().branch} + + {wt().parentBranch} + +
+ )} +
+
+ Status + + {STATE_LABEL[props.pr.state]} + +
+ + {(review) => ( +
+ Review + {REVIEW_LABEL[review()]} +
+ )} +
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx new file mode 100644 index 00000000000..ec37c45940a --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx @@ -0,0 +1,63 @@ +/** @jsxImportSource solid-js */ +import { Component, Show } from "solid-js" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import type { WorktreeState } from "../../src/types/messages" +import type { PRStatus } from "../../src/types/messages" +import { PRBadge } from "./PRBadge" +import { PROverview } from "./PROverview" +import { PRReviewers } from "./PRReviewers" +import { PRDescription } from "./PRDescription" +import { PRChecks } from "./PRChecks" +import { PRComments } from "./PRComments" +import { PRSummary } from "./PRSummary" +import "./pr-panel.css" + +interface PRPanelProps { + pr: PRStatus + worktree?: WorktreeState + onClose: () => void + onOpenExternal: () => void +} + +export const PRPanel: Component = (props) => { + return ( +
+
+
+ + {props.pr.title} + #{props.pr.number} +
+
+ + + + + + +
+
+
+ + + 0}> + + + {(body) => } + 0}> + + + + {(comments) => } + +
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRReviewers.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRReviewers.tsx new file mode 100644 index 00000000000..071da1f119d --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRReviewers.tsx @@ -0,0 +1,44 @@ +/** @jsxImportSource solid-js */ +import { For, Show, createSignal } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import type { PRReviewer, ReviewerState } from "./pr-types" +import { SectionHeading } from "./SectionHeading" + +const REVIEWER_ICON: Record = { + approved: "circle-check", + changes_requested: "refresh", + commented: "edit", + pending: "dash", +} + +const REVIEWER_LABEL: Record = { + approved: "Approved", + changes_requested: "Changes requested", + commented: "Commented", + pending: "Awaiting", +} + +export function PRReviewers(props: { reviewers: PRReviewer[] }) { + const [open, setOpen] = createSignal(true) + return ( + <> +
+
+ setOpen((v) => !v)} /> + +
+ + {(reviewer) => ( +
+ + + {REVIEWER_LABEL[reviewer.state]} +
+ )} +
+
+
+
+ + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx new file mode 100644 index 00000000000..48d9d2ee2e8 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx @@ -0,0 +1,72 @@ +/** @jsxImportSource solid-js */ +import { Show } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import type { PRStatus } from "../../src/types/messages" + +interface PRSummaryProps { + pr: PRStatus +} + +function summaryRows(pr: PRStatus): Array<{ icon: string; label: string; status: string }> { + const rows = [] + + if (pr.checks.total > 0) { + const { passed, total, status } = pr.checks + rows.push({ + icon: status === "success" ? "circle-check" : status === "failure" ? "circle-x-outline" : "play", + label: status === "success" ? "All checks passing" : `${passed}/${total} checks passed`, + status, + }) + } + + if (pr.review) { + const status = pr.review === "approved" ? "success" : pr.review === "changes_requested" ? "failure" : "pending" + rows.push({ + icon: status === "success" ? "circle-check" : status === "failure" ? "circle-x-outline" : "play", + label: status === "success" ? "Approved" : status === "failure" ? "Changes requested" : "Review pending", + status, + }) + } + + if (pr.comments && pr.comments.unresolved > 0) { + rows.push({ + icon: "comment", + label: `${pr.comments.unresolved} unresolved comment${pr.comments.unresolved > 1 ? "s" : ""}`, + status: "warning", + }) + } + + return rows +} + +export function PRSummary(props: PRSummaryProps) { + const rows = () => summaryRows(props.pr) + return ( + 0}> +
+
+ PR Summary + + 0}> + {props.pr.files}f + + 0}> + +{props.pr.additions} + + 0}> + −{props.pr.deletions} + + +
+
+ {rows().map((row) => ( +
+ + {row.label} +
+ ))} +
+
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/SectionHeading.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/SectionHeading.tsx new file mode 100644 index 00000000000..1de9bba802e --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/SectionHeading.tsx @@ -0,0 +1,23 @@ +/** @jsxImportSource solid-js */ +import { Show } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" + +export function SectionHeading(props: { + title: string + open: boolean + onToggle: () => void + count?: string + countClass?: string +}) { + return ( + + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css new file mode 100644 index 00000000000..e505989acee --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css @@ -0,0 +1,457 @@ +/* PR Panel */ +.am-pr-panel { + height: 100%; + overflow: hidden; +} + +.am-pr-row { + display: flex; + align-items: center; +} +.am-pr-col { + display: flex; + flex-direction: column; +} + +.am-pr-panel-header { + align-items: flex-start; + justify-content: space-between; + gap: 8px; + padding: 10px 12px 8px; + border-bottom: 1px solid var(--vscode-panel-border); + flex-shrink: 0; +} + +.am-pr-panel-title-row { + gap: 8px; + min-width: 0; +} + +.am-pr-panel-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 4px; + border-radius: 4px; + font-size: var(--kilo-font-size-10); + font-variant-numeric: tabular-nums; + background: color-mix(in srgb, var(--am-pr-accent) 15%, transparent); + color: var(--am-pr-accent); + border: 1px solid color-mix(in srgb, var(--am-pr-accent) 40%, transparent); + white-space: nowrap; + flex-shrink: 0; +} + +.am-pr-panel-badge.am-pr-badge-pending { + animation: am-pr-badge-pulse 1.5s ease-in-out infinite; +} + +.am-pr-panel-title { + font-size: var(--kilo-font-size-14); + color: var(--vscode-foreground); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.am-pr-panel-number { + font-size: var(--kilo-font-size-12); + color: var(--vscode-descriptionForeground); + font-variant-numeric: tabular-nums; + flex-shrink: 0; +} + +.am-pr-panel-actions { + gap: 2px; + flex-shrink: 0; +} + +.am-pr-panel-body { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +.am-pr-panel-section { + padding: 4px 12px; +} + +.am-pr-panel-section-heading { + justify-content: space-between; + font-size: var(--font-size-small); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-weak); + padding-bottom: 6px; +} + +.am-pr-panel-section-toggle { + width: 100%; + background: none; + border: none; + cursor: pointer; + padding: 0; + text-align: left; + border-radius: 3px; +} + +.am-pr-panel-section-toggle:hover { + color: var(--vscode-foreground); +} + +.am-pr-panel-section-heading-left { + gap: 4px; +} + +.am-pr-section-chevron { + flex-shrink: 0; + opacity: 0.6; +} + +/* Branch → base row */ +.am-pr-panel-branch { + gap: 4px; + flex-wrap: wrap; +} + +.am-pr-branch-name { + font-family: var(--font-mono, monospace); + font-size: var(--kilo-font-size-11); +} + +.am-pr-branch-arrow { + opacity: 0.5; + flex-shrink: 0; +} + +/* Check count color by status */ +.am-pr-checks-count-success { + color: var(--vscode-testing-iconPassed, #34d399); +} +.am-pr-checks-count-failure { + color: var(--vscode-testing-iconFailed, #f87171); +} +.am-pr-checks-count-pending { + color: var(--vscode-charts-yellow, #fbbf24); +} + +.am-pr-panel-section-count { + font-size: var(--kilo-font-size-11); + font-weight: 400; + text-transform: none; + letter-spacing: 0; + color: var(--text-weak); +} + +.am-pr-panel-divider { + height: 1px; + background: var(--vscode-panel-border); + margin: 6px 0; +} + +.am-pr-panel-row { + justify-content: space-between; + gap: 8px; + padding: 3px 0; + min-height: 22px; +} + +.am-pr-panel-label { + font-size: var(--kilo-font-size-11); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-weaker); + flex-shrink: 0; +} + +.am-pr-panel-value { + font-size: var(--kilo-font-size-12); + color: var(--vscode-foreground); + text-align: right; +} + +.am-pr-panel-value[data-pr-state="open"] { + color: var(--vscode-testing-iconPassed, #34d399); +} +.am-pr-panel-value[data-pr-state="merged"] { + color: var(--vscode-charts-purple, #a78bfa); +} +.am-pr-panel-value[data-pr-state="closed"] { + color: var(--vscode-testing-iconFailed, #f87171); +} +.am-pr-panel-value[data-pr-state="draft"] { + color: var(--text-weaker); +} + +.am-pr-panel-value[data-checks="failure"] { + color: var(--vscode-testing-iconFailed, #f87171); +} +.am-pr-panel-value[data-checks="pending"] { + color: var(--vscode-charts-yellow, #fbbf24); +} +.am-pr-panel-value[data-checks="success"] { + color: var(--vscode-testing-iconPassed, #34d399); +} + +.am-pr-panel-diff { + gap: 6px; +} +.am-pr-panel-unresolved { + color: var(--vscode-charts-yellow, #fbbf24); +} + +/* PR comment list */ +.am-pr-panel-comment-list { + gap: 8px; + margin-top: 6px; +} + +.am-pr-panel-comment { + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + padding: 8px; + opacity: 1; +} + +.am-pr-panel-comment-resolved { + opacity: 0.5; +} + +.am-pr-panel-comment-header { + gap: 6px; + margin-bottom: 4px; + flex-wrap: wrap; +} + +.am-pr-panel-comment-author { + font-size: var(--kilo-font-size-12); + font-weight: 600; + color: var(--vscode-foreground); +} + +.am-pr-panel-comment-file { + font-size: var(--kilo-font-size-11); + color: var(--text-weak); + font-family: var(--font-mono, monospace); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 180px; +} + +.am-pr-panel-comment-resolved-badge { + font-size: var(--kilo-font-size-10); + color: var(--text-weaker); + border: 1px solid var(--vscode-panel-border); + border-radius: 3px; + padding: 1px 4px; + margin-left: auto; +} + +.am-pr-copy-btn { + margin-left: auto; +} + +.am-pr-panel-comment-body [data-component="markdown"] { + font-size: var(--kilo-font-size-12); + color: var(--vscode-foreground); +} + +.am-pr-panel-description { + line-height: 1.5; + padding: 2px 0 4px; +} + +.am-pr-panel-description [data-component="markdown"], +.am-pr-panel-comment-body [data-component="markdown"] { + font-size: var(--kilo-font-size-13); + color: var(--vscode-foreground); +} + +.am-pr-panel-description [data-component="markdown"] h1, +.am-pr-panel-description [data-component="markdown"] h2, +.am-pr-panel-description [data-component="markdown"] h3, +.am-pr-panel-description [data-component="markdown"] h4, +.am-pr-panel-description [data-component="markdown"] h5, +.am-pr-panel-description [data-component="markdown"] h6 { + font-weight: 600; + color: var(--vscode-foreground); + margin-top: 0.75em; + margin-bottom: 0.25em; +} + +.am-pr-panel-description [data-component="markdown"] h1 { + font-size: var(--kilo-font-size-16); +} +.am-pr-panel-description [data-component="markdown"] h2 { + font-size: var(--kilo-font-size-14); +} +.am-pr-panel-description [data-component="markdown"] h3 { + font-size: var(--kilo-font-size-13); +} + +/* PR Reviewers */ +.am-pr-panel-reviewers { + gap: 2px; +} + +.am-pr-panel-reviewer { + gap: 6px; + padding: 3px 0; + font-size: var(--kilo-font-size-12); +} + +.am-pr-reviewer-icon { + flex-shrink: 0; +} + +.am-pr-panel-reviewer[data-state="approved"] .am-pr-reviewer-icon { + color: var(--vscode-testing-iconPassed, #34d399); +} +.am-pr-panel-reviewer[data-state="changes_requested"] .am-pr-reviewer-icon { + color: var(--vscode-charts-yellow, #fbbf24); +} +.am-pr-panel-reviewer[data-state="commented"] .am-pr-reviewer-icon { + color: var(--text-weak); +} +.am-pr-panel-reviewer[data-state="pending"] .am-pr-reviewer-icon { + color: var(--text-weaker); +} + +.am-pr-reviewer-login { + flex: 1; + color: var(--vscode-foreground); +} + +.am-pr-reviewer-state { + color: var(--text-weak); + flex-shrink: 0; +} + +.am-pr-panel-checks { + gap: 2px; + margin-top: 4px; +} + +.am-pr-panel-check-item { + gap: 6px; + padding: 3px 0; + font-size: var(--kilo-font-size-12); +} + +.am-pr-check-icon { + flex-shrink: 0; +} + +.am-pr-panel-check-item[data-status="success"] .am-pr-check-icon { + color: var(--vscode-testing-iconPassed, #34d399); +} +.am-pr-panel-check-item[data-status="failure"] .am-pr-check-icon { + color: var(--vscode-testing-iconFailed, #f87171); +} +.am-pr-panel-check-item[data-status="cancelled"] .am-pr-check-icon { + color: var(--text-weaker); +} +.am-pr-panel-check-item[data-status="skipped"] .am-pr-check-icon { + color: var(--text-weaker); +} +.am-pr-panel-check-item[data-status="pending"] .am-pr-check-icon { + color: var(--vscode-charts-yellow, #fbbf24); +} + +.am-pr-check-name { + flex: 1; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.am-pr-check-status { + color: var(--text-weak); + flex-shrink: 0; +} + +.am-pr-check-duration { + color: var(--text-weaker); + font-size: var(--kilo-font-size-10); + flex-shrink: 0; +} + +.am-pr-check-link { + color: var(--text-weak); + display: flex; + align-items: center; + flex-shrink: 0; + cursor: pointer; + text-decoration: none; +} + +.am-pr-check-link:hover { + color: var(--text-base); +} + +/* PR toggle button in tab bar */ +.am-pr-toggle-number { + font-size: var(--kilo-font-size-10); + font-variant-numeric: tabular-nums; +} + +/* PR Summary banner */ +.am-pr-summary { + margin: 8px 12px; + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + overflow: hidden; +} + +.am-pr-summary-header { + justify-content: space-between; + padding: 6px 6px 6px 10px; + border-bottom: 1px solid var(--vscode-panel-border); + background: var(--vscode-editor-background); +} + +.am-pr-summary-title { + font-size: var(--font-size-small); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-weak); +} + +.am-pr-summary-rows { + padding: 6px 10px; + gap: 4px; + background: var(--vscode-editor-background); +} + +.am-pr-summary-row { + gap: 7px; + padding: 2px 0; + font-size: var(--kilo-font-size-12); +} + +.am-pr-summary-icon { + flex-shrink: 0; +} + +.am-pr-summary-row[data-status="success"] .am-pr-summary-icon { + color: var(--vscode-testing-iconPassed, #34d399); +} +.am-pr-summary-row[data-status="failure"] .am-pr-summary-icon { + color: var(--vscode-testing-iconFailed, #f87171); +} +.am-pr-summary-row[data-status="pending"] .am-pr-summary-icon { + color: var(--vscode-charts-yellow, #fbbf24); +} +.am-pr-summary-row[data-status="warning"] .am-pr-summary-icon { + color: var(--vscode-charts-yellow, #fbbf24); +} + +.am-pr-summary-label { + color: var(--vscode-foreground); +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts new file mode 100644 index 00000000000..d5d0a89d6c6 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts @@ -0,0 +1,34 @@ +// PR sub-types — source of truth for all PR-related types used in the PR panel. +// PRStatus lives in src/types/messages/agent-manager.ts for broad consumption. + +export type PRState = "open" | "draft" | "merged" | "closed" +export type ReviewDecision = "approved" | "changes_requested" | "pending" +export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled" +export type AggregateCheckStatus = "success" | "failure" | "pending" | "none" + +export interface PRCheck { + name: string + status: CheckStatus + url?: string + duration?: string +} + +export interface PRComment { + id: string + author: string + avatar?: string + body: string + file?: string + line?: number + url?: string + resolved: boolean + createdAt?: number +} + +export type ReviewerState = "approved" | "changes_requested" | "pending" | "commented" + +export interface PRReviewer { + login: string + avatar?: string + state: ReviewerState +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts b/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts index 3b74f85d3d1..2fb492bcfa7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts @@ -5,6 +5,12 @@ export const SIDE_RESIZE_INTERVAL_MS = 32 type Frame = (callback: (time: number) => void) => number +export enum SidePanel { + Diff = "diff", + PR = "pr", + Terminal = "terminal", +} + function viewportWidth(viewport: number): number { return Number.isFinite(viewport) && viewport > 0 ? viewport : MIN_PANEL_WIDTH } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/ambient.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/ambient.ts index aac693f50e7..c674c80d79f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/ambient.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/ambient.ts @@ -13,11 +13,12 @@ import { createEffect, createSignal, type Accessor } from "solid-js" import type { ScriptTerminalStatus, TerminalStateControls } from "./state" import type { TerminalTabStateWithContext } from "./state" +import type { SidePanelState } from "../AgentManagerApp" interface AmbientSetupDeps { terms: TerminalStateControls selection: Accessor - sidePanel: Accessor + sidePanel: Accessor setSidePanel(panel: null): void } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx index c548b06c76d..5d966e9c578 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx @@ -451,6 +451,18 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { setErrors("models", (v) => v.filter((_, i) => i !== index)) } + function toggleAllReasoning() { + const all = form.models.length > 0 && form.models.every((m) => m.reasoning) + const target = !all + form.models.forEach((_, i) => setForm("models", i, "reasoning", target)) + } + + function toggleAllImages() { + const all = form.models.length > 0 && form.models.every((m) => m.supportsImages) + const target = !all + form.models.forEach((_, i) => setForm("models", i, "supportsImages", target)) + } + function addHeader() { setForm("headers", (v) => [...v, { key: "", value: "" }]) setErrors("headers", (v) => [...v, {}]) @@ -515,6 +527,7 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { return ( { style={{ display: "flex", "flex-direction": "column", - gap: "24px", - padding: "0 10px 12px 10px", + gap: "20px", + padding: "0 16px 16px 16px", "overflow-y": "auto", - "max-height": "60vh", + flex: 1, + width: "100%", + "box-sizing": "border-box", }} > -
+
{
-
-
- {language.t("provider.custom.description.prefix")} - { - e.preventDefault() - vscode.postMessage({ - type: "openExternal", - url: "https://kilo.ai/docs/ai-providers#custom-provider", - }) - }} - > - {language.t("provider.custom.description.link")} - - {language.t("provider.custom.description.suffix")} + +
+ - @@ -651,19 +665,49 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { {/* Models */}
-
- - - - +
+
+ + + + +
+
+ + +
{(m, i) => ( diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx index a33984738c3..49338afe000 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx @@ -98,42 +98,44 @@ export function ModelCard(props: ModelCardProps) { />
- {/* Reasoning toggle */} - + {/* Reasoning and Image toggles */} +
+ - + +
{(error) => ( diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx index a88bc752668..163cd5adf30 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx @@ -50,12 +50,13 @@ export const ModelPreview: Component = (props) => { {(model) => { const cost = () => model().cost + const hasPricing = () => cost() && (cost()!.input > 0 || cost()!.output > 0) const bench = () => model().terminalBench const cachedText = () => { - if (!cost()) return "" + if (!cost() || !hasPricing()) return "" return fmtCachedPrice(cost()!) ?? language.t("model.preview.value.notSupported") } - const avg = () => (cost() ? avgPrice(cost()!) : undefined) + const avg = () => (cost() && hasPricing() ? avgPrice(cost()!) : undefined) const freeLabel = () => language.t("model.tag.free") const dataLabel = () => freeDataLabel(language.t("model.tag.free"), language.t("model.tag.dataCollected")) const autoLabel = () => autoSummary(model()) @@ -139,8 +140,8 @@ export const ModelPreview: Component = (props) => { {fmtDate(model().releaseDate!)} - {/* Pricing — hidden for free models */} - + {/* Pricing — hidden for free models or models without fixed pricing */} + {language.t("model.preview.label.input")} {fmtPrice(cost()!.input)} {language.t("model.preview.label.output")} diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/model-preview-utils.ts b/packages/kilo-vscode/webview-ui/src/components/shared/model-preview-utils.ts index 980ab78f368..10cd1f44d76 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/model-preview-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/shared/model-preview-utils.ts @@ -3,6 +3,7 @@ * Expects `n` in $/M tokens (as stored in model.cost.input / model.cost.output). */ export function fmtPrice(n: number): string { + if (n < 0) return "—" if (n === 0) return "Free" if (n < 0.01) return `$${n.toFixed(4)}/1M` return `$${n.toFixed(2)}/1M` diff --git a/packages/kilo-vscode/webview-ui/src/context/provider-shell.tsx b/packages/kilo-vscode/webview-ui/src/context/provider-shell.tsx index 48ecd641c7c..d457f5e4f00 100644 --- a/packages/kilo-vscode/webview-ui/src/context/provider-shell.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/provider-shell.tsx @@ -52,6 +52,8 @@ const Root: ParentComponent = (props) => ( + {/* MarkedProvider is required here for all markdown consumers in the tree, + including PRPanel's PRDescription and PRComments components. Do not remove. */} diff --git a/packages/kilo-vscode/webview-ui/src/context/session-errors.ts b/packages/kilo-vscode/webview-ui/src/context/session-errors.ts index a5fd388726f..7d7f56aa072 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-errors.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-errors.ts @@ -3,6 +3,27 @@ import type { Message } from "../types/messages" type Entry = { id: string; error?: Message["error"] } type Error = NonNullable +function sameError(a: Message, b: Message) { + if (!a.error || !b.error || a.error.name !== b.error.name) return false + if (a.parentID !== b.parentID) return false + return JSON.stringify(a.error.data) === JSON.stringify(b.error.data) +} + +export function withoutResolvedSessionErrors(current: Message[], incoming: Message[]) { + const events = new Set(incoming.map((msg) => msg.sessionErrorID).filter((id): id is string => !!id)) + return current.filter((msg) => { + if (!msg.sessionErrorID) return true + if (events.has(msg.sessionErrorID)) return false + return !incoming.some((next) => !next.sessionErrorID && sameError(msg, next)) + }) +} + +export function preserveSessionErrors(current: Message[], incoming: Message[]) { + const ids = new Set(incoming.map((msg) => msg.id)) + const errors = withoutResolvedSessionErrors(current, incoming).filter((msg) => msg.sessionErrorID && !ids.has(msg.id)) + return [...incoming, ...errors] +} + export function errorIDs(messages: Entry[]) { return messages.filter((msg) => !!msg.error).map((msg) => msg.id) } diff --git a/packages/kilo-vscode/webview-ui/src/context/session-merge.ts b/packages/kilo-vscode/webview-ui/src/context/session-merge.ts new file mode 100644 index 00000000000..2a59bf0d0ad --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/session-merge.ts @@ -0,0 +1,43 @@ +import type { Message, MessageLoadMode, Part } from "../types/messages" +import { sameParts } from "./session-parts" +import { withoutResolvedSessionErrors } from "./session-errors" + +export function mergeMessages(current: Message[], incoming: Message[], mode: Exclude) { + const kept = withoutResolvedSessionErrors(current, incoming) + if (mode === "reconcile") { + // Tail reconcile: incoming is the authoritative newest-N snapshot. + // Local state may already hold some of those IDs and may also hold + // newer optimistic entries created after the fetch was taken. Merge + // by id (server wins on collision) then sort by createdAt so new + // server messages land in the right position and optimistic tail + // entries stay at the end. + const byId = new Map() + for (const msg of kept) byId.set(msg.id, msg) + for (const msg of incoming) byId.set(msg.id, msg) + return [...byId.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + } + const seen = new Set() + const source = mode === "prepend" ? [...incoming, ...kept] : incoming + return source.filter((msg) => { + if (seen.has(msg.id)) return false + seen.add(msg.id) + return true + }) +} + +// Cheap tail check: same ids in the same order and no visible streamed-part +// correction to apply. It skips store churn when SSE already matches the +// snapshot, but lets reconcile heal part removals and finalized text. +export function sameReconcileShape( + current: Message[], + incoming: Message[], + getParts: (messageID: string) => Part[] | undefined, +): boolean { + if (current.length !== incoming.length) return false + for (const [i, n] of incoming.entries()) { + const c = current[i]! + if (c.id !== n.id) return false + if (!sameParts(getParts(c.id) ?? c.parts, n.parts)) return false + } + return true +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index d13c719b410..cb31ff49af9 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -71,9 +71,10 @@ import { Identifier } from "../utils/id" import { resolveModelSelection } from "./model-selection" import { getAgentModel } from "./session-model-store" import { resolveMessagePrefs } from "./session-preferences" -import { errorIDs } from "./session-errors" +import { errorIDs, preserveSessionErrors, withoutResolvedSessionErrors } from "./session-errors" import { PartStash } from "./part-stash" -import { mergeParts, sameParts } from "./session-parts" +import { mergeParts } from "./session-parts" +import { mergeMessages, sameReconcileShape } from "./session-merge" import { state as todoState } from "./todo-revert" import { sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store" import { createSessionVariants } from "./session-variants" @@ -1168,7 +1169,7 @@ export const SessionProvider: ParentComponent = (props) => { break case "sessionError": { - if (message.error?.name === "MessageAbortedError") break + if (!message.error || message.error.name === "MessageAbortedError") break const sid = message.sessionID ?? currentSessionID() if (!sid) break // Find the last user message in this session to use as parentID @@ -1181,6 +1182,7 @@ export const SessionProvider: ParentComponent = (props) => { createdAt: new Date().toISOString(), parentID: parent?.id, error: message.error, + sessionErrorID: message.eventID, } handleMessageCreated(errorMsg) break @@ -1373,28 +1375,6 @@ export const SessionProvider: ParentComponent = (props) => { setPages(sessionID, { ...(pages[sessionID] ?? emptyPageState), ...patch }) } - function mergeMessages(current: Message[], incoming: Message[], mode: Exclude) { - if (mode === "reconcile") { - // Tail reconcile: incoming is the authoritative newest-N snapshot. - // Local state may already hold some of those IDs and may also hold - // newer optimistic entries created after the fetch was taken. Merge - // by id (server wins on collision) then sort by createdAt so new - // server messages land in the right position and optimistic tail - // entries stay at the end. - const byId = new Map() - for (const msg of current) byId.set(msg.id, msg) - for (const msg of incoming) byId.set(msg.id, msg) - return [...byId.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) - } - const seen = new Set() - const source = mode === "prepend" ? [...incoming, ...current] : incoming - return source.filter((msg) => { - if (seen.has(msg.id)) return false - seen.add(msg.id) - return true - }) - } - function recoverPrefs(sessionID: string, messages: Message[], names = agentNames()) { const prefs = resolveMessagePrefs(messages, names) if (prefs.agent && !store.agentSelections[sessionID]) { @@ -1411,25 +1391,13 @@ export const SessionProvider: ParentComponent = (props) => { } function withPending(sessionID: string, messages: Message[]) { - const pending = pendingOptimistic.get(sessionID) - if (!pending || pending.size === 0) return messages - const ids = new Set(messages.map((msg) => msg.id)) const current = store.messages[sessionID] ?? [] + const merged = preserveSessionErrors(current, messages) + const pending = pendingOptimistic.get(sessionID) + if (!pending || pending.size === 0) return merged + const ids = new Set(merged.map((msg) => msg.id)) const orphans = current.filter((msg) => pending.has(msg.id) && !ids.has(msg.id)) - return [...messages, ...orphans] - } - - // Cheap tail check: same ids in the same order and no visible streamed-part - // correction to apply. It skips store churn when SSE already matches the - // snapshot, but lets reconcile heal part removals and finalized text. - function sameReconcileShape(current: Message[], incoming: Message[]): boolean { - if (current.length !== incoming.length) return false - for (const [i, n] of incoming.entries()) { - const c = current[i]! - if (c.id !== n.id) return false - if (!sameParts(store.parts[c.id] ?? c.parts, n.parts)) return false - } - return true + return [...merged, ...orphans] } function setTools(sessionID: string, tools: ToolPart[]) { @@ -1480,7 +1448,10 @@ export const SessionProvider: ParentComponent = (props) => { // Reconcile fast-path: if the tail matches local state shape-wise, every // message+part-count already agrees with the server. Skip the reactive // store churn entirely — virtualizer and rendering stay untouched. - if (mode === "reconcile" && sameReconcileShape(store.messages[sessionID] ?? [], messages)) { + if ( + mode === "reconcile" && + sameReconcileShape(store.messages[sessionID] ?? [], messages, (id) => store.parts[id]) + ) { const parts = messageParts(messages) for (const msg of messages) { if (store.parts[msg.id]) delete parts[msg.id] @@ -1594,16 +1565,18 @@ export const SessionProvider: ParentComponent = (props) => { const exists = (store.messages[message.sessionID] ?? []).some((msg) => msg.id === message.id) setStore("messages", message.sessionID, (msgs = []) => { + if (message.sessionErrorID && msgs.some((msg) => msg.sessionErrorID === message.sessionErrorID)) return msgs + const current = withoutResolvedSessionErrors(msgs, [message]) // Check if message already exists (optimistic or update case). // Since we now use the same messageID for optimistic and server messages, // this naturally handles the optimistic→real transition. - const idx = msgs.findIndex((m) => m.id === message.id) + const idx = current.findIndex((m) => m.id === message.id) if (idx >= 0) { - const updated = [...msgs] - updated[idx] = { ...msgs[idx], ...message } + const updated = [...current] + updated[idx] = { ...current[idx], ...message } return updated } - return [...msgs, message] + return [...current, message] }) patchPage(message.sessionID, { lastMutation: exists ? "update" : "append" }) diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 4e8295ad13d..4c5496bc988 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -443,6 +443,8 @@ export const dict = { "provider.custom.models.name.placeholder": "الاسم المعروض", "provider.custom.models.reasoning.label": "الاستدلال", "provider.custom.models.modalities.image": "صورة", + "provider.custom.models.toggleReasoning": "تبديل التفكير للكل", + "provider.custom.models.toggleImages": "تبديل الصور للكل", "provider.custom.models.remove": "إزالة النموذج", "provider.custom.models.add": "إضافة نموذج", "provider.custom.models.fetch.authError": "فشلت المصادقة. تحقق من مفتاح API أعلاه وحاول مرة أخرى.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 8e825534964..f7412b3e7ea 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -454,6 +454,8 @@ export const dict = { "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.toggleReasoning": "Alternar raciocínio para todos", + "provider.custom.models.toggleImages": "Alternar imagem para todos", "provider.custom.models.remove": "Remover modelo", "provider.custom.models.add": "Adicionar modelo", "provider.custom.models.fetch.authError": "Falha na autenticação. Verifique a chave de API acima e tente novamente.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 36817205996..f372413ef25 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -496,6 +496,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Naziv za prikaz", "provider.custom.models.reasoning.label": "Zaključivanje", "provider.custom.models.modalities.image": "Slika", + "provider.custom.models.toggleReasoning": "Uključi/isključi rezonovanje za sve", + "provider.custom.models.toggleImages": "Uključi/isključi slike za sve", "provider.custom.models.remove": "Ukloni model", "provider.custom.models.add": "Dodaj model", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index af7a402f188..2c5df219a56 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -494,6 +494,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Visningsnavn", "provider.custom.models.reasoning.label": "Ræsonnement", "provider.custom.models.modalities.image": "Billede", + "provider.custom.models.toggleReasoning": "Slå ræsonnement til/fra for alle", + "provider.custom.models.toggleImages": "Slå billede til/fra for alle", "provider.custom.models.remove": "Fjern model", "provider.custom.models.add": "Tilføj model", "provider.custom.models.fetch.authError": "Godkendelse mislykkedes. Kontrollér API-nøglen ovenfor, og prøv igen.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 3bd2234ee43..9e83f815f8c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -504,6 +504,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Anzeigename", "provider.custom.models.reasoning.label": "Schlussfolgerung", "provider.custom.models.modalities.image": "Bild", + "provider.custom.models.toggleReasoning": "Schlussfolgerung für alle umschalten", + "provider.custom.models.toggleImages": "Bild für alle umschalten", "provider.custom.models.remove": "Modell entfernen", "provider.custom.models.add": "Modell hinzufügen", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 637305d8f22..cf4a857e1af 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -408,6 +408,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Display Name", "provider.custom.models.reasoning.label": "Reasoning", "provider.custom.models.modalities.image": "Image", + "provider.custom.models.toggleReasoning": "Toggle reasoning for all", + "provider.custom.models.toggleImages": "Toggle image for all", "provider.custom.models.remove": "Remove model", "provider.custom.models.add": "Add model", "provider.custom.models.fetch.authError": "Authentication failed. Check the API key above and try again.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 60816982138..644800a5221 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -497,6 +497,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Nombre para mostrar", "provider.custom.models.reasoning.label": "Razonamiento", "provider.custom.models.modalities.image": "Imagen", + "provider.custom.models.toggleReasoning": "Alternar razonamiento para todos", + "provider.custom.models.toggleImages": "Alternar imagen para todos", "provider.custom.models.remove": "Eliminar modelo", "provider.custom.models.add": "Añadir modelo", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index 2a6ac366bb9..3b1530349db 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -409,6 +409,8 @@ export const dict = { "provider.custom.models.name.placeholder": "نام نمایشی", "provider.custom.models.reasoning.label": "استدلال", "provider.custom.models.modalities.image": "تصویر", + "provider.custom.models.toggleReasoning": "تغییر وضعیت استدلال برای همه", + "provider.custom.models.toggleImages": "تغییر وضعیت تصویر برای همه", "provider.custom.models.remove": "حذف مدل", "provider.custom.models.add": "افزودن مدل", "provider.custom.models.fetch.authError": "احراز هویت ناموفق بود. کلید API بالا را بررسی کرده و دوباره امتحان کنید.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index e1957ba7500..5ee7c64be6b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -498,6 +498,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Nom d'affichage", "provider.custom.models.reasoning.label": "Raisonnement", "provider.custom.models.modalities.image": "Image", + "provider.custom.models.toggleReasoning": "Basculer le raisonnement pour tous", + "provider.custom.models.toggleImages": "Basculer l'image pour tous", "provider.custom.models.remove": "Supprimer le modèle", "provider.custom.models.add": "Ajouter un modèle", "provider.custom.models.fetch.authError": "Échec de l'authentification. Vérifiez la clé API ci-dessus et réessayez.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 3f804c4b4dd..833f568ff41 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -318,6 +318,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Nome visualizzato", "provider.custom.models.reasoning.label": "Reasoning", "provider.custom.models.modalities.image": "Immagine", + "provider.custom.models.toggleReasoning": "Attiva/disattiva reasoning per tutti", + "provider.custom.models.toggleImages": "Attiva/disattiva immagine per tutti", "provider.custom.models.remove": "Rimuovi modello", "provider.custom.models.add": "Aggiungi modello", "provider.custom.models.fetch.authError": "Autenticazione non riuscita. Controlla l'API key sopra e riprova.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 36b3bb91cb3..511b5034aa8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -490,6 +490,8 @@ export const dict = { "provider.custom.models.name.placeholder": "表示名", "provider.custom.models.reasoning.label": "推論", "provider.custom.models.modalities.image": "画像", + "provider.custom.models.toggleReasoning": "すべてのモデルの推論を切り替え", + "provider.custom.models.toggleImages": "すべてのモデルの画像を切り替え", "provider.custom.models.remove": "モデルを削除", "provider.custom.models.add": "モデルを追加", "provider.custom.models.fetch.authError": "認証に失敗しました。上記のAPIキーを確認して再試行してください。", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index ed69706294b..aad05fc7ca1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -451,6 +451,8 @@ export const dict = { "provider.custom.models.name.placeholder": "표시 이름", "provider.custom.models.reasoning.label": "추론", "provider.custom.models.modalities.image": "이미지", + "provider.custom.models.toggleReasoning": "모든 모델의 추론 전환", + "provider.custom.models.toggleImages": "모든 모델의 이미지 전환", "provider.custom.models.remove": "모델 제거", "provider.custom.models.add": "모델 추가", "provider.custom.models.fetch.authError": "인증에 실패했습니다. 위의 API 키를 확인하고 다시 시도하세요.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 71ad69b7f7c..5d1a26d21f9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -447,6 +447,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Weergavenaam", "provider.custom.models.reasoning.label": "Redeneren", "provider.custom.models.modalities.image": "Afbeelding", + "provider.custom.models.toggleReasoning": "Redeneren voor alle modellen in-/uitschakelen", + "provider.custom.models.toggleImages": "Afbeelding voor alle modellen in-/uitschakelen", "provider.custom.models.remove": "Model verwijderen", "provider.custom.models.add": "Model toevoegen", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index f2c3e5425e2..eb98954aba1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -457,6 +457,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Visningsnavn", "provider.custom.models.reasoning.label": "Resonnering", "provider.custom.models.modalities.image": "Bilde", + "provider.custom.models.toggleReasoning": "Veksle resonnering for alle", + "provider.custom.models.toggleImages": "Veksle bilde for alle", "provider.custom.models.remove": "Fjern modell", "provider.custom.models.add": "Legg til modell", "provider.custom.models.fetch.authError": "Autentisering mislyktes. Sjekk API-nøkkelen ovenfor og prøv igjen.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index e2048370847..d711cff1355 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -452,6 +452,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Nazwa wyświetlana", "provider.custom.models.reasoning.label": "Rozumowanie", "provider.custom.models.modalities.image": "Obraz", + "provider.custom.models.toggleReasoning": "Przełącz wnioskowanie dla wszystkich", + "provider.custom.models.toggleImages": "Przełącz obraz dla wszystkich", "provider.custom.models.remove": "Usuń model", "provider.custom.models.add": "Dodaj model", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index b022005b489..eae378a40f7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -491,6 +491,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Отображаемое имя", "provider.custom.models.reasoning.label": "Рассуждение", "provider.custom.models.modalities.image": "Изображение", + "provider.custom.models.toggleReasoning": "Переключить рассуждения для всех", + "provider.custom.models.toggleImages": "Переключить изображения для всех", "provider.custom.models.remove": "Удалить модель", "provider.custom.models.add": "Добавить модель", "provider.custom.models.fetch.authError": "Ошибка аутентификации. Проверьте API-ключ выше и попробуйте снова.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 189ae8e5b5e..4dba9385c68 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -488,6 +488,8 @@ export const dict = { "provider.custom.models.name.placeholder": "ชื่อที่แสดง", "provider.custom.models.reasoning.label": "การใช้เหตุผล", "provider.custom.models.modalities.image": "รูปภาพ", + "provider.custom.models.toggleReasoning": "สลับการให้เหตุผลสำหรับทุกโมเดล", + "provider.custom.models.toggleImages": "สลับรูปภาพสำหรับทุกโมเดล", "provider.custom.models.remove": "ลบโมเดล", "provider.custom.models.add": "เพิ่มโมเดล", "provider.custom.models.fetch.authError": "การยืนยันตัวตนล้มเหลว ตรวจสอบคีย์ API ด้านบนแล้วลองอีกครั้ง", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 7c569cf1db9..e8767e36004 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -442,6 +442,8 @@ export const dict = { "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.toggleReasoning": "Tüm modeller için akıl yürütmeyi aç/kapat", + "provider.custom.models.toggleImages": "Tüm modeller için görüntüyü aç/kapat", "provider.custom.models.remove": "Modeli kaldır", "provider.custom.models.add": "Model ekle", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index ea30dd59d27..84672e9f904 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -446,6 +446,8 @@ export const dict = { "provider.custom.models.name.placeholder": "Відображувана назва", "provider.custom.models.reasoning.label": "Міркування", "provider.custom.models.modalities.image": "Зображення", + "provider.custom.models.toggleReasoning": "Перемкнути міркування для всіх", + "provider.custom.models.toggleImages": "Перемкнути зображення для всіх", "provider.custom.models.remove": "Видалити модель", "provider.custom.models.add": "Додати модель", "provider.custom.models.fetch.authError": "Автентифікація не вдалася. Перевірте API-ключ вище і спробуйте ще раз.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 11bbdef8350..28fcf881cd9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -473,6 +473,8 @@ export const dict = { "provider.custom.models.name.placeholder": "显示名称", "provider.custom.models.reasoning.label": "推理", "provider.custom.models.modalities.image": "图片", + "provider.custom.models.toggleReasoning": "切换所有模型的推理", + "provider.custom.models.toggleImages": "切换所有模型的图像", "provider.custom.models.remove": "移除模型", "provider.custom.models.add": "添加模型", "provider.custom.models.fetch.authError": "认证失败。请检查上方的 API 密钥后重试。", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index d679c55c7fe..e6842c06da4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -433,6 +433,8 @@ export const dict = { "provider.custom.models.name.placeholder": "顯示名稱", "provider.custom.models.reasoning.label": "推理", "provider.custom.models.modalities.image": "圖片", + "provider.custom.models.toggleReasoning": "切換所有模型的推理", + "provider.custom.models.toggleImages": "切換所有模型的圖像", "provider.custom.models.remove": "移除模型", "provider.custom.models.add": "新增模型", "provider.custom.models.fetch.authError": "驗證失敗。請檢查上方的 API 金鑰後重試。", diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index e74cac8b0fb..817310e1bb9 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -612,7 +612,8 @@ const basePR: PRStatus = { url: "https://github.com/org/repo/pull/8594", state: "open", review: null, - checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, items: [] }, + checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, checks: [] }, + reviewers: [], additions: 978, deletions: 202, files: 12, diff --git a/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx index cf348a4d339..a359eefea5f 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx @@ -593,7 +593,8 @@ export const WithPRBadges: Story = { additions: 120, deletions: 30, files: 5, - checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, items: [] }, + reviewers: [], + checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, checks: [] }, }} /> {/* Open + failing checks → green badge, red ✗ (no longer confusable with closed) */} @@ -611,7 +612,8 @@ export const WithPRBadges: Story = { additions: 15, deletions: 8, files: 3, - checks: { status: "failure", total: 5, passed: 3, failed: 2, pending: 0, items: [] }, + reviewers: [], + checks: { status: "failure", total: 5, passed: 3, failed: 2, pending: 0, checks: [] }, }} /> {/* Open + changes requested → green badge, amber warning */} @@ -629,7 +631,8 @@ export const WithPRBadges: Story = { additions: 60, deletions: 12, files: 4, - checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, items: [] }, + reviewers: [], + checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, checks: [] }, }} /> {/* Open + checks running → pulsing amber badge (animation disabled in snapshots) */} @@ -647,7 +650,8 @@ export const WithPRBadges: Story = { additions: 200, deletions: 0, files: 8, - checks: { status: "pending", total: 5, passed: 2, failed: 0, pending: 3, items: [] }, + reviewers: [], + checks: { status: "pending", total: 5, passed: 2, failed: 0, pending: 3, checks: [] }, }} /> {/* Draft → gray badge */} @@ -665,7 +669,8 @@ export const WithPRBadges: Story = { additions: 30, deletions: 5, files: 2, - checks: { status: "none", total: 0, passed: 0, failed: 0, pending: 0, items: [] }, + reviewers: [], + checks: { status: "none", total: 0, passed: 0, failed: 0, pending: 0, checks: [] }, }} /> {/* Merged → purple badge, no status icon */} @@ -683,7 +688,8 @@ export const WithPRBadges: Story = { additions: 90, deletions: 20, files: 6, - checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, items: [] }, + reviewers: [], + checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, checks: [] }, }} /> {/* Closed → red badge, no status icon (distinct from a failing open PR) */} @@ -701,7 +707,8 @@ export const WithPRBadges: Story = { additions: 10, deletions: 4, files: 1, - checks: { status: "failure", total: 5, passed: 1, failed: 4, pending: 0, items: [] }, + reviewers: [], + checks: { status: "failure", total: 5, passed: 1, failed: 4, pending: 0, checks: [] }, }} />
diff --git a/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx index 7b510d6a644..34738b5943f 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx @@ -17,6 +17,8 @@ import ModeEditView from "../components/settings/ModeEditView" import McpEditView from "../components/settings/McpEditView" import type { AgentConfig, CommandConfig, Config } from "../types/messages" import IndexingTab from "../components/settings/IndexingTab" +import CustomProviderDialog from "../components/settings/CustomProviderDialog" +import { useDialog } from "@kilocode/kilo-ui/context/dialog" import { SidebarEmptyState } from "../components/chat/SidebarEmptyState" import { WorkStyleContext, type WorkStyleContextValue } from "../context/work-style" @@ -695,3 +697,44 @@ export const IndexingKiloCatalogLoading: Story = { ) }, } + +function CustomProviderDialogMount(props: { existing?: Parameters[0]["existing"] }) { + const dialog = useDialog() + onMount(() => dialog.show(() => )) + return null +} + +export const CustomProviderCreateDialog: Story = { + name: "Custom Provider — create dialog", + render: () => ( + + + + ), +} + +export const CustomProviderEditDialog: Story = { + name: "Custom Provider — edit dialog", + render: () => ( + + + + ), +} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts b/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts index 231731b1bdf..4ccb83c0745 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts @@ -45,36 +45,31 @@ export interface SectionState { } // --------------------------------------------------------------------------- -// PR status types (mirrored from extension types.ts) +// PR status types — sub-types live in agent-manager/pr/pr-types.ts // --------------------------------------------------------------------------- -export type PRState = "open" | "draft" | "merged" | "closed" -export type ReviewDecision = "approved" | "changes_requested" | "pending" -export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled" -export type AggregateCheckStatus = "success" | "failure" | "pending" | "none" - -export interface PRCheck { - name: string - status: CheckStatus - url?: string - duration?: string -} - -export interface PRComment { - id: string - author: string - avatar?: string - body: string - file?: string - line?: number - url?: string - resolved: boolean - createdAt?: number -} +import type { + PRState, + ReviewDecision, + AggregateCheckStatus, + PRCheck, + PRComment, + PRReviewer, +} from "../../../agent-manager/pr/pr-types" +export type { + PRState, + ReviewDecision, + CheckStatus, + AggregateCheckStatus, + PRCheck, + PRComment, + PRReviewer, +} from "../../../agent-manager/pr/pr-types" export interface PRStatus { number: number title: string + body?: string url: string state: PRState review: ReviewDecision | null @@ -84,12 +79,13 @@ export interface PRStatus { passed: number failed: number pending: number - items: PRCheck[] + checks: PRCheck[] } + reviewers: PRReviewer[] comments?: { total: number unresolved: number - items: PRComment[] + comments: PRComment[] } additions: number deletions: number diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index ed8aa570f7f..4008e5d1ea0 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -148,6 +148,7 @@ export interface SessionTurnClosedMessage { export interface SessionErrorMessage { type: "sessionError" + eventID: string sessionID?: string error?: { name: string; data?: Record } } @@ -1046,6 +1047,11 @@ export interface AgentManagerPRStatusMessage { error?: "gh_missing" | "gh_auth" | "fetch_failed" } +export interface AgentManagerPRErrorMessage { + type: "agentManager.prError" + error: "gh_missing" | "gh_auth" | "fetch_failed" +} + // Sidebar: Live worktree diff stats (extension → webview) export interface WorktreeStatsLoadedMessage { type: "worktreeStatsLoaded" @@ -1411,6 +1417,7 @@ export type ExtensionMessage = | AgentManagerWorktreeStatsMessage | AgentManagerLocalStatsMessage | AgentManagerPRStatusMessage + | AgentManagerPRErrorMessage | AgentManagerTerminalCreatedMessage | AgentManagerTerminalRestartedMessage | AgentManagerTerminalFontChangedMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts b/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts index 05477b126a8..eec557fe1e7 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts @@ -22,6 +22,7 @@ export interface Message { parentID?: string path?: { cwd: string; root: string } error?: { name: string; data?: Record } + sessionErrorID?: string summary?: { title?: string; body?: string; diffs?: unknown[] } | boolean cost?: number tokens?: TokenUsage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 98de565b812..d444f7fb881 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -988,6 +988,7 @@ export interface OpenPRMessage { type: "agentManager.openPR" projectId?: string worktreeId: string + url?: string } export interface ApplyWorktreeDiffMessage { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index d70acebf91e..e367e9d432f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -42,6 +42,7 @@ import z from "zod" // kilocode_change - Kilo config compatibility schemas // kilocode_change start import { ZodOverride } from "@opencode-ai/core/effect-zod" import { KilocodeConfig } from "../kilocode/config/config" +import { sanitizeProjectMcpHeaders } from "../kilocode/config/mcp-headers" import { primaryPaths } from "../kilocode/primary-worktree" import { Git } from "@/git" import { KilocodeDefaultPlugins } from "@/kilocode/config/default-plugins" @@ -66,8 +67,9 @@ function mergeConfig(target: Info, source: Info): Info { return mergeDeep(target, source) as Info } -function mergeConfigConcatArrays(target: Info, source: Info): Info { - const merged = mergeConfig(target, source) +function mergeConfigConcatArrays(target: Info, source: Info, trusted = true): Info { + // kilocode_change + const merged = trusted ? mergeConfig(target, source) : KilocodeConfig.mergeProject(target, source) if (target.instructions && source.instructions) { merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) } @@ -310,7 +312,7 @@ const layer = Layer.effect( const loadConfig = Effect.fnUntraced(function* ( text: string, - options: { path: string } | { dir: string; source: string }, + options: { path: string; original?: string } | { dir: string; source: string }, // kilocode_change env?: Record, // kilocode_change start - trusted allows {env:}; fileScope confines untrusted {file:} reads to a root trusted?: boolean, @@ -333,12 +335,13 @@ const layer = Layer.effect( if (!data.$schema) { // kilocode_change start data.$schema = "https://app.kilo.ai/config.json" - const edits = modify(text, ["$schema"], "https://app.kilo.ai/config.json", { + const original = options.original ?? text + const edits = modify(original, ["$schema"], "https://app.kilo.ai/config.json", { formattingOptions: { insertSpaces: true, tabSize: 2 }, getInsertionIndex: () => 0, }) - const updated = applyEdits(text, edits) - if (updated !== text) { + const updated = applyEdits(original, edits) + if (updated !== original) { yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void)) } // kilocode_change end @@ -351,11 +354,25 @@ const layer = Layer.effect( env?: Record, trusted?: boolean, // kilocode_change fileScope?: ConfigVariable.FileScope, // kilocode_change + configWarnings?: Warning[], // kilocode_change - collect MCP header expansion warnings ) { yield* Effect.logInfo("loading", { path: filepath }) const text = yield* readConfigFile(filepath) if (!text) return {} as Info - return yield* loadConfig(text, { path: filepath }, env, trusted, fileScope) // kilocode_change + // kilocode_change start - remove variable-bearing project MCP headers before generic substitution can read them + const sanitized = + trusted === false ? sanitizeProjectMcpHeaders(ConfigParse.jsonc(text, filepath), filepath) : undefined + const content = sanitized ? (JSON.stringify(sanitized.config) ?? text) : text + if (sanitized && configWarnings) configWarnings.push(...sanitized.warnings) + const data = yield* loadConfig( + content, + { path: filepath, original: text }, + trusted === false ? undefined : env, + trusted, + fileScope, + ) + // kilocode_change end + return data }) let globalStamp = "" // kilocode_change @@ -548,7 +565,7 @@ const layer = Layer.effect( const scope = kind ?? (yield* pluginScopeForSource(source)) const trusted = sourceTrusted ?? scope === "global" const scoped = KilocodeConfig.scopeIndexing(SandboxConfig.scope(next, scope), scope) - result = mergeConfigConcatArrays(result, scoped) + result = mergeConfigConcatArrays(result, scoped, trusted) // kilocode_change if (scoped.agent) configuredAgents = mergeDeep(configuredAgents, scoped.agent) if (next.instructions?.length) { result.instruction_origins = origins(result.instruction_origins, next.instructions, trusted, source) @@ -668,8 +685,8 @@ const layer = Layer.effect( for (const file of yield* ConfigPaths.files(name, ctx.directory, ctx.worktree).pipe(Effect.orDie)) { yield* merge( file, - // kilocode_change - project config is untrusted: {env:} rejected, {file:} confined to projectRoot - yield* loadFile(file, authEnv, false, { root: projectRoot, source: file }).pipe( + // kilocode_change - project config is untrusted: {env:} rejected by substitution; MCP entries with variable-bearing headers dropped pre-substitution, {file:} confined to projectRoot + yield* loadFile(file, authEnv, false, { root: projectRoot, source: file }, warnings).pipe( Effect.catchDefect((err: unknown) => { caughtWarning(warnings, file, err) return Effect.succeed({} as Info) @@ -721,7 +738,7 @@ const layer = Layer.effect( const fileScope = dirTrusted ? undefined : { root: projectRoot, source } yield* merge( source, - yield* loadFile(source, authEnv, dirTrusted, fileScope).pipe( + yield* loadFile(source, authEnv, dirTrusted, fileScope, dirTrusted ? undefined : warnings).pipe( // kilocode_change Effect.catchDefect((err: unknown) => { caughtWarning(warnings, source, err) diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/memory-events.ts b/packages/opencode/src/kilocode/cli/cmd/tui/memory-events.ts index c388231b922..293feee20cf 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/memory-events.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui/memory-events.ts @@ -1,3 +1,5 @@ +import { TRANSIENT } from "@kilocode/kilo-memory/schema" + type Event = { on(type: "memory.error", fn: (event: MemoryEvent) => void): void | (() => void) } @@ -22,6 +24,7 @@ export namespace MemoryTuiEvents { }) { const handler = (event: MemoryEvent) => { if (event.properties.sessionID && event.properties.sessionID !== input.sessionID) return + if (event.properties.reason === TRANSIENT) return const detail = event.properties.detail if (!detail || typeof detail !== "object") { input.toast.show({ diff --git a/packages/opencode/src/kilocode/components/model-info-panel-utils.ts b/packages/opencode/src/kilocode/components/model-info-panel-utils.ts index 1c7bb828b56..5cfc69d7b70 100644 --- a/packages/opencode/src/kilocode/components/model-info-panel-utils.ts +++ b/packages/opencode/src/kilocode/components/model-info-panel-utils.ts @@ -8,6 +8,7 @@ interface Cost { } export function fmtPrice(n: number): string { + if (n < 0) return "—" if (n === 0) return "Free" if (n < 0.01) return `$${n.toFixed(4)}/1M` return `$${n.toFixed(2)}/1M` diff --git a/packages/opencode/src/kilocode/components/model-info-panel.tsx b/packages/opencode/src/kilocode/components/model-info-panel.tsx index e7fa1893499..8ed65dda997 100644 --- a/packages/opencode/src/kilocode/components/model-info-panel.tsx +++ b/packages/opencode/src/kilocode/components/model-info-panel.tsx @@ -27,8 +27,9 @@ export function ModelInfoPanel(props: Props) { const maxHeight = createMemo(() => Math.floor(dimensions().height / 2) - 3) const cost = () => m().cost - const cached = () => (cost() ? fmtCachedPrice(cost()) : null) - const avg = () => (cost() ? avgPrice(cost()) : undefined) + const hasPricing = () => m().cost?.input > 0 || m().cost?.output > 0 + const cached = () => (cost() && hasPricing() ? fmtCachedPrice(cost()) : null) + const avg = () => (cost() && hasPricing() ? avgPrice(cost()) : undefined) const caps = () => m().capabilities const inputs = () => caps()?.input const outputs = () => caps()?.output @@ -99,25 +100,27 @@ export function ModelInfoPanel(props: Props) {
- - Input - {m() ? fmtPrice(m().cost.input) : "—"} - - - Output - {m() ? fmtPrice(m().cost.output) : "—"} - - + - Cached - {cached()} + Input + {m() ? fmtPrice(m().cost.input) : "—"} - - - Avg Cost - {m() ? fmtPrice(avg()!) : "—"} + Output + {m() ? fmtPrice(m().cost.output) : "—"} + + + Cached + {cached()} + + + + + Avg Cost + {m() ? fmtPrice(avg()!) : "—"} + + Context diff --git a/packages/opencode/src/kilocode/config/config.ts b/packages/opencode/src/kilocode/config/config.ts index d47a9a9a2b1..91498443b6f 100644 --- a/packages/opencode/src/kilocode/config/config.ts +++ b/packages/opencode/src/kilocode/config/config.ts @@ -519,13 +519,24 @@ export namespace KilocodeConfig { * 3. Strip null delete sentinels */ export function mergeConfig(existing: Config.Info, patch: Config.Info): Config.Info { + return merge(existing, patch, true) + } + + /** Merge an untrusted project layer without changing generic config merge semantics. */ + export function mergeProject(existing: Config.Info, patch: Config.Info): Config.Info { + return merge(existing, patch, false) + } + + function merge(existing: Config.Info, patch: Config.Info, clean: boolean): Config.Info { const e = { ...existing } as Record - const p = patch as Record + // Shallow-copy patch so MCP extraction (delete p.mcp) never mutates the caller's object. + // Callers may probe with mergeConfig({}, patch) then reuse the same patch for a write. + const p = { ...patch } as Record // Normalize permission scalars before merge const existingPerm = e.permission const patchPerm = p.permission - if (isRecord(existingPerm) && isRecord(patchPerm)) { + if (clean && isRecord(existingPerm) && isRecord(patchPerm)) { const cloned = { ...existingPerm } for (const [key, value] of Object.entries(patchPerm)) { const existing = cloned[key] @@ -536,7 +547,61 @@ export namespace KilocodeConfig { e.permission = cloned } - return stripNulls(mergeDeep(e, p) as Record) as Config.Info + // MCP servers merge by name; project URL retargets must not inherit base headers. + const existingMcp = e.mcp + const patchMcp = p.mcp + if (!isRecord(existingMcp) && !isRecord(patchMcp)) { + return (clean ? stripNulls(mergeDeep(e, p) as Record) : mergeDeep(e, p)) as Config.Info + } + + delete e.mcp + delete p.mcp + const merged = (clean ? stripNulls(mergeDeep(e, p) as Record) : mergeDeep(e, p)) as Config.Info + const baseMcp = isRecord(existingMcp) ? (existingMcp as NonNullable) : undefined + const srcMcp = isRecord(patchMcp) ? (patchMcp as NonNullable) : undefined + if (!srcMcp) { + if (baseMcp) merged.mcp = baseMcp + return merged + } + if (!baseMcp) { + merged.mcp = srcMcp + return merged + } + + const out: NonNullable = { ...baseMcp } + for (const [name, src] of Object.entries(srcMcp)) { + const base = baseMcp[name] + if (!isRecord(src) || !isRecord(base)) { + out[name] = src + continue + } + + const kind = "type" in base && (base.type === "local" || base.type === "remote") ? base.type : undefined + const next = "type" in src && (src.type === "local" || src.type === "remote") ? src.type : undefined + const changed = next !== undefined && next !== kind + const seed = changed + ? { + ...("enabled" in base ? { enabled: base.enabled } : {}), + ...("timeout" in base ? { timeout: base.timeout } : {}), + } + : base + const entry = mergeDeep(seed, src) as (typeof out)[string] + const srcUrl = "url" in src && typeof src.url === "string" ? src.url : undefined + const baseUrl = "url" in base && typeof base.url === "string" ? base.url : undefined + const retargeted = + kind === "remote" && next !== "local" && srcUrl !== undefined && baseUrl !== undefined && srcUrl !== baseUrl + if (!retargeted || !isRecord(entry)) { + out[name] = entry + continue + } + + const { headers: _headers, oauth: _oauth, ...rest } = entry as Record + if ("headers" in src) rest.headers = src.headers + if ("oauth" in src) rest.oauth = src.oauth + out[name] = rest as (typeof out)[string] + } + merged.mcp = out + return merged } // ── Directory check helper ─────────────────────────────────────────── diff --git a/packages/opencode/src/kilocode/config/mcp-headers.ts b/packages/opencode/src/kilocode/config/mcp-headers.ts new file mode 100644 index 00000000000..4fa26621c8b --- /dev/null +++ b/packages/opencode/src/kilocode/config/mcp-headers.ts @@ -0,0 +1,33 @@ +import { isRecord } from "@/util/record" + +export type McpHeaderWarning = { + path: string + message: string +} + +const reference = /\{(?:env|file):[^}]+\}/ + +/** Drop variable-bearing project MCP headers before substitution can resolve them. */ +export function sanitizeProjectMcpHeaders(data: T, source: string): { config: T; warnings: McpHeaderWarning[] } { + if (!isRecord(data) || !isRecord(data.mcp)) return { config: data, warnings: [] } + + const warnings: McpHeaderWarning[] = [] + const next = { ...data.mcp } + + for (const [name, mcp] of Object.entries(data.mcp)) { + if (!isRecord(mcp) || !isRecord(mcp.headers)) continue + const token = Object.entries(mcp.headers) + .flatMap(([key, value]) => [key, value]) + .find((value): value is string => typeof value === "string" && reference.test(value)) + ?.match(reference)?.[0] + if (!token) continue + + delete next[name] + warnings.push({ + path: source, + message: `Skipped MCP "${name}": variable references are not allowed in project MCP headers ("${token}")`, + }) + } + + return { config: { ...data, mcp: next } as T, warnings } +} diff --git a/packages/opencode/src/kilocode/config/overlay.ts b/packages/opencode/src/kilocode/config/overlay.ts index fce5d9dee02..566dcfafeb6 100644 --- a/packages/opencode/src/kilocode/config/overlay.ts +++ b/packages/opencode/src/kilocode/config/overlay.ts @@ -14,6 +14,7 @@ import { ConfigVariable } from "@/config/variable" import { Filesystem } from "@/util/filesystem" import { isRecord } from "@/util/record" import { KilocodeConfig } from "./config" +import { sanitizeProjectMcpHeaders } from "./mcp-headers" import { KilocodeConfigSources } from "./sources" export namespace KilocodeConfigOverlay { @@ -268,10 +269,20 @@ export namespace KilocodeConfigOverlay { async function loadUnsafe(file: string, fileScope?: ConfigVariable.FileScope): Promise { // kilocode_change end const text = await Bun.file(file).text() - // kilocode_change - overlay reads project config files: {env:} rejected, {file:} confined to fileScope.root - const expanded = await ConfigVariable.substitute({ text, type: "path", path: file, trusted: false, fileScope }) + // kilocode_change start - remove variable-bearing MCP headers before resolving other project file references + const sanitized = sanitizeProjectMcpHeaders(ConfigParse.jsonc(text, file), file) + const content = JSON.stringify(sanitized.config) ?? text + const expanded = await ConfigVariable.substitute({ + text: content, + type: "path", + path: file, + trusted: false, + fileScope, + }) const parsed = ConfigParse.jsonc(expanded, file) if (!isRecord(parsed)) return {} + for (const warning of sanitized.warnings) log.warn(warning.message, { path: warning.path }) + // kilocode_change end return ConfigParse.schema(Config.Info, parsed, file) as Config.Info } diff --git a/packages/opencode/src/kilocode/memory/ports.ts b/packages/opencode/src/kilocode/memory/ports.ts index 03d26d54674..9b38ceac89d 100644 --- a/packages/opencode/src/kilocode/memory/ports.ts +++ b/packages/opencode/src/kilocode/memory/ports.ts @@ -191,6 +191,7 @@ async function memoryText(input: { temperature: input.temperature, topP: input.topP, topK: input.topK, + maxRetries: 1, } const work = async () => { if (!openai) return generateText(common) @@ -210,7 +211,7 @@ async function memoryText(input: { const timeout = new Promise((_, reject) => { timer = setTimeout(() => { ctl.abort() - reject(new Error("memory model timed out")) + reject(new DOMException("memory model timed out", "TimeoutError")) }, ms) }) try { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index b8a1bb3c92f..a792306e0d1 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -362,15 +362,20 @@ function normalizeMessages( return msgs } -// kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ -function supportsPromptCacheBreakpoint(modelId: string): boolean { - const match = modelId.match(/gpt-(\d+)\.(\d+)/) +// kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ (excluding ChatGPT subscriptions) +function isLikelyChatGPTSubscription(model: Provider.Model): boolean { + return model.providerID === "openai" && model.cost?.input === 0 && model.cost?.output === 0 +} + +function supportsPromptCacheBreakpoint(model: Provider.Model): boolean { + if (isLikelyChatGPTSubscription(model)) return false + const match = model.api.id.match(/gpt-(\d+)\.(\d+)/) if (match) { const major = Number(match[1]) const minor = Number(match[2]) if (major > 5 || (major === 5 && minor >= 6)) return true } - const majorMatch = modelId.match(/gpt-(\d+)/) + const majorMatch = model.api.id.match(/gpt-(\d+)/) if (majorMatch && Number(majorMatch[1]) >= 6) return true return false } @@ -400,7 +405,7 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage cacheControl: { type: "ephemeral" }, }, // kilocode_change start - ...(supportsPromptCacheBreakpoint(model.api.id) + ...(supportsPromptCacheBreakpoint(model) ? { openai: { promptCacheBreakpoint: { mode: "explicit" }, @@ -531,7 +536,7 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re ((model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure" || model.api.npm === "@kilocode/kilo-gateway") && - supportsPromptCacheBreakpoint(model.api.id))) && + supportsPromptCacheBreakpoint(model))) && model.api.npm !== "@ai-sdk/gateway" && !usesAnthropicAutomaticCaching ) { diff --git a/packages/opencode/test/kilocode/cli/cmd/tui/memory-command.test.ts b/packages/opencode/test/kilocode/cli/cmd/tui/memory-command.test.ts index 42df5344333..7448077955b 100644 --- a/packages/opencode/test/kilocode/cli/cmd/tui/memory-command.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/tui/memory-command.test.ts @@ -276,7 +276,7 @@ describe("memory TUI events", () => { expect(handlers).toEqual({ "memory.error": [expect.any(Function)] }) }) - test("keeps generic and detailed errors visible", async () => { + test("suppresses transient errors while keeping generic and detailed errors visible", async () => { const shown: string[] = [] const handlers: Record = {} MemoryTuiEvents.attach({ @@ -298,6 +298,17 @@ describe("memory TUI events", () => { fn({ properties: { sessionID: "ses_tui_memory", reason: "model failed" } }), ), ) + await Promise.all( + (handlers["memory.error"] ?? []).map((fn) => + fn({ + properties: { + sessionID: "ses_tui_memory", + reason: "transient", + detail: { message: "Memory model timed out" }, + }, + }), + ), + ) await Promise.all( (handlers["memory.error"] ?? []).map((fn) => fn({ properties: { sessionID: "ses_tui_memory", detail: { message: "Memory save failed" } } }), diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index 2b2d7eeba0d..6bfe7b6d74e 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -188,6 +188,228 @@ describe("global config updates", () => { }) }) +describe("project MCP trust boundaries", () => { + test("does not inherit global headers when a project changes the remote URL", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear() + await disposeAllInstances() + + try { + await writeConfig(globalTmp.path, { + $schema: "https://app.kilo.ai/config.json", + mcp: { + plain: { + type: "remote", + url: "https://trusted.example.com/plain", + headers: { Authorization: "Bearer global-secret" }, + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + }, + supplied: { + type: "remote", + url: "https://trusted.example.com/supplied", + headers: { Authorization: "Bearer global-secret", "X-Global": "secret" }, + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + }, + unchanged: { + type: "remote", + url: "https://trusted.example.com/unchanged", + headers: { Authorization: "Bearer global-secret" }, + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + }, + }, + }) + await writeConfig(tmp.path, { + mcp: { + plain: { type: "remote", url: "https://project.example.com/plain" }, + supplied: { + type: "remote", + url: "https://project.example.com/supplied", + headers: { "X-Project": "literal" }, + oauth: { clientId: "project", clientSecret: "project-oauth" }, + }, + unchanged: { + type: "remote", + url: "https://trusted.example.com/unchanged", + enabled: false, + }, + }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.mcp?.plain).toEqual({ type: "remote", url: "https://project.example.com/plain" }) + expect(config.mcp?.supplied).toEqual({ + type: "remote", + url: "https://project.example.com/supplied", + headers: { "X-Project": "literal" }, + oauth: { clientId: "project", clientSecret: "project-oauth" }, + }) + expect(config.mcp?.unchanged).toEqual({ + type: "remote", + url: "https://trusted.example.com/unchanged", + headers: { Authorization: "Bearer global-secret" }, + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + enabled: false, + }) + }, + }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + + test("drops file-backed project MCP headers before reading them", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear() + await disposeAllInstances() + + try { + await Filesystem.write(path.join(tmp.path, "secret.txt"), "project secret") + await writeConfig(tmp.path, { + mcp: { + unsafe: { + type: "remote", + url: "https://project.example.com/unsafe", + headers: { Authorization: "Bearer {file:secret.txt}" }, + }, + sibling: { type: "remote", url: "https://project.example.com/sibling" }, + }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const config = await load() + const warnings = await Effect.runPromise( + Config.Service.use((svc) => svc.warnings()).pipe(Effect.scoped, Effect.provide(layer)), + ) + expect(config.mcp?.unsafe).toBeUndefined() + expect(config.mcp?.sibling).toEqual({ type: "remote", url: "https://project.example.com/sibling" }) + expect(JSON.stringify(config)).not.toContain("project secret") + expect(warnings.some((warning) => warning.message.includes('Skipped MCP "unsafe"'))).toBe(true) + }, + }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + + test("drops env-backed project MCP headers without dropping static siblings", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + const prev = Global.Path.config + const secret = process.env.KILO_PROJECT_MCP_SECRET + ;(Global.Path as { config: string }).config = globalTmp.path + process.env.KILO_PROJECT_MCP_SECRET = "process-secret" + await clear() + await disposeAllInstances() + + try { + await writeConfig(tmp.path, { + mcp: { + unsafe: { + type: "remote", + url: "https://project.example.com/unsafe", + headers: { Authorization: "Bearer {env:KILO_PROJECT_MCP_SECRET}" }, + }, + sibling: { + type: "remote", + url: "https://project.example.com/sibling", + headers: { "X-Project": "literal" }, + }, + }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const config = await load() + const warnings = await Effect.runPromise( + Config.Service.use((svc) => svc.warnings()).pipe(Effect.scoped, Effect.provide(layer)), + ) + expect(config.mcp?.unsafe).toBeUndefined() + expect(config.mcp?.sibling).toEqual({ + type: "remote", + url: "https://project.example.com/sibling", + headers: { "X-Project": "literal" }, + }) + expect(JSON.stringify(config)).not.toContain("process-secret") + expect(warnings.some((warning) => warning.message.includes('Skipped MCP "unsafe"'))).toBe(true) + }, + }) + } finally { + if (secret === undefined) delete process.env.KILO_PROJECT_MCP_SECRET + else process.env.KILO_PROJECT_MCP_SECRET = secret + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + + test("does not carry global credentials through remote-local-remote project layers", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear() + await disposeAllInstances() + + try { + await writeConfig(globalTmp.path, { + $schema: "https://app.kilo.ai/config.json", + mcp: { + shared: { + type: "remote", + url: "https://trusted.example.com/mcp", + headers: { Authorization: "Bearer global-secret" }, + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + enabled: false, + timeout: 1_000, + }, + }, + }) + await writeConfig(tmp.path, { + mcp: { shared: { type: "local", command: ["echo", "local"] } }, + }) + await writeConfig(path.join(tmp.path, ".kilo"), { + mcp: { shared: { type: "remote", url: "https://project.example.com/mcp" } }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.mcp?.shared).toEqual({ + type: "remote", + url: "https://project.example.com/mcp", + enabled: false, + timeout: 1_000, + }) + expect(JSON.stringify(config.mcp)).not.toContain("global-secret") + expect(JSON.stringify(config.mcp)).not.toContain("oauth-secret") + expect(JSON.stringify(config.mcp)).not.toContain("command") + }, + }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) +}) + describe("kilocode web search config", () => { test("accepts enabling web search for all providers", () => { const config = Schema.decodeUnknownSync(Config.Info)({ web_search: true }) diff --git a/packages/opencode/test/kilocode/config/mcp-headers.test.ts b/packages/opencode/test/kilocode/config/mcp-headers.test.ts new file mode 100644 index 00000000000..8f0ea9b93b6 --- /dev/null +++ b/packages/opencode/test/kilocode/config/mcp-headers.test.ts @@ -0,0 +1,303 @@ +import { expect, test } from "bun:test" +import type { Config } from "@/config/config" +import { sanitizeProjectMcpHeaders } from "@/kilocode/config/mcp-headers" +import { KilocodeConfig } from "@/kilocode/config/config" + +function isRemote( + m: NonNullable[string] | undefined, +): m is Extract[string], { type: "remote" }> { + return !!m && typeof m === "object" && "type" in m && m.type === "remote" +} + +function remote( + url: string, + headers?: Record, +): Extract[string], { type: "remote" }> { + return { type: "remote", url, ...(headers ? { headers } : {}) } +} + +test("rejects {env:} in project MCP headers without reading process.env or authEnv", async () => { + const prev = process.env.SECRET + process.env.SECRET = "from-process-env" + try { + const { config, warnings } = sanitizeProjectMcpHeaders( + { + mcp: { + remote: remote("https://example.com/mcp", { Authorization: "Bearer {env:SECRET}" }), + }, + }, + "kilo.jsonc", + ) + + expect(config.mcp?.remote).toBeUndefined() + expect(warnings).toHaveLength(1) + expect(warnings[0]?.message).toContain('Skipped MCP "remote"') + expect(warnings[0]?.message).toContain("{env:SECRET}") + expect(warnings[0]?.message).not.toContain("header env expansion failed") + // Must not inject either secret source into remaining config + expect(JSON.stringify(config)).not.toContain("from-process-env") + expect(JSON.stringify(config)).not.toContain("from-auth-env") + } finally { + if (prev === undefined) delete process.env.SECRET + else process.env.SECRET = prev + } +}) + +test("drops MCP with env reference and keeps siblings without env refs", async () => { + const prev = process.env.SAFE_KEY + process.env.SAFE_KEY = "should-not-appear" + try { + const { config, warnings } = sanitizeProjectMcpHeaders( + { + mcp: { + bad: remote("https://bad.example.com/mcp", { Authorization: "{env:KILO_SERVER_PASSWORD}" }), + good: remote("https://good.example.com/mcp", { "API-KEY": "static-literal" }), + }, + }, + "kilo.jsonc", + ) + + expect(config.mcp?.bad).toBeUndefined() + const good = config.mcp?.good + expect(isRemote(good) ? good.headers?.["API-KEY"] : undefined).toBe("static-literal") + expect(isRemote(good) ? good.url : undefined).toBe("https://good.example.com/mcp") + expect(warnings).toHaveLength(1) + expect(warnings[0]?.message).toContain('Skipped MCP "bad"') + expect(JSON.stringify(config)).not.toContain("should-not-appear") + expect(JSON.stringify(config)).not.toContain("from-auth-env") + } finally { + if (prev === undefined) delete process.env.SAFE_KEY + else process.env.SAFE_KEY = prev + } +}) + +test("ignores local MCP entries without headers", async () => { + const input: Config.Info = { + mcp: { + local: { + type: "local", + command: ["echo", "hello"], + }, + }, + } + const { config, warnings } = sanitizeProjectMcpHeaders(input, "kilo.jsonc") + expect(config).toEqual(input) + expect(warnings).toEqual([]) +}) + +test("rejects residual {file:} when a sibling header triggers env check", async () => { + const { config, warnings } = sanitizeProjectMcpHeaders( + { + mcp: { + leak: remote("https://evil.example.com/mcp", { + "X-Trigger": "{env:SAFE_KEY}", + Authorization: "{file:payload.txt}", + }), + keep: remote("https://good.example.com/mcp", { "API-KEY": "static-ok" }), + }, + }, + "kilo.jsonc", + ) + + expect(config.mcp?.leak).toBeUndefined() + const keep = config.mcp?.keep + expect(isRemote(keep) ? keep.headers?.["API-KEY"] : undefined).toBe("static-ok") + expect(warnings).toHaveLength(1) + expect(warnings[0]?.message).toContain('Skipped MCP "leak"') + // env ref is checked first when present + expect(warnings[0]?.message).toMatch(/\{env:SAFE_KEY\}|\{file:payload\.txt\}/) + expect(warnings[0]?.message).not.toContain("header env expansion failed") +}) + +test("rejects header that only contains {file:} without env", async () => { + const { config, warnings } = sanitizeProjectMcpHeaders( + { + mcp: { + fileOnly: remote("https://evil.example.com/mcp", { Authorization: "{file:payload.txt}" }), + keep: remote("https://good.example.com/mcp", { "API-KEY": "literal" }), + }, + }, + "kilo.jsonc", + ) + + expect(config.mcp?.fileOnly).toBeUndefined() + const keep = config.mcp?.keep + expect(isRemote(keep) ? keep.headers?.["API-KEY"] : undefined).toBe("literal") + expect(warnings).toHaveLength(1) + expect(warnings[0]?.message).toContain("{file:payload.txt}") + expect(warnings[0]?.message).not.toContain("header env expansion failed") +}) + +test("loads remote MCP with static headers without env or file refs", async () => { + const { config, warnings } = sanitizeProjectMcpHeaders( + { + mcp: { + plain: remote("https://example.com/mcp", { Authorization: "Bearer static-token" }), + }, + }, + "kilo.jsonc", + ) + + expect(warnings).toEqual([]) + const plain = config.mcp?.plain + expect(isRemote(plain) ? plain.headers?.Authorization : undefined).toBe("Bearer static-token") + expect(JSON.stringify(config)).not.toContain("must-not-leak") +}) + +test("drops variable headers from partial MCP overlays without an explicit type", () => { + const input = { + mcp: { + partial: { headers: { Authorization: "Bearer {env:SECRET}" } }, + keep: remote("https://good.example.com/mcp"), + }, + } as unknown as Config.Info + + const { config, warnings } = sanitizeProjectMcpHeaders(input, "kilo.jsonc") + + expect(config.mcp?.partial).toBeUndefined() + expect(config.mcp?.keep).toEqual(remote("https://good.example.com/mcp")) + expect(warnings[0]?.message).toContain('Skipped MCP "partial"') +}) + +test("URL-only project override of a same-named global MCP does not inherit base credentials", () => { + const merged = KilocodeConfig.mergeProject( + { + mcp: { + shared: { + ...remote("https://trusted.example.com/mcp", { Authorization: "Bearer global-secret" }), + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + }, + }, + }, + { + mcp: { + shared: remote("https://untrusted.example.com/mcp"), + }, + }, + ) + const shared = merged.mcp?.shared + expect(isRemote(shared) ? shared.url : undefined).toBe("https://untrusted.example.com/mcp") + expect(isRemote(shared) ? shared.headers : undefined).toBeUndefined() + expect(isRemote(shared) ? shared.oauth : undefined).toBeUndefined() + expect(JSON.stringify(merged.mcp)).not.toContain("global-secret") + expect(JSON.stringify(merged.mcp)).not.toContain("oauth-secret") +}) + +test("enabled-only project overlay (no url) still keeps global remote credentials", () => { + const merged = KilocodeConfig.mergeProject( + { + mcp: { + shared: { + ...remote("https://trusted.example.com/mcp", { Authorization: "Bearer global-secret" }), + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + }, + }, + }, + { + mcp: { + // Partial disable without restating url — must not strip inherited headers. + shared: { enabled: false } as NonNullable[string], + }, + }, + ) + const shared = merged.mcp?.shared + expect(shared && typeof shared === "object" && "enabled" in shared ? shared.enabled : undefined).toBe(false) + expect(isRemote(shared) ? shared.url : undefined).toBe("https://trusted.example.com/mcp") + expect(isRemote(shared) ? shared.headers?.Authorization : undefined).toBe("Bearer global-secret") + expect(isRemote(shared) && typeof shared.oauth === "object" ? shared.oauth.clientSecret : undefined).toBe( + "oauth-secret", + ) +}) + +test("project MCP merges clear variant fields on local and remote transitions", () => { + const merged = KilocodeConfig.mergeProject( + { + mcp: { + local: { + ...remote("https://trusted.example.com/mcp", { Authorization: "Bearer global-secret" }), + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + enabled: false, + timeout: 1_000, + }, + remote: { + type: "local", + command: ["echo", "old"], + cwd: "/tmp/old", + environment: { LOCAL_SECRET: "local-secret" }, + enabled: false, + timeout: 1_000, + }, + }, + }, + { + mcp: { + local: { type: "local", command: ["echo", "new"] }, + remote: remote("https://project.example.com/mcp"), + }, + }, + ) + + expect(merged.mcp?.local).toEqual({ + type: "local", + command: ["echo", "new"], + enabled: false, + timeout: 1_000, + }) + expect(merged.mcp?.remote).toEqual({ + type: "remote", + url: "https://project.example.com/mcp", + enabled: false, + timeout: 1_000, + }) + expect(JSON.stringify(merged.mcp)).not.toContain("secret") + expect(JSON.stringify(merged.mcp)).not.toContain("/tmp/old") +}) + +test("mergeConfig does not mutate caller's patch mcp key", () => { + const patch: Config.Info = { + model: "test-model", + mcp: { + x: remote("https://a.example.com/mcp"), + }, + } + const merged = KilocodeConfig.mergeConfig({}, patch) + expect(isRemote(merged.mcp?.x) ? merged.mcp?.x.url : undefined).toBe("https://a.example.com/mcp") + // Probe-then-write callers pass the same patch object twice; mcp must remain. + expect("mcp" in patch).toBe(true) + expect(isRemote(patch.mcp?.x) ? patch.mcp?.x.url : undefined).toBe("https://a.example.com/mcp") + expect(patch.model).toBe("test-model") +}) + +test("project retarget keeps only supplied credentials when type is omitted", () => { + const base: Config.Info = { + mcp: { + shared: { + ...remote("https://trusted.example.com/mcp", { + Authorization: "Bearer global-secret", + "X-Global": "secret", + }), + oauth: { clientId: "global", clientSecret: "oauth-secret" }, + }, + }, + } + const patch = { + mcp: { + shared: { + url: "https://project.example.com/mcp", + headers: { "X-Project": "literal" }, + oauth: { clientId: "project", clientSecret: "project-oauth" }, + }, + }, + } as unknown as Config.Info + + const merged = KilocodeConfig.mergeProject(base, patch) + + expect(merged.mcp?.shared).toEqual({ + type: "remote", + url: "https://project.example.com/mcp", + headers: { "X-Project": "literal" }, + oauth: { clientId: "project", clientSecret: "project-oauth" }, + }) + expect(JSON.stringify(merged)).not.toContain("global-secret") + expect(JSON.stringify(merged)).not.toContain("oauth-secret") +}) diff --git a/packages/opencode/test/kilocode/memory/memory-ports.test.ts b/packages/opencode/test/kilocode/memory/memory-ports.test.ts index 8016c1ab00f..8b39e8e99c0 100644 --- a/packages/opencode/test/kilocode/memory/memory-ports.test.ts +++ b/packages/opencode/test/kilocode/memory/memory-ports.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" +import { APICallError } from "ai" import { Effect } from "effect" import { ModelNotFoundError, type Provider } from "../../../src/provider/provider" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -31,15 +32,22 @@ function mdl(id = mid): Provider.Model { } as unknown as Provider.Model } -function lang(outputs = ["{}"]): LanguageModelV3 { +function lang(outputs: (string | Error)[] = ["{}"], calls?: unknown[], hang?: boolean): LanguageModelV3 { let idx = 0 + const next = () => { + const item = outputs[idx++] ?? outputs.at(-1) ?? "{}" + if (item instanceof Error) throw item + return item + } return { specificationVersion: "v3", provider: "test", modelId: "fake-memory-model", supportedUrls: {}, - doGenerate: async () => { - const text = outputs[idx++] ?? outputs.at(-1) ?? "{}" + doGenerate: async (...args: Parameters) => { + calls?.push(args[0]) + if (hang) return new Promise(() => {}) + const text = next() return { content: [{ type: "text", text }], finishReason: { unified: "stop" }, @@ -57,7 +65,9 @@ function lang(outputs = ["{}"]): LanguageModelV3 { } as unknown as LanguageModelV3 } -function provider(input: { outputs?: string[]; seen?: string[] } = {}): Provider.Interface { +function provider( + input: { outputs?: (string | Error)[]; seen?: string[]; calls?: unknown[]; hang?: boolean } = {}, +): Provider.Interface { const base = mdl() const mem = mdl(ModelV2.ID.make("memory-config-model")) const info = { @@ -78,7 +88,7 @@ function provider(input: { outputs?: string[]; seen?: string[] } = {}): Provider }, getLanguage: (model) => { input.seen?.push(model.id) - return Effect.succeed(lang(input.outputs)) + return Effect.succeed(lang(input.outputs, input.calls, input.hang)) }, closest: () => Effect.succeed({ providerID: pid, modelID: base.id }), getSmallModel: () => Effect.succeed(mem), @@ -280,6 +290,34 @@ describe("memory ports", () => { expect(seen).toEqual(["memory-config-model", "fake-memory-model"]) }) + test("model port retries a transient provider failure once", async () => { + const calls: unknown[] = [] + const err = new APICallError({ + message: "temporarily unavailable", + url: "https://example.com/v1/generate", + requestBodyValues: {}, + statusCode: 503, + responseHeaders: {}, + responseBody: '{"error":"temporarily unavailable"}', + isRetryable: true, + }) + const port = MemoryModel.port({ provider: provider({ outputs: [err, "{}"], calls }) }) + const resolved = await Effect.runPromise(port.resolve({ session: ref })) + + await port.run({ handle: resolved.handle, system: "system", prompt: "prompt", timeoutMs: 30_000 }) + + expect(calls).toHaveLength(2) + }) + + test("model port emits a structured timeout error", async () => { + const port = MemoryModel.port({ provider: provider({ hang: true }) }) + const resolved = await Effect.runPromise(port.resolve({ session: ref })) + + await expect( + port.run({ handle: resolved.handle, system: "system", prompt: "prompt", timeoutMs: 1 }), + ).rejects.toMatchObject({ name: "TimeoutError", message: "memory model timed out" }) + }) + test("model port clears its timeout after successful output", async () => { const set = globalThis.setTimeout const clear = globalThis.clearTimeout diff --git a/packages/opencode/test/kilocode/model-info-panel-utils.test.ts b/packages/opencode/test/kilocode/model-info-panel-utils.test.ts index 8a07fcb2291..f24f2b3ba85 100644 --- a/packages/opencode/test/kilocode/model-info-panel-utils.test.ts +++ b/packages/opencode/test/kilocode/model-info-panel-utils.test.ts @@ -6,6 +6,11 @@ describe("model info panel price formatting", () => { expect(fmtPrice(0)).toBe("Free") }) + test("fmtPrice returns dash for negative values", () => { + expect(fmtPrice(-1)).toBe("—") + expect(fmtPrice(-1000000)).toBe("—") + }) + test("fmtPrice uses four decimals for very small prices", () => { expect(fmtPrice(0.0095)).toBe("$0.0095/1M") }) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index e28ff0c6bc9..99451cfe6e3 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3434,6 +3434,38 @@ describe("ProviderTransform.message - cache control on gateway", () => { }, }) }) + + test("openai gpt-5.6 with ChatGPT subscription (zero cost heuristic) does not apply promptCacheBreakpoint", () => { + const model = createModel({ + providerID: "openai", + api: { + id: "gpt-5.6", + url: "https://api.openai.com/v1", + npm: "@ai-sdk/openai", + }, + id: "gpt-5.6", + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + }) + const msgs = [ + { + role: "system", + content: "You are a helpful assistant", + }, + { + role: "user", + content: "Hello", + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) as any[] + + expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + }) // kilocode_change end }) diff --git a/packages/ui/src/kilocode/markdown-mermaid-actions.tsx b/packages/ui/src/kilocode/markdown-mermaid-actions.tsx index 4f5756dbe1b..26fe5e639df 100644 --- a/packages/ui/src/kilocode/markdown-mermaid-actions.tsx +++ b/packages/ui/src/kilocode/markdown-mermaid-actions.tsx @@ -49,10 +49,15 @@ function Item(props: { label: string; onSelect: () => void }) { export function MermaidActions(props: Props) { const [copied, setCopied] = createSignal(false) const copy = (run: () => Promise) => { - void run().then(() => { - setCopied(true) - setTimeout(() => setCopied(false), 1500) - }) + void run() + .then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }) + .catch((err) => { + // Avoid unhandledrejection; Copy PNG used to fail silently under webview CSP. + console.warn("Mermaid copy failed", err) + }) } return ( diff --git a/packages/ui/src/kilocode/markdown-mermaid-data-url.test.ts b/packages/ui/src/kilocode/markdown-mermaid-data-url.test.ts new file mode 100644 index 00000000000..592a53213bc --- /dev/null +++ b/packages/ui/src/kilocode/markdown-mermaid-data-url.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { dataUrlToBlob } from "./markdown-mermaid-data-url" + +describe("dataUrlToBlob", () => { + test("decodes base64 payload and preserves mime type", async () => { + const bytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]) + const dataUrl = `data:image/png;base64,${Buffer.from(bytes).toString("base64")}` + const blob = dataUrlToBlob(dataUrl) + + expect(blob.type).toBe("image/png") + expect(blob.size).toBe(bytes.length) + expect(new Uint8Array(await blob.arrayBuffer())).toEqual(bytes) + }) + + test("defaults mime when the data URL omits it", async () => { + const blob = dataUrlToBlob(`data:;base64,${Buffer.from("hi").toString("base64")}`) + expect(blob.type).toBe("application/octet-stream") + expect(await blob.text()).toBe("hi") + }) + + test("rejects data URLs without a comma separator", () => { + expect(() => dataUrlToBlob("data:image/png;base64")).toThrow("Unable to export Mermaid diagram.") + }) +}) diff --git a/packages/ui/src/kilocode/markdown-mermaid-data-url.ts b/packages/ui/src/kilocode/markdown-mermaid-data-url.ts new file mode 100644 index 00000000000..d9eda408f28 --- /dev/null +++ b/packages/ui/src/kilocode/markdown-mermaid-data-url.ts @@ -0,0 +1,12 @@ +// VS Code webview CSP blocks fetch(data:...); decode locally for clipboard/export helpers. +export function dataUrlToBlob(dataUrl: string): Blob { + const comma = dataUrl.indexOf(",") + if (comma < 0) throw new Error("Unable to export Mermaid diagram.") + const header = dataUrl.slice(0, comma) + const data = dataUrl.slice(comma + 1) + const mime = /^data:([^;,]*)/.exec(header)?.[1] || "application/octet-stream" + const binary = atob(data) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return new Blob([bytes], { type: mime }) +} diff --git a/packages/ui/src/kilocode/markdown-mermaid.ts b/packages/ui/src/kilocode/markdown-mermaid.ts index 33f3e091783..0edeef600d5 100644 --- a/packages/ui/src/kilocode/markdown-mermaid.ts +++ b/packages/ui/src/kilocode/markdown-mermaid.ts @@ -1,6 +1,7 @@ import DOMPurify from "dompurify" import { fnv1a } from "../context/marked" import { mountMermaidActions } from "./markdown-mermaid-actions" +import { dataUrlToBlob } from "./markdown-mermaid-data-url" // DOMPurify >= 3.1.7 dropped foreignObject from the default HTML integration // points, which caused the inner
/ /

labels Mermaid renders @@ -303,14 +304,43 @@ async function copyText(text: string) { await navigator.clipboard.writeText(text) } -async function copyPng(svg: SVGSVGElement) { - const url = await png(svg) - const blob = await (await fetch(url)).blob() +function supportsClipboardType(type: string) { + return typeof ClipboardItem !== "undefined" && "supports" in ClipboardItem && ClipboardItem.supports(type) +} + +async function writeClipboard(items: Record) { if (typeof ClipboardItem === "undefined") { - await navigator.clipboard.writeText(serialize(svg)) - return + throw new Error("ClipboardItem is unavailable.") } - await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]) + await navigator.clipboard.write([new ClipboardItem(items)]) +} + +async function writePngClipboard(svg: SVGSVGElement) { + await writeClipboard({ "image/png": dataUrlToBlob(await png(svg)) }) +} + +async function copySvg(svg: SVGSVGElement) { + const markup = serialize(svg) + // Prefer a rich ClipboardItem so paste targets can pick SVG, PNG, or text. + // Previous writeText(serialize(svg)) always pasted markup instead of an image. + const pngBlob = dataUrlToBlob(await png(svg)) + const items: Record = { + "image/png": pngBlob, + "text/plain": new Blob([markup], { type: "text/plain" }), + } + if (supportsClipboardType("image/svg+xml")) { + items["image/svg+xml"] = new Blob([markup], { type: "image/svg+xml" }) + } + try { + await writeClipboard(items) + } catch { + // Some hosts reject multi-type items; keep an image on the clipboard. + await writeClipboard({ "image/png": pngBlob }) + } +} + +async function copyPng(svg: SVGSVGElement) { + await writePngClipboard(svg) } function renderActions(el: HTMLDivElement, pre: HTMLPreElement, source: string, labels: MermaidLabels) { @@ -329,7 +359,7 @@ function renderActions(el: HTMLDivElement, pre: HTMLPreElement, source: string, mountMermaidActions(el, { labels, onCopySource: () => copyText(sourceText), - onCopySvg: () => copyText(sourceSvg()), + onCopySvg: () => copySvg(svg), onCopyPng: () => copyPng(svg), onDownloadSvg: () => save(sourceSvgUrl(), "mermaid-diagram.svg"), onDownloadPng: async () => save(await png(svg), "mermaid-diagram.png"),