From dfeb7bf722c7000a2d7a318ee5f56a36908a0173 Mon Sep 17 00:00:00 2001 From: Thomas Boom Date: Tue, 24 Mar 2026 21:01:28 +0100 Subject: [PATCH 01/55] Update bonus credits and AI model versions in README Updated the bonus credits offer and AI model versions in the README. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 31d9e5cf348..b1557add3c9 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ - ⚡ Inline autocomplete suggestions - 🤖 Latest AI models - 🎁 API keys optional -- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.2 +- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.4 ## Quick Links @@ -38,7 +38,7 @@ ## Get Started in Visual Studio Code 1. Install the Kilo Code extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code). -2. Create your account to access 500+ cutting-edge AI models including Gemini 3 Pro, Claude 4.5 Sonnet & Opus, and GPT-5 – with transparent pricing that matches provider rates exactly. +2. Create your account to access 500+ cutting-edge AI models including Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.4 – with transparent pricing that matches provider rates exactly. 3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo in action: [![Watch the video](https://img.youtube.com/vi/pqGfYXgrhig/maxresdefault.jpg)](https://youtu.be/pqGfYXgrhig) From 613606b07d21d7d36a19ee67362981fa59c9f975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 6 Apr 2026 16:30:59 -0300 Subject: [PATCH 02/55] feat(vscode): add exponential backoff retry with cancel button for rate limiting Implement retry with exponential backoff when the extension encounters rate limiting (HTTP 429) or server errors. Retries on: 5s -> 10s -> 30s -> 60s -> 300s, with a maximum of 5 attempts. - Add retry utility module (retry.ts) with backoff calculation - Add withRetry() wrapper for SDK calls in KiloProvider - Add Cancel button to WorkingIndicator during retry status - Clear error messages when model changes (issue #8203) - Add i18n translations for cancel button (18 languages) Users can manually cancel via the cancel button or the retry loop automatically stops after 5 failed attempts. Issues: #8333, #8203 --- packages/kilo-vscode/src/KiloProvider.ts | 137 ++++++++++++++---- packages/kilo-vscode/src/util/retry.ts | 70 +++++++++ .../components/shared/WorkingIndicator.tsx | 25 +++- .../webview-ui/src/context/session.tsx | 4 + .../kilo-vscode/webview-ui/src/i18n/ar.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 4 + 23 files changed, 286 insertions(+), 26 deletions(-) create mode 100644 packages/kilo-vscode/src/util/retry.ts diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 372d076d5c4..d8ad6804f26 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -40,6 +40,7 @@ import { resolveProjectDirectory } from "./project-directory" import { getBusySessionCount, seedSessionStatuses } from "./session-status" import { slimPart, slimParts } from "./kilo-provider/slim-metadata" import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session" +import { retryable, backoff, MAX_RETRIES } from "./util/retry" // legacy-migration start import { checkAndShowMigrationWizard, @@ -523,6 +524,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper break } case "abort": + this.cancelRetry(message.sessionID ?? "") await this.handleAbort(message.sessionID) break case "revertSession": @@ -2125,6 +2127,85 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return { sid, dir } } + /** Abort controllers for active retry loops, keyed by session ID */ + private retryAbortControllers = new Map() + + /** + * Execute an SDK call with exponential backoff on HTTP errors. + * Retries on 429, 5xx, and other retryable status codes. + * When the response includes `Retry-After` / `Retry-After-MS` headers, + * the delay honours that value (capped at 5 min). Otherwise uses the + * predefined backoff schedule: 5s -> 10s -> 30s -> 60s -> 300s. + * + * After MAX_RETRIES (5) attempts, automatically throws the error. + * Users can cancel via the cancel button in the UI which sends an abort + * message — this interrupts the backoff delay and stops the retry loop. + * + * The webview receives `sessionStatus` messages with a countdown so the + * user can see that a retry is in progress. + */ + private async withRetry(fn: () => Promise<{ error?: unknown; response: Response }>, sid: string): Promise { + const abortController = new AbortController() + this.retryAbortControllers.set(sid, abortController) + + try { + for (let attempt = 1; ; attempt++) { + if (abortController.signal.aborted) { + // User cancelled — return normally without triggering sendMessageFailed + return + } + + const result = await fn() + if (!result.error) return + + const status = result.response?.status ?? 0 + + // Non-retryable status codes fail immediately without retry + if (!retryable(status)) { + this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" }) + throw result.error + } + + // Stop retrying after MAX_RETRIES attempts + if (attempt >= MAX_RETRIES) { + this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" }) + throw result.error + } + + const delay = backoff(attempt, result.response?.headers) + console.log(`[Kilo New] KiloProvider: Retry on ${status}, attempt ${attempt}/${MAX_RETRIES}, delay ${delay}ms`) + + this.postMessage({ + type: "sessionStatus", + sessionID: sid, + status: "retry", + attempt, + message: `Error (${status}). Retrying...`, + next: Date.now() + delay, + }) + + // Wait for delay or until aborted + await new Promise((resolve) => { + const timer = setTimeout(resolve, delay) + abortController.signal.addEventListener("abort", () => { + clearTimeout(timer) + }) + }) + } + } finally { + this.retryAbortControllers.delete(sid) + } + } + + /** Cancel an active retry loop for a session */ + private cancelRetry(sid: string): void { + const controller = this.retryAbortControllers.get(sid) + if (controller) { + controller.abort() + this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" }) + } + } + private async handleSendMessage( text: string, messageID?: string, @@ -2167,18 +2248,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.connectionService.recordMessageSessionId(messageID, resolved!.sid) } - await this.client.session.promptAsync( - { - sessionID: resolved!.sid, - directory: resolved!.dir, - messageID, - parts, - model: providerID && modelID ? { providerID, modelID } : undefined, - agent, - variant, - editorContext, - }, - { throwOnError: true }, + const sid = resolved!.sid + const dir = resolved!.dir + await this.withRetry( + () => + this.client!.session.promptAsync({ + sessionID: sid, + directory: dir, + messageID, + parts, + model: providerID && modelID ? { providerID, modelID } : undefined, + agent, + variant, + editorContext, + }), + sid, ) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to send message:", error) @@ -2229,19 +2313,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const parts = files?.map((f) => ({ type: "file" as const, mime: f.mime, url: f.url })) - await this.client.session.command( - { - sessionID: resolved!.sid, - directory: resolved!.dir, - command, - arguments: args, - messageID, - model: providerID && modelID ? `${providerID}/${modelID}` : undefined, - agent, - variant, - parts, - }, - { throwOnError: true }, + const sid = resolved!.sid + const dir = resolved!.dir + await this.withRetry( + () => + this.client!.session.command({ + sessionID: sid, + directory: dir, + command, + arguments: args, + messageID, + model: providerID && modelID ? `${providerID}/${modelID}` : undefined, + agent, + variant, + parts, + }), + sid, ) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to send command:", error) diff --git a/packages/kilo-vscode/src/util/retry.ts b/packages/kilo-vscode/src/util/retry.ts new file mode 100644 index 00000000000..5c35542821c --- /dev/null +++ b/packages/kilo-vscode/src/util/retry.ts @@ -0,0 +1,70 @@ +/** + * Exponential backoff retry utilities for rate-limited API calls. + * + * When the CLI backend (or the upstream AI provider it proxies) returns + * HTTP 429, retries are scheduled with exponential backoff. The delay + * respects `Retry-After` / `Retry-After-MS` headers when present. + */ + +/** Backoff delays per attempt: 5s -> 10s -> 30s -> 60s -> 300s */ +const BACKOFF_DELAYS_MS = [5_000, 10_000, 30_000, 60_000, 300_000] + +/** Maximum backoff delay in ms (5 minutes) */ +const MAX_MS = 300_000 + +/** Maximum number of retry attempts */ +const MAX_RETRIES = BACKOFF_DELAYS_MS.length + +/** HTTP status codes that are safe to retry */ +const RETRYABLE = new Set([408, 409, 425, 429, 500, 502, 503, 504]) + +/** + * Whether an HTTP status code is retryable. + */ +export function retryable(status: number): boolean { + if (RETRYABLE.has(status)) return true + return status >= 500 +} + +/** + * Extract a retry delay (in ms) from standard response headers. + * + * Checks `retry-after-ms` first (milliseconds), then `retry-after` + * (seconds or HTTP-date). Returns `null` when no usable header is found. + */ +export function headerDelay(headers: Headers): number | null { + const ms = headers.get("retry-after-ms") + if (ms) { + const parsed = Number.parseFloat(ms) + if (!Number.isNaN(parsed) && parsed > 0) return parsed + } + + const after = headers.get("retry-after") + if (after) { + const seconds = Number.parseFloat(after) + if (!Number.isNaN(seconds) && seconds > 0) return Math.ceil(seconds * 1000) + // Try HTTP-date format + const date = Date.parse(after) - Date.now() + if (!Number.isNaN(date) && date > 0) return Math.ceil(date) + } + + return null +} + +/** + * Calculate backoff delay for a given attempt. + * + * If `headers` are provided and contain a `Retry-After` value, that + * value is used (capped at MAX_MS). Otherwise uses the predefined + * backoff schedule: 5s, 10s, 30s, 60s, 300s. + */ +export function backoff(attempt: number, headers?: Headers): number { + if (headers) { + const fromHeader = headerDelay(headers) + if (fromHeader !== null) return Math.min(fromHeader, MAX_MS) + } + const index = Math.min(attempt - 1, BACKOFF_DELAYS_MS.length - 1) + return BACKOFF_DELAYS_MS[index] ?? MAX_MS +} + +export { MAX_RETRIES, MAX_MS } diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx index 6d35fed0344..9585b87bf86 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx @@ -4,14 +4,17 @@ * Matches the v1.0.25 working indicator UX. */ -import { Component, Show, createSignal, createEffect, onCleanup } from "solid-js" +import { type Component, Show, createSignal, createEffect, onCleanup } from "solid-js" import { Spinner } from "@kilocode/kilo-ui/spinner" +import { Button } from "@kilocode/kilo-ui/button" import { useSession } from "../../context/session" import { useLanguage } from "../../context/language" +import { useVSCode } from "../../context/vscode" export const WorkingIndicator: Component = () => { const session = useSession() const language = useLanguage() + const vscode = useVSCode() const [elapsed, setElapsed] = createSignal(0) const [retryCountdown, setRetryCountdown] = createSignal(0) @@ -80,6 +83,15 @@ export const WorkingIndicator: Component = () => { return perms.length > 0 || questions.length > 0 } + const isRetrying = () => session.statusInfo().type === "retry" + + const handleCancelRetry = () => { + const sid = session.currentSessionID() + if (sid) { + vscode.postMessage({ type: "abort", sessionID: sid }) + } + } + return (
@@ -88,6 +100,17 @@ export const WorkingIndicator: Component = () => { 0}> {formatElapsed()} + + +
) diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 6e5959e0169..bd80bfc704f 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -411,6 +411,10 @@ export const SessionProvider: ParentComponent = (props) => { function selectModel(providerID: string, modelID: string) { applyModel(selectedAgentName(), { providerID, modelID }) + const sid = currentSessionID() + if (sid) { + setStore("messages", sid, (msgs = []) => msgs.filter((m) => !m.error)) + } } /** The config/default model for the current mode (what settings says). */ diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 801c067983e..0f7e11508e6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -935,6 +935,10 @@ export const dict = { "session.status.retrying": "...إعادة المحاولة (المحاولة {{ attempt }})… {{ message }}", "session.status.working": "...جارٍ العمل", + "ui.sessionTurn.cancel": "إلغاء", + "ui.sessionTurn.status.thinking": "...جارٍ التفكير", + "ui.sessionTurn.status.consideringNextSteps": "...جارٍ التفكير في الخطوات التالية", + "dialog.model.noProviders": "لا يوجد موفرون", "prompt.placeholder.connecting": "جارٍ الاتصال بالخادم...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 54184cd4122..cca0626f221 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -943,6 +943,10 @@ export const dict = { "session.status.retrying": "Tentando novamente (tentativa {{ attempt }})… {{ message }}", "session.status.working": "Trabalhando…", + "ui.sessionTurn.cancel": "Cancelar", + "ui.sessionTurn.status.thinking": "Pensando...", + "ui.sessionTurn.status.consideringNextSteps": "Considerando próximos passos...", + "dialog.model.noProviders": "Nenhum provedor", "prompt.placeholder.connecting": "Conectando ao servidor...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index e17c8c4b917..8087d2dd8cf 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -948,6 +948,10 @@ export const dict = { "session.status.retrying": "Ponovni pokušaj (pokušaj {{ attempt }})… {{ message }}", "session.status.working": "Radim…", + "ui.sessionTurn.cancel": "Otkaži", + "ui.sessionTurn.status.thinking": "Razmišljam...", + "ui.sessionTurn.status.consideringNextSteps": "Razmatram sljedeće korake...", + "dialog.model.noProviders": "Nema pružatelja", "prompt.placeholder.connecting": "Povezivanje na server...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index e82c786f017..4bbf0dc1a0b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -941,6 +941,10 @@ export const dict = { "session.status.retrying": "Prøver igen (forsøg {{ attempt }})… {{ message }}", "session.status.working": "Arbejder…", + "ui.sessionTurn.cancel": "Annuller", + "ui.sessionTurn.status.thinking": "Tænker...", + "ui.sessionTurn.status.consideringNextSteps": "Overvejer næste trin...", + "dialog.model.noProviders": "Ingen udbydere", "prompt.placeholder.connecting": "Opretter forbindelse til server...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index f77dedaff6c..127c2e29c19 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -953,6 +953,10 @@ export const dict = { "session.status.retrying": "Erneuter Versuch ({{ attempt }})… {{ message }}", "session.status.working": "Wird bearbeitet…", + "ui.sessionTurn.cancel": "Abbrechen", + "ui.sessionTurn.status.thinking": "Denke nach...", + "ui.sessionTurn.status.consideringNextSteps": "Überlege nächste Schritte...", + "dialog.model.noProviders": "Keine Anbieter", "prompt.placeholder.connecting": "Verbindung zum Server wird hergestellt...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 63125949253..485968d1157 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -941,6 +941,10 @@ export const dict = { "session.status.retrying": "Retrying (attempt {{ attempt }})… {{ message }}", "session.status.working": "Working...", + "ui.sessionTurn.cancel": "Cancel", + "ui.sessionTurn.status.thinking": "Thinking...", + "ui.sessionTurn.status.consideringNextSteps": "Considering next steps...", + "dialog.model.noProviders": "No providers", "prompt.placeholder.connecting": "Connecting to server...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index be87b6b6e01..02de604b81a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -949,6 +949,10 @@ export const dict = { "session.status.retrying": "Reintentando (intento {{ attempt }})… {{ message }}", "session.status.working": "Trabajando…", + "ui.sessionTurn.cancel": "Cancelar", + "ui.sessionTurn.status.thinking": "Pensando...", + "ui.sessionTurn.status.consideringNextSteps": "Considerando siguientes pasos...", + "dialog.model.noProviders": "Sin proveedores", "prompt.placeholder.connecting": "Conectando al servidor...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index c46f4c5f4bc..0ec675b0a96 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -955,6 +955,10 @@ export const dict = { "session.status.retrying": "Nouvelle tentative (essai {{ attempt }})… {{ message }}", "session.status.working": "En cours…", + "ui.sessionTurn.cancel": "Annuler", + "ui.sessionTurn.status.thinking": "Réflexion...", + "ui.sessionTurn.status.consideringNextSteps": "Envisage les prochaines étapes...", + "dialog.model.noProviders": "Aucun fournisseur", "prompt.placeholder.connecting": "Connexion au serveur...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 8e5275e3a82..1525ee90d98 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -940,6 +940,10 @@ export const dict = { "session.status.retrying": "再試行中({{ attempt }}回目)… {{ message }}", "session.status.working": "作業中…", + "ui.sessionTurn.cancel": "キャンセル", + "ui.sessionTurn.status.thinking": "考え中...", + "ui.sessionTurn.status.consideringNextSteps": "次のステップを検討中...", + "dialog.model.noProviders": "プロバイダーなし", "prompt.placeholder.connecting": "サーバーに接続中...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 65a11e1936a..26200bf20dc 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -940,6 +940,10 @@ export const dict = { "session.status.retrying": "재시도 중 ({{ attempt }}번째 시도)… {{ message }}", "session.status.working": "작업 중...", + "ui.sessionTurn.cancel": "취소", + "ui.sessionTurn.status.thinking": "생각 중...", + "ui.sessionTurn.status.consideringNextSteps": "다음 단계 고려 중...", + "dialog.model.noProviders": "공급자 없음", "prompt.placeholder.connecting": "서버에 연결 중...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 9f3f77c9f0d..879c43a577e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -942,6 +942,10 @@ export const dict = { "session.status.retrying": "Opnieuw proberen (poging {{ attempt }})... {{ message }}", "session.status.working": "Bezig...", + "ui.sessionTurn.cancel": "Annuleren", + "ui.sessionTurn.status.thinking": "Denken...", + "ui.sessionTurn.status.consideringNextSteps": "Volgende stappen overwegen...", + "dialog.model.noProviders": "Geen providers", "prompt.placeholder.connecting": "Verbinden met server...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 1d783ea1a89..fe311454170 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -945,6 +945,10 @@ export const dict = { "session.status.retrying": "Prøver på nytt (forsøk {{ attempt }})… {{ message }}", "session.status.working": "Arbeider…", + "ui.sessionTurn.cancel": "Avbryt", + "ui.sessionTurn.status.thinking": "Tenker...", + "ui.sessionTurn.status.consideringNextSteps": "Vurderer neste steg...", + "dialog.model.noProviders": "Ingen leverandører", "prompt.placeholder.connecting": "Kobler til server...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index c45d4612ddd..ffa85fb3012 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -945,6 +945,10 @@ export const dict = { "session.status.retrying": "Ponawiam próbę ({{ attempt }})… {{ message }}", "session.status.working": "Pracuję…", + "ui.sessionTurn.cancel": "Anuluj", + "ui.sessionTurn.status.thinking": "Myślę...", + "ui.sessionTurn.status.consideringNextSteps": "Rozważam następne kroki...", + "dialog.model.noProviders": "Brak dostawców", "prompt.placeholder.connecting": "Łączenie z serwerem...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 29690a2d705..118acae5858 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -948,6 +948,10 @@ export const dict = { "session.status.retrying": "Повторная попытка ({{ attempt }})… {{ message }}", "session.status.working": "Работаю…", + "ui.sessionTurn.cancel": "Отмена", + "ui.sessionTurn.status.thinking": "Думаю...", + "ui.sessionTurn.status.consideringNextSteps": "Продумываю следующие шаги...", + "dialog.model.noProviders": "Нет провайдеров", "prompt.placeholder.connecting": "Подключение к серверу...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 8d9c11fb50a..1fbcfe6a6be 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -936,6 +936,10 @@ export const dict = { "session.status.retrying": "กำลังลองใหม่ (ครั้งที่ {{ attempt }})… {{ message }}", "session.status.working": "กำลังทำงาน...", + "ui.sessionTurn.cancel": "ยกเลิก", + "ui.sessionTurn.status.thinking": "กำลังคิด...", + "ui.sessionTurn.status.consideringNextSteps": "กำลังพิจารณาขั้นตอนถัดไป...", + "dialog.model.noProviders": "ไม่มีผู้ให้บริการ", "prompt.placeholder.connecting": "กำลังเชื่อมต่อกับเซิร์ฟเวอร์...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 62e46cdd179..080a3a0d2ea 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -944,6 +944,10 @@ export const dict = { "session.status.retrying": "Yeniden deneniyor (deneme {{ attempt }})… {{ message }}", "session.status.working": "Çalışıyor...", + "ui.sessionTurn.cancel": "İptal", + "ui.sessionTurn.status.thinking": "Düşünüyor...", + "ui.sessionTurn.status.consideringNextSteps": "Sonraki adımları değerlendiriyor...", + "dialog.model.noProviders": "Sağlayıcı yok", "prompt.placeholder.connecting": "Sunucuya bağlanılıyor...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 38c9bea9857..072294be09b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -945,6 +945,10 @@ export const dict = { "session.status.retrying": "Повторна спроба (спроба {{ attempt }})… {{ message }}", "session.status.working": "Працює...", + "ui.sessionTurn.cancel": "Скасувати", + "ui.sessionTurn.status.thinking": "Думаю...", + "ui.sessionTurn.status.consideringNextSteps": "Обдумую наступні кроки...", + "dialog.model.noProviders": "Немає провайдерів", "prompt.placeholder.connecting": "Підключення до сервера...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 33a831a0c0d..16339616557 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -928,6 +928,10 @@ export const dict = { "session.status.retrying": "正在重试(第 {{ attempt }} 次)… {{ message }}", "session.status.working": "处理中…", + "ui.sessionTurn.cancel": "取消", + "ui.sessionTurn.status.thinking": "思考中...", + "ui.sessionTurn.status.consideringNextSteps": "正在考虑下一步...", + "dialog.model.noProviders": "无供应商", "prompt.placeholder.connecting": "正在连接服务器...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 6c6f3e8cae0..b545ffccb19 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -930,6 +930,10 @@ export const dict = { "session.status.retrying": "正在重試(第 {{ attempt }} 次)… {{ message }}", "session.status.working": "處理中…", + "ui.sessionTurn.cancel": "取消", + "ui.sessionTurn.status.thinking": "思考中...", + "ui.sessionTurn.status.consideringNextSteps": "正在考慮下一步...", + "dialog.model.noProviders": "沒有供應商", "prompt.placeholder.connecting": "正在連線至伺服器...", From 9cafa6f8b9aadb17be63977617b94b811b358210 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 10:48:30 +0300 Subject: [PATCH 03/55] test(cli): add regression tests for config resilience Add 4 tests that reproduce the crash when agent or command markdown files have valid YAML but invalid Zod schema values (e.g. mode: banana). These currently pass against the fix that will follow. --- .../test/kilocode/config-resilience.test.ts | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 packages/opencode/test/kilocode/config-resilience.test.ts diff --git a/packages/opencode/test/kilocode/config-resilience.test.ts b/packages/opencode/test/kilocode/config-resilience.test.ts new file mode 100644 index 00000000000..a5d013c746c --- /dev/null +++ b/packages/opencode/test/kilocode/config-resilience.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, test } from "bun:test" +import path from "path" +import { Bus } from "../../src/bus" +import { Config } from "../../src/config/config" +import { Instance } from "../../src/project/instance" +import { Filesystem } from "../../src/util/filesystem" +import { tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await Instance.disposeAll() + Config.global.reset() +}) + +describe("config resilience", () => { + test("skips invalid agent markdown configs", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, ".kilo", "agent", "skip.md"), + `--- +mode: "banana" +--- +Broken agent prompt`, + ) + await Filesystem.write( + path.join(dir, ".kilo", "agent", "keep.md"), + `--- +model: test/model +--- +Valid agent prompt`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cfg = await Config.get() + + expect(cfg.agent?.["skip"]).toBeUndefined() + expect(cfg.agent?.["keep"]).toMatchObject({ + name: "keep", + model: "test/model", + prompt: "Valid agent prompt", + }) + }, + }) + }) + + test("publishes an error for invalid agent markdown configs", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, ".kilo", "agent", "skip.md"), + `--- +mode: "banana" +--- +Broken agent prompt`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seen: Array<{ type: string; properties: { error: { name: string; data: { message: string } } } }> = [] + const unsub = Bus.subscribeAll((event) => { + if (event.type === "session.error") seen.push(event) + }) + + await Config.get() + unsub() + + expect( + seen.some( + (item) => + item.properties.error.name === "UnknownError" && + item.properties.error.data.message.includes("skip.md") && + item.properties.error.data.message.includes("mode"), + ), + ).toBe(true) + }, + }) + }) + + test("skips invalid command markdown configs", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, ".kilo", "command", "skip.md"), + `--- +subtask: "banana" +--- +Broken command template`, + ) + await Filesystem.write( + path.join(dir, ".kilo", "command", "keep.md"), + `--- +description: Valid command +--- +Valid command template`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cfg = await Config.get() + + expect(cfg.command?.["skip"]).toBeUndefined() + expect(cfg.command?.["keep"]).toEqual({ + description: "Valid command", + template: "Valid command template", + }) + }, + }) + }) + + test("publishes an error for invalid command markdown configs", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, ".kilo", "command", "skip.md"), + `--- +subtask: "banana" +--- +Broken command template`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seen: Array<{ type: string; properties: { error: { name: string; data: { message: string } } } }> = [] + const unsub = Bus.subscribeAll((event) => { + if (event.type === "session.error") seen.push(event) + }) + + await Config.get() + unsub() + + expect( + seen.some( + (item) => + item.properties.error.name === "UnknownError" && + item.properties.error.data.message.includes("skip.md") && + item.properties.error.data.message.includes("subtask"), + ), + ).toBe(true) + }, + }) + }) +}) From e3a5b69054a490a6ac32363859484f3e8bb32c96 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 10:48:37 +0300 Subject: [PATCH 04/55] fix(cli): skip invalid agent/command configs instead of crashing Replace throw with log-and-skip in loadAgent() and loadCommand() when Zod validation fails on markdown config files. Publish a session.error bus event so the UI can surface the problem. Add a try/catch safety net around the per-directory config loading loop in stateInit(). --- packages/opencode/src/config/config.ts | 51 +++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5b3e3b28de4..60d80eb3f8a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -30,6 +30,7 @@ import { Installation } from "@/installation" import { ConfigMarkdown } from "./markdown" import { constants, existsSync } from "fs" import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" // kilocode_change import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" import { Glob } from "../util/glob" @@ -282,10 +283,16 @@ export namespace Config { }), ) - result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) - result.agent = mergeDeep(result.agent, await loadAgent(dir)) - result.agent = mergeDeep(result.agent, await loadMode(dir)) - result.plugin.push(...(await loadPlugin(dir))) + // kilocode_change start + try { + result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) + result.agent = mergeDeep(result.agent, await loadAgent(dir)) + result.agent = mergeDeep(result.agent, await loadMode(dir)) + result.plugin.push(...(await loadPlugin(dir))) + } catch (err: unknown) { + log.error("failed to load config directory", { dir, err }) + } + // kilocode_change end } // Inline config content overrides all non-managed config sources. @@ -463,6 +470,34 @@ export namespace Config { return ext.length ? file.slice(0, -ext.length) : file } + // kilocode_change start + // Local event definition that matches Session.Event.Error's type string. + // Avoids importing @/session during config init (which causes a circular dependency). + const SessionError = BusEvent.define("session.error", z.object({ error: z.any() })) + + function detail(issues: z.core.$ZodIssue[]) { + return issues + .map((issue) => { + const loc = issue.path.map(String).join(".") + if (!loc) return issue.message + return `${loc}: ${issue.message}` + }) + .join("\n") + } + + async function invalid(kind: "agent" | "command", item: string, issues: z.core.$ZodIssue[], cause: Error) { + const text = detail(issues) + const message = text ? `Config file at ${item} is invalid: ${text}` : `Config file at ${item} is invalid` + Bus.publish(SessionError, { error: new NamedError.Unknown({ message }).toObject() }) + const err = new InvalidError({ path: item, issues }, { cause }) + if (kind === "command") { + log.error("failed to load command", { command: item, err }) + return + } + log.error("failed to load agent", { agent: item, err }) + } + // kilocode_change end + async function loadCommand(dir: string) { const result: Record = {} for (const item of await Glob.scan("{command,commands}/**/*.md", { @@ -505,7 +540,9 @@ export namespace Config { result[config.name] = parsed.data continue } - throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error }) + // kilocode_change start + await invalid("command", item, parsed.error.issues, parsed.error) + // kilocode_change end } return result } @@ -555,7 +592,9 @@ export namespace Config { result[config.name] = parsed.data continue } - throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error }) + // kilocode_change start + await invalid("agent", item, parsed.error.issues, parsed.error) + // kilocode_change end } return result } From 6c98dd062c3a6fd85161ba3d09523beffa6c0362 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 11:25:00 +0300 Subject: [PATCH 05/55] fix(cli): remove unscoped session.error publish --- packages/opencode/src/config/config.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 60d80eb3f8a..a091703982e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -30,7 +30,6 @@ import { Installation } from "@/installation" import { ConfigMarkdown } from "./markdown" import { constants, existsSync } from "fs" import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" // kilocode_change import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" import { Glob } from "../util/glob" @@ -471,10 +470,6 @@ export namespace Config { } // kilocode_change start - // Local event definition that matches Session.Event.Error's type string. - // Avoids importing @/session during config init (which causes a circular dependency). - const SessionError = BusEvent.define("session.error", z.object({ error: z.any() })) - function detail(issues: z.core.$ZodIssue[]) { return issues .map((issue) => { @@ -488,13 +483,12 @@ export namespace Config { async function invalid(kind: "agent" | "command", item: string, issues: z.core.$ZodIssue[], cause: Error) { const text = detail(issues) const message = text ? `Config file at ${item} is invalid: ${text}` : `Config file at ${item} is invalid` - Bus.publish(SessionError, { error: new NamedError.Unknown({ message }).toObject() }) const err = new InvalidError({ path: item, issues }, { cause }) if (kind === "command") { - log.error("failed to load command", { command: item, err }) + log.error("failed to load command", { command: item, err, message }) return } - log.error("failed to load agent", { agent: item, err }) + log.error("failed to load agent", { agent: item, err, message }) } // kilocode_change end From a8a2e3b8725c1c7dc258aa941e9e07de839f6d79 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 11:38:13 +0300 Subject: [PATCH 06/55] fix(cli): surface schema validation errors to user --- packages/opencode/src/config/config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a091703982e..7fabc3481e6 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -484,6 +484,8 @@ export namespace Config { const text = detail(issues) const message = text ? `Config file at ${item} is invalid: ${text}` : `Config file at ${item} is invalid` const err = new InvalidError({ path: item, issues }, { cause }) + const { Session } = await import("@/session") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) if (kind === "command") { log.error("failed to load command", { command: item, err, message }) return From f2dea0a67759ff073f3a881082a3404f9bb42f52 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 10:49:46 +0300 Subject: [PATCH 07/55] docs(cli): add legacy paths to kilo-config skill The built-in kilo-config skill only documented modern config paths (.kilo/, ~/.config/kilo/), but the CLI still loads legacy directories (.kilocode/, .opencode/) at every session. Users with configs in legacy locations got no guidance from the agent. Expand the Config File Locations section to document all scanned directories, glob patterns with plural variants, legacy config filenames, and environment variable overrides. --- .../src/kilocode/skills/kilo-config.md | 61 +++++++++++++++---- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/kilocode/skills/kilo-config.md b/packages/opencode/src/kilocode/skills/kilo-config.md index 70289d08dcd..e34a77f7518 100644 --- a/packages/opencode/src/kilocode/skills/kilo-config.md +++ b/packages/opencode/src/kilocode/skills/kilo-config.md @@ -4,7 +4,7 @@ All config lives in `kilo.json` (or `kilo.jsonc`). Precedence low-to-high: remot ## Commands (`.kilo/command/*.md`) -Markdown files with YAML frontmatter. The filename (minus `.md`) becomes the command name invoked via `/name`. +Markdown files with YAML frontmatter. The filename (minus `.md`) becomes the command name invoked via `/name`. Also loaded from `.kilocode/` and `.opencode/` directories (legacy), and plural `commands/` variants. See Config File Locations for the full search order. ```yaml --- @@ -22,6 +22,8 @@ Template variables: `$1`-`$N` (positional args), `$ARGUMENTS` (full string), `@f ## Agents (`.kilo/agent/*.md`) +Also loaded from `.kilocode/` and `.opencode/` directories (legacy), and plural `agents/` variants. + ```yaml --- description: When to use this agent @@ -127,7 +129,7 @@ Additional skill directories and remote URLs: } ``` -Skills are markdown files at `skills//SKILL.md` with `name` and `description` in frontmatter. +Skills are markdown files at `skills//SKILL.md` (or `skill//SKILL.md`) with `name` and `description` in frontmatter. Discovered inside `.kilo/`, `.kilocode/`, and `.opencode/` directories. ## Other Top-Level Fields @@ -200,12 +202,49 @@ Toggle notifications, Toggle animations, Toggle diff wrapping, Toggle sidebar (` ## Config File Locations -| Scope | Path | -| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Project | `./kilo.json`, `./.kilo/kilo.json` | -| Global | `~/.config/kilo/kilo.json` | -| Managed | Linux: `/etc/kilo/kilo.json`, macOS: `/Library/Application Support/kilo/kilo.json`, Windows: `%ProgramData%\kilo\kilo.json` (enterprise, highest priority) | -| Commands | `.kilo/command/*.md` (project), `~/.config/kilo/command/*.md` (global) | -| Agents | `.kilo/agent/*.md` (project), `~/.config/kilo/agent/*.md` (global) | -| Skills | `.kilo/skill/*/SKILL.md`, `.kilo/skills/*/SKILL.md` | -| Instructions | `AGENTS.md`, `.kilo/instructions.md`, glob patterns from `instructions` | +### Config files (kilo.json) + +| Scope | Path | +| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Project | `./kilo.json`, `./kilo.jsonc`, `./opencode.json` (legacy), `./opencode.jsonc` (legacy) | +| Global | `~/.config/kilo/kilo.json`, `~/.config/kilo/kilo.jsonc`, `~/.config/kilo/opencode.json` (legacy), `~/.config/kilo/config.json` (legacy) | +| Managed | Linux: `/etc/kilo/kilo.json`, macOS: `/Library/Application Support/kilo/kilo.json`, Windows: `%ProgramData%\kilo\kilo.json` (enterprise, highest priority) | + +Each config directory (`.kilo/`, `.kilocode/`, `.opencode/`) can also contain `kilo.json`, `kilo.jsonc`, `opencode.json`, or `opencode.jsonc`. + +### Config directories + +Three directory names are scanned: `.kilo` (modern), `.kilocode` (legacy), `.opencode` (legacy). All three are checked at each level: + +- **Project**: walks up from CWD to the git worktree root, checking for all three at each directory level +- **Home**: `~/.kilo/`, `~/.kilocode/`, `~/.opencode/` +- **XDG global**: `~/.config/kilo/` (always loaded, lowest file-based precedence) + +### Commands, agents, modes, plugins + +Glob patterns run inside every discovered config directory (including legacy): + +| Type | Pattern | +| ------- | ---------------------------- | +| Command | `{command,commands}/**/*.md` | +| Agent | `{agent,agents}/**/*.md` | +| Mode | `{mode,modes}/*.md` | +| Plugin | `{plugin,plugins}/*.{ts,js}` | + +Example: `~/.config/kilo/command/*.md` (modern global), `~/.kilocode/command/*.md` (legacy global), `.opencode/commands/*.md` (legacy project) all load commands. + +### Skills and instructions + +| Scope | Path | +| ------------ | ----------------------------------------------------------------------- | +| Skills | `{skill,skills}//SKILL.md` inside any config directory | +| Instructions | `AGENTS.md`, `.kilo/instructions.md`, glob patterns from `instructions` | + +### Environment variable overrides + +| Variable | Description | +| ----------------------------- | ---------------------------------------------------------------- | +| `KILO_CONFIG` | Path to an additional config file (loaded after global) | +| `KILO_CONFIG_DIR` | Path to an additional config directory (appended to search list) | +| `KILO_CONFIG_CONTENT` | Inline JSON config string (high precedence, after project dirs) | +| `KILO_DISABLE_PROJECT_CONFIG` | Skip project-level `.kilo`/`.kilocode`/`.opencode` directories | From 6a7c296d341291d8a773846df9846cc0ba62d18b Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 10:59:54 +0300 Subject: [PATCH 08/55] fix(cli): widen kilo-config skill to cover path location questions The skill description and opening paragraph now explicitly mention locating config/command/agent/skill paths across global, project, and legacy directories (.kilo/, .kilocode/, .opencode/, ~/.config/kilo/). This ensures the agent loads the skill for 'where is this command?' prompts, not just configure/customize prompts. --- packages/opencode/src/kilocode/skills/builtin.ts | 2 +- packages/opencode/src/kilocode/skills/kilo-config.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/skills/builtin.ts b/packages/opencode/src/kilocode/skills/builtin.ts index 9172a39cf95..96accd8f5ad 100644 --- a/packages/opencode/src/kilocode/skills/builtin.ts +++ b/packages/opencode/src/kilocode/skills/builtin.ts @@ -15,7 +15,7 @@ export const BUILTIN_SKILLS: BuiltinSkill[] = [ { name: "kilo-config", description: - "Guide for configuring Kilo CLI: commands, agents, MCP servers, skills, permissions, instructions, plugins, providers, all kilo.json fields, and TUI settings (themes, appearance, keybinds, ctrl+p commands). Use when the user asks about configuring, customizing, or changing settings in Kilo.", + "Guide for configuring Kilo CLI and locating config, command, agent, and skill paths (global, project, legacy), plus MCP servers, permissions, instructions, plugins, providers, kilo.json fields, and TUI settings (themes, appearance, keybinds, ctrl+p commands). Use when the user asks about configuring Kilo, where it loads things from, or how to change settings.", content: KILO_CONFIG, }, ] diff --git a/packages/opencode/src/kilocode/skills/kilo-config.md b/packages/opencode/src/kilocode/skills/kilo-config.md index e34a77f7518..cc2540678f8 100644 --- a/packages/opencode/src/kilocode/skills/kilo-config.md +++ b/packages/opencode/src/kilocode/skills/kilo-config.md @@ -2,6 +2,8 @@ All config lives in `kilo.json` (or `kilo.jsonc`). Precedence low-to-high: remote well-known, global (`~/.config/kilo/kilo.json`), env `KILO_CONFIG`, project `./kilo.json`, `.kilo/kilo.json`, `KILO_CONFIG_CONTENT`, managed (see Config File Locations). Deep-merged; later wins. +This also covers where Kilo looks for config files, commands, agents, and skills across project, global, and legacy paths such as `.kilo/`, `.kilocode/`, `.opencode/`, and `~/.config/kilo/`. + ## Commands (`.kilo/command/*.md`) Markdown files with YAML frontmatter. The filename (minus `.md`) becomes the command name invoked via `/name`. Also loaded from `.kilocode/` and `.opencode/` directories (legacy), and plural `commands/` variants. See Config File Locations for the full search order. From cafccaf68760f59eb6a4a754fd1b7533b6ba311a Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 14:10:10 +0300 Subject: [PATCH 09/55] fix(cli): add named-command lookup guidance to kilo-config skill The skill loaded but the agent still only searched the repo root when asked 'where is /review-pr?'. Add an explicit procedural subsection that tells the agent to search ~/.config/kilo/, ~/.kilo/, ~/.kilocode/, and ~/.opencode/ with explicit path arguments before falling back to project directories. Add a regression test verifying the guidance is present in the builtin skill output. --- .../src/kilocode/skills/kilo-config.md | 19 ++++++++++- packages/opencode/test/tool/skill.test.ts | 32 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/skills/kilo-config.md b/packages/opencode/src/kilocode/skills/kilo-config.md index cc2540678f8..e8e763a2e16 100644 --- a/packages/opencode/src/kilocode/skills/kilo-config.md +++ b/packages/opencode/src/kilocode/skills/kilo-config.md @@ -6,7 +6,7 @@ This also covers where Kilo looks for config files, commands, agents, and skills ## Commands (`.kilo/command/*.md`) -Markdown files with YAML frontmatter. The filename (minus `.md`) becomes the command name invoked via `/name`. Also loaded from `.kilocode/` and `.opencode/` directories (legacy), and plural `commands/` variants. See Config File Locations for the full search order. +Markdown files with YAML frontmatter. The filename (minus `.md`) becomes the command name invoked via `/name`. Commands can live in `.kilo/`, `.kilocode/`, `.opencode/`, and global config roots, with both `command/` and `commands/` directory names supported. See Config File Locations for the full search order. ```yaml --- @@ -22,6 +22,23 @@ Reference files with @file and shell output with !`cmd`. Template variables: `$1`-`$N` (positional args), `$ARGUMENTS` (full string), `@file` (file contents), `` !`cmd` `` (shell output). +### Finding a named command + +When asked where `/name` lives, do not search only the repo root. Search these roots explicitly, and use an explicit search `path` for each one: + +1. `~/.config/kilo/` +2. `~/.kilo/` +3. `~/.kilocode/` +4. `~/.opencode/` +5. project `.kilo/`, `.kilocode/`, and `.opencode/` directories from the current working directory up to the worktree root + +Use exact patterns first: + +- `**/command/.md` +- `**/commands/.md` + +If found, return the full path. If not found in those roots, explain that the command is not present in the loaded config paths. + ## Agents (`.kilo/agent/*.md`) Also loaded from `.kilocode/` and `.opencode/` directories (legacy), and plural `agents/` variants. diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 70649dd9016..0fa1f13d4be 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -109,4 +109,36 @@ Use this skill. process.env.KILO_TEST_HOME = home } }) + + test("built-in kilo-config includes named command lookup guidance", async () => { + await using tmp = await tmpdir({ git: true }) + + const home = process.env.KILO_TEST_HOME + process.env.KILO_TEST_HOME = tmp.path + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await SkillTool.init() + const ctx: Tool.Context = { + ...baseCtx, + ask: async () => {}, + } + + const result = await tool.execute({ name: "kilo-config" }, ctx) + + expect(tool.description).toContain("where it loads things from") + expect(result.metadata.dir).toBe("builtin") + expect(result.output).toContain("### Finding a named command") + expect(result.output).toContain("`~/.config/kilo/`") + expect(result.output).toContain("`~/.kilocode/`") + expect(result.output).toContain("`**/command/.md`") + expect(result.output).toContain("explicit search `path`") + }, + }) + } finally { + process.env.KILO_TEST_HOME = home + } + }) }) From 6a91d2a6aab351a2ff38e87512ae02181a9bc141 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 14:16:06 +0300 Subject: [PATCH 10/55] docs(cli): add missing opencode.jsonc to global paths --- packages/opencode/src/kilocode/skills/kilo-config.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/kilocode/skills/kilo-config.md b/packages/opencode/src/kilocode/skills/kilo-config.md index e8e763a2e16..972542739a5 100644 --- a/packages/opencode/src/kilocode/skills/kilo-config.md +++ b/packages/opencode/src/kilocode/skills/kilo-config.md @@ -223,11 +223,11 @@ Toggle notifications, Toggle animations, Toggle diff wrapping, Toggle sidebar (` ### Config files (kilo.json) -| Scope | Path | -| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Project | `./kilo.json`, `./kilo.jsonc`, `./opencode.json` (legacy), `./opencode.jsonc` (legacy) | -| Global | `~/.config/kilo/kilo.json`, `~/.config/kilo/kilo.jsonc`, `~/.config/kilo/opencode.json` (legacy), `~/.config/kilo/config.json` (legacy) | -| Managed | Linux: `/etc/kilo/kilo.json`, macOS: `/Library/Application Support/kilo/kilo.json`, Windows: `%ProgramData%\kilo\kilo.json` (enterprise, highest priority) | +| Scope | Path | +| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Project | `./kilo.json`, `./kilo.jsonc`, `./opencode.json` (legacy), `./opencode.jsonc` (legacy) | +| Global | `~/.config/kilo/kilo.json`, `~/.config/kilo/kilo.jsonc`, `~/.config/kilo/opencode.json` (legacy), `~/.config/kilo/opencode.jsonc` (legacy), `~/.config/kilo/config.json` (legacy) | +| Managed | Linux: `/etc/kilo/kilo.json`, macOS: `/Library/Application Support/kilo/kilo.json`, Windows: `%ProgramData%\kilo\kilo.json` (enterprise, highest priority) | Each config directory (`.kilo/`, `.kilocode/`, `.opencode/`) can also contain `kilo.json`, `kilo.jsonc`, `opencode.json`, or `opencode.jsonc`. From 2302f7f67acc46b48207059e76cdf2699a10e2e7 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 14:16:40 +0300 Subject: [PATCH 11/55] docs(cli): fix instructions auto-discovery list --- packages/opencode/src/kilocode/skills/kilo-config.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/kilocode/skills/kilo-config.md b/packages/opencode/src/kilocode/skills/kilo-config.md index 972542739a5..7c2d52b9c0d 100644 --- a/packages/opencode/src/kilocode/skills/kilo-config.md +++ b/packages/opencode/src/kilocode/skills/kilo-config.md @@ -254,10 +254,10 @@ Example: `~/.config/kilo/command/*.md` (modern global), `~/.kilocode/command/*.m ### Skills and instructions -| Scope | Path | -| ------------ | ----------------------------------------------------------------------- | -| Skills | `{skill,skills}//SKILL.md` inside any config directory | -| Instructions | `AGENTS.md`, `.kilo/instructions.md`, glob patterns from `instructions` | +| Scope | Path | +| ------------ | ------------------------------------------------------------------------ | +| Skills | `{skill,skills}//SKILL.md` inside any config directory | +| Instructions | `AGENTS.md`, `CLAUDE.md`, glob patterns from `instructions` config field | ### Environment variable overrides From b86265102389ebe6a704744f535d3a0988cec7a1 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 14:17:15 +0300 Subject: [PATCH 12/55] docs(cli): list all managed config files --- packages/opencode/src/kilocode/skills/kilo-config.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/kilocode/skills/kilo-config.md b/packages/opencode/src/kilocode/skills/kilo-config.md index 7c2d52b9c0d..edb460c2eb5 100644 --- a/packages/opencode/src/kilocode/skills/kilo-config.md +++ b/packages/opencode/src/kilocode/skills/kilo-config.md @@ -223,11 +223,11 @@ Toggle notifications, Toggle animations, Toggle diff wrapping, Toggle sidebar (` ### Config files (kilo.json) -| Scope | Path | -| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Project | `./kilo.json`, `./kilo.jsonc`, `./opencode.json` (legacy), `./opencode.jsonc` (legacy) | -| Global | `~/.config/kilo/kilo.json`, `~/.config/kilo/kilo.jsonc`, `~/.config/kilo/opencode.json` (legacy), `~/.config/kilo/opencode.jsonc` (legacy), `~/.config/kilo/config.json` (legacy) | -| Managed | Linux: `/etc/kilo/kilo.json`, macOS: `/Library/Application Support/kilo/kilo.json`, Windows: `%ProgramData%\kilo\kilo.json` (enterprise, highest priority) | +| Scope | Path | +| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Project | `./kilo.json`, `./kilo.jsonc`, `./opencode.json` (legacy), `./opencode.jsonc` (legacy) | +| Global | `~/.config/kilo/kilo.json`, `~/.config/kilo/kilo.jsonc`, `~/.config/kilo/opencode.json` (legacy), `~/.config/kilo/opencode.jsonc` (legacy), `~/.config/kilo/config.json` (legacy) | +| Managed | Linux: `/etc/kilo/`, macOS: `/Library/Application Support/kilo/`, Windows: `%ProgramData%\kilo\` — loads `kilo.json`, `kilo.jsonc`, `opencode.json`, `opencode.jsonc` (enterprise, highest priority) | Each config directory (`.kilo/`, `.kilocode/`, `.opencode/`) can also contain `kilo.json`, `kilo.jsonc`, `opencode.json`, or `opencode.jsonc`. From eb1494f122c963fb27671d01e75c46bc65604a95 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 14:17:52 +0300 Subject: [PATCH 13/55] docs(cli): widen KILO_DISABLE_PROJECT_CONFIG desc --- packages/opencode/src/kilocode/skills/kilo-config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/skills/kilo-config.md b/packages/opencode/src/kilocode/skills/kilo-config.md index edb460c2eb5..df435e5815c 100644 --- a/packages/opencode/src/kilocode/skills/kilo-config.md +++ b/packages/opencode/src/kilocode/skills/kilo-config.md @@ -266,4 +266,4 @@ Example: `~/.config/kilo/command/*.md` (modern global), `~/.kilocode/command/*.m | `KILO_CONFIG` | Path to an additional config file (loaded after global) | | `KILO_CONFIG_DIR` | Path to an additional config directory (appended to search list) | | `KILO_CONFIG_CONTENT` | Inline JSON config string (high precedence, after project dirs) | -| `KILO_DISABLE_PROJECT_CONFIG` | Skip project-level `.kilo`/`.kilocode`/`.opencode` directories | +| `KILO_DISABLE_PROJECT_CONFIG` | Skip all project-level config (files and directories) | From 5a9520d3f0a0e5e757d54a5d53ba2ea3056b0cea Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 14:26:46 +0300 Subject: [PATCH 14/55] docs(cli): add KILO_CONFIG_DIR to command lookup --- packages/opencode/src/kilocode/skills/kilo-config.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/skills/kilo-config.md b/packages/opencode/src/kilocode/skills/kilo-config.md index df435e5815c..682e5ba747e 100644 --- a/packages/opencode/src/kilocode/skills/kilo-config.md +++ b/packages/opencode/src/kilocode/skills/kilo-config.md @@ -30,7 +30,8 @@ When asked where `/name` lives, do not search only the repo root. Search these r 2. `~/.kilo/` 3. `~/.kilocode/` 4. `~/.opencode/` -5. project `.kilo/`, `.kilocode/`, and `.opencode/` directories from the current working directory up to the worktree root +5. The `KILO_CONFIG_DIR` directory (if the env var is set) +6. project `.kilo/`, `.kilocode/`, and `.opencode/` directories from the current working directory up to the worktree root Use exact patterns first: From 27057648d326e43972d1e76a58537813d071480b Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 14:32:15 +0300 Subject: [PATCH 15/55] docs(cli): add CONTEXT.md to instructions list --- packages/opencode/src/kilocode/skills/kilo-config.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/kilocode/skills/kilo-config.md b/packages/opencode/src/kilocode/skills/kilo-config.md index 682e5ba747e..a7ca25d7b53 100644 --- a/packages/opencode/src/kilocode/skills/kilo-config.md +++ b/packages/opencode/src/kilocode/skills/kilo-config.md @@ -255,10 +255,10 @@ Example: `~/.config/kilo/command/*.md` (modern global), `~/.kilocode/command/*.m ### Skills and instructions -| Scope | Path | -| ------------ | ------------------------------------------------------------------------ | -| Skills | `{skill,skills}//SKILL.md` inside any config directory | -| Instructions | `AGENTS.md`, `CLAUDE.md`, glob patterns from `instructions` config field | +| Scope | Path | +| ------------ | -------------------------------------------------------------------------------------- | +| Skills | `{skill,skills}//SKILL.md` inside any config directory | +| Instructions | `AGENTS.md`, `CLAUDE.md`, `CONTEXT.md`, glob patterns from `instructions` config field | ### Environment variable overrides From f0118047e539112c6c6c06228a10a3bd38cbd232 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 16:26:46 +0300 Subject: [PATCH 16/55] feat(cli): add config warnings endpoint and surface errors to TUI/extension Collect warnings during config loading instead of crashing when config files have invalid JSON, schema errors, or frontmatter issues. Warnings are exposed via a new GET /config/warnings pull-based endpoint. - Add Config.Warning schema and warnings collection in stateInit() - Wrap all loadFile/load calls with try/catch to collect warnings - Thread warnings through loadCommand/loadAgent/loadMode/invalid - Wrap well-known remote config block to prevent network/parse crashes - Add GET /config/warnings server route with OpenAPI spec - Regenerate SDK with config.warnings() method - TUI: fetch warnings in bootstrap non-blocking phase, show as toast - VS Code: fetch warnings on SSE connect, show as native notifications - Add 5 new tests for warning collection (JSON, schema, markdown) --- packages/kilo-vscode/src/KiloProvider.ts | 24 + .../opencode/src/cli/cmd/tui/context/sync.tsx | 19 + packages/opencode/src/config/config.ts | 173 +- packages/opencode/src/server/routes/config.ts | 23 + .../test/kilocode/config-resilience.test.ts | 104 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 31 + packages/sdk/js/src/v2/gen/types.gen.ts | 22 + packages/sdk/openapi.json | 2157 ++++------------- 8 files changed, 792 insertions(+), 1761 deletions(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index de9c78c8176..05194727b75 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -124,6 +124,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private cachedMcpStatusMessage: unknown = null /** Ref-count of in-flight handleUpdateConfig calls; prevents fetchAndSendConfig from sending stale data */ private pending = 0 + private configWarningsShown = false // kilocode_change - prevent repeated config warnings on SSE reconnect /** Cached notificationsLoaded payload */ private cachedNotificationsMessage: unknown = null private pendingReviewComments: { comments: unknown[]; autoSend: boolean }[] = [] @@ -1058,6 +1059,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await this.flushPendingSessionRefresh("sse-connected") await fetchAndSendPendingPermissions(this.permissionCtx) await fetchAndSendPendingQuestions(this.questionCtx) + await this.checkConfigWarnings() // kilocode_change } catch (error) { console.error("[Kilo New] KiloProvider: ❌ Failed during connected state handling:", error) this.postMessage({ @@ -1947,6 +1949,28 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + // kilocode_change start + /** + * Fetch config warnings from the server and display them as VS Code warning messages. + * Only shown once per provider lifecycle (flag resets on dispose/re-create, not on SSE reconnect). + */ + private async checkConfigWarnings(): Promise { + if (this.configWarningsShown || !this.client) return + try { + const dir = this.getWorkspaceDirectory() + const result = await this.client.config.warnings({ directory: dir }) + const list = result?.data ?? [] + if (list.length === 0) return + this.configWarningsShown = true + for (const warning of list) { + vscode.window.showWarningMessage(`Config: ${warning.message}`) + } + } catch { + // Endpoint may not exist on older CLI versions — silently ignore + } + } + // kilocode_change end + /** * Fetch Kilo news/notifications and send to webview. * Uses the cached message pattern so the webview gets data immediately on refresh. diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index e7049fcbe4e..36119ccd7a2 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -29,6 +29,7 @@ import { batch, onMount } from "solid-js" import { Log } from "@/util/log" import type { Path } from "@kilocode/sdk" import type { Workspace } from "@kilocode/sdk/v2" +import { useToast } from "../ui/toast" // kilocode_change export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -106,6 +107,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) const sdk = useSDK() + const toast = useToast() // kilocode_change const fullSyncedSessions = new Set() // kilocode_change @@ -464,6 +466,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))), sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))), syncWorkspaces(), + // kilocode_change start - show config warnings as toasts + sdk.client.config + .warnings() + .then((x) => { + const list = x.data ?? [] + if (list.length === 0) return + const first = list[0] + const suffix = list.length > 1 ? ` (and ${list.length - 1} more)` : "" + toast.show({ + title: "Config Warning", + message: first.message + suffix, + variant: "warning", + duration: 8000, + }) + }) + .catch(() => {}), + // kilocode_change end ]).then(() => { setStore("status", "complete") }) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7fabc3481e6..152fb3fe21b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -52,6 +52,14 @@ export namespace Config { const log = Log.create({ service: "config" }) + // kilocode_change start + export const Warning = z.object({ + path: z.string(), + message: z.string(), + }) + export type Warning = z.infer + // kilocode_change end + // Managed settings directory for enterprise deployments (highest priority, admin-controlled) // These settings override all user and project settings function systemManagedConfigDir(): string { @@ -85,6 +93,18 @@ export namespace Config { // kilocode_change start — capture init so resetState() can invalidate the cache entry const stateInit = async () => { + // kilocode_change end + // kilocode_change start + const warnings: Warning[] = [] + const caught = (err: unknown, source: string) => { + const w = toWarning(err) + if (w) { + warnings.push(w) + log.warn("skipped config due to error", { source, err }) + return + } + throw err + } // kilocode_change end const auth = await Auth.all() @@ -200,24 +220,32 @@ export namespace Config { for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { const url = key.replace(/\/+$/, "") - process.env[value.key] = value.token - log.debug("fetching remote config", { url: `${url}/.well-known/opencode` }) - const response = await fetch(`${url}/.well-known/opencode`) - if (!response.ok) { - throw new Error(`failed to fetch remote config from ${url}: ${response.status}`) + const source = `${url}/.well-known/opencode` + try { + process.env[value.key] = value.token + log.debug("fetching remote config", { url: source }) + const response = await fetch(source) + if (!response.ok) { + throw new Error(`failed to fetch remote config from ${url}: ${response.status}`) + } + const wellknown = (await response.json()) as any + const remoteConfig = wellknown.config ?? {} + // Add $schema to prevent load() from trying to write back to a non-existent file + if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json" // kilocode_change + result = mergeConfigConcatArrays( + result, + await load(JSON.stringify(remoteConfig), { + dir: path.dirname(source), + source, + }), + ) + log.debug("loaded remote config from well-known", { url }) + } catch (err) { + const w = toWarning(err) + if (w) warnings.push(w) + else warnings.push({ path: source, message: err instanceof Error ? err.message : String(err) }) + log.warn("skipped remote config due to error", { url, err }) } - const wellknown = (await response.json()) as any - const remoteConfig = wellknown.config ?? {} - // Add $schema to prevent load() from trying to write back to a non-existent file - if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json" // kilocode_change - result = mergeConfigConcatArrays( - result, - await load(JSON.stringify(remoteConfig), { - dir: path.dirname(`${url}/.well-known/opencode`), - source: `${url}/.well-known/opencode`, - }), - ) - log.debug("loaded remote config from well-known", { url }) } } @@ -226,12 +254,20 @@ export namespace Config { } // Global user config overrides remote config. - result = mergeConfigConcatArrays(result, await global()) + try { + result = mergeConfigConcatArrays(result, await global()) + } catch (err) { + caught(err, "global config") + } // Custom config path overrides global config. if (Flag.KILO_CONFIG) { - result = mergeConfigConcatArrays(result, await loadFile(Flag.KILO_CONFIG)) - log.debug("loaded custom config", { path: Flag.KILO_CONFIG }) + try { + result = mergeConfigConcatArrays(result, await loadFile(Flag.KILO_CONFIG)) + log.debug("loaded custom config", { path: Flag.KILO_CONFIG }) + } catch (err) { + caught(err, Flag.KILO_CONFIG) + } } // Project config overrides global and remote config. @@ -239,7 +275,11 @@ export namespace Config { // kilocode_change start for (const file of ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"]) { // kilocode_change end - result = mergeConfigConcatArrays(result, await loadFile(file)) + try { + result = mergeConfigConcatArrays(result, await loadFile(file)) + } catch (err) { + caught(err, file) + } } } @@ -267,7 +307,11 @@ export namespace Config { for (const file of ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"]) { // kilocode_change end log.debug(`loading config from ${path.join(dir, file)}`) - result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file))) + try { + result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file))) + } catch (err) { + caught(err, path.join(dir, file)) + } // to satisfy the type checker result.agent ??= {} result.mode ??= {} @@ -284,9 +328,9 @@ export namespace Config { // kilocode_change start try { - result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) - result.agent = mergeDeep(result.agent, await loadAgent(dir)) - result.agent = mergeDeep(result.agent, await loadMode(dir)) + result.command = mergeDeep(result.command ?? {}, await loadCommand(dir, warnings)) + result.agent = mergeDeep(result.agent, await loadAgent(dir, warnings)) + result.agent = mergeDeep(result.agent, await loadMode(dir, warnings)) result.plugin.push(...(await loadPlugin(dir))) } catch (err: unknown) { log.error("failed to load config directory", { dir, err }) @@ -296,14 +340,18 @@ export namespace Config { // Inline config content overrides all non-managed config sources. if (process.env.KILO_CONFIG_CONTENT) { - result = mergeConfigConcatArrays( - result, - await load(process.env.KILO_CONFIG_CONTENT, { - dir: Instance.directory, - source: "KILO_CONFIG_CONTENT", - }), - ) - log.debug("loaded custom config from KILO_CONFIG_CONTENT") + try { + result = mergeConfigConcatArrays( + result, + await load(process.env.KILO_CONFIG_CONTENT, { + dir: Instance.directory, + source: "KILO_CONFIG_CONTENT", + }), + ) + log.debug("loaded custom config from KILO_CONFIG_CONTENT") + } catch (err) { + caught(err, "KILO_CONFIG_CONTENT") + } } // Load managed config files last (highest priority) - enterprise admin-controlled @@ -314,7 +362,11 @@ export namespace Config { // kilocode_change start for (const file of ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"]) { // kilocode_change end - result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file))) + try { + result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file))) + } catch (err) { + caught(err, path.join(managedDir, file)) + } } } @@ -367,6 +419,7 @@ export namespace Config { config: result, directories, deps, + warnings, } } // kilocode_change start — create state from named init so resetState() can invalidate it @@ -470,6 +523,25 @@ export namespace Config { } // kilocode_change start + function toWarning(err: unknown): Warning | undefined { + if (ConfigPaths.JsonError.isInstance(err)) + return { + path: err.data.path, + message: + `Config file at ${err.data.path} is not valid JSON(C)` + (err.data.message ? `: ${err.data.message}` : ""), + } + if (ConfigPaths.InvalidError.isInstance(err)) { + const text = err.data.issues ? detail(err.data.issues) : err.data.message + return { + path: err.data.path, + message: text + ? `Configuration is invalid at ${err.data.path}: ${text}` + : `Configuration is invalid at ${err.data.path}`, + } + } + return undefined + } + function detail(issues: z.core.$ZodIssue[]) { return issues .map((issue) => { @@ -480,10 +552,17 @@ export namespace Config { .join("\n") } - async function invalid(kind: "agent" | "command", item: string, issues: z.core.$ZodIssue[], cause: Error) { + async function invalid( + kind: "agent" | "command", + item: string, + issues: z.core.$ZodIssue[], + cause: Error, + warnings?: Warning[], + ) { const text = detail(issues) const message = text ? `Config file at ${item} is invalid: ${text}` : `Config file at ${item} is invalid` const err = new InvalidError({ path: item, issues }, { cause }) + if (warnings) warnings.push({ path: item, message }) const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) if (kind === "command") { @@ -494,7 +573,7 @@ export namespace Config { } // kilocode_change end - async function loadCommand(dir: string) { + async function loadCommand(dir: string, warnings?: Warning[]) { const result: Record = {} for (const item of await Glob.scan("{command,commands}/**/*.md", { cwd: dir, @@ -506,6 +585,7 @@ export namespace Config { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse command ${item}` + if (warnings) warnings.push({ path: item, message }) const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load command", { command: item, err }) @@ -537,13 +617,13 @@ export namespace Config { continue } // kilocode_change start - await invalid("command", item, parsed.error.issues, parsed.error) + await invalid("command", item, parsed.error.issues, parsed.error, warnings) // kilocode_change end } return result } - async function loadAgent(dir: string) { + async function loadAgent(dir: string, warnings?: Warning[]) { const result: Record = {} for (const item of await Glob.scan("{agent,agents}/**/*.md", { @@ -556,6 +636,7 @@ export namespace Config { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse agent ${item}` + if (warnings) warnings.push({ path: item, message }) const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load agent", { agent: item, err }) @@ -589,13 +670,13 @@ export namespace Config { continue } // kilocode_change start - await invalid("agent", item, parsed.error.issues, parsed.error) + await invalid("agent", item, parsed.error.issues, parsed.error, warnings) // kilocode_change end } return result } - async function loadMode(dir: string) { + async function loadMode(dir: string, warnings?: Warning[]) { const result: Record = {} for (const item of await Glob.scan("{mode,modes}/*.md", { cwd: dir, @@ -607,6 +688,7 @@ export namespace Config { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse mode ${item}` + if (warnings) warnings.push({ path: item, message }) const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load mode", { mode: item, err }) @@ -627,6 +709,9 @@ export namespace Config { } continue } + // kilocode_change start + await invalid("agent", item, parsed.error.issues, parsed.error, warnings) + // kilocode_change end } return result } @@ -1532,6 +1617,12 @@ export namespace Config { return state().then((x) => x.config) } + // kilocode_change start + export async function warnings() { + return state().then((x) => x.warnings) + } + // kilocode_change end + export async function getGlobal() { return global() } @@ -1741,5 +1832,3 @@ export namespace Config { return state().then((x) => x.directories) } } -Filesystem.write -Filesystem.write diff --git a/packages/opencode/src/server/routes/config.ts b/packages/opencode/src/server/routes/config.ts index f9ac7e42976..faa46c40972 100644 --- a/packages/opencode/src/server/routes/config.ts +++ b/packages/opencode/src/server/routes/config.ts @@ -62,6 +62,29 @@ export const ConfigRoutes = lazy(() => return c.json(config) }, ) + // kilocode_change start + .get( + "/warnings", + describeRoute({ + summary: "Get config warnings", + description: "Get warnings generated during config loading (e.g., invalid JSON, schema errors).", + operationId: "config.warnings", + responses: { + 200: { + description: "Config warnings", + content: { + "application/json": { + schema: resolver(Config.Warning.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await Config.warnings()) + }, + ) + // kilocode_change end .get( "/providers", describeRoute({ diff --git a/packages/opencode/test/kilocode/config-resilience.test.ts b/packages/opencode/test/kilocode/config-resilience.test.ts index a5d013c746c..7e48d68dec9 100644 --- a/packages/opencode/test/kilocode/config-resilience.test.ts +++ b/packages/opencode/test/kilocode/config-resilience.test.ts @@ -152,4 +152,108 @@ Broken command template`, }, }) }) + + test("collects warnings for invalid agent markdown configs", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, ".kilo", "agent", "broken.md"), + `--- +mode: "banana" +--- +Broken agent`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Config.get() + const warns = await Config.warnings() + + expect(warns.some((w) => w.path.includes("broken.md") && w.message.includes("invalid"))).toBe(true) + }, + }) + }) + + test("collects warnings for invalid command markdown configs", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, ".kilo", "command", "broken.md"), + `--- +subtask: "banana" +--- +Broken command`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Config.get() + const warns = await Config.warnings() + + expect(warns.some((w) => w.path.includes("broken.md") && w.message.includes("invalid"))).toBe(true) + }, + }) + }) + + test("collects warnings for invalid JSON in .kilo directory config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, ".kilo", "kilo.json"), "{ not valid json !!!") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cfg = await Config.get() + const warns = await Config.warnings() + + // Config loading should not crash + expect(cfg).toBeDefined() + // Warning should reference the bad file + expect(warns.some((w) => w.path.includes("kilo.json") && w.message.includes("not valid JSON"))).toBe(true) + }, + }) + }) + + test("collects warnings for invalid schema in .kilo directory config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, ".kilo", "kilo.json"), JSON.stringify({ unknownField: true })) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cfg = await Config.get() + const warns = await Config.warnings() + + expect(cfg).toBeDefined() + expect(warns.some((w) => w.path.includes("kilo.json") && w.message.includes("invalid"))).toBe(true) + }, + }) + }) + + test("returns empty warnings when config is valid", async () => { + await using tmp = await tmpdir({ + config: { model: "test/model" }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Config.get() + const warns = await Config.warnings() + + expect(warns).toEqual([]) + }, + }) + }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 0217a99a5fd..fa72a0bb006 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -21,6 +21,7 @@ import type { ConfigProvidersResponses, ConfigUpdateErrors, ConfigUpdateResponses, + ConfigWarningsResponses, EnhancePromptEnhanceErrors, EnhancePromptEnhanceResponses, EventSubscribeResponses, @@ -843,6 +844,36 @@ export class Config2 extends HeyApiClient { }) } + /** + * Get config warnings + * + * Get warnings generated during config loading (e.g., invalid JSON, schema errors). + */ + public warnings( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/config/warnings", + ...options, + ...params, + }) + } + /** * List config providers * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4b719f8b994..e8f9bd24bfc 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2462,6 +2462,28 @@ export type ConfigUpdateResponses = { export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] +export type ConfigWarningsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/config/warnings" +} + +export type ConfigWarningsResponses = { + /** + * Config warnings + */ + 200: Array<{ + path: string + message: string + }> +} + +export type ConfigWarningsResponse = ConfigWarningsResponses[keyof ConfigWarningsResponses] + export type ConfigProvidersData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 905852293d2..b8192c91aa8 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -27,10 +27,7 @@ "type": "string" } }, - "required": [ - "healthy", - "version" - ] + "required": ["healthy", "version"] } } } @@ -745,10 +742,7 @@ "type": "number" } }, - "required": [ - "rows", - "cols" - ] + "required": ["rows", "cols"] } } } @@ -978,6 +972,59 @@ ] } }, + "/config/warnings": { + "get": { + "operationId": "config.warnings", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Get config warnings", + "description": "Get warnings generated during config loading (e.g., invalid JSON, schema errors).", + "responses": { + "200": { + "description": "Config warnings", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["path", "message"] + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.config.warnings({\n ...\n})" + } + ] + } + }, "/config/providers": { "get": { "operationId": "config.providers", @@ -1023,10 +1070,7 @@ } } }, - "required": [ - "providers", - "default" - ] + "required": ["providers", "default"] } } } @@ -1233,11 +1277,7 @@ ] } }, - "required": [ - "type", - "branch", - "extra" - ] + "required": ["type", "branch", "extra"] } } } @@ -2148,9 +2188,7 @@ ], "summary": "Get session", "description": "Retrieve detailed information about a specific Kilo session.", - "tags": [ - "Session" - ], + "tags": ["Session"], "responses": { "200": { "description": "Get session", @@ -2377,9 +2415,7 @@ } ], "summary": "Get session children", - "tags": [ - "Session" - ], + "tags": ["Session"], "description": "Retrieve all child sessions that were forked from the specified parent session.", "responses": { "200": { @@ -2576,11 +2612,7 @@ "pattern": "^msg.*" } }, - "required": [ - "modelID", - "providerID", - "messageID" - ] + "required": ["modelID", "providerID", "messageID"] } } } @@ -3004,10 +3036,7 @@ "type": "boolean" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] } } } @@ -3077,10 +3106,7 @@ } } }, - "required": [ - "info", - "parts" - ] + "required": ["info", "parts"] } } } @@ -3161,10 +3187,7 @@ } } }, - "required": [ - "info", - "parts" - ] + "required": ["info", "parts"] } } } @@ -3210,10 +3233,7 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "agent": { "type": "string" @@ -3283,9 +3303,7 @@ } } }, - "required": [ - "parts" - ] + "required": ["parts"] } } } @@ -3355,10 +3373,7 @@ } } }, - "required": [ - "info", - "parts" - ] + "required": ["info", "parts"] } } } @@ -3725,10 +3740,7 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "agent": { "type": "string" @@ -3798,9 +3810,7 @@ } } }, - "required": [ - "parts" - ] + "required": ["parts"] } } } @@ -3861,10 +3871,7 @@ } } }, - "required": [ - "info", - "parts" - ] + "required": ["info", "parts"] } } } @@ -3942,20 +3949,13 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": [ - "type", - "mime", - "url" - ] + "required": ["type", "mime", "url"] } ] } } }, - "required": [ - "arguments", - "command" - ] + "required": ["arguments", "command"] } } } @@ -4049,19 +4049,13 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "command": { "type": "string" } }, - "required": [ - "agent", - "command" - ] + "required": ["agent", "command"] } } } @@ -4150,9 +4144,7 @@ "pattern": "^prt.*" } }, - "required": [ - "messageID" - ] + "required": ["messageID"] } } } @@ -4312,16 +4304,10 @@ "properties": { "response": { "type": "string", - "enum": [ - "once", - "always", - "reject" - ] + "enum": ["once", "always", "reject"] } }, - "required": [ - "response" - ] + "required": ["response"] } } } @@ -4458,19 +4444,13 @@ "properties": { "reply": { "type": "string", - "enum": [ - "once", - "always", - "reject" - ] + "enum": ["once", "always", "reject"] }, "message": { "type": "string" } }, - "required": [ - "reply" - ] + "required": ["reply"] } } } @@ -4738,9 +4718,7 @@ } } }, - "required": [ - "answers" - ] + "required": ["answers"] } } } @@ -4917,15 +4895,10 @@ "properties": { "field": { "type": "string", - "enum": [ - "reasoning_content", - "reasoning_details" - ] + "enum": ["reasoning_content", "reasoning_details"] } }, - "required": [ - "field" - ], + "required": ["field"], "additionalProperties": false } ] @@ -4961,16 +4934,10 @@ "type": "number" } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] }, "limit": { "type": "object", @@ -4985,10 +4952,7 @@ "type": "number" } }, - "required": [ - "context", - "output" - ] + "required": ["context", "output"] }, "modalities": { "type": "object", @@ -4997,70 +4961,39 @@ "type": "array", "items": { "type": "string", - "enum": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "enum": ["text", "audio", "image", "video", "pdf"] } }, "output": { "type": "array", "items": { "type": "string", - "enum": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "enum": ["text", "audio", "image", "video", "pdf"] } } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] }, "recommendedIndex": { "type": "number" }, "prompt": { "type": "string", - "enum": [ - "codex", - "gemini", - "beast", - "anthropic", - "trinity", - "anthropic_without_todo" - ] + "enum": ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] }, "isFree": { "type": "boolean" }, "ai_sdk_provider": { "type": "string", - "enum": [ - "anthropic", - "openai", - "openai-compatible", - "openrouter" - ] + "enum": ["anthropic", "openai", "openai-compatible", "openrouter"] }, "experimental": { "type": "boolean" }, "status": { "type": "string", - "enum": [ - "alpha", - "beta", - "deprecated" - ] + "enum": ["alpha", "beta", "deprecated"] }, "options": { "type": "object", @@ -5117,12 +5050,7 @@ } } }, - "required": [ - "name", - "env", - "id", - "models" - ] + "required": ["name", "env", "id", "models"] } }, "default": { @@ -5141,11 +5069,7 @@ } } }, - "required": [ - "all", - "default", - "connected" - ] + "required": ["all", "default", "connected"] } } } @@ -5272,9 +5196,7 @@ "type": "number" } }, - "required": [ - "method" - ] + "required": ["method"] } } } @@ -5354,9 +5276,7 @@ "type": "string" } }, - "required": [ - "method" - ] + "required": ["method"] } } } @@ -5431,9 +5351,7 @@ "additionalProperties": {} } }, - "required": [ - "event" - ] + "required": ["event"] } } } @@ -5482,10 +5400,7 @@ "type": "boolean" } }, - "required": [ - "enabled", - "connected" - ] + "required": ["enabled", "connected"] } } } @@ -5535,10 +5450,7 @@ "type": "boolean" } }, - "required": [ - "enabled", - "connected" - ] + "required": ["enabled", "connected"] } } } @@ -5588,10 +5500,7 @@ "type": "boolean" } }, - "required": [ - "enabled", - "connected" - ] + "required": ["enabled", "connected"] } } } @@ -5638,9 +5547,7 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } } } @@ -5678,9 +5585,7 @@ "type": "string" } }, - "required": [ - "path" - ] + "required": ["path"] } } } @@ -5726,9 +5631,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] } } } @@ -5756,9 +5659,7 @@ "minLength": 1 } }, - "required": [ - "text" - ] + "required": ["text"] } } } @@ -5810,10 +5711,7 @@ "type": "boolean" } }, - "required": [ - "ok", - "id" - ] + "required": ["ok", "id"] } } } @@ -5877,13 +5775,7 @@ } } }, - "required": [ - "id", - "worktree", - "timeCreated", - "timeUpdated", - "sandboxes" - ] + "required": ["id", "worktree", "timeCreated", "timeUpdated", "sandboxes"] } } } @@ -5935,10 +5827,7 @@ "type": "boolean" } }, - "required": [ - "ok", - "id" - ] + "required": ["ok", "id"] } } } @@ -5966,6 +5855,9 @@ "projectID": { "type": "string" }, + "force": { + "type": "boolean" + }, "workspaceID": { "type": "string" }, @@ -6010,11 +5902,7 @@ } } }, - "required": [ - "additions", - "deletions", - "files" - ] + "required": ["additions", "deletions", "files"] }, "revert": { "type": "object", @@ -6032,9 +5920,7 @@ "type": "string" } }, - "required": [ - "messageID" - ] + "required": ["messageID"] }, "permission": { "type": "object", @@ -6056,16 +5942,7 @@ "type": "number" } }, - "required": [ - "id", - "projectID", - "slug", - "directory", - "title", - "version", - "timeCreated", - "timeUpdated" - ] + "required": ["id", "projectID", "slug", "directory", "title", "version", "timeCreated", "timeUpdated"] } } } @@ -6117,10 +5994,7 @@ "type": "boolean" } }, - "required": [ - "ok", - "id" - ] + "required": ["ok", "id"] } } } @@ -6167,9 +6041,7 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] }, "agent": { "type": "string" @@ -6184,10 +6056,7 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "tools": { "type": "object", @@ -6199,12 +6068,7 @@ } } }, - "required": [ - "role", - "time", - "agent", - "model" - ] + "required": ["role", "time", "agent", "model"] }, { "type": "object", @@ -6223,9 +6087,7 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] }, "parentID": { "type": "string" @@ -6252,10 +6114,7 @@ "type": "string" } }, - "required": [ - "cwd", - "root" - ] + "required": ["cwd", "root"] }, "summary": { "type": "boolean" @@ -6288,18 +6147,10 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] } }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ] + "required": ["input", "output", "reasoning", "cache"] }, "structured": {}, "variant": { @@ -6325,12 +6176,7 @@ ] } }, - "required": [ - "id", - "sessionID", - "timeCreated", - "data" - ] + "required": ["id", "sessionID", "timeCreated", "data"] } } } @@ -6382,10 +6228,7 @@ "type": "boolean" } }, - "required": [ - "ok", - "id" - ] + "required": ["ok", "id"] } } } @@ -6447,9 +6290,7 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] }, "metadata": { "type": "object", @@ -6459,10 +6300,7 @@ "additionalProperties": {} } }, - "required": [ - "type", - "text" - ] + "required": ["type", "text"] }, { "type": "object", @@ -6491,16 +6329,10 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] } }, - "required": [ - "type", - "text", - "time" - ] + "required": ["type", "text", "time"] }, { "type": "object", @@ -6535,11 +6367,7 @@ "type": "string" } }, - "required": [ - "status", - "input", - "raw" - ] + "required": ["status", "input", "raw"] }, { "type": "object", @@ -6572,16 +6400,10 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] } }, - "required": [ - "status", - "input", - "time" - ] + "required": ["status", "input", "time"] }, { "type": "object", @@ -6623,20 +6445,10 @@ "type": "number" } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] } }, - "required": [ - "status", - "input", - "output", - "title", - "metadata", - "time" - ] + "required": ["status", "input", "output", "title", "metadata", "time"] }, { "type": "object", @@ -6672,18 +6484,10 @@ "type": "number" } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] } }, - "required": [ - "status", - "input", - "error", - "time" - ] + "required": ["status", "input", "error", "time"] } ] }, @@ -6695,22 +6499,12 @@ "additionalProperties": {} } }, - "required": [ - "type", - "callID", - "tool", - "state" - ] + "required": ["type", "callID", "tool", "state"] } ] } }, - "required": [ - "id", - "messageID", - "sessionID", - "data" - ] + "required": ["id", "messageID", "sessionID", "data"] } } } @@ -6776,9 +6570,7 @@ "type": "string" } }, - "required": [ - "location" - ] + "required": ["location"] } } } @@ -6844,9 +6636,7 @@ "type": "string" } }, - "required": [ - "name" - ] + "required": ["name"] } } } @@ -6912,17 +6702,11 @@ "type": "string" } }, - "required": [ - "id", - "name", - "role" - ] + "required": ["id", "name", "role"] } } }, - "required": [ - "email" - ] + "required": ["email"] }, "balance": { "anyOf": [ @@ -6933,9 +6717,7 @@ "type": "number" } }, - "required": [ - "balance" - ] + "required": ["balance"] }, { "type": "null" @@ -6953,11 +6735,7 @@ ] } }, - "required": [ - "profile", - "balance", - "currentOrgId" - ] + "required": ["profile", "balance", "currentOrgId"] } } } @@ -7041,9 +6819,7 @@ ] } }, - "required": [ - "organizationId" - ] + "required": ["organizationId"] } } } @@ -7171,9 +6947,7 @@ } } }, - "required": [ - "modes" - ] + "required": ["modes"] } } } @@ -7284,10 +7058,7 @@ "type": "number" } }, - "required": [ - "prefix", - "suffix" - ] + "required": ["prefix", "suffix"] } } } @@ -7350,10 +7121,7 @@ "type": "string" } }, - "required": [ - "actionText", - "actionURL" - ] + "required": ["actionText", "actionURL"] }, "showIn": { "type": "array", @@ -7365,11 +7133,7 @@ "type": "string" } }, - "required": [ - "id", - "title", - "message" - ] + "required": ["id", "title", "message"] } } } @@ -7512,9 +7276,7 @@ "type": "string" } }, - "required": [ - "sessionId" - ] + "required": ["sessionId"] } } } @@ -7560,14 +7322,7 @@ "anyOf": [ { "type": "string", - "enum": [ - "provisioned", - "starting", - "restarting", - "running", - "stopped", - "destroying" - ] + "enum": ["provisioned", "starting", "restarting", "running", "stopped", "destroying"] }, { "type": "null" @@ -7590,10 +7345,7 @@ "type": "number" } }, - "required": [ - "cpus", - "memory_mb" - ] + "required": ["cpus", "memory_mb"] }, "openclawVersion": { "anyOf": [ @@ -7635,9 +7387,7 @@ "type": "string" } }, - "required": [ - "status" - ] + "required": ["status"] } } } @@ -7695,12 +7445,7 @@ "type": "string" } }, - "required": [ - "apiKey", - "userId", - "userToken", - "channelId" - ] + "required": ["apiKey", "userId", "userToken", "channelId"] }, { "type": "null" @@ -7797,13 +7542,7 @@ "type": "number" } }, - "required": [ - "session_id", - "title", - "created_at", - "updated_at", - "version" - ] + "required": ["session_id", "title", "created_at", "updated_at", "version"] } }, "nextCursor": { @@ -7817,10 +7556,7 @@ ] } }, - "required": [ - "cliSessions", - "nextCursor" - ] + "required": ["cliSessions", "nextCursor"] } } } @@ -7890,9 +7626,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] }, "lines": { "type": "object", @@ -7901,9 +7635,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] }, "line_number": { "type": "number" @@ -7923,9 +7655,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] }, "start": { "type": "number" @@ -7934,21 +7664,11 @@ "type": "number" } }, - "required": [ - "match", - "start", - "end" - ] + "required": ["match", "start", "end"] } } }, - "required": [ - "path", - "lines", - "line_number", - "absolute_offset", - "submatches" - ] + "required": ["path", "lines", "line_number", "absolute_offset", "submatches"] } } } @@ -7994,10 +7714,7 @@ "name": "dirs", "schema": { "type": "string", - "enum": [ - "true", - "false" - ] + "enum": ["true", "false"] } }, { @@ -8005,10 +7722,7 @@ "name": "type", "schema": { "type": "string", - "enum": [ - "file", - "directory" - ] + "enum": ["file", "directory"] } }, { @@ -8357,10 +8071,7 @@ ] } }, - "required": [ - "name", - "config" - ] + "required": ["name", "config"] } } } @@ -8415,9 +8126,7 @@ "type": "string" } }, - "required": [ - "authorizationUrl" - ] + "required": ["authorizationUrl"] } } } @@ -8491,9 +8200,7 @@ "const": true } }, - "required": [ - "success" - ] + "required": ["success"] } } } @@ -8589,9 +8296,7 @@ "type": "string" } }, - "required": [ - "code" - ] + "required": ["code"] } } } @@ -8822,9 +8527,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] } } } @@ -9136,9 +8839,7 @@ "type": "string" } }, - "required": [ - "command" - ] + "required": ["command"] } } } @@ -9198,12 +8899,7 @@ }, "variant": { "type": "string", - "enum": [ - "info", - "success", - "warning", - "error" - ] + "enum": ["info", "success", "warning", "error"] }, "duration": { "description": "Duration in milliseconds", @@ -9211,10 +8907,7 @@ "type": "number" } }, - "required": [ - "message", - "variant" - ] + "required": ["message", "variant"] } } } @@ -9365,9 +9058,7 @@ "pattern": "^ses" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } } } @@ -9414,10 +9105,7 @@ }, "body": {} }, - "required": [ - "path", - "body" - ] + "required": ["path", "body"] } } } @@ -9702,12 +9390,7 @@ "level": { "description": "Log level", "type": "string", - "enum": [ - "debug", - "info", - "error", - "warn" - ] + "enum": ["debug", "info", "error", "warn"] }, "message": { "description": "Log message", @@ -9722,11 +9405,7 @@ "additionalProperties": {} } }, - "required": [ - "service", - "level", - "message" - ] + "required": ["service", "level", "message"] } } } @@ -9827,12 +9506,7 @@ "type": "string" } }, - "required": [ - "name", - "description", - "location", - "content" - ] + "required": ["name", "description", "location", "content"] } } } @@ -9993,15 +9667,10 @@ "type": "string" } }, - "required": [ - "version" - ] + "required": ["version"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.installation.update-available": { "type": "object", @@ -10017,15 +9686,10 @@ "type": "string" } }, - "required": [ - "version" - ] + "required": ["version"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Project": { "type": "object", @@ -10079,10 +9743,7 @@ "type": "number" } }, - "required": [ - "created", - "updated" - ] + "required": ["created", "updated"] }, "sandboxes": { "type": "array", @@ -10091,12 +9752,7 @@ } } }, - "required": [ - "id", - "worktree", - "time", - "sandboxes" - ] + "required": ["id", "worktree", "time", "sandboxes"] }, "Event.project.updated": { "type": "object", @@ -10109,10 +9765,7 @@ "$ref": "#/components/schemas/Project" } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.server.instance.disposed": { "type": "object", @@ -10128,15 +9781,10 @@ "type": "string" } }, - "required": [ - "directory" - ] + "required": ["directory"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.server.connected": { "type": "object", @@ -10150,10 +9798,7 @@ "properties": {} } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.global.disposed": { "type": "object", @@ -10167,10 +9812,7 @@ "properties": {} } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.global.config.updated": { "type": "object", @@ -10184,10 +9826,7 @@ "properties": {} } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.lsp.client.diagnostics": { "type": "object", @@ -10206,16 +9845,10 @@ "type": "string" } }, - "required": [ - "serverID", - "path" - ] + "required": ["serverID", "path"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.lsp.updated": { "type": "object", @@ -10229,10 +9862,7 @@ "properties": {} } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.file.edited": { "type": "object", @@ -10248,15 +9878,10 @@ "type": "string" } }, - "required": [ - "file" - ] + "required": ["file"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "OutputFormatText": { "type": "object", @@ -10266,9 +9891,7 @@ "const": "text" } }, - "required": [ - "type" - ] + "required": ["type"] }, "JSONSchema": { "type": "object", @@ -10294,10 +9917,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "type", - "schema" - ] + "required": ["type", "schema"] }, "OutputFormat": { "anyOf": [ @@ -10329,20 +9949,10 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] } }, - "required": [ - "file", - "before", - "after", - "additions", - "deletions" - ] + "required": ["file", "before", "after", "additions", "deletions"] }, "UserMessage": { "type": "object", @@ -10364,9 +9974,7 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] }, "format": { "$ref": "#/components/schemas/OutputFormat" @@ -10387,9 +9995,7 @@ } } }, - "required": [ - "diffs" - ] + "required": ["diffs"] }, "agent": { "type": "string" @@ -10404,10 +10010,7 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "system": { "type": "string" @@ -10448,14 +10051,7 @@ } } }, - "required": [ - "id", - "sessionID", - "role", - "time", - "agent", - "model" - ] + "required": ["id", "sessionID", "role", "time", "agent", "model"] }, "ProviderAuthError": { "type": "object", @@ -10474,16 +10070,10 @@ "type": "string" } }, - "required": [ - "providerID", - "message" - ] + "required": ["providerID", "message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "UnknownError": { "type": "object", @@ -10499,15 +10089,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "MessageOutputLengthError": { "type": "object", @@ -10521,10 +10106,7 @@ "properties": {} } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "MessageAbortedError": { "type": "object", @@ -10540,15 +10122,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "StructuredOutputError": { "type": "object", @@ -10567,16 +10144,10 @@ "type": "number" } }, - "required": [ - "message", - "retries" - ] + "required": ["message", "retries"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "ContextOverflowError": { "type": "object", @@ -10595,15 +10166,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "APIError": { "type": "object", @@ -10646,16 +10212,10 @@ } } }, - "required": [ - "message", - "isRetryable" - ] + "required": ["message", "isRetryable"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "AssistantMessage": { "type": "object", @@ -10680,9 +10240,7 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] }, "error": { "anyOf": [ @@ -10734,10 +10292,7 @@ "type": "string" } }, - "required": [ - "cwd", - "root" - ] + "required": ["cwd", "root"] }, "summary": { "type": "boolean" @@ -10770,18 +10325,10 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] } }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ] + "required": ["input", "output", "reasoning", "cache"] }, "structured": {}, "variant": { @@ -10830,15 +10377,10 @@ "$ref": "#/components/schemas/Message" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.message.removed": { "type": "object", @@ -10857,16 +10399,10 @@ "type": "string" } }, - "required": [ - "sessionID", - "messageID" - ] + "required": ["sessionID", "messageID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "TextPart": { "type": "object", @@ -10903,9 +10439,7 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] }, "metadata": { "type": "object", @@ -10915,13 +10449,7 @@ "additionalProperties": {} } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "text" - ] + "required": ["id", "sessionID", "messageID", "type", "text"] }, "SubtaskPart": { "type": "object", @@ -10958,24 +10486,13 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "command": { "type": "string" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "prompt", - "description", - "agent" - ] + "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"] }, "ReasoningPart": { "type": "object", @@ -11013,19 +10530,10 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "text", - "time" - ] + "required": ["id", "sessionID", "messageID", "type", "text", "time"] }, "FilePartSourceText": { "type": "object", @@ -11044,11 +10552,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "value", - "start", - "end" - ] + "required": ["value", "start", "end"] }, "FileSource": { "type": "object", @@ -11064,11 +10568,7 @@ "type": "string" } }, - "required": [ - "text", - "type", - "path" - ] + "required": ["text", "type", "path"] }, "Range": { "type": "object", @@ -11083,10 +10583,7 @@ "type": "number" } }, - "required": [ - "line", - "character" - ] + "required": ["line", "character"] }, "end": { "type": "object", @@ -11098,16 +10595,10 @@ "type": "number" } }, - "required": [ - "line", - "character" - ] + "required": ["line", "character"] } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] }, "SymbolSource": { "type": "object", @@ -11134,14 +10625,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "text", - "type", - "path", - "range", - "name", - "kind" - ] + "required": ["text", "type", "path", "range", "name", "kind"] }, "ResourceSource": { "type": "object", @@ -11160,12 +10644,7 @@ "type": "string" } }, - "required": [ - "text", - "type", - "clientName", - "uri" - ] + "required": ["text", "type", "clientName", "uri"] }, "FilePartSource": { "anyOf": [ @@ -11209,14 +10688,7 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "mime", - "url" - ] + "required": ["id", "sessionID", "messageID", "type", "mime", "url"] }, "ToolStatePending": { "type": "object", @@ -11236,11 +10708,7 @@ "type": "string" } }, - "required": [ - "status", - "input", - "raw" - ] + "required": ["status", "input", "raw"] }, "ToolStateRunning": { "type": "object", @@ -11273,16 +10741,10 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] } }, - "required": [ - "status", - "input", - "time" - ] + "required": ["status", "input", "time"] }, "ToolStateCompleted": { "type": "object", @@ -11324,10 +10786,7 @@ "type": "number" } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] }, "attachments": { "type": "array", @@ -11336,14 +10795,7 @@ } } }, - "required": [ - "status", - "input", - "output", - "title", - "metadata", - "time" - ] + "required": ["status", "input", "output", "title", "metadata", "time"] }, "ToolStateError": { "type": "object", @@ -11379,18 +10831,10 @@ "type": "number" } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] } }, - "required": [ - "status", - "input", - "error", - "time" - ] + "required": ["status", "input", "error", "time"] }, "ToolState": { "anyOf": [ @@ -11441,15 +10885,7 @@ "additionalProperties": {} } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "callID", - "tool", - "state" - ] + "required": ["id", "sessionID", "messageID", "type", "callID", "tool", "state"] }, "StepStartPart": { "type": "object", @@ -11471,12 +10907,7 @@ "type": "string" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type" - ] + "required": ["id", "sessionID", "messageID", "type"] }, "StepFinishPart": { "type": "object", @@ -11528,29 +10959,13 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] } }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ] + "required": ["input", "output", "reasoning", "cache"] } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "reason", - "cost", - "tokens" - ] + "required": ["id", "sessionID", "messageID", "type", "reason", "cost", "tokens"] }, "SnapshotPart": { "type": "object", @@ -11572,13 +10987,7 @@ "type": "string" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "snapshot" - ] + "required": ["id", "sessionID", "messageID", "type", "snapshot"] }, "PatchPart": { "type": "object", @@ -11606,14 +11015,7 @@ } } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "hash", - "files" - ] + "required": ["id", "sessionID", "messageID", "type", "hash", "files"] }, "AgentPart": { "type": "object", @@ -11651,20 +11053,10 @@ "maximum": 9007199254740991 } }, - "required": [ - "value", - "start", - "end" - ] + "required": ["value", "start", "end"] } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "name" - ] + "required": ["id", "sessionID", "messageID", "type", "name"] }, "RetryPart": { "type": "object", @@ -11695,20 +11087,10 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "attempt", - "error", - "time" - ] + "required": ["id", "sessionID", "messageID", "type", "attempt", "error", "time"] }, "CompactionPart": { "type": "object", @@ -11733,13 +11115,7 @@ "type": "boolean" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "auto" - ] + "required": ["id", "sessionID", "messageID", "type", "auto"] }, "Part": { "anyOf": [ @@ -11795,15 +11171,10 @@ "$ref": "#/components/schemas/Part" } }, - "required": [ - "part" - ] + "required": ["part"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.message.part.delta": { "type": "object", @@ -11831,19 +11202,10 @@ "type": "string" } }, - "required": [ - "sessionID", - "messageID", - "partID", - "field", - "delta" - ] + "required": ["sessionID", "messageID", "partID", "field", "delta"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.message.part.removed": { "type": "object", @@ -11865,17 +11227,10 @@ "type": "string" } }, - "required": [ - "sessionID", - "messageID", - "partID" - ] + "required": ["sessionID", "messageID", "partID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "PermissionRequest": { "type": "object", @@ -11920,20 +11275,10 @@ "type": "string" } }, - "required": [ - "messageID", - "callID" - ] + "required": ["messageID", "callID"] } }, - "required": [ - "id", - "sessionID", - "permission", - "patterns", - "metadata", - "always" - ] + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] }, "Event.permission.asked": { "type": "object", @@ -11946,10 +11291,7 @@ "$ref": "#/components/schemas/PermissionRequest" } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.permission.replied": { "type": "object", @@ -11969,24 +11311,13 @@ }, "reply": { "type": "string", - "enum": [ - "once", - "always", - "reject" - ] + "enum": ["once", "always", "reject"] } }, - "required": [ - "sessionID", - "requestID", - "reply" - ] + "required": ["sessionID", "requestID", "reply"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "SessionStatus": { "anyOf": [ @@ -11998,9 +11329,7 @@ "const": "idle" } }, - "required": [ - "type" - ] + "required": ["type"] }, { "type": "object", @@ -12019,12 +11348,7 @@ "type": "number" } }, - "required": [ - "type", - "attempt", - "message", - "next" - ] + "required": ["type", "attempt", "message", "next"] }, { "type": "object", @@ -12034,9 +11358,7 @@ "const": "busy" } }, - "required": [ - "type" - ] + "required": ["type"] } ] }, @@ -12057,16 +11379,10 @@ "$ref": "#/components/schemas/SessionStatus" } }, - "required": [ - "sessionID", - "status" - ] + "required": ["sessionID", "status"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.idle": { "type": "object", @@ -12082,15 +11398,10 @@ "type": "string" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "QuestionOption": { "type": "object", @@ -12108,10 +11419,7 @@ "type": "string" } }, - "required": [ - "label", - "description" - ] + "required": ["label", "description"] }, "QuestionInfo": { "type": "object", @@ -12140,11 +11448,7 @@ "type": "boolean" } }, - "required": [ - "question", - "header", - "options" - ] + "required": ["question", "header", "options"] }, "QuestionRequest": { "type": "object", @@ -12174,17 +11478,10 @@ "type": "string" } }, - "required": [ - "messageID", - "callID" - ] + "required": ["messageID", "callID"] } }, - "required": [ - "id", - "sessionID", - "questions" - ] + "required": ["id", "sessionID", "questions"] }, "Event.question.asked": { "type": "object", @@ -12197,10 +11494,7 @@ "$ref": "#/components/schemas/QuestionRequest" } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "QuestionAnswer": { "type": "array", @@ -12231,17 +11525,10 @@ } } }, - "required": [ - "sessionID", - "requestID", - "answers" - ] + "required": ["sessionID", "requestID", "answers"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.question.rejected": { "type": "object", @@ -12260,16 +11547,10 @@ "type": "string" } }, - "required": [ - "sessionID", - "requestID" - ] + "required": ["sessionID", "requestID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.compacted": { "type": "object", @@ -12285,15 +11566,10 @@ "type": "string" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.file.watcher.updated": { "type": "object", @@ -12325,16 +11601,10 @@ ] } }, - "required": [ - "file", - "event" - ] + "required": ["file", "event"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Todo": { "type": "object", @@ -12352,11 +11622,7 @@ "type": "string" } }, - "required": [ - "content", - "status", - "priority" - ] + "required": ["content", "status", "priority"] }, "Event.todo.updated": { "type": "object", @@ -12378,16 +11644,10 @@ } } }, - "required": [ - "sessionID", - "todos" - ] + "required": ["sessionID", "todos"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.tui.prompt.append": { "type": "object", @@ -12403,15 +11663,10 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.tui.command.execute": { "type": "object", @@ -12452,15 +11707,10 @@ ] } }, - "required": [ - "command" - ] + "required": ["command"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.tui.toast.show": { "type": "object", @@ -12480,12 +11730,7 @@ }, "variant": { "type": "string", - "enum": [ - "info", - "success", - "warning", - "error" - ] + "enum": ["info", "success", "warning", "error"] }, "duration": { "description": "Duration in milliseconds", @@ -12493,16 +11738,10 @@ "type": "number" } }, - "required": [ - "message", - "variant" - ] + "required": ["message", "variant"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.tui.session.select": { "type": "object", @@ -12520,15 +11759,10 @@ "pattern": "^ses" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.mcp.tools.changed": { "type": "object", @@ -12544,15 +11778,10 @@ "type": "string" } }, - "required": [ - "server" - ] + "required": ["server"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.mcp.browser.open.failed": { "type": "object", @@ -12571,16 +11800,10 @@ "type": "string" } }, - "required": [ - "mcpName", - "url" - ] + "required": ["mcpName", "url"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.command.executed": { "type": "object", @@ -12607,26 +11830,14 @@ "pattern": "^msg.*" } }, - "required": [ - "name", - "sessionID", - "arguments", - "messageID" - ] + "required": ["name", "sessionID", "arguments", "messageID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "PermissionAction": { "type": "string", - "enum": [ - "allow", - "deny", - "ask" - ] + "enum": ["allow", "deny", "ask"] }, "PermissionRule": { "type": "object", @@ -12641,11 +11852,7 @@ "$ref": "#/components/schemas/PermissionAction" } }, - "required": [ - "permission", - "pattern", - "action" - ] + "required": ["permission", "pattern", "action"] }, "PermissionRuleset": { "type": "array", @@ -12704,26 +11911,14 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] } }, - "required": [ - "file", - "additions", - "deletions" - ] + "required": ["file", "additions", "deletions"] } } }, - "required": [ - "additions", - "deletions", - "files" - ] + "required": ["additions", "deletions", "files"] }, "share": { "type": "object", @@ -12732,9 +11927,7 @@ "type": "string" } }, - "required": [ - "url" - ] + "required": ["url"] }, "title": { "type": "string" @@ -12758,10 +11951,7 @@ "type": "number" } }, - "required": [ - "created", - "updated" - ] + "required": ["created", "updated"] }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" @@ -12782,20 +11972,10 @@ "type": "string" } }, - "required": [ - "messageID" - ] + "required": ["messageID"] } }, - "required": [ - "id", - "slug", - "projectID", - "directory", - "title", - "version", - "time" - ] + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"] }, "Event.session.created": { "type": "object", @@ -12811,15 +11991,10 @@ "$ref": "#/components/schemas/Session" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.updated": { "type": "object", @@ -12835,15 +12010,10 @@ "$ref": "#/components/schemas/Session" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.deleted": { "type": "object", @@ -12859,15 +12029,10 @@ "$ref": "#/components/schemas/Session" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.diff": { "type": "object", @@ -12889,16 +12054,10 @@ } } }, - "required": [ - "sessionID", - "diff" - ] + "required": ["sessionID", "diff"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.error": { "type": "object", @@ -12941,10 +12100,7 @@ } } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.turn.open": { "type": "object", @@ -12960,15 +12116,10 @@ "type": "string" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.turn.close": { "type": "object", @@ -12985,23 +12136,13 @@ }, "reason": { "type": "string", - "enum": [ - "completed", - "error", - "interrupted" - ] + "enum": ["completed", "error", "interrupted"] } }, - "required": [ - "sessionID", - "reason" - ] + "required": ["sessionID", "reason"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.vcs.branch.updated": { "type": "object", @@ -13019,10 +12160,7 @@ } } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.workspace.ready": { "type": "object", @@ -13038,15 +12176,10 @@ "type": "string" } }, - "required": [ - "name" - ] + "required": ["name"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.workspace.failed": { "type": "object", @@ -13062,15 +12195,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Pty": { "type": "object", @@ -13096,24 +12224,13 @@ }, "status": { "type": "string", - "enum": [ - "running", - "exited" - ] + "enum": ["running", "exited"] }, "pid": { "type": "number" } }, - "required": [ - "id", - "title", - "command", - "args", - "cwd", - "status", - "pid" - ] + "required": ["id", "title", "command", "args", "cwd", "status", "pid"] }, "Event.pty.created": { "type": "object", @@ -13129,15 +12246,10 @@ "$ref": "#/components/schemas/Pty" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.pty.updated": { "type": "object", @@ -13153,15 +12265,10 @@ "$ref": "#/components/schemas/Pty" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.pty.exited": { "type": "object", @@ -13181,16 +12288,10 @@ "type": "number" } }, - "required": [ - "id", - "exitCode" - ] + "required": ["id", "exitCode"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.pty.deleted": { "type": "object", @@ -13207,15 +12308,10 @@ "pattern": "^pty.*" } }, - "required": [ - "id" - ] + "required": ["id"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.worktree.ready": { "type": "object", @@ -13234,16 +12330,10 @@ "type": "string" } }, - "required": [ - "name", - "branch" - ] + "required": ["name", "branch"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.worktree.failed": { "type": "object", @@ -13259,15 +12349,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event": { "anyOf": [ @@ -13427,20 +12512,12 @@ "$ref": "#/components/schemas/Event" } }, - "required": [ - "directory", - "payload" - ] + "required": ["directory", "payload"] }, "LogLevel": { "description": "Log level", "type": "string", - "enum": [ - "DEBUG", - "INFO", - "WARN", - "ERROR" - ] + "enum": ["DEBUG", "INFO", "WARN", "ERROR"] }, "ServerConfig": { "description": "Server configuration for kilo serve and web commands", @@ -13478,11 +12555,7 @@ "anyOf": [ { "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] + "enum": ["ask", "allow", "deny"] }, { "type": "null" @@ -13625,11 +12698,7 @@ }, "mode": { "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] + "enum": ["subagent", "primary", "all"] }, "hidden": { "description": "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)", @@ -13651,15 +12720,7 @@ }, { "type": "string", - "enum": [ - "primary", - "secondary", - "accent", - "success", - "warning", - "error", - "info" - ] + "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] } ] }, @@ -13745,15 +12806,10 @@ "properties": { "field": { "type": "string", - "enum": [ - "reasoning_content", - "reasoning_details" - ] + "enum": ["reasoning_content", "reasoning_details"] } }, - "required": [ - "field" - ], + "required": ["field"], "additionalProperties": false } ] @@ -13789,16 +12845,10 @@ "type": "number" } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] }, "limit": { "type": "object", @@ -13813,10 +12863,7 @@ "type": "number" } }, - "required": [ - "context", - "output" - ] + "required": ["context", "output"] }, "modalities": { "type": "object", @@ -13825,70 +12872,39 @@ "type": "array", "items": { "type": "string", - "enum": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "enum": ["text", "audio", "image", "video", "pdf"] } }, "output": { "type": "array", "items": { "type": "string", - "enum": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "enum": ["text", "audio", "image", "video", "pdf"] } } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] }, "recommendedIndex": { "type": "number" }, "prompt": { "type": "string", - "enum": [ - "codex", - "gemini", - "beast", - "anthropic", - "trinity", - "anthropic_without_todo" - ] + "enum": ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] }, "isFree": { "type": "boolean" }, "ai_sdk_provider": { "type": "string", - "enum": [ - "anthropic", - "openai", - "openai-compatible", - "openrouter" - ] + "enum": ["anthropic", "openai", "openai-compatible", "openrouter"] }, "experimental": { "type": "boolean" }, "status": { "type": "string", - "enum": [ - "alpha", - "beta", - "deprecated" - ] + "enum": ["alpha", "beta", "deprecated"] }, "options": { "type": "object", @@ -14024,10 +13040,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "type", - "command" - ], + "required": ["type", "command"], "additionalProperties": false }, "McpOAuthConfig": { @@ -14093,19 +13106,13 @@ "maximum": 9007199254740991 } }, - "required": [ - "type", - "url" - ], + "required": ["type", "url"], "additionalProperties": false }, "LayoutConfig": { "description": "@deprecated Always uses stretch layout.", "type": "string", - "enum": [ - "auto", - "stretch" - ] + "enum": ["auto", "stretch"] }, "Config": { "type": "object", @@ -14145,9 +13152,7 @@ "type": "boolean" } }, - "required": [ - "template" - ] + "required": ["template"] } }, "skills": { @@ -14193,11 +13198,7 @@ "share": { "description": "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", "type": "string", - "enum": [ - "manual", - "auto", - "disabled" - ] + "enum": ["manual", "auto", "disabled"] }, "autoshare": { "description": "@deprecated Use 'share' field instead. Share newly created sessions automatically", @@ -14352,9 +13353,7 @@ "type": "boolean" } }, - "required": [ - "enabled" - ], + "required": ["enabled"], "additionalProperties": false } ] @@ -14424,9 +13423,7 @@ "const": true } }, - "required": [ - "disabled" - ] + "required": ["disabled"] }, { "type": "object", @@ -14463,9 +13460,7 @@ "additionalProperties": {} } }, - "required": [ - "command" - ] + "required": ["command"] } ] } @@ -14582,11 +13577,7 @@ "const": false } }, - "required": [ - "data", - "errors", - "success" - ] + "required": ["data", "errors", "success"] }, "OAuth": { "type": "object", @@ -14611,12 +13602,7 @@ "type": "string" } }, - "required": [ - "type", - "refresh", - "access", - "expires" - ] + "required": ["type", "refresh", "access", "expires"] }, "ApiAuth": { "type": "object", @@ -14629,10 +13615,7 @@ "type": "string" } }, - "required": [ - "type", - "key" - ] + "required": ["type", "key"] }, "WellKnownAuth": { "type": "object", @@ -14648,11 +13631,7 @@ "type": "string" } }, - "required": [ - "type", - "key", - "token" - ] + "required": ["type", "key", "token"] }, "Auth": { "anyOf": [ @@ -14681,15 +13660,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "Model": { "type": "object", @@ -14713,11 +13687,7 @@ "type": "string" } }, - "required": [ - "id", - "url", - "npm" - ] + "required": ["id", "url", "npm"] }, "name": { "type": "string" @@ -14759,13 +13729,7 @@ "type": "boolean" } }, - "required": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "required": ["text", "audio", "image", "video", "pdf"] }, "output": { "type": "object", @@ -14786,13 +13750,7 @@ "type": "boolean" } }, - "required": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "required": ["text", "audio", "image", "video", "pdf"] }, "interleaved": { "anyOf": [ @@ -14804,28 +13762,15 @@ "properties": { "field": { "type": "string", - "enum": [ - "reasoning_content", - "reasoning_details" - ] + "enum": ["reasoning_content", "reasoning_details"] } }, - "required": [ - "field" - ] + "required": ["field"] } ] } }, - "required": [ - "temperature", - "reasoning", - "attachment", - "toolcall", - "input", - "output", - "interleaved" - ] + "required": ["temperature", "reasoning", "attachment", "toolcall", "input", "output", "interleaved"] }, "cost": { "type": "object", @@ -14846,10 +13791,7 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] }, "experimentalOver200K": { "type": "object", @@ -14870,24 +13812,13 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] } }, - "required": [ - "input", - "output", - "cache" - ] + "required": ["input", "output", "cache"] } }, - "required": [ - "input", - "output", - "cache" - ] + "required": ["input", "output", "cache"] }, "limit": { "type": "object", @@ -14902,19 +13833,11 @@ "type": "number" } }, - "required": [ - "context", - "output" - ] + "required": ["context", "output"] }, "status": { "type": "string", - "enum": [ - "alpha", - "beta", - "deprecated", - "active" - ] + "enum": ["alpha", "beta", "deprecated", "active"] }, "options": { "type": "object", @@ -14953,26 +13876,14 @@ }, "prompt": { "type": "string", - "enum": [ - "codex", - "gemini", - "beast", - "anthropic", - "trinity", - "anthropic_without_todo" - ] + "enum": ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] }, "isFree": { "type": "boolean" }, "ai_sdk_provider": { "type": "string", - "enum": [ - "anthropic", - "openai", - "openai-compatible", - "openrouter" - ] + "enum": ["anthropic", "openai", "openai-compatible", "openrouter"] } }, "required": [ @@ -15000,12 +13911,7 @@ }, "source": { "type": "string", - "enum": [ - "env", - "config", - "custom", - "api" - ] + "enum": ["env", "config", "custom", "api"] }, "env": { "type": "array", @@ -15033,14 +13939,7 @@ } } }, - "required": [ - "id", - "name", - "source", - "env", - "options", - "models" - ] + "required": ["id", "name", "source", "env", "options", "models"] }, "ToolIDs": { "type": "array", @@ -15059,11 +13958,7 @@ }, "parameters": {} }, - "required": [ - "id", - "description", - "parameters" - ] + "required": ["id", "description", "parameters"] }, "ToolList": { "type": "array", @@ -15123,15 +14018,7 @@ "type": "string" } }, - "required": [ - "id", - "type", - "branch", - "name", - "directory", - "extra", - "projectID" - ] + "required": ["id", "type", "branch", "name", "directory", "extra", "projectID"] }, "Worktree": { "type": "object", @@ -15146,11 +14033,7 @@ "type": "string" } }, - "required": [ - "name", - "branch", - "directory" - ] + "required": ["name", "branch", "directory"] }, "WorktreeCreateInput": { "type": "object", @@ -15171,9 +14054,7 @@ "type": "string" } }, - "required": [ - "directory" - ] + "required": ["directory"] }, "WorktreeResetInput": { "type": "object", @@ -15182,9 +14063,7 @@ "type": "string" } }, - "required": [ - "directory" - ] + "required": ["directory"] }, "WorktreeDiffItem": { "type": "object", @@ -15206,11 +14085,7 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] }, "tracked": { "type": "boolean" @@ -15250,10 +14125,7 @@ "type": "string" } }, - "required": [ - "id", - "worktree" - ] + "required": ["id", "worktree"] }, "GlobalSession": { "type": "object", @@ -15306,26 +14178,14 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] } }, - "required": [ - "file", - "additions", - "deletions" - ] + "required": ["file", "additions", "deletions"] } } }, - "required": [ - "additions", - "deletions", - "files" - ] + "required": ["additions", "deletions", "files"] }, "share": { "type": "object", @@ -15334,9 +14194,7 @@ "type": "string" } }, - "required": [ - "url" - ] + "required": ["url"] }, "title": { "type": "string" @@ -15360,10 +14218,7 @@ "type": "number" } }, - "required": [ - "created", - "updated" - ] + "required": ["created", "updated"] }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" @@ -15384,9 +14239,7 @@ "type": "string" } }, - "required": [ - "messageID" - ] + "required": ["messageID"] }, "project": { "anyOf": [ @@ -15399,16 +14252,7 @@ ] } }, - "required": [ - "id", - "slug", - "projectID", - "directory", - "title", - "version", - "time", - "project" - ] + "required": ["id", "slug", "projectID", "directory", "title", "version", "time", "project"] }, "McpResource": { "type": "object", @@ -15429,11 +14273,7 @@ "type": "string" } }, - "required": [ - "name", - "uri", - "client" - ] + "required": ["name", "uri", "client"] }, "TextPartInput": { "type": "object", @@ -15464,9 +14304,7 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] }, "metadata": { "type": "object", @@ -15476,10 +14314,7 @@ "additionalProperties": {} } }, - "required": [ - "type", - "text" - ] + "required": ["type", "text"] }, "FilePartInput": { "type": "object", @@ -15504,11 +14339,7 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": [ - "type", - "mime", - "url" - ] + "required": ["type", "mime", "url"] }, "AgentPartInput": { "type": "object", @@ -15540,17 +14371,10 @@ "maximum": 9007199254740991 } }, - "required": [ - "value", - "start", - "end" - ] + "required": ["value", "start", "end"] } }, - "required": [ - "type", - "name" - ] + "required": ["type", "name"] }, "SubtaskPartInput": { "type": "object", @@ -15581,21 +14405,13 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "command": { "type": "string" } }, - "required": [ - "type", - "prompt", - "description", - "agent" - ] + "required": ["type", "prompt", "description", "agent"] }, "ProviderAuthMethod": { "type": "object", @@ -15616,10 +14432,7 @@ "type": "string" } }, - "required": [ - "type", - "label" - ] + "required": ["type", "label"] }, "ProviderAuthAuthorization": { "type": "object", @@ -15643,11 +14456,7 @@ "type": "string" } }, - "required": [ - "url", - "method", - "instructions" - ] + "required": ["url", "method", "instructions"] }, "Symbol": { "type": "object", @@ -15668,17 +14477,10 @@ "$ref": "#/components/schemas/Range" } }, - "required": [ - "uri", - "range" - ] + "required": ["uri", "range"] } }, - "required": [ - "name", - "kind", - "location" - ] + "required": ["name", "kind", "location"] }, "FileNode": { "type": "object", @@ -15694,32 +14496,20 @@ }, "type": { "type": "string", - "enum": [ - "file", - "directory" - ] + "enum": ["file", "directory"] }, "ignored": { "type": "boolean" } }, - "required": [ - "name", - "path", - "absolute", - "type", - "ignored" - ] + "required": ["name", "path", "absolute", "type", "ignored"] }, "FileContent": { "type": "object", "properties": { "type": { "type": "string", - "enum": [ - "text", - "binary" - ] + "enum": ["text", "binary"] }, "content": { "type": "string" @@ -15766,24 +14556,14 @@ } } }, - "required": [ - "oldStart", - "oldLines", - "newStart", - "newLines", - "lines" - ] + "required": ["oldStart", "oldLines", "newStart", "newLines", "lines"] } }, "index": { "type": "string" } }, - "required": [ - "oldFileName", - "newFileName", - "hunks" - ] + "required": ["oldFileName", "newFileName", "hunks"] }, "encoding": { "type": "string", @@ -15793,10 +14573,7 @@ "type": "string" } }, - "required": [ - "type", - "content" - ] + "required": ["type", "content"] }, "File": { "type": "object", @@ -15816,19 +14593,10 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] } }, - "required": [ - "path", - "added", - "removed", - "status" - ] + "required": ["path", "added", "removed", "status"] }, "MCPStatusConnected": { "type": "object", @@ -15838,9 +14606,7 @@ "const": "connected" } }, - "required": [ - "status" - ] + "required": ["status"] }, "MCPStatusDisabled": { "type": "object", @@ -15850,9 +14616,7 @@ "const": "disabled" } }, - "required": [ - "status" - ] + "required": ["status"] }, "MCPStatusFailed": { "type": "object", @@ -15865,10 +14629,7 @@ "type": "string" } }, - "required": [ - "status", - "error" - ] + "required": ["status", "error"] }, "MCPStatusNeedsAuth": { "type": "object", @@ -15878,9 +14639,7 @@ "const": "needs_auth" } }, - "required": [ - "status" - ] + "required": ["status"] }, "MCPStatusNeedsClientRegistration": { "type": "object", @@ -15893,10 +14652,7 @@ "type": "string" } }, - "required": [ - "status", - "error" - ] + "required": ["status", "error"] }, "MCPStatus": { "anyOf": [ @@ -15936,13 +14692,7 @@ "type": "string" } }, - "required": [ - "home", - "state", - "config", - "worktree", - "directory" - ] + "required": ["home", "state", "config", "worktree", "directory"] }, "VcsInfo": { "type": "object", @@ -15951,9 +14701,7 @@ "type": "string" } }, - "required": [ - "branch" - ] + "required": ["branch"] }, "Command": { "type": "object", @@ -15972,11 +14720,7 @@ }, "source": { "type": "string", - "enum": [ - "command", - "mcp", - "skill" - ] + "enum": ["command", "mcp", "skill"] }, "template": { "anyOf": [ @@ -15998,11 +14742,7 @@ } } }, - "required": [ - "name", - "template", - "hints" - ] + "required": ["name", "template", "hints"] }, "Agent": { "type": "object", @@ -16018,11 +14758,7 @@ }, "mode": { "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] + "enum": ["subagent", "primary", "all"] }, "native": { "type": "boolean" @@ -16055,10 +14791,7 @@ "type": "string" } }, - "required": [ - "modelID", - "providerID" - ] + "required": ["modelID", "providerID"] }, "variant": { "type": "string" @@ -16079,12 +14812,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "name", - "mode", - "permission", - "options" - ] + "required": ["name", "mode", "permission", "options"] }, "LSPStatus": { "type": "object", @@ -16111,12 +14839,7 @@ ] } }, - "required": [ - "id", - "name", - "root", - "status" - ] + "required": ["id", "name", "root", "status"] }, "FormatterStatus": { "type": "object", @@ -16134,12 +14857,8 @@ "type": "boolean" } }, - "required": [ - "name", - "extensions", - "enabled" - ] + "required": ["name", "extensions", "enabled"] } } } -} \ No newline at end of file +} From 770887e96ceb5417f46dafdfe1494605145c0437 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 17:05:08 +0300 Subject: [PATCH 17/55] fix(cli): improve config warning UX across CLI, TUI, and VS Code Separate verbose parse details into a new Warning.detail field so toast messages stay short while 'kilo config check' and VS Code's Show Details action expose the full diagnostic. Make the TUI config-warning toast persistent (dismissed on first prompt submit) and fire VS Code's checkConfigWarnings() independently so prior failures in the SSE handler cannot suppress it. Add 'kilo config check' subcommand that prints all warnings with color and exits non-zero. --- packages/kilo-vscode/src/KiloProvider.ts | 28 ++++++++++--- packages/opencode/src/cli/cmd/config.ts | 40 +++++++++++++++++++ .../cli/cmd/tui/component/prompt/index.tsx | 1 + .../opencode/src/cli/cmd/tui/context/sync.tsx | 4 +- .../opencode/src/cli/cmd/tui/ui/toast.tsx | 15 +++++-- packages/opencode/src/config/config.ts | 9 +++-- packages/opencode/src/index.ts | 2 + packages/sdk/js/src/v2/gen/types.gen.ts | 1 + packages/sdk/openapi.json | 3 ++ 9 files changed, 88 insertions(+), 15 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/config.ts diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 05194727b75..55c2408bb0b 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1048,6 +1048,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage({ type: "connectionState", state }) if (state === "connected") { + // kilocode_change start - fire config warnings independently so a failure in the + // sequential await chain doesn't prevent warnings from being shown + void this.checkConfigWarnings() + // kilocode_change end try { // Profile fetch is best-effort — returns 401 when user isn't logged into gateway. const sdkClient = this.client @@ -1059,7 +1063,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await this.flushPendingSessionRefresh("sse-connected") await fetchAndSendPendingPermissions(this.permissionCtx) await fetchAndSendPendingQuestions(this.questionCtx) - await this.checkConfigWarnings() // kilocode_change } catch (error) { console.error("[Kilo New] KiloProvider: ❌ Failed during connected state handling:", error) this.postMessage({ @@ -1951,7 +1954,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // kilocode_change start /** - * Fetch config warnings from the server and display them as VS Code warning messages. + * Fetch config warnings from the server and display a single consolidated + * VS Code warning with a "Show Details" action button. * Only shown once per provider lifecycle (flag resets on dispose/re-create, not on SSE reconnect). */ private async checkConfigWarnings(): Promise { @@ -1962,11 +1966,23 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const list = result?.data ?? [] if (list.length === 0) return this.configWarningsShown = true - for (const warning of list) { - vscode.window.showWarningMessage(`Config: ${warning.message}`) + + const first = list[0]! + const summary = list.length === 1 ? first.message : `${first.message} (and ${list.length - 1} more)` + + const action = await vscode.window.showWarningMessage(`Config: ${summary}`, "Show Details") + if (action === "Show Details") { + const lines = list.map((w) => { + const base = `${w.path}\n ${w.message}` + return w.detail ? `${base}\n ${w.detail}` : base + }) + const channel = vscode.window.createOutputChannel("Kilo Config Warnings") + channel.clear() + channel.appendLine(lines.join("\n\n")) + channel.show() } - } catch { - // Endpoint may not exist on older CLI versions — silently ignore + } catch (err) { + console.warn("[Kilo New] KiloProvider: checkConfigWarnings failed:", err) } } // kilocode_change end diff --git a/packages/opencode/src/cli/cmd/config.ts b/packages/opencode/src/cli/cmd/config.ts new file mode 100644 index 00000000000..746946bff81 --- /dev/null +++ b/packages/opencode/src/cli/cmd/config.ts @@ -0,0 +1,40 @@ +// kilocode_change - new file +import { EOL } from "os" +import { Config } from "../../config/config" +import { bootstrap } from "../bootstrap" +import { cmd } from "./cmd" +import { UI } from "../ui" + +export const ConfigCommand = cmd({ + command: "config", + describe: "configuration tools", + builder: (yargs) => + yargs + .command({ + command: "check", + describe: "check configuration for warnings and errors", + async handler() { + await bootstrap(process.cwd(), async () => { + const list = await Config.warnings() + if (list.length === 0) { + process.stdout.write("No config warnings." + EOL) + return + } + const S = UI.Style + for (const warning of list) { + process.stderr.write(S.TEXT_WARNING_BOLD + warning.path + S.TEXT_NORMAL + EOL) + process.stderr.write(" " + warning.message + EOL) + if (warning.detail) { + for (const line of warning.detail.split("\n")) { + process.stderr.write(" " + S.TEXT_DIM + line + S.TEXT_NORMAL + EOL) + } + } + process.stderr.write(EOL) + } + process.exitCode = 1 + }) + }, + }) + .demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 68660bb9ed1..f8935c321bb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -636,6 +636,7 @@ export function Prompt(props: PromptProps) { }) .catch(() => {}) } + toast.dismiss() // kilocode_change - dismiss persistent config warning on first submit history.append({ ...store.prompt, mode: currentMode, diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 36119ccd7a2..aa4766bf959 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -466,7 +466,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))), sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))), syncWorkspaces(), - // kilocode_change start - show config warnings as toasts + // kilocode_change start - show config warnings as persistent toast sdk.client.config .warnings() .then((x) => { @@ -478,7 +478,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ title: "Config Warning", message: first.message + suffix, variant: "warning", - duration: 8000, + duration: 0, }) }) .catch(() => {}), diff --git a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx index 36095580fb0..32b8deac14d 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx @@ -60,9 +60,18 @@ function init() { const { duration, ...currentToast } = parsedOptions setStore("currentToast", currentToast) if (timeoutHandle) clearTimeout(timeoutHandle) - timeoutHandle = setTimeout(() => { - setStore("currentToast", null) - }, duration).unref() + timeoutHandle = null + if (duration && duration > 0) { + timeoutHandle = setTimeout(() => { + setStore("currentToast", null) + timeoutHandle = null + }, duration).unref() + } + }, + dismiss() { + if (timeoutHandle) clearTimeout(timeoutHandle) + timeoutHandle = null + setStore("currentToast", null) }, error: (err: any) => { if (err instanceof Error) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 152fb3fe21b..36d1b550559 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -56,6 +56,7 @@ export namespace Config { export const Warning = z.object({ path: z.string(), message: z.string(), + detail: z.string().optional(), }) export type Warning = z.infer // kilocode_change end @@ -527,8 +528,8 @@ export namespace Config { if (ConfigPaths.JsonError.isInstance(err)) return { path: err.data.path, - message: - `Config file at ${err.data.path} is not valid JSON(C)` + (err.data.message ? `: ${err.data.message}` : ""), + message: `Config file at ${err.data.path} is not valid JSON(C)`, + detail: err.data.message || undefined, } if (ConfigPaths.InvalidError.isInstance(err)) { const text = err.data.issues ? detail(err.data.issues) : err.data.message @@ -560,9 +561,9 @@ export namespace Config { warnings?: Warning[], ) { const text = detail(issues) - const message = text ? `Config file at ${item} is invalid: ${text}` : `Config file at ${item} is invalid` + const message = `Config file at ${item} is invalid` const err = new InvalidError({ path: item, issues }, { cause }) - if (warnings) warnings.push({ path: item, message }) + if (warnings) warnings.push({ path: item, message, detail: text || undefined }) const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) if (kind === "command") { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 688bc25b742..0b5bc16bc36 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -15,6 +15,7 @@ import { FormatError } from "./cli/error" import { ServeCommand } from "./cli/cmd/serve" import { WorkspaceServeCommand } from "./cli/cmd/workspace-serve" import { Filesystem } from "./util/filesystem" +import { ConfigCommand as ConfigCLICommand } from "./cli/cmd/config" // kilocode_change import { DebugCommand } from "./cli/cmd/debug" import { StatsCommand } from "./cli/cmd/stats" import { McpCommand } from "./cli/cmd/mcp" @@ -195,6 +196,7 @@ let cli = yargs(hideBin(process.argv)) .command(SessionCommand) .command(RemoteCommand) // kilocode_change .command(DbCommand) + .command(ConfigCLICommand) // kilocode_change if (Installation.isLocal()) { cli = cli.command(WorkspaceServeCommand) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e8f9bd24bfc..44dfca17bb5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2479,6 +2479,7 @@ export type ConfigWarningsResponses = { 200: Array<{ path: string message: string + detail?: string }> } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index b8192c91aa8..65994bb0b9e 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1008,6 +1008,9 @@ }, "message": { "type": "string" + }, + "detail": { + "type": "string" } }, "required": ["path", "message"] From 5accaead16bd807aa2af4a8d894b57f70f3678b1 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 17:57:10 +0300 Subject: [PATCH 18/55] fix(cli): include detail in validation error --- packages/opencode/src/config/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 36d1b550559..16800e74f34 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -561,7 +561,7 @@ export namespace Config { warnings?: Warning[], ) { const text = detail(issues) - const message = `Config file at ${item} is invalid` + const message = text ? `Config file at ${item} is invalid: ${text}` : `Config file at ${item} is invalid` const err = new InvalidError({ path: item, issues }, { cause }) if (warnings) warnings.push({ path: item, message, detail: text || undefined }) const { Session } = await import("@/session") From f0a30751b835c5f2dfe95506adcbca62a8e4756c Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 17:57:34 +0300 Subject: [PATCH 19/55] fix(cli): let managed config errors propagate --- packages/opencode/src/config/config.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 16800e74f34..794e034e9d2 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -363,11 +363,7 @@ export namespace Config { // kilocode_change start for (const file of ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"]) { // kilocode_change end - try { - result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file))) - } catch (err) { - caught(err, path.join(managedDir, file)) - } + result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file))) } } From 0de8282c04377ea562998720ccd8e0aceb29bdb6 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 17:58:24 +0300 Subject: [PATCH 20/55] style(vscode): remove kilocode_change markers --- packages/kilo-vscode/src/KiloProvider.ts | 33 +++++++++++++++++------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 55c2408bb0b..cd1019b2a93 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -124,7 +124,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private cachedMcpStatusMessage: unknown = null /** Ref-count of in-flight handleUpdateConfig calls; prevents fetchAndSendConfig from sending stale data */ private pending = 0 - private configWarningsShown = false // kilocode_change - prevent repeated config warnings on SSE reconnect + private configWarningsShown = false /** Cached notificationsLoaded payload */ private cachedNotificationsMessage: unknown = null private pendingReviewComments: { comments: unknown[]; autoSend: boolean }[] = [] @@ -1048,10 +1048,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage({ type: "connectionState", state }) if (state === "connected") { - // kilocode_change start - fire config warnings independently so a failure in the + // Fire config warnings independently so a failure in the // sequential await chain doesn't prevent warnings from being shown - void this.checkConfigWarnings() - // kilocode_change end + void this.checkConfigWarnings("state") try { // Profile fetch is best-effort — returns 401 when user isn't logged into gateway. const sdkClient = this.client @@ -1127,6 +1126,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } this.postMessage({ type: "connectionState", state: this.connectionState }) + + // connect() can resolve after SSE reaches "connected" but before this + // provider subscribes to onStateChange(). In that case the initial + // connected callback is missed, so run the warning check here too. + if (this.connectionState === "connected") { + void this.checkConfigWarnings("init") + } + await this.syncWebviewState("initializeConnection") await this.flushPendingSessionRefresh("initializeConnection") @@ -1952,23 +1959,32 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - // kilocode_change start /** * Fetch config warnings from the server and display a single consolidated * VS Code warning with a "Show Details" action button. * Only shown once per provider lifecycle (flag resets on dispose/re-create, not on SSE reconnect). */ - private async checkConfigWarnings(): Promise { - if (this.configWarningsShown || !this.client) return + private async checkConfigWarnings(from: string): Promise { + if (this.configWarningsShown) { + console.log("[Kilo New] KiloProvider: config warnings already shown", { from }) + return + } + if (!this.client) { + console.log("[Kilo New] KiloProvider: config warnings skipped (no client)", { from }) + return + } try { const dir = this.getWorkspaceDirectory() + console.log("[Kilo New] KiloProvider: checking config warnings", { from, dir }) const result = await this.client.config.warnings({ directory: dir }) const list = result?.data ?? [] + console.log("[Kilo New] KiloProvider: config warnings fetched", { from, count: list.length }) if (list.length === 0) return this.configWarningsShown = true const first = list[0]! const summary = list.length === 1 ? first.message : `${first.message} (and ${list.length - 1} more)` + console.warn("[Kilo New] KiloProvider: showing config warnings", { from, count: list.length, path: first.path }) const action = await vscode.window.showWarningMessage(`Config: ${summary}`, "Show Details") if (action === "Show Details") { @@ -1982,10 +1998,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper channel.show() } } catch (err) { - console.warn("[Kilo New] KiloProvider: checkConfigWarnings failed:", err) + console.warn("[Kilo New] KiloProvider: checkConfigWarnings failed:", { from, err }) } } - // kilocode_change end /** * Fetch Kilo news/notifications and send to webview. From 5d184aba94faec942e36a64aef221044f7340605 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 18:01:37 +0300 Subject: [PATCH 21/55] style(cli): fix kilocode_change markers --- packages/opencode/src/config/config.ts | 30 +++++++++++++++++++------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 794e034e9d2..eb0e363e05a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -221,6 +221,7 @@ export namespace Config { for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { const url = key.replace(/\/+$/, "") + // kilocode_change start const source = `${url}/.well-known/opencode` try { process.env[value.key] = value.token @@ -232,7 +233,7 @@ export namespace Config { const wellknown = (await response.json()) as any const remoteConfig = wellknown.config ?? {} // Add $schema to prevent load() from trying to write back to a non-existent file - if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json" // kilocode_change + if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json" result = mergeConfigConcatArrays( result, await load(JSON.stringify(remoteConfig), { @@ -246,6 +247,7 @@ export namespace Config { if (w) warnings.push(w) else warnings.push({ path: source, message: err instanceof Error ? err.message : String(err) }) log.warn("skipped remote config due to error", { url, err }) + // kilocode_change end } } } @@ -255,33 +257,37 @@ export namespace Config { } // Global user config overrides remote config. + // kilocode_change start try { result = mergeConfigConcatArrays(result, await global()) } catch (err) { caught(err, "global config") } + // kilocode_change end // Custom config path overrides global config. if (Flag.KILO_CONFIG) { + // kilocode_change start try { result = mergeConfigConcatArrays(result, await loadFile(Flag.KILO_CONFIG)) log.debug("loaded custom config", { path: Flag.KILO_CONFIG }) } catch (err) { caught(err, Flag.KILO_CONFIG) } + // kilocode_change end } // Project config overrides global and remote config. if (!Flag.KILO_DISABLE_PROJECT_CONFIG) { // kilocode_change start for (const file of ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"]) { - // kilocode_change end try { result = mergeConfigConcatArrays(result, await loadFile(file)) } catch (err) { caught(err, file) } } + // kilocode_change end } result.agent = result.agent || {} @@ -306,7 +312,6 @@ export namespace Config { dir === Flag.KILO_CONFIG_DIR ) { for (const file of ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"]) { - // kilocode_change end log.debug(`loading config from ${path.join(dir, file)}`) try { result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file))) @@ -318,6 +323,7 @@ export namespace Config { result.mode ??= {} result.plugin ??= [] } + // kilocode_change end } deps.push( @@ -341,6 +347,7 @@ export namespace Config { // Inline config content overrides all non-managed config sources. if (process.env.KILO_CONFIG_CONTENT) { + // kilocode_change start try { result = mergeConfigConcatArrays( result, @@ -353,6 +360,7 @@ export namespace Config { } catch (err) { caught(err, "KILO_CONFIG_CONTENT") } + // kilocode_change end } // Load managed config files last (highest priority) - enterprise admin-controlled @@ -362,9 +370,9 @@ export namespace Config { if (existsSync(managedDir)) { // kilocode_change start for (const file of ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"]) { - // kilocode_change end result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file))) } + // kilocode_change end } // Migrate deprecated mode field to agent field @@ -416,7 +424,7 @@ export namespace Config { config: result, directories, deps, - warnings, + warnings, // kilocode_change } } // kilocode_change start — create state from named init so resetState() can invalidate it @@ -570,7 +578,9 @@ export namespace Config { } // kilocode_change end + // kilocode_change start async function loadCommand(dir: string, warnings?: Warning[]) { + // kilocode_change end const result: Record = {} for (const item of await Glob.scan("{command,commands}/**/*.md", { cwd: dir, @@ -582,7 +592,7 @@ export namespace Config { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse command ${item}` - if (warnings) warnings.push({ path: item, message }) + if (warnings) warnings.push({ path: item, message }) // kilocode_change const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load command", { command: item, err }) @@ -620,7 +630,9 @@ export namespace Config { return result } + // kilocode_change start async function loadAgent(dir: string, warnings?: Warning[]) { + // kilocode_change end const result: Record = {} for (const item of await Glob.scan("{agent,agents}/**/*.md", { @@ -633,7 +645,7 @@ export namespace Config { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse agent ${item}` - if (warnings) warnings.push({ path: item, message }) + if (warnings) warnings.push({ path: item, message }) // kilocode_change const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load agent", { agent: item, err }) @@ -673,7 +685,9 @@ export namespace Config { return result } + // kilocode_change start async function loadMode(dir: string, warnings?: Warning[]) { + // kilocode_change end const result: Record = {} for (const item of await Glob.scan("{mode,modes}/*.md", { cwd: dir, @@ -685,7 +699,7 @@ export namespace Config { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse mode ${item}` - if (warnings) warnings.push({ path: item, message }) + if (warnings) warnings.push({ path: item, message }) // kilocode_change const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load mode", { mode: item, err }) From 2ca9542254bdd2aa9b29110a77e65c68c88cbc4b Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 7 Apr 2026 18:06:48 +0300 Subject: [PATCH 22/55] fix(cli): guard session import in config error handlers --- packages/opencode/src/config/config.ts | 32 ++++++++++++---- .../test/kilocode/config-resilience.test.ts | 37 +++---------------- 2 files changed, 30 insertions(+), 39 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index eb0e363e05a..f7cb7268915 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -568,8 +568,12 @@ export namespace Config { const message = text ? `Config file at ${item} is invalid: ${text}` : `Config file at ${item} is invalid` const err = new InvalidError({ path: item, issues }, { cause }) if (warnings) warnings.push({ path: item, message, detail: text || undefined }) - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + try { + const { Session } = await import("@/session") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + } catch (e) { + log.warn("could not publish session error", { message, err: e }) + } if (kind === "command") { log.error("failed to load command", { command: item, err, message }) return @@ -593,8 +597,12 @@ export namespace Config { ? err.data.message : `Failed to parse command ${item}` if (warnings) warnings.push({ path: item, message }) // kilocode_change - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + try { + const { Session } = await import("@/session") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + } catch (e) { + log.warn("could not publish session error", { message, err: e }) + } log.error("failed to load command", { command: item, err }) return undefined }) @@ -646,8 +654,12 @@ export namespace Config { ? err.data.message : `Failed to parse agent ${item}` if (warnings) warnings.push({ path: item, message }) // kilocode_change - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + try { + const { Session } = await import("@/session") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + } catch (e) { + log.warn("could not publish session error", { message, err: e }) + } log.error("failed to load agent", { agent: item, err }) return undefined }) @@ -700,8 +712,12 @@ export namespace Config { ? err.data.message : `Failed to parse mode ${item}` if (warnings) warnings.push({ path: item, message }) // kilocode_change - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + try { + const { Session } = await import("@/session") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + } catch (e) { + log.warn("could not publish session error", { message, err: e }) + } log.error("failed to load mode", { mode: item, err }) return undefined }) diff --git a/packages/opencode/test/kilocode/config-resilience.test.ts b/packages/opencode/test/kilocode/config-resilience.test.ts index 7e48d68dec9..5cb72a91385 100644 --- a/packages/opencode/test/kilocode/config-resilience.test.ts +++ b/packages/opencode/test/kilocode/config-resilience.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import path from "path" -import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" @@ -47,7 +46,7 @@ Valid agent prompt`, }) }) - test("publishes an error for invalid agent markdown configs", async () => { + test("reports a warning for invalid agent markdown configs", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Filesystem.write( @@ -63,22 +62,10 @@ Broken agent prompt`, await Instance.provide({ directory: tmp.path, fn: async () => { - const seen: Array<{ type: string; properties: { error: { name: string; data: { message: string } } } }> = [] - const unsub = Bus.subscribeAll((event) => { - if (event.type === "session.error") seen.push(event) - }) - await Config.get() - unsub() + const warns = await Config.warnings() - expect( - seen.some( - (item) => - item.properties.error.name === "UnknownError" && - item.properties.error.data.message.includes("skip.md") && - item.properties.error.data.message.includes("mode"), - ), - ).toBe(true) + expect(warns.some((w) => w.path.includes("skip.md") && w.message.includes("mode"))).toBe(true) }, }) }) @@ -117,7 +104,7 @@ Valid command template`, }) }) - test("publishes an error for invalid command markdown configs", async () => { + test("reports a warning for invalid command markdown configs", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Filesystem.write( @@ -133,22 +120,10 @@ Broken command template`, await Instance.provide({ directory: tmp.path, fn: async () => { - const seen: Array<{ type: string; properties: { error: { name: string; data: { message: string } } } }> = [] - const unsub = Bus.subscribeAll((event) => { - if (event.type === "session.error") seen.push(event) - }) - await Config.get() - unsub() + const warns = await Config.warnings() - expect( - seen.some( - (item) => - item.properties.error.name === "UnknownError" && - item.properties.error.data.message.includes("skip.md") && - item.properties.error.data.message.includes("subtask"), - ), - ).toBe(true) + expect(warns.some((w) => w.path.includes("skip.md") && w.message.includes("subtask"))).toBe(true) }, }) }) From 5650850aa6681aa98430dc69a7336add4e2dfa02 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Tue, 7 Apr 2026 16:19:27 +0000 Subject: [PATCH 23/55] release: v7.1.23 --- bun.lock | 32 +++++++++++++------------- package.json | 2 +- packages/app/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/kilo-docs/package.json | 2 +- packages/kilo-gateway/package.json | 2 +- packages/kilo-i18n/package.json | 2 +- packages/kilo-telemetry/package.json | 2 +- packages/kilo-ui/package.json | 2 +- packages/kilo-vscode/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/script/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/storybook/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- script/upstream/package.json | 2 +- sdks/vscode/package.json | 2 +- 21 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index 2665ef50623..eeaccd23638 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -79,7 +79,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -112,7 +112,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -142,7 +142,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -171,7 +171,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@ai-sdk/anthropic": "2.0.65", "@ai-sdk/openai": "2.0.101", @@ -206,7 +206,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.1.22", + "version": "7.1.23", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -219,7 +219,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "@opentelemetry/api": "1.9.0", @@ -239,7 +239,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/util": "workspace:*", @@ -274,7 +274,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", @@ -327,7 +327,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.1.22", + "version": "7.1.23", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -451,7 +451,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@kilocode/sdk": "workspace:*", "zod": "catalog:", @@ -465,14 +465,14 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.1.22", + "version": "7.1.23", "devDependencies": { "@types/bun": "catalog:", }, }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.1.22", + "version": "7.1.23", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -483,7 +483,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.1.22", + "version": "7.1.23", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -506,7 +506,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -553,7 +553,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "zod": "catalog:", }, diff --git a/package.json b/package.json index 12e9faefbcf..7c626177b8e 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,6 @@ "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch", "ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch" }, - "version": "7.1.22", + "version": "7.1.23", "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index 5805d8eaf40..9ea7ae31522 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "7.1.22", + "version": "7.1.23", "description": "", "type": "module", "exports": { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 0507a2b7b24..1bd4786dac6 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "7.1.22", + "version": "7.1.23", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index ece81c56afa..83a34dc8782 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "7.1.22", + "version": "7.1.23", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 676bc0335b6..e2b800dc95a 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.1.22" +version = "7.1.23" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.23/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.23/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.23/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.23/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.23/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index fab9fae0df6..f3b83f7590c 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.1.22", + "version": "7.1.23", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 1eb86997edf..ffca4a9415f 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.1.22", + "version": "7.1.23", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 02faf1a7a8f..83091e58381 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.1.22", + "version": "7.1.23", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index ba725ee81c4..6bed2eac5d5 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.1.22", + "version": "7.1.23", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index c09afb6476f..327a1083c71 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.1.22", + "version": "7.1.23", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 09cbad788a3..44ef0219c8e 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.1.22", + "version": "7.1.23", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 1449cda2dd2..c2409c27264 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.1.22", + "version": "7.1.23", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 75d2c26d6b4..cf439ced8aa 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.1.22", + "version": "7.1.23", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index ec4fd0a3e4b..c951dcd5687 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -8,7 +8,7 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.1.22", + "version": "7.1.23", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index be174960413..969a6d0aa44 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.1.22", + "version": "7.1.23", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 406ac86df58..c874f5501d4 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.1.22", + "version": "7.1.23", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index c9110706fca..6535b8dcf0f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.1.22", + "version": "7.1.23", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index b8d00e59a36..ed93fce66e7 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "7.1.22", + "version": "7.1.23", "private": true, "type": "module", "license": "MIT", diff --git a/script/upstream/package.json b/script/upstream/package.json index facad75d5ae..76643aab7bc 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.1.22", + "version": "7.1.23", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index fe9c979ed85..12b653118d0 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "7.1.22", + "version": "7.1.23", "publisher": "sst-dev", "repository": { "type": "git", From afe950dc0c6820317975ca1da44f73b38ecd23b0 Mon Sep 17 00:00:00 2001 From: Johnny Amancio Date: Mon, 6 Apr 2026 22:47:33 +0200 Subject: [PATCH 24/55] perf(snapshot): add mutex lock, incremental add, and batched revert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port snapshot optimizations from upstream OpenCode to reduce git process spawning and prevent concurrent corruption: - Per-gitdir mutex lock (upstream #17878, v1.3.0): serializes concurrent snapshot operations to prevent race conditions - Incremental git add (upstream #17878, v1.3.0): replaces naive `git add .` with diff-files + ls-files + size filtering, skipping files >2MB - Batched revert (upstream #20564, v1.3.14): groups up to 100 files per git checkout call with path-conflict detection, falling back to single-file revert on failure Benchmarks (200 files): - revert: 2,851ms → 191ms (15x faster, 8x fewer git processes) - concurrent track: 2/5 succeed → 5/5 succeed (corruption fixed) --- packages/opencode/src/snapshot/index.ts | 228 ++++++++-- .../opencode/test/snapshot/snapshot.test.ts | 411 ++++++++++++++++++ 2 files changed, 599 insertions(+), 40 deletions(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 7764b874a0c..0a38ddb70b9 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -10,12 +10,14 @@ import { Config } from "../config/config" import { Instance } from "../project/instance" import { Scheduler } from "../scheduler" import * as KiloSnapshot from "../kilocode/snapshot" // kilocode_change +import { Lock } from "../util/lock" // kilocode_change export namespace Snapshot { const log = Log.create({ service: "snapshot" }) const hour = 60 * 60 * 1000 const prune = "7.days" export const MAX_DIFF_SIZE = 256 * 1024 // kilocode_change + const MAX_SNAPSHOT_FILE_SIZE = 2 * 1024 * 1024 // kilocode_change — skip files >2MB during snapshot add export function init() { Scheduler.register({ @@ -36,6 +38,7 @@ export namespace Snapshot { .then(() => true) .catch(() => false) if (!exists) return + using _lock = await Lock.write(git) // kilocode_change const result = await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}` .quiet() @@ -57,6 +60,7 @@ export namespace Snapshot { const cfg = await Config.get() if (cfg.snapshot === false) return const git = await KiloSnapshot.prepare() // kilocode_change + using _lock = await Lock.write(git) // kilocode_change await add(git) const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree` .quiet() @@ -75,6 +79,7 @@ export namespace Snapshot { export async function patch(hash: string): Promise { const git = await KiloSnapshot.prepare() // kilocode_change + using _lock = await Lock.write(git) // kilocode_change await add(git) const result = await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .` @@ -103,6 +108,7 @@ export namespace Snapshot { export async function restore(snapshot: string) { log.info("restore", { commit: snapshot }) const git = await KiloSnapshot.prepare() // kilocode_change + using _lock = await Lock.write(git) // kilocode_change const result = await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f` .quiet() @@ -119,41 +125,136 @@ export namespace Snapshot { } } - export async function revert(patches: Patch[]) { - const files = new Set() - for (const item of patches) { - const git = await KiloSnapshot.prepare() // kilocode_change - for (const file of item.files) { - if (files.has(file)) continue - log.info("reverting", { file, hash: item.hash }) - const result = - await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}` - .quiet() - .cwd(Instance.worktree) - .nothrow() - if (result.exitCode !== 0) { - const relativePath = path.relative(Instance.worktree, file) - const checkTree = - await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}` - .quiet() - .cwd(Instance.worktree) - .nothrow() - if (checkTree.exitCode === 0 && checkTree.text().trim()) { - log.info("file existed in snapshot but checkout failed, keeping", { - file, - }) - } else { - log.info("file did not exist in snapshot, deleting", { file }) - await fs.unlink(file).catch(() => {}) - } - } - files.add(file) + // kilocode_change start — batched revert: group up to 100 files per git checkout (port of upstream #20564) + type RevertOp = { hash: string; file: string; rel: string } + + /** Revert a single file: checkout from snapshot or delete if it didn't exist. */ + async function revertSingle(git: string, worktree: string, op: RevertOp) { + log.info("reverting", { file: op.file, hash: op.hash }) + const result = + await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${worktree} checkout ${op.hash} -- ${op.file}` + .quiet() + .cwd(worktree) + .nothrow() + if (result.exitCode === 0) return + const tree = + await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${worktree} ls-tree ${op.hash} -- ${op.rel}` + .quiet() + .cwd(worktree) + .nothrow() + if (tree.exitCode === 0 && tree.text().trim()) { + log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash }) + return + } + log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) + await fs.unlink(op.file).catch(() => {}) + } + + /** Revert a batch of files sharing the same hash. Falls back to single-file on failure. */ + async function revertBatch(git: string, worktree: string, batch: RevertOp[]) { + const hash = batch[0]!.hash + + // Check which files exist in the snapshot + const tree = + await $`git -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${worktree} ls-tree --name-only ${hash} -- ${batch.map((op) => op.rel)}` + .quiet() + .cwd(worktree) + .nothrow() + + if (tree.exitCode !== 0) { + log.info("batched ls-tree failed, falling back to single-file revert", { hash, files: batch.length }) + for (const op of batch) await revertSingle(git, worktree, op) + return + } + + const existing = new Set(tree.text().trim().split("\n").map((l) => l.trim()).filter(Boolean)) + + // Checkout files that exist in the snapshot + const toCheckout = batch.filter((op) => existing.has(op.rel)) + if (toCheckout.length) { + log.info("reverting", { hash, files: toCheckout.length }) + const result = + await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${worktree} checkout ${hash} -- ${toCheckout.map((op) => op.file)}` + .quiet() + .cwd(worktree) + .nothrow() + if (result.exitCode !== 0) { + log.info("batched checkout failed, falling back to single-file revert", { hash, files: toCheckout.length }) + for (const op of batch) await revertSingle(git, worktree, op) + return } } + + // Delete files that didn't exist in the snapshot + for (const op of batch) { + if (existing.has(op.rel)) continue + log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) + await fs.unlink(op.file).catch(() => {}) + } } + /** True when one path is a parent of the other (e.g. "a/b" and "a/b/c"). */ + function pathsClash(a: string, b: string) { + return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`) + } + + /** Can this op be added to the current batch? */ + function canBatch(batch: RevertOp[], op: RevertOp): boolean { + if (batch.length >= 100) return false + if (op.hash !== batch[0]!.hash) return false + if (batch.some((existing) => pathsClash(existing.rel, op.rel))) return false + return true + } + + /** + * Group consecutive ops into batches that share the same hash, + * have no path conflicts, and contain at most 100 files each. + */ + function groupIntoBatches(ops: RevertOp[]): RevertOp[][] { + const batches: RevertOp[][] = [] + let batch: RevertOp[] = [] + + for (const op of ops) { + if (batch.length > 0 && !canBatch(batch, op)) { + batches.push(batch) + batch = [] + } + batch.push(op) + } + if (batch.length > 0) batches.push(batch) + + return batches + } + + export async function revert(patches: Patch[]) { + const git = await KiloSnapshot.prepare() // kilocode_change + using _lock = await Lock.write(git) // kilocode_change + const worktree = Instance.worktree + + // Deduplicate files preserving patch order + const ops: RevertOp[] = [] + const seen = new Set() + for (const item of patches) { + for (const file of item.files) { + if (seen.has(file)) continue + seen.add(file) + ops.push({ hash: item.hash, file, rel: path.relative(worktree, file).replaceAll("\\", "/") }) + } + } + + for (const batch of groupIntoBatches(ops)) { + if (batch.length === 1) { + await revertSingle(git, worktree, batch[0]!) + } else { + await revertBatch(git, worktree, batch) + } + } + } + // kilocode_change end + export async function diff(hash: string) { const git = await KiloSnapshot.prepare() // kilocode_change + using _lock = await Lock.write(git) // kilocode_change await add(git) const result = await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .` @@ -208,10 +309,11 @@ export namespace Snapshot { diffCache.set(key, pending) return pending } + // kilocode_change end async function diffFullUncached(from: string, to: string): Promise { const git = await KiloSnapshot.prepare() // kilocode_change - // kilocode_change end + using _lock = await Lock.write(git) // kilocode_change const result: FileDiff[] = [] const status = new Map() @@ -277,26 +379,72 @@ export namespace Snapshot { return KiloSnapshot.gitdir() // kilocode_change } + // kilocode_change start — incremental add: diff-files + ls-files + size filter (port of upstream #17878) async function add(git: string) { - await syncExclude(git) - await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} add .` + const cwd = Instance.directory + const worktree = Instance.worktree + + // Run diff-files and ls-files concurrently to find changed + untracked files + const [diffResult, otherResult] = await Promise.all([ + $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${worktree} diff-files --name-only -z -- .` + .quiet() + .cwd(cwd) + .nothrow(), + $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${worktree} ls-files --others --exclude-standard -z -- .` + .quiet() + .cwd(cwd) + .nothrow(), + ]) + + if (diffResult.exitCode !== 0 || otherResult.exitCode !== 0) { + log.warn("failed to list snapshot files", { + diffCode: diffResult.exitCode, + diffStderr: diffResult.stderr.toString(), + otherCode: otherResult.exitCode, + otherStderr: otherResult.stderr.toString(), + }) + return + } + + const tracked = diffResult.text().split("\0").filter(Boolean) + const all = Array.from(new Set([...tracked, ...otherResult.text().split("\0").filter(Boolean)])) + if (!all.length) { + await syncExclude(git) + return + } + + // Filter out oversized files (>2MB) + const large = ( + await Promise.all( + all.map(async (item) => { + const stat = await fs.stat(path.join(cwd, item)).catch(() => null) + return stat?.isFile() && stat.size > MAX_SNAPSHOT_FILE_SIZE ? item : undefined + }), + ) + ).filter(Boolean) as string[] + + await syncExclude(git, large) + await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${worktree} add --sparse .` .quiet() - .cwd(Instance.directory) + .cwd(cwd) .nothrow() } - async function syncExclude(git: string) { + async function syncExclude(git: string, largeFiles: string[] = []) { const file = await excludes() const target = path.join(git, "info", "exclude") await fs.mkdir(path.join(git, "info"), { recursive: true }) - if (!file) { - await Filesystem.write(target, "") - return + const parts: string[] = [] + if (file) { + const text = await Filesystem.readText(file).catch(() => "") + if (text.trim()) parts.push(text.trimEnd()) } - const text = await Filesystem.readText(file).catch(() => "") - - await Filesystem.write(target, text) + for (const item of largeFiles) { + parts.push(`/${item.replaceAll("\\", "/")}`) + } + await Filesystem.write(target, parts.length ? parts.join("\n") + "\n" : "") } + // kilocode_change end async function excludes() { const file = await $`git rev-parse --path-format=absolute --git-path info/exclude` diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 1804ab5c2a2..e70ce1d2eed 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1178,3 +1178,414 @@ test("diffFull with whitespace changes", async () => { }, }) }) + +// ── Tests for snapshot optimizations (upstream #17878, #20564) ──────── + +test("concurrent track() calls return consistent results", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Snapshot.track() + + await Filesystem.write(`${tmp.path}/a.txt`, "concurrent-change") + + const results = await Promise.all([ + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + ]) + + const hashes = results.filter(Boolean) + expect(hashes.length).toBe(5) + // All concurrent calls must return the same hash + expect(new Set(hashes).size).toBe(1) + }, + }) +}) + +test("batch revert with many files sharing same hash", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + // Create enough files to trigger batching (>1 per hash) + for (let i = 0; i < 20; i++) { + await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Modify all 20 files + for (let i = 0; i < 20; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `changed-${i}`) + } + // Add 5 new files + for (let i = 0; i < 5; i++) { + await Filesystem.write(`${tmp.path}/new${i}.txt`, `new-${i}`) + } + + const patch = await Snapshot.patch(before!) + expect(patch.files.length).toBe(25) + + await Snapshot.revert([patch]) + + // Modified files should be restored + for (let i = 0; i < 20; i++) { + const content = await Filesystem.readText(`${tmp.path}/file${i}.txt`) + expect(content).toBe(`original-${i}`) + } + // New files should be deleted + for (let i = 0; i < 5; i++) { + expect( + await fs + .access(`${tmp.path}/new${i}.txt`) + .then(() => true) + .catch(() => false), + ).toBe(false) + } + }, + }) +}) + +test("batch revert with files in nested subdirectories", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await fs.mkdir(`${dir}/a/b/c`, { recursive: true }) + await fs.mkdir(`${dir}/d/e`, { recursive: true }) + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${dir}/a/b/c/file${i}.txt`, `deep-${i}`) + await Filesystem.write(`${dir}/d/e/file${i}.txt`, `other-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${tmp.path}/a/b/c/file${i}.txt`, `modified-${i}`) + await Filesystem.write(`${tmp.path}/d/e/file${i}.txt`, `modified-${i}`) + } + + await Snapshot.revert([await Snapshot.patch(before!)]) + + for (let i = 0; i < 10; i++) { + expect(await Filesystem.readText(`${tmp.path}/a/b/c/file${i}.txt`)).toBe(`deep-${i}`) + expect(await Filesystem.readText(`${tmp.path}/d/e/file${i}.txt`)).toBe(`other-${i}`) + } + }, + }) +}) + +test("incremental add skips new files larger than 2MB", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Create a file larger than 2MB + const largeContent = "x".repeat(3 * 1024 * 1024) + await Filesystem.write(`${tmp.path}/large.bin`, largeContent) + // Also create a small file + await Filesystem.write(`${tmp.path}/small.txt`, "small change") + + const after = await Snapshot.track() + expect(after).toBeTruthy() + + const patch = await Snapshot.patch(before!) + // Small file should be tracked, large file should be excluded + const files = patch.files.map((f) => path.basename(f)) + expect(files).toContain("small.txt") + expect(files).not.toContain("large.bin") + }, + }) +}) + +test("incremental add: tracked files that grow past 2MB are still visible in patch", async () => { + // The 2MB size filter only prevents NEW large files from being added to the + // snapshot index. Already-tracked files that grow past 2MB still appear in + // patch/diff because those compare against the working tree. This matches + // upstream OpenCode behavior. + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Filesystem.write(`${tmp.path}/growing.txt`, "small") + const before = await Snapshot.track() + expect(before).toBeTruthy() + + await Filesystem.write(`${tmp.path}/growing.txt`, "x".repeat(3 * 1024 * 1024)) + await Filesystem.write(`${tmp.path}/a.txt`, "changed") + + const patch = await Snapshot.patch(before!) + const files = patch.files.map((f) => path.basename(f)) + expect(files).toContain("a.txt") + expect(files).toContain("growing.txt") + }, + }) +}) + +test("concurrent patch() calls return consistent results", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + await Filesystem.write(`${tmp.path}/a.txt`, "changed") + + const results = await Promise.all([ + Snapshot.patch(before!), + Snapshot.patch(before!), + Snapshot.patch(before!), + ]) + + // All should report the same changed files + for (const result of results) { + expect(result.files).toContain(fwd(tmp.path, "a.txt")) + } + }, + }) +}) + +test("batch revert with mix of modified, new, and deleted files", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + for (let i = 0; i < 15; i++) { + await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Modify some files + for (let i = 0; i < 5; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `modified-${i}`) + } + // Delete some files + for (let i = 5; i < 10; i++) { + await fs.unlink(`${tmp.path}/file${i}.txt`) + } + // Add new files + for (let i = 0; i < 5; i++) { + await Filesystem.write(`${tmp.path}/added${i}.txt`, `added-${i}`) + } + + await Snapshot.revert([await Snapshot.patch(before!)]) + + // Modified files restored + for (let i = 0; i < 5; i++) { + expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`original-${i}`) + } + // Deleted files restored + for (let i = 5; i < 10; i++) { + expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`original-${i}`) + } + // Untouched files unchanged + for (let i = 10; i < 15; i++) { + expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`original-${i}`) + } + // New files removed + for (let i = 0; i < 5; i++) { + expect( + await fs + .access(`${tmp.path}/added${i}.txt`) + .then(() => true) + .catch(() => false), + ).toBe(false) + } + }, + }) +}) + +test("batch revert with multiple patches from different snapshots", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${dir}/file${i}.txt`, `v0-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Snapshot A + const snapA = await Snapshot.track() + expect(snapA).toBeTruthy() + + // Modify first 5 files + for (let i = 0; i < 5; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `v1-${i}`) + } + + // Snapshot B (captures v1 state) + const snapB = await Snapshot.track() + expect(snapB).toBeTruthy() + + // Modify last 5 files + for (let i = 5; i < 10; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `v2-${i}`) + } + + // Revert with patches from two different hashes + const patchA = await Snapshot.patch(snapA!) + const patchB = await Snapshot.patch(snapB!) + // patchA covers files 0-4 (changed in v1) + files 5-9 (changed in v2) + // patchB covers files 5-9 (changed in v2) + // Reverting [patchA, patchB]: patchA's hash wins for all files (first seen) + await Snapshot.revert([patchA, patchB]) + + // All files should be at v0 (snapA's state) + for (let i = 0; i < 10; i++) { + expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`v0-${i}`) + } + }, + }) +}) + +test("concurrent track calls each produce a valid snapshot", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Snapshot.track() // warm up + + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `changed-${i}`) + } + + // Fire 5 concurrent tracks, then verify each hash is a usable snapshot + const hashes = (await Promise.all([ + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + ])).filter(Boolean) as string[] + + expect(hashes.length).toBe(5) + + // Every hash should produce a valid diffFull against itself (empty diff) + for (const hash of hashes) { + const diff = await Snapshot.diffFull(hash, hash) + expect(diff).toEqual([]) + } + + // Every hash should be usable for restore without error + await Snapshot.restore(hashes[0]!) + for (let i = 0; i < 10; i++) { + expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`changed-${i}`) + } + }, + }) +}) + +test("track after revert produces clean snapshot", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `changed-${i}`) + } + + await Snapshot.revert([await Snapshot.patch(before!)]) + + // After revert, a new track should match the original snapshot + const after = await Snapshot.track() + expect(after).toBeTruthy() + expect(after).toBe(before) + }, + }) +}) + +test("incremental add tracks newly created files", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Create several new files in subdirectories + await fs.mkdir(`${tmp.path}/newdir/sub`, { recursive: true }) + await Filesystem.write(`${tmp.path}/newdir/one.txt`, "one") + await Filesystem.write(`${tmp.path}/newdir/sub/two.txt`, "two") + await Filesystem.write(`${tmp.path}/three.txt`, "three") + + const patch = await Snapshot.patch(before!) + const files = patch.files.map((f) => path.basename(f)) + expect(files).toContain("one.txt") + expect(files).toContain("two.txt") + expect(files).toContain("three.txt") + }, + }) +}) + +test("incremental add tracks modified and deleted files", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Modify one file, delete the other + await Filesystem.write(`${tmp.path}/a.txt`, "modified-a") + await fs.unlink(`${tmp.path}/b.txt`) + + const patch = await Snapshot.patch(before!) + const files = patch.files.map((f) => path.basename(f)) + expect(files).toContain("a.txt") + expect(files).toContain("b.txt") + }, + }) +}) From ee8c26f80c040ae3320c446b5d5819733a26b1ac Mon Sep 17 00:00:00 2001 From: Imanol Maiztegui Date: Tue, 7 Apr 2026 19:13:31 +0200 Subject: [PATCH 25/55] fix: remove local storage from ignored folder (#8431) --- packages/opencode/src/file/ignore.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/opencode/src/file/ignore.ts b/packages/opencode/src/file/ignore.ts index 225983131c3..b9731040c7d 100644 --- a/packages/opencode/src/file/ignore.ts +++ b/packages/opencode/src/file/ignore.ts @@ -31,8 +31,6 @@ export namespace FileIgnore { "mypy_cache", ".history", ".gradle", - ".kilocode", // kilocode_change — ignore legacy local storage (#8379) - ".opencode", // kilocode_change — ignore legacy local storage (#8379) ]) const FILES = [ From aa34a47ccbd9b8cc19c5794357142e9810536cdd Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 7 Apr 2026 20:52:54 +0200 Subject: [PATCH 26/55] fix(vscode): retry transient fetch failures in session.command and promptAsync (#8525) Recovered from PR #7948 which was lost when kilo-maintainer[bot] force-pushed release v7.1.23 on a stale base. --- packages/kilo-vscode/src/KiloProvider.ts | 42 ++++++---- .../src/services/cli-backend/retry.ts | 34 ++++++++ packages/kilo-vscode/tests/unit/retry.test.ts | 81 +++++++++++++++++++ 3 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 packages/kilo-vscode/src/services/cli-backend/retry.ts create mode 100644 packages/kilo-vscode/tests/unit/retry.test.ts diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 2b153315bb4..18e0205dfc9 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -38,6 +38,7 @@ import { getWorkspaceRoot } from "./review-utils" import { MarketplaceService } from "./services/marketplace" import { resolveProjectDirectory } from "./project-directory" import { getBusySessionCount, seedSessionStatuses } from "./session-status" +import { retry } from "./services/cli-backend/retry" import { slimPart, slimParts } from "./kilo-provider/slim-metadata" import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session" // legacy-migration start @@ -281,7 +282,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Use fire-and-forget (no throwOnError) to match old getProfile() which returned null on error. if (this.connectionState === "connected" && this.client) { console.log("[Kilo New] KiloProvider: 👤 syncWebviewState fetching profile...") - const profileResult = await this.client.kilo.profile() + const profileResult = await retry(() => this.client!.kilo.profile()) const profileData = profileResult.data ?? null console.log("[Kilo New] KiloProvider: 👤 syncWebviewState profile:", profileData ? "received" : "null") this.postMessage({ @@ -1225,9 +1226,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const workspaceDir = this.getWorkspaceDirectory(sessionID) - const { data: messagesData } = await this.client.session.messages( - { sessionID, directory: workspaceDir }, - { throwOnError: true, signal: abort.signal }, + const { data: messagesData } = await retry(() => + this.client!.session.messages( + { sessionID, directory: workspaceDir }, + { throwOnError: true, signal: abort.signal }, + ), ) // If this request was aborted while awaiting, skip posting stale results @@ -1328,9 +1331,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const workspaceDir = this.getWorkspaceDirectory(sessionID) - const { data: messagesData } = await this.client.session.messages( - { sessionID, directory: workspaceDir }, - { throwOnError: true }, + const { data: messagesData } = await retry(() => + this.client!.session.messages({ sessionID, directory: workspaceDir }, { throwOnError: true }), ) const messages = messagesData.map((m) => ({ @@ -1600,7 +1602,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const workspaceDir = this.getWorkspaceDirectory() - const { data: agents } = await this.client.app.agents({ directory: workspaceDir }, { throwOnError: true }) + const { data: agents } = await retry(() => + this.client!.app.agents({ directory: workspaceDir }, { throwOnError: true }), + ) const { visible, defaultAgent } = filterVisibleAgents(agents) @@ -1634,7 +1638,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const workspaceDir = this.getWorkspaceDirectory() - const { data: skills } = await this.client.app.skills({ directory: workspaceDir }, { throwOnError: true }) + const { data: skills } = await retry(() => + this.client!.app.skills({ directory: workspaceDir }, { throwOnError: true }), + ) const message = { type: "skillsLoaded", @@ -1657,7 +1663,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const dir = this.getWorkspaceDirectory() - const { data: commands } = await this.client.command.list({ directory: dir }, { throwOnError: true }) + const { data: commands } = await retry(() => + this.client!.command.list({ directory: dir }, { throwOnError: true }), + ) const message = { type: "commandsLoaded", @@ -1679,7 +1687,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (!this.client) return undefined try { const dir = this.getWorkspaceDirectory() - const { data } = await this.client.app.skills({ directory: dir }, { throwOnError: true }) + const { data } = await retry(() => this.client!.app.skills({ directory: dir }, { throwOnError: true })) return data } catch (error) { console.error("[Kilo New] KiloProvider: Failed to fetch CLI skills for marketplace:", error) @@ -1787,7 +1795,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const directory = this.getWorkspaceDirectory() - const { data } = await this.client.mcp.status({ directory }) + const { data } = await retry(() => this.client!.mcp.status({ directory })) if (data) { const message = { type: "mcpStatusLoaded", status: data } this.cachedMcpStatusMessage = message @@ -1898,7 +1906,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const workspaceDir = this.getWorkspaceDirectory() - const { data: config } = await this.client.config.get({ directory: workspaceDir }, { throwOnError: true }) + const { data: config } = await retry(() => + this.client!.config.get({ directory: workspaceDir }, { throwOnError: true }), + ) const message = { type: "configLoaded", @@ -1945,7 +1955,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (!this.client || this.connectionState !== "connected") return try { const dir = this.getWorkspaceDirectory() - const { data: config } = await this.client.config.get({ directory: dir }, { throwOnError: true }) + const { data: config } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })) this.cachedConfigMessage = { type: "configLoaded", config } this.postMessage({ type: "configUpdated", config }) } catch (error) { @@ -1978,7 +1988,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } try { - const { data: all } = await this.client.kilo.notifications(undefined, { throwOnError: true }) + const { data: all } = await retry(() => this.client!.kilo.notifications(undefined, { throwOnError: true })) const notifications = all.filter((n) => !n.showIn || n.showIn.includes("extension")) const existing = this.extensionContext?.globalState.get("kilo.dismissedNotificationIds", []) ?? [] const active = new Set(notifications.map((n) => n.id)) @@ -2093,7 +2103,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Config.state is reset by updateGlobal (via Instance.resetStateEntry) so // config.get() returns fresh data without a full dispose cycle. const dir = this.getWorkspaceDirectory() - const { data: merged } = await this.client.config.get({ directory: dir }, { throwOnError: true }) + const { data: merged } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })) this.cachedConfigMessage = { type: "configLoaded", config: merged } this.postMessage({ type: "configUpdated", config: merged }) diff --git a/packages/kilo-vscode/src/services/cli-backend/retry.ts b/packages/kilo-vscode/src/services/cli-backend/retry.ts new file mode 100644 index 00000000000..1e3cc054af4 --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/retry.ts @@ -0,0 +1,34 @@ +// Replicated from packages/util/src/retry.ts to avoid adding @opencode-ai/util +// as a dependency of the extension. Keep in sync with the original. + +const TRANSIENT = [ + "load failed", + "network connection was lost", + "network request failed", + "failed to fetch", + "fetch failed", + "econnreset", + "econnrefused", + "etimedout", + "socket hang up", +] + +function transient(error: unknown): boolean { + if (!error) return false + const msg = String(error instanceof Error ? error.message : error).toLowerCase() + return TRANSIENT.some((m) => msg.includes(m)) +} + +export async function retry(fn: () => Promise, attempts = 3, delay = 500): Promise { + let last: unknown + for (let i = 0; i < attempts; i++) { + try { + return await fn() + } catch (error) { + last = error + if (i === attempts - 1 || !transient(error)) throw error + await new Promise((r) => setTimeout(r, delay * 2 ** i)) + } + } + throw last +} diff --git a/packages/kilo-vscode/tests/unit/retry.test.ts b/packages/kilo-vscode/tests/unit/retry.test.ts new file mode 100644 index 00000000000..983d37aa57c --- /dev/null +++ b/packages/kilo-vscode/tests/unit/retry.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "bun:test" +import { retry } from "../../src/services/cli-backend/retry" + +describe("retry", () => { + it("returns on first success", async () => { + const result = await retry(() => Promise.resolve(42)) + expect(result).toBe(42) + }) + + it("retries transient errors and succeeds", async () => { + let calls = 0 + const result = await retry( + () => { + calls++ + if (calls < 3) throw new TypeError("fetch failed") + return Promise.resolve("ok") + }, + 3, + 10, + ) + expect(result).toBe("ok") + expect(calls).toBe(3) + }) + + it("throws immediately on non-transient errors", async () => { + let calls = 0 + await expect( + retry( + () => { + calls++ + throw new Error("404 not found") + }, + 3, + 10, + ), + ).rejects.toThrow("404 not found") + expect(calls).toBe(1) + }) + + it("throws after exhausting attempts on transient errors", async () => { + let calls = 0 + await expect( + retry( + () => { + calls++ + throw new Error("ECONNREFUSED") + }, + 3, + 10, + ), + ).rejects.toThrow("ECONNREFUSED") + expect(calls).toBe(3) + }) + + it("detects all transient error messages", async () => { + const messages = [ + "load failed", + "network connection was lost", + "network request failed", + "failed to fetch", + "fetch failed", + "ECONNRESET", + "ECONNREFUSED", + "ETIMEDOUT", + "socket hang up", + ] + for (const msg of messages) { + let calls = 0 + await retry( + () => { + calls++ + if (calls === 1) throw new Error(msg) + return Promise.resolve(true) + }, + 2, + 10, + ) + expect(calls).toBe(2) + } + }) +}) From 81af73f17777c81ccea658a7bb2f0db6374b1f94 Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 7 Apr 2026 20:53:10 +0200 Subject: [PATCH 27/55] feat(agent-manager): add GitHub PR status badge to worktree sidebar (#8524) * feat(agent-manager): add GitHub PR status badge to worktree sidebar Recovered from PR #8515 which was lost when kilo-maintainer[bot] force-pushed release v7.1.23 on a stale base. Co-authored-by: LLRHook <131478441+LLRHook@users.noreply.github.com> * chore: update kilo-vscode visual regression baselines --------- Co-authored-by: LLRHook <131478441+LLRHook@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .../src/agent-manager/AgentManagerProvider.ts | 20 +- .../src/agent-manager/PRStatusPoller.ts | 486 ++++++++++++++++++ .../src/agent-manager/WorktreeStateManager.ts | 16 + .../kilo-vscode/src/agent-manager/host.ts | 9 + .../src/agent-manager/pr-status-bridge.ts | 110 ++++ .../kilo-vscode/src/agent-manager/types.ts | 72 +++ .../src/agent-manager/vscode-host.ts | 12 +- .../tests/unit/agent-manager-arch.test.ts | 2 +- .../worktree-item-active-chromium-linux.png | 4 +- .../worktree-item-default-chromium-linux.png | 4 +- .../worktree-item-grouped-chromium-linux.png | 4 +- ...ree-item-pending-delete-chromium-linux.png | 4 +- .../worktree-item-stale-chromium-linux.png | 4 +- ...orktree-item-with-stats-chromium-linux.png | 4 +- .../agent-manager/AgentManagerApp.tsx | 100 ++-- .../webview-ui/agent-manager/WorktreeItem.tsx | 325 +++++++----- .../agent-manager/agent-manager.css | 217 ++++++-- .../webview-ui/src/types/messages.ts | 80 +++ 18 files changed, 1270 insertions(+), 203 deletions(-) create mode 100644 packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts create mode 100644 packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 424d0b54b07..d70103ab362 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -8,6 +8,7 @@ import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager" import { WorktreeStateManager, remoteRef } from "./WorktreeStateManager" import { chooseBaseBranch, normalizeBaseBranch } from "./base-branch" import { GitStatsPoller, type WorktreePresenceResult } from "./GitStatsPoller" +import { PRStatusBridge } from "./pr-status-bridge" import { GitOps, type ApplyConflict } from "./GitOps" import { versionedName } from "./branch-name" import { normalizePath, classifyWorktreeError } from "./git-import" @@ -53,6 +54,7 @@ export class AgentManagerProvider implements Disposable { private diffSessionId: string | undefined private lastDiffHash: string | undefined private statsPoller: GitStatsPoller + private prBridge!: PRStatusBridge private gitOps: GitOps private cachedDiffTarget: { sessionId: string; directory: string; baseBranch: string } | undefined private staleWorktreeIds = new Set() @@ -94,6 +96,15 @@ export class AgentManagerProvider implements Disposable { log: (...args) => this.log(...args), git: this.gitOps, }) + this.prBridge = PRStatusBridge.create({ + getWorktrees: () => this.state?.getWorktrees() ?? [], + getWorkspaceRoot: () => this.getRoot(), + postToWebview: (m) => this.postToWebview(m), + updateWorktreePR: (id, n, u, s) => this.state?.updateWorktreePR(id, n, u, s), + hasPersistedPR: (id: string) => !!this.state?.getWorktree(id)?.prNumber, + openExternal: (u) => this.host.openExternal(u), + log: (...a) => this.log(...a), + }) } private log(...args: unknown[]) { @@ -147,13 +158,14 @@ export class AgentManagerProvider implements Disposable { this.stateReady = this.initializeState() void this.sendRepoInfo() this.sendKeybindings() - + this.prBridge.attachPanel(ctx) ctx.onDidDispose(() => { // Only clear if this is still the active panel — a newer panel may // have already replaced us via attachPanel. if (this.panel === ctx) { this.log("Panel disposed") this.statsPoller.stop() + this.prBridge.poller.stop() this.stopDiffPolling() this.panel = undefined } @@ -209,6 +221,7 @@ export class AgentManagerProvider implements Disposable { // --------------------------------------------------------------------------- private async onMessage(msg: Record): Promise | null> { + if (this.prBridge.handleMessage(msg)) return null const m = msg as unknown as AgentManagerInMessage if (m.type === "agentManager.createWorktree") { @@ -292,6 +305,7 @@ export class AgentManagerProvider implements Disposable { // already emitted before the webview was ready to receive messages. if (this.cachedWorktreeStats) this.postToWebview(this.cachedWorktreeStats) if (this.cachedLocalStats) this.postToWebview(this.cachedLocalStats) + this.prBridge.replay() // Refresh sessions after pushState so the webview's sessionsLoaded // handler is guaranteed to be registered (requestState fires from // onMount). Without this, the initial refreshSessions() in @@ -408,6 +422,7 @@ export class AgentManagerProvider implements Disposable { if (m.type === "loadMessages") { this.activeSessionId = m.sessionID this.terminalManager.syncOnSessionSwitch(m.sessionID) + this.prBridge.poller.setActiveWorktreeId(this.state?.getSession(m.sessionID)?.worktreeId ?? undefined) } // After clearSession, clear active tracking and re-register worktree sessions @@ -671,6 +686,7 @@ export class AgentManagerProvider implements Disposable { } // Remove from state BEFORE disk removal so pollers immediately stop targeting this worktree. this.statsPoller.skipWorktree(worktreeId) + this.prBridge.remove(worktreeId) const orphaned = state.removeWorktree(worktreeId) if (shouldStopDiffPolling(worktree.path, orphaned, this.cachedDiffTarget, this.diffSessionId)) { this.stopDiffPolling() @@ -1474,6 +1490,7 @@ export class AgentManagerProvider implements Disposable { }) this.statsPoller.setEnabled(worktrees.length > 0 || this.panel !== undefined) + this.prBridge.poller.setEnabled(worktrees.length > 0) } /** Push empty state when the folder is not a git repo or has no folder open. */ @@ -1893,6 +1910,7 @@ export class AgentManagerProvider implements Disposable { public dispose(): void { this.stopDiffPolling() this.statsPoller.stop() + this.prBridge.poller.stop() this.terminalManager.dispose() this.panel?.dispose() this.outputChannel.dispose() diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts new file mode 100644 index 00000000000..a1d37297c16 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -0,0 +1,486 @@ +import type { Worktree } from "./WorktreeStateManager" +import type { PRStatus, PRCheck, PRComment, CheckStatus, AggregateCheckStatus, PRState, ReviewDecision } from "./types" +import { execWithShellEnv } from "./shell-env" +import { classifyPRError } from "./git-import" + +interface PRStatusPollerOptions { + getWorktrees: () => Worktree[] + getWorkspaceRoot: () => string | undefined + onStatus: (worktreeId: string, pr: PRStatus | null, error?: "gh_missing" | "gh_auth" | "fetch_failed") => void + log: (...args: unknown[]) => void + intervalMs?: number +} + +const GH_PROBE_TTL = 300_000 // 5 minutes — gh installation state rarely changes at runtime +const MAX_BACKOFF = 120_000 // 2 minutes — cap for exponential backoff on repeated errors +const BACKOFF_MULTIPLIER = 2 + +export class PRStatusPoller { + private timer: ReturnType | undefined + private active = false + private visible = true + private busy = false + private lastHash = new Map() + private lastError: string | undefined // tracks global error state for de-duplication + private failures = 0 // consecutive failure count for backoff + private ghAvailable: boolean | undefined + private ghProbeTime = 0 + private activeWorktreeId: string | undefined + private cachedRepo: { owner: string; name: string; cwd: string } | undefined + private readonly intervalMs: number + + constructor(private readonly options: PRStatusPollerOptions) { + this.intervalMs = options.intervalMs ?? 15_000 + } + + setEnabled(enabled: boolean): void { + if (enabled) { + if (this.active) return + this.start() + return + } + this.stop() + } + + /** Pause/resume polling based on panel visibility. */ + setVisible(visible: boolean): void { + if (this.visible === visible) return + this.visible = visible + if (!this.active) return + if (visible) { + // Resume — poll immediately then schedule normally + if (this.timer) clearTimeout(this.timer) + this.timer = undefined + void this.poll() + return + } + // Pause — cancel pending timer + if (this.timer) { + clearTimeout(this.timer) + this.timer = undefined + } + } + + stop(): void { + this.active = false + if (this.timer) { + clearTimeout(this.timer) + this.timer = undefined + } + this.busy = false + this.lastHash.clear() + this.lastError = undefined + this.failures = 0 + this.ghAvailable = undefined + this.ghProbeTime = 0 + this.cachedRepo = undefined + } + + /** Force-refresh a specific worktree immediately. */ + refresh(worktreeId: string): void { + if (!this.active) return + void this.fetchOne(worktreeId) + } + + setActiveWorktreeId(id: string | undefined): void { + this.activeWorktreeId = id + } + + private start(): void { + this.stop() + this.active = true + // Don't override this.visible — it may already be set to false by + // setVisible() before setEnabled(true) is called. + void this.poll() + } + + private nextDelay(): number { + if (this.failures === 0) return this.intervalMs + return Math.min(this.intervalMs * Math.pow(BACKOFF_MULTIPLIER, this.failures), MAX_BACKOFF) + } + + private schedule(): void { + if (!this.active || !this.visible) return + const delay = this.nextDelay() + this.timer = setTimeout(() => { + void this.poll() + }, delay) + } + + private poll(): Promise { + if (!this.active || !this.visible) return Promise.resolve() + if (this.busy) return Promise.resolve() + this.busy = true + return this.fetchAll().finally(() => { + this.busy = false + this.schedule() + }) + } + + private async probeGh(): Promise { + const now = Date.now() + if (this.ghAvailable !== undefined && now - this.ghProbeTime < GH_PROBE_TTL) { + return this.ghAvailable + } + try { + await execWithShellEnv("gh", ["--version"], { timeout: 5_000 }) + this.ghAvailable = true + } catch { + this.ghAvailable = false + } + this.ghProbeTime = Date.now() + return this.ghAvailable + } + + private async fetchAll(): Promise { + if (!(await this.probeGh())) { + // De-duplicate: only emit gh_missing once, not every poll cycle + if (this.lastError !== "gh_missing") { + this.lastError = "gh_missing" + for (const wt of this.options.getWorktrees()) { + this.options.onStatus(wt.id, null, "gh_missing") + } + } + this.failures++ + return + } + + this.lastError = undefined + const worktrees = this.options.getWorktrees() + const results = await Promise.allSettled(worktrees.map((wt) => this.fetchOne(wt.id))) + const ok = results.every((r) => r.status === "fulfilled") + if (ok) { + this.failures = 0 + return + } + this.failures++ + } + + private async fetchOne(worktreeId: string): Promise { + const worktrees = this.options.getWorktrees() + const wt = worktrees.find((w) => w.id === worktreeId) + if (!wt) return + + if (!this.options.getWorkspaceRoot()) return + + try { + const pr = await this.fetchPRForBranch(wt.branch, wt.path) + if (!pr) { + const hash = `${worktreeId}:none` + if (this.lastHash.get(worktreeId) === hash) return + this.lastHash.set(worktreeId, hash) + this.options.onStatus(worktreeId, null) + return + } + + const [checks, comments] = await Promise.all([ + this.fetchChecks(pr.number, wt.path), + this.activeWorktreeId === worktreeId ? this.fetchComments(pr.number, wt.path) : undefined, + ]) + + const status: PRStatus = { + number: pr.number, + title: pr.title, + url: pr.url, + state: pr.state, + review: pr.review, + checks, + ...(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 ?? ""}` + if (this.lastHash.get(worktreeId) === hash) return + this.lastHash.set(worktreeId, hash) + + this.options.onStatus(worktreeId, status) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + const kind = classifyPRError(msg) + this.options.log(`PR fetch failed for ${wt.branch}:`, msg) + + const errKey = kind === "gh_missing" ? "gh_missing" : kind === "gh_auth" ? "gh_auth" : "fetch_failed" + if (kind === "gh_missing") this.ghAvailable = false + + // De-duplicate: only emit if the error state changed for this worktree + const hash = `${worktreeId}:error:${errKey}` + if (this.lastHash.get(worktreeId) !== hash) { + this.lastHash.set(worktreeId, hash) + this.options.onStatus(worktreeId, null, errKey) + } + throw err // propagate so fetchAll can track failures for backoff + } + } + + private static readonly PR_JSON_FIELDS = + "number,title,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid" + + private async fetchPRForBranch(branch: string, cwd: string): Promise { + // Strategy 1: bare `gh pr view` — resolves via the branch's tracking ref. + // Works for fork PRs checked out with `gh pr checkout` (tracking ref = refs/pull/N/head). + // Strategy 2: `gh pr view ` — works for same-repo branches pushed to origin. + // Strategy 3: `gh pr list --search ""` — last resort, finds PRs by HEAD commit SHA. + return (await this.ghPRView(cwd)) ?? (await this.ghPRView(cwd, branch)) ?? (await this.ghPRListBySHA(cwd)) + } + + /** Run `gh pr view [branch] --json ...` and parse the result, or return null. */ + private async ghPRView(cwd: string, branch?: string): Promise { + try { + const args = ["pr", "view"] + if (branch) args.push(branch) + args.push("--json", PRStatusPoller.PR_JSON_FIELDS) + + const { stdout } = await execWithShellEnv("gh", args, { cwd, timeout: 15_000 }) + return parsePRResult(stdout) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (msg.includes("no pull requests found") || msg.includes("Could not resolve")) return null + throw err + } + } + + /** Search for PRs containing the current HEAD SHA. Finds PRs when branch name/tracking ref don't match. */ + private async ghPRListBySHA(cwd: string): Promise { + try { + const { stdout: sha } = await execWithShellEnv("git", ["rev-parse", "HEAD"], { cwd, timeout: 5_000 }) + const head = sha.trim() + if (!head) return null + + const { stdout } = await execWithShellEnv( + "gh", + [ + "pr", + "list", + "--state", + "all", + "--search", + `${head} is:pr`, + "--limit", + "5", + "--json", + PRStatusPoller.PR_JSON_FIELDS, + ], + { cwd, timeout: 15_000 }, + ) + const items = JSON.parse(stdout) as unknown[] + if (!Array.isArray(items) || items.length === 0) return null + + // Only accept PRs where headRefOid matches our HEAD exactly + for (const item of items) { + const data = item as Record + if (data.headRefOid === head) return parsePRResult(JSON.stringify(data)) + } + return null + } catch { + return null + } + } + + private async fetchChecks( + prNumber: number, + cwd: string, + ): Promise<{ + status: AggregateCheckStatus + total: number + passed: number + failed: number + pending: number + items: PRCheck[] + }> { + try { + const { stdout } = await execWithShellEnv( + "gh", + ["pr", "checks", String(prNumber), "--json", "name,state,link,startedAt,completedAt"], + { cwd, timeout: 15_000 }, + ) + const data = JSON.parse(stdout) as Array<{ + name: string + state: string + link?: string + startedAt?: string + completedAt?: string + }> + + const items: PRCheck[] = data.map((c) => ({ + name: c.name, + status: mapCheckStatus(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 status: AggregateCheckStatus = + total === 0 ? "none" : failed > 0 ? "failure" : pending > 0 ? "pending" : "success" + + return { status, total, passed, failed, pending, items } + } catch { + return { status: "none", total: 0, passed: 0, failed: 0, pending: 0, items: [] } + } + } + + private async getRepoInfo(cwd: string): Promise<{ owner: string; name: string }> { + if (this.cachedRepo && this.cachedRepo.cwd === cwd) { + return this.cachedRepo + } + const { stdout } = await execWithShellEnv("gh", ["repo", "view", "--json", "owner,name"], { + cwd, + timeout: 10_000, + }) + const data = JSON.parse(stdout) + const info = { owner: data.owner.login as string, name: data.name as string, cwd } + this.cachedRepo = info + return info + } + + private async fetchComments( + prNumber: number, + cwd: string, + ): Promise<{ total: number; unresolved: number; items: 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) { + reviewThreads(first: 100) { + nodes { + isResolved + comments(first: 1) { + nodes { + id + author { login avatarUrl } + body + path + line + url + createdAt + } + } + } + } + } + } + }` + + const { stdout } = await execWithShellEnv( + "gh", + [ + "api", + "graphql", + "-f", + `query=${query}`, + "-F", + `owner=${repo.owner}`, + "-F", + `repo=${repo.name}`, + "-F", + `number=${prNumber}`, + ], + { 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 } + } catch (err) { + this.options.log("Failed to fetch PR comments:", err) + return { total: 0, unresolved: 0, items: [] } + } + } +} + +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` +} diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index 1148fbc3f02..e7c0ba50214 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -26,6 +26,12 @@ export interface Worktree { groupId?: string /** User-provided display name for the worktree. */ label?: string + /** Cached PR number for instant badge display on reload. */ + prNumber?: number + /** Cached PR URL for instant badge display on reload. */ + prUrl?: string + /** Cached PR state for correct badge color on reload (open/merged/closed/draft). */ + prState?: string } /** @@ -171,6 +177,16 @@ export class WorktreeStateManager { void this.save() } + updateWorktreePR(id: string, prNumber?: number, prUrl?: string, prState?: string): void { + const wt = this.worktrees.get(id) + if (!wt) return + if (wt.prNumber === prNumber && wt.prUrl === prUrl && wt.prState === prState) return + wt.prNumber = prNumber + wt.prUrl = prUrl + wt.prState = prState + void this.save() + } + removeWorktree(id: string): ManagedSession[] { const removed = this.worktrees.delete(id) if (!removed) return [] diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index 34bbdd3b7f3..596e32cf88c 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -56,9 +56,15 @@ export interface PanelContext { /** Whether the panel is currently the active tab. */ readonly active: boolean + /** Whether the panel is visible (may be unfocused in a split editor group). */ + readonly visible: boolean + /** Session provider wired to this panel. */ readonly sessions: SessionProvider + /** Register a callback for when panel visibility changes. */ + onDidChangeVisibility(cb: (visible: boolean) => void): Disposable + /** Register a callback for when the panel is disposed. */ onDidDispose(cb: () => void): Disposable @@ -107,6 +113,9 @@ export interface Host { /** Capture a telemetry event. */ capture(event: string, properties?: Record): void + /** Open a URL in the user's default browser. */ + openExternal(url: string): void + /** Ask VS Code's git extension to re-scan repositories (e.g. after worktree ref migration). */ refreshGit(): void diff --git a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts new file mode 100644 index 00000000000..15d5d754d7e --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts @@ -0,0 +1,110 @@ +/** + * Bridges the PRStatusPoller with the AgentManagerProvider. + * + * Owns the poller instance, the cached PR messages, and all message/panel handling + * so the provider only needs thin delegation calls. + */ +import type { Worktree } from "./WorktreeStateManager" +import type { AgentManagerOutMessage, PRStatus } from "./types" +import type { Disposable } from "./host" +import { PRStatusPoller } from "./PRStatusPoller" + +interface PRBridgeHost { + getWorktrees(): Worktree[] + getWorkspaceRoot(): string | undefined + postToWebview(msg: AgentManagerOutMessage): void + updateWorktreePR(id: string, number?: number, url?: string, state?: string): void + hasPersistedPR(id: string): boolean + openExternal(url: string): void + log(...args: unknown[]): void +} + +/** Minimal panel surface needed by the bridge (subset of PanelContext). */ +interface PanelLike { + readonly visible: boolean + onDidChangeVisibility(cb: (visible: boolean) => void): Disposable +} + +export class PRStatusBridge { + readonly poller: PRStatusPoller + private readonly cache = new Map() + private readonly host: PRBridgeHost + + constructor(host: PRBridgeHost) { + this.host = host + this.poller = new PRStatusPoller(bridgePollerOpts(this, host)) + } + + static create(opts: { + getWorktrees: () => Worktree[] + getWorkspaceRoot: () => string | undefined + postToWebview: (msg: AgentManagerOutMessage) => void + updateWorktreePR: (id: string, n?: number, u?: string, s?: string) => void + hasPersistedPR: (id: string) => boolean + openExternal: (url: string) => void + log: (...args: unknown[]) => void + }): PRStatusBridge { + return new PRStatusBridge(opts) + } + + /** Wire visibility tracking to a panel — pauses polling when hidden. */ + attachPanel(panel: PanelLike): void { + this.poller.setVisible(panel.visible) + panel.onDidChangeVisibility((v) => { + this.poller.setVisible(v) + }) + } + + /** Replay cached PR statuses to a freshly-connected webview. */ + replay(): void { + this.cache.forEach((msg) => this.host.postToWebview(msg)) + } + + /** Handle an incoming webview message. Returns true if handled. */ + handleMessage(m: Record): boolean { + if (m.type === "agentManager.refreshPR") { + this.poller.refresh(m.worktreeId as string) + 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) + return true + } + return false + } + + /** Remove cached status for a deleted worktree. */ + remove(worktreeId: string): void { + this.cache.delete(worktreeId) + } +} + +/** Build PRStatusPoller options that forward events through the bridge cache. */ +function bridgePollerOpts(bridge: PRStatusBridge, host: PRBridgeHost) { + return { + getWorktrees: () => host.getWorktrees(), + getWorkspaceRoot: () => host.getWorkspaceRoot(), + onStatus: (id: string, pr: PRStatus | null, err?: "gh_missing" | "gh_auth" | "fetch_failed") => { + if (err) { + // Don't forward errors to the webview when we have prior PR data + // (in-memory cache or persisted prNumber) — that would overwrite + // the live badge with pr:null. Only forward when there's truly no + // prior data (first poll failed, nothing persisted). + if (!bridge["cache"].has(id) && !host.hasPersistedPR(id)) + host.postToWebview({ + type: "agentManager.prStatus", + worktreeId: id, + pr: null, + error: err, + } as AgentManagerOutMessage) + return + } + const msg = { type: "agentManager.prStatus", worktreeId: id, pr, error: err } as AgentManagerOutMessage + bridge["cache"].set(id, msg) + host.postToWebview(msg) + host.updateWorktreePR(id, pr?.number, pr?.url, pr?.state) + }, + log: (...args: unknown[]) => host.log(...args), + } +} diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index aeffd54bbce..6c116034638 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -29,6 +29,58 @@ export type WorktreeDiffEntry = FileDiff & { stamp?: string } +// --------------------------------------------------------------------------- +// PR status types +// --------------------------------------------------------------------------- + +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 interface PRStatus { + number: number + title: string + url: string + state: PRState + review: ReviewDecision | null + checks: { + status: AggregateCheckStatus + total: number + passed: number + failed: number + pending: number + items: PRCheck[] + } + comments?: { + total: number + unresolved: number + items: PRComment[] + } + additions: number + deletions: number + files: number +} + // --------------------------------------------------------------------------- // Extension → Webview messages (postToWebview) // --------------------------------------------------------------------------- @@ -175,6 +227,13 @@ interface WorktreeDiffFileMessage { diff: WorktreeDiffEntry | null } +interface PRStatusOutMessage { + type: "agentManager.prStatus" + worktreeId: string + pr: PRStatus | null + error?: "gh_missing" | "gh_auth" | "fetch_failed" +} + interface ActionOutMessage { type: "action" action: string @@ -202,6 +261,7 @@ export type AgentManagerOutMessage = | WorktreeDiffLoadingMessage | WorktreeDiffMessage | WorktreeDiffFileMessage + | PRStatusOutMessage | ActionOutMessage // --------------------------------------------------------------------------- @@ -379,6 +439,16 @@ interface StopDiffWatchIn { type: "agentManager.stopDiffWatch" } +interface RefreshPRIn { + type: "agentManager.refreshPR" + worktreeId: string +} + +interface OpenPRIn { + type: "agentManager.openPR" + worktreeId: string +} + interface OpenFileIn { type: "agentManager.openFile" sessionId: string @@ -489,6 +559,8 @@ export type AgentManagerInMessage = | ApplyWorktreeDiffIn | StartDiffWatchIn | StopDiffWatchIn + | RefreshPRIn + | OpenPRIn | OpenFileIn | GenericOpenFileIn | PreviewImageIn diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index f3f3a4c969f..7ea4403cbc0 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -92,6 +92,9 @@ export class VscodeHost implements Host { get active() { return panel.active }, + get visible() { + return panel.visible + }, postMessage(msg) { void panel.webview.postMessage(msg) }, @@ -99,6 +102,9 @@ export class VscodeHost implements Host { panel.reveal(vscode.ViewColumn.One, preserveFocus ?? false) }, sessions, + onDidChangeVisibility(cb) { + return panel.onDidChangeViewState((e) => cb(e.webviewPanel.visible)) + }, onDidDispose(cb) { return panel.onDidDispose(cb) }, @@ -160,9 +166,11 @@ export class VscodeHost implements Host { TelemetryProxy.capture(event as TelemetryEventName, properties) } + openExternal(url: string): void { + void vscode.env.openExternal(vscode.Uri.parse(url)) + } + refreshGit(): void { - // Trigger VS Code's built-in git extension to re-scan repositories. - // This picks up worktrees whose gitdir refs were just rewritten by migration. void vscode.commands.executeCommand("git.refresh") } 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 c991cc6f5b6..65a20a1af4f 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -532,7 +532,7 @@ const VSCODE_ALLOWED: Record = { */ const MAX_LINES: Record = { "AgentManagerProvider.ts": { - maxLines: 1910, + maxLines: 2000, note: "primary extraction target: break into smaller orchestrators", }, } diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-active-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-active-chromium-linux.png index 5eea0cc85df..b657e55ee74 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-active-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-active-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ed6e36e191174cd14d61fc3480014d59c9a92bd526f4f889dc71bdbab4b44e66 -size 2106 +oid sha256:270e0dafc2601996e7d048873e55db4680e8803d6800dc44b1fa6ce6239827cc +size 2017 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-default-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-default-chromium-linux.png index 3138a0847c8..bc4f471b091 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-default-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-default-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b313dd76197ff089105d6b80ff22bdf570b878f67dcc99e5d050e85573c52d60 -size 1959 +oid sha256:5feb854ee2693b655ab504b8e2ed8794af77a357c3d716e2deb9668a08f3aa0c +size 1847 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-grouped-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-grouped-chromium-linux.png index 853b1606174..b012417eb4f 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-grouped-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-grouped-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e386126977ece9eae41cce0b13677ae4243b985b39a7ee0977e9b5da32415796 -size 5837 +oid sha256:937c4398730fd3e07bc064b4cb370717a5bc280ffaced87fa6f3c626774878a8 +size 5598 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-pending-delete-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-pending-delete-chromium-linux.png index 19a85104afe..2ec70d4ede2 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-pending-delete-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-pending-delete-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:24d02a1a394c5e18e3783b19c8c7145eafbd8cabfb624c53c82137369d2c5784 -size 2570 +oid sha256:1d9935cc07b9eefe0c1eca1852b00ac2b39b76cdf12ee2f01c52e4ac0771b39e +size 2910 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-stale-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-stale-chromium-linux.png index 39a8bb71569..9cdd496d4a1 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-stale-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-stale-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:07039f10de94b6aa2eba14674a9f3370fef2f8b23d6712e91f43ddd77f169c34 -size 1930 +oid sha256:54e7142d591391c6e99631c9bf87b011321e52f17a8ee39e5aeeaa9b1350f2f9 +size 1831 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-with-stats-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-with-stats-chromium-linux.png index 4d9c9a4f74c..43f94d6b804 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-with-stats-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-with-stats-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:519310e5cb841302d651f450b4fc281936c331646a4d3759de0c010d1761fe63 -size 2687 +oid sha256:7e4a37b5b7d71e0eb7f043176f410030385a8a61f3913bd6419b1d96c6cad2de +size 2422 diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 49d7728424e..dee52089681 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -34,6 +34,8 @@ import type { WorktreeGitStats, LocalGitStats, WorktreeState, + PRStatus, + AgentManagerPRStatusMessage, ManagedSessionState, SessionInfo, BranchInfo, @@ -120,6 +122,8 @@ interface ApplyState { /** 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" | null + const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) // Fallback keybindings before extension sends resolved ones @@ -328,8 +332,8 @@ const AgentManagerContent: Component = () => { let diffRaf: number | undefined let pendingDiffWidth: number | undefined - // Diff panel state - const [diffOpen, setDiffOpen] = createSignal(false) + const [sidePanel, setSidePanel] = createSignal(null) + const diffOpen = () => sidePanel() === "diff" const [diffDatas, setDiffDatas] = createSignal>({}) const [diffLoading, setDiffLoading] = createSignal(false) const [diffFileLoading, setDiffFileLoading] = createSignal>>({}) @@ -345,6 +349,9 @@ const AgentManagerContent: Component = () => { // Per-worktree git stats (diff additions/deletions, commits missing from origin) const [worktreeStats, setWorktreeStats] = createSignal>({}) + // Per-worktree PR status data + const [prStatuses, setPrStatuses] = createSignal>({}) + // Local repo git stats (branch name, diff additions/deletions, commits) const [localStats, setLocalStats] = createSignal() @@ -1012,9 +1019,9 @@ const AgentManagerContent: Component = () => { } else if (msg.action === "toggleDiff") { if (reviewActive()) { closeReviewTab() - setDiffOpen(true) + setSidePanel("diff") } else { - setDiffOpen((prev) => !prev) + setSidePanel((prev) => (prev === "diff" ? null : "diff")) } } else if (msg.action === "newTab") handleNewTabForCurrentSelection() else if (msg.action === "closeTab") closeActiveTab() @@ -1153,7 +1160,7 @@ const AgentManagerContent: Component = () => { setSelection(ev.worktreeId) } // Close diff/review panels — nothing to show during setup - setDiffOpen(false) + setSidePanel(null) setReviewActive(false) setSetup({ active: true, message: ev.message, branch: ev.branch, worktreeId: ev.worktreeId }) } @@ -1383,6 +1390,11 @@ const AgentManagerContent: Component = () => { setLocalStats(ev.stats) setRepoBranch(ev.stats.branch) } + + if (msg.type === "agentManager.prStatus") { + const ev = msg as AgentManagerPRStatusMessage + setPrStatuses((prev) => ({ ...prev, [ev.worktreeId]: ev.pr })) + } }) onCleanup(() => { @@ -1458,7 +1470,7 @@ const AgentManagerContent: Component = () => { const openReviewTab = () => { const sel = selection() if (sel === null) return - setDiffOpen(false) + setSidePanel(null) setReviewOpenForContext(sel, true) setReviewActive(true) } @@ -2327,6 +2339,34 @@ const AgentManagerContent: Component = () => { renameValue={renameValue()} closeKeybind={kb().closeWorktree ?? ""} openKeybind={kb().openWorktree ?? ""} + pr={ + prStatuses()[wt.id] !== undefined + ? prStatuses()[wt.id] + : wt.prNumber + ? { + number: wt.prNumber, + title: "", + url: wt.prUrl ?? "", + state: (wt.prState ?? "open") as PRStatus["state"], + review: null, + checks: { + status: "none" as const, + total: 0, + passed: 0, + failed: 0, + pending: 0, + items: [], + }, + comments: { total: 0, unresolved: 0, items: [] }, + additions: 0, + deletions: 0, + files: 0, + } + : undefined + } + onOpenPR={() => + vscode.postMessage({ type: "agentManager.openPR", worktreeId: wt.id }) + } onClick={() => { if (pendingDelete() === wt.id) { confirmDeleteWorktree(wt.id) @@ -2622,10 +2662,10 @@ const AgentManagerContent: Component = () => { onClick={() => { if (reviewActive()) { closeReviewTab() - setDiffOpen(true) + setSidePanel("diff") return } - setDiffOpen((prev) => !prev) + setSidePanel((prev) => (prev === "diff" ? null : "diff")) }} title={t("agentManager.diff.toggle")} > @@ -2752,7 +2792,7 @@ const AgentManagerContent: Component = () => { {/* Chat + side diff panel (hidden when review tab is active) */}
@@ -2810,7 +2850,7 @@ const AgentManagerContent: Component = () => {
- +
{ }} />
- setDiffOpen(false)} - onExpand={selection() !== null ? openReviewTab : undefined} - onRequestDiff={requestDiffFile} - onOpenFile={(file) => { - const id = currentDiffSessionId() - if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file }) - else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file }) - }} - /> + + setSidePanel(null)} + onExpand={selection() !== null ? openReviewTab : undefined} + onRequestDiff={requestDiffFile} + onOpenFile={(file) => { + const id = currentDiffSessionId() + if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file }) + else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file }) + }} + /> +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx index 1bf24c5c087..d021910413f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx @@ -10,7 +10,7 @@ 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 } from "../src/types/messages" +import type { WorktreeState, WorktreeGitStats, PRStatus } from "../src/types/messages" import { useLanguage } from "../src/context/language" import { formatRelativeDate } from "../src/utils/date" @@ -51,6 +51,10 @@ interface WorktreeItemProps { closeKeybind: string /** 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 + /** Callback when the PR badge is clicked. */ + onOpenPR?: () => void onClick: () => void onDelete: (e: MouseEvent) => void @@ -68,11 +72,41 @@ const MAX_SHORTCUT = 9 const hasStats = (s: WorktreeGitStats | undefined): s is WorktreeGitStats => !!s && (s.files > 0 || s.additions > 0 || s.deletions > 0 || s.ahead > 0 || s.behind > 0) +/** Returns the accent color for a PR badge based on state priority. */ +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 === "failure") return "#ef4444" + if (pr.review === "changes_requested") return "#fbbf24" + if (pr.checks.status === "pending") return "#fbbf24" + return "#34d399" +} + +function prStateLabel(state: PRStatus["state"]): string { + if (state === "draft") return "Draft" + if (state === "merged") return "Merged" + if (state === "closed") return "Closed" + return "Open" +} + +function reviewLabel(review: string): string { + if (review === "approved") return "Approved" + if (review === "changes_requested") return "Changes Requested" + return "Pending" +} + export const WorktreeItem: Component = (props) => { const { t } = useLanguage() const [hovered, setHovered] = createSignal(false) const [overClose, setOverClose] = createSignal(false) + const handleOpenPR = (e: MouseEvent) => { + e.stopPropagation() + e.preventDefault() + props.onOpenPR?.() + } + return ( <> @@ -102,128 +136,159 @@ export const WorktreeItem: Component = (props) => { data-sidebar-id={props.worktree.id} onClick={() => props.onClick()} > - }> - - - - - - - - - - { - e.stopPropagation() - props.onStartRename(props.label) - }} - title={t("agentManager.worktree.doubleClickRename")} - > - {props.label} - - } - > - props.onRenameInput(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault() - props.onCommitRename() - } - if (e.key === "Escape") { - e.preventDefault() - props.onCancelRename() - } - }} - onBlur={() => props.onCommitRename()} - onClick={(e) => e.stopPropagation()} - ref={(el) => - requestAnimationFrame(() => - requestAnimationFrame(() => { - el.focus() - el.select() - }), - ) - } - /> - - = 2 && props.shortcut <= MAX_SHORTCUT}> - - {isMac ? "⌘" : "Ctrl+"} - {props.shortcut} - - - -
-
-
-
- - -
+
+ }> + + +
+
+ {/* Row 1: label + stale badge + stats/hover-actions overlay */} +
+ + + + + + + 0 || props.stats!.deletions > 0} + when={props.renaming} fallback={ - 0}> - {props.stats!.files}f + { + e.stopPropagation() + props.onStartRename(props.label) + }} + title={t("agentManager.worktree.doubleClickRename")} + > + {props.label} + + } + > + props.onRenameInput(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + props.onCommitRename() + } + if (e.key === "Escape") { + e.preventDefault() + props.onCancelRename() + } + }} + onBlur={() => props.onCommitRename()} + onClick={(e) => e.stopPropagation()} + ref={(el) => + requestAnimationFrame(() => + requestAnimationFrame(() => { + el.focus() + el.select() + }), + ) + } + /> + + {/* Grid cell: stats visible by default, hover actions on top */} +
+ +
+
+
+ + +
+ 0}> + ↓{props.stats!.behind} + + 0}> + ↑{props.stats!.ahead} + + 0 || props.stats!.deletions > 0} + fallback={ + 0}> + {props.stats!.files}f + + } + > + 0}> + +{props.stats!.additions} + + 0}> + −{props.stats!.deletions} + + +
+
+ + {t("agentManager.worktree.confirmDelete")} + +
+ = 2 && props.shortcut <= MAX_SHORTCUT}> + + {isMac ? "⌘" : "Ctrl+"} + {props.shortcut} + + + +
setOverClose(true)} + onMouseLeave={() => setOverClose(false)} + > + + props.onDelete(e)} + /> + +
+
+
+
+
+ {/* Row 2: always rendered for consistent height; PR badge or skeleton */} +
+ +
} > -
- 0}> - +{props.stats!.additions} - - 0}> - −{props.stats!.deletions} - -
- - 0 || props.stats!.behind > 0}> -
- 0}> - ↑{props.stats!.ahead} - - 0}> - ↓{props.stats!.behind} - -
+ {(pr) => { + const accent = () => prAccentColor(pr()) + return ( + + + #{pr().number} + + ) + }}
-
- - {t("agentManager.worktree.confirmDelete")} - - -
setOverClose(true)} - onMouseLeave={() => setOverClose(false)} - > - - props.onDelete(e)} - /> - -
-
+
} @@ -313,6 +378,34 @@ export const WorktreeItem: Component = (props) => {
+ + {(pr) => ( + <> +
+
+ PR #{pr().number} + + + + + {prStateLabel(pr().state)} + +
+ +
+ Review + {reviewLabel(pr().review!)} +
+
+
+ Checks + + {pr().checks.passed}/{pr().checks.total} passed + +
+ + )} +
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 6cce27a333a..99d27402a75 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -181,12 +181,11 @@ button.am-section-toggle:hover .am-section-label { } .am-worktree-item { - position: relative; display: flex; - align-items: center; + align-items: flex-start; gap: 8px; - padding: 8px 10px; - min-height: 40px; + padding: 6px 10px; + min-height: 36px; box-sizing: border-box; border-radius: var(--radius-sm); cursor: pointer; @@ -197,6 +196,72 @@ button.am-section-toggle:hover .am-section-label { transition: background 200ms ease; } +/* Left icon column — shrink-0, vertically centered with first row */ +.am-wt-icon { + flex-shrink: 0; + display: flex; + align-items: center; + height: 20px; +} + +/* Right content column — flex-1 with two rows */ +.am-wt-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +/* Row 1: label + stats/hover-actions */ +.am-wt-row1 { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +/* Grid overlay cell: stats visible by default, hover actions stacked on top */ +.am-wt-actions-cell { + position: relative; + flex-shrink: 0; + display: grid; + align-items: center; + margin-left: auto; +} +.am-wt-actions-cell > * { + grid-column: 1; + grid-row: 1; +} + +/* Hover actions (shortcut badge + close button) — hidden by default, shown on hover */ +.am-wt-hover-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + opacity: 0; + visibility: hidden; +} +.am-worktree-item:hover .am-wt-hover-actions { + opacity: 1; + visibility: visible; +} +/* Stats fade out on hover (replaced by hover actions in same grid cell) */ +.am-worktree-item:hover .am-wt-actions-cell > .am-worktree-stats, +.am-worktree-item:hover .am-wt-actions-cell > .am-worktree-stats-skeleton { + opacity: 0; + visibility: hidden; +} + +/* Row 2: always present for consistent height; PR badge right-aligned */ +.am-wt-row2 { + display: flex; + align-items: center; + justify-content: flex-end; + min-height: 14px; +} + .am-worktree-item:hover { background: var(--surface-inset-base-hover); } @@ -238,13 +303,9 @@ button.am-section-toggle:hover .am-section-label { outline: none; } -/* Shortcut badge — hover-reveal index badge (⌘1, ⌘2, etc.) matching Superset style */ +/* Shortcut badge — hover-reveal index badge (⌘1, ⌘2, etc.) */ .am-shortcut-badge { - position: absolute; - right: 30px; - opacity: 0; - transition: opacity 150ms ease; font-size: 10px; font-variant-numeric: tabular-nums; color: var(--text-weaker); @@ -256,8 +317,7 @@ button.am-section-toggle:hover .am-section-label { right: 8px; } -.am-local-item:hover .am-shortcut-badge, -.am-worktree-item:hover .am-shortcut-badge { +.am-local-item:hover .am-shortcut-badge { opacity: 1; } @@ -278,29 +338,27 @@ button.am-section-toggle:hover .am-section-label { color: var(--text-weak); } -.am-worktree-item:has(.am-worktree-rename-input) .am-shortcut-badge { - opacity: 0; +.am-worktree-item:has(.am-worktree-rename-input) .am-wt-row2 { + display: none; } .am-worktree-close { - position: absolute; - right: 4px; flex-shrink: 0; +} +/* Compact close button inside worktree items */ +.am-worktree-close [data-component="icon-button"] { + width: 18px; + height: 18px; + padding: 2px; +} +.am-worktree-close [data-component="icon"] { + width: 14px; + height: 14px; +} + +.am-worktree-item:has(.am-worktree-rename-input) .am-wt-hover-actions { opacity: 0; -} - -.am-worktree-item:hover .am-worktree-branch { - mask-image: linear-gradient(to right, black calc(100% - 72px), transparent calc(100% - 36px)); - -webkit-mask-image: linear-gradient(to right, black calc(100% - 72px), transparent calc(100% - 36px)); -} - -.am-worktree-item:hover .am-worktree-close { - opacity: 1; -} - -.am-worktree-item:has(.am-worktree-rename-input) .am-worktree-close { - opacity: 0; - pointer-events: none; + visibility: hidden; } /* Inline delete confirmation */ @@ -325,16 +383,22 @@ button.am-section-toggle:hover .am-section-label { display: none; } -.am-worktree-pending-delete .am-shortcut-badge { +.am-worktree-pending-delete .am-wt-row2 { + visibility: hidden; +} + +.am-worktree-pending-delete .am-wt-hover-actions { opacity: 0 !important; + visibility: hidden !important; } .am-worktree-delete-hint { + position: absolute; + right: 0; font-size: var(--font-size-small); color: var(--text-on-brand-base); background: var(--surface-critical-strong); white-space: nowrap; - flex-shrink: 0; padding: 1px 8px; border-radius: 4px; cursor: pointer; @@ -383,16 +447,14 @@ button.am-section-toggle:hover .am-section-label { .am-worktree-spinner { width: 16px; height: 16px; - flex-shrink: 0; } /* Per-worktree git stats (diff lines + commits missing from origin) */ .am-worktree-stats { display: flex; - flex-direction: column; - align-items: flex-end; - gap: 1px; + align-items: center; + gap: 4px; flex-shrink: 0; font-family: var(--font-mono, monospace); font-size: 10px; @@ -408,9 +470,7 @@ button.am-section-toggle:hover .am-section-label { .am-worktree-stats-skeleton { display: flex; - flex-direction: column; - align-items: flex-end; - gap: 3px; + align-items: center; flex-shrink: 0; } @@ -422,8 +482,14 @@ button.am-section-toggle:hover .am-section-label { animation: am-skeleton-pulse 1.5s ease-in-out infinite; } -.am-worktree-stats-skeleton-row:nth-child(2) { - animation-delay: 0.15s; +.am-pr-badge-skeleton { + width: 52px; + height: 14px; + border-radius: 6px; + background: var(--text-base); + animation: am-skeleton-pulse 1.5s ease-in-out infinite; + animation-delay: 0.3s; + opacity: 0; } .am-stat-files { @@ -438,6 +504,75 @@ button.am-section-toggle:hover .am-section-label { color: #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; + border-radius: 6px; + border: none; + background: color-mix(in srgb, var(--pr-accent) 12%, transparent); + color: var(--pr-accent); + cursor: pointer; + font-size: 11px; + font-weight: 500; + font-variant-numeric: tabular-nums; + transition: background 150ms ease; +} +.am-pr-badge:hover { + background: color-mix(in srgb, var(--pr-accent) 25%, transparent); +} +.am-pr-badge [data-component="icon"] { + width: 12px; + height: 12px; + color: var(--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); +} +.am-pr-badge[data-pending] { + animation: am-pr-pulse 1.5s ease-in-out infinite; +} +@keyframes am-pr-pulse { + 0%, + 100% { + opacity: 0.9; + } + 50% { + opacity: 0.4; + } +} + +.am-worktree-item-active .am-pr-badge { + background: color-mix(in srgb, var(--pr-accent) 18%, transparent); +} +.am-worktree-item-active .am-pr-badge:hover { + background: color-mix(in srgb, var(--pr-accent) 30%, transparent); +} + +/* Clickable link icon in hover card to open PR on GitHub */ +.am-pr-link { + display: inline-flex; + align-items: center; + margin-right: 3px; + cursor: pointer; + opacity: 0.5; + transition: opacity 100ms ease; +} +.am-pr-link:hover { + opacity: 1; +} +.am-pr-link [data-component="icon"] { + width: 11px; + height: 11px; +} + .am-worktree-item-active .am-stat-files { color: rgba(255, 255, 255, 0.7); } @@ -470,8 +605,6 @@ button.am-section-toggle:hover .am-section-label { color: #fca5a5; } -.am-worktree-item:hover .am-worktree-stats, -.am-worktree-item:hover .am-worktree-stats-skeleton, .am-local-item:hover .am-worktree-stats, .am-local-item:hover .am-worktree-stats-skeleton { opacity: 0; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 0b4f0f348c8..2f36feec95a 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -799,6 +799,64 @@ export interface WorktreeState { groupId?: string /** User-provided display name for the worktree. */ label?: string + /** Cached PR number for instant badge display on reload. */ + prNumber?: number + /** Cached PR URL for instant badge display on reload. */ + prUrl?: string + /** Cached PR state for correct badge color on reload (open/merged/closed/draft). */ + prState?: string +} + +// --------------------------------------------------------------------------- +// PR status types (mirrored from extension 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 +} + +export interface PRStatus { + number: number + title: string + url: string + state: PRState + review: ReviewDecision | null + checks: { + status: AggregateCheckStatus + total: number + passed: number + failed: number + pending: number + items: PRCheck[] + } + comments?: { + total: number + unresolved: number + items: PRComment[] + } + additions: number + deletions: number + files: number } export interface ManagedSessionState { @@ -983,6 +1041,14 @@ export interface AgentManagerLocalStatsMessage { stats: LocalGitStats } +// Agent Manager: PR status push (extension → webview) +export interface AgentManagerPRStatusMessage { + type: "agentManager.prStatus" + worktreeId: string + pr: PRStatus | null + error?: "gh_missing" | "gh_auth" | "fetch_failed" +} + // Sidebar: Live worktree diff stats (extension → webview) export interface WorktreeStatsLoadedMessage { type: "worktreeStatsLoaded" @@ -1360,6 +1426,7 @@ export type ExtensionMessage = | AgentManagerApplyWorktreeDiffResultMessage | AgentManagerWorktreeStatsMessage | AgentManagerLocalStatsMessage + | AgentManagerPRStatusMessage // legacy-migration start | MigrationStateMessage | LegacyMigrationDataMessage @@ -1959,6 +2026,17 @@ export interface StopDiffWatchMessage { type: "agentManager.stopDiffWatch" } +// Agent Manager: PR messages (webview → extension) +export interface RefreshPRMessage { + type: "agentManager.refreshPR" + worktreeId: string +} + +export interface OpenPRMessage { + type: "agentManager.openPR" + worktreeId: string +} + export interface ApplyWorktreeDiffMessage { type: "agentManager.applyWorktreeDiff" worktreeId: string @@ -2198,6 +2276,8 @@ export type WebviewMessage = | RequestWorktreeDiffFileMessage | StartDiffWatchMessage | StopDiffWatchMessage + | RefreshPRMessage + | OpenPRMessage // legacy-migration start | RequestLegacyMigrationDataMessage | StartLegacyMigrationMessage From 760f24608e4bf848771bd38141b15e7141f53772 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Tue, 7 Apr 2026 19:11:12 +0000 Subject: [PATCH 28/55] release: v7.2.0 --- bun.lock | 32 +++++++++++++------------- package.json | 2 +- packages/app/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/kilo-docs/package.json | 2 +- packages/kilo-gateway/package.json | 2 +- packages/kilo-i18n/package.json | 2 +- packages/kilo-telemetry/package.json | 2 +- packages/kilo-ui/package.json | 2 +- packages/kilo-vscode/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/script/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/storybook/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- script/upstream/package.json | 2 +- sdks/vscode/package.json | 2 +- 21 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index eeaccd23638..19f273a6ac1 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "7.1.23", + "version": "7.2.0", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -79,7 +79,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "7.1.23", + "version": "7.2.0", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -112,7 +112,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "7.1.23", + "version": "7.2.0", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -142,7 +142,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.1.23", + "version": "7.2.0", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -171,7 +171,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.1.23", + "version": "7.2.0", "dependencies": { "@ai-sdk/anthropic": "2.0.65", "@ai-sdk/openai": "2.0.101", @@ -206,7 +206,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.1.23", + "version": "7.2.0", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -219,7 +219,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.1.23", + "version": "7.2.0", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "@opentelemetry/api": "1.9.0", @@ -239,7 +239,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.1.23", + "version": "7.2.0", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/util": "workspace:*", @@ -274,7 +274,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.1.23", + "version": "7.2.0", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", @@ -327,7 +327,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.1.23", + "version": "7.2.0", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -451,7 +451,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.1.23", + "version": "7.2.0", "dependencies": { "@kilocode/sdk": "workspace:*", "zod": "catalog:", @@ -465,14 +465,14 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.1.23", + "version": "7.2.0", "devDependencies": { "@types/bun": "catalog:", }, }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.1.23", + "version": "7.2.0", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -483,7 +483,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.1.23", + "version": "7.2.0", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -506,7 +506,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.1.23", + "version": "7.2.0", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -553,7 +553,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "7.1.23", + "version": "7.2.0", "dependencies": { "zod": "catalog:", }, diff --git a/package.json b/package.json index 7c626177b8e..654595cb6e9 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,6 @@ "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch", "ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch" }, - "version": "7.1.23", + "version": "7.2.0", "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index 9ea7ae31522..b13141ca050 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "7.1.23", + "version": "7.2.0", "description": "", "type": "module", "exports": { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 1bd4786dac6..37418e6d590 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "7.1.23", + "version": "7.2.0", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 83a34dc8782..5722e09ba89 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "7.1.23", + "version": "7.2.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index e2b800dc95a..95cc4d7dcfb 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.1.23" +version = "7.2.0" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.23/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.23/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.23/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.23/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.23/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index f3b83f7590c..c503bf17710 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.1.23", + "version": "7.2.0", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index ffca4a9415f..34bc04c675e 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.1.23", + "version": "7.2.0", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 83091e58381..780eb6d7052 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.1.23", + "version": "7.2.0", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 6bed2eac5d5..121b2d317a7 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.1.23", + "version": "7.2.0", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 327a1083c71..0450b0335f9 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.1.23", + "version": "7.2.0", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 44ef0219c8e..99082d05f0d 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.1.23", + "version": "7.2.0", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c2409c27264..ee31c7c871a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.1.23", + "version": "7.2.0", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index cf439ced8aa..63bcd4a3bd1 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.1.23", + "version": "7.2.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index c951dcd5687..1a6b1ee9aac 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -8,7 +8,7 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.1.23", + "version": "7.2.0", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 969a6d0aa44..1c2f28473c6 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.1.23", + "version": "7.2.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/storybook/package.json b/packages/storybook/package.json index c874f5501d4..181a277cf52 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.1.23", + "version": "7.2.0", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 6535b8dcf0f..f0a6d88dcef 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.1.23", + "version": "7.2.0", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index ed93fce66e7..2f92151be1c 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "7.1.23", + "version": "7.2.0", "private": true, "type": "module", "license": "MIT", diff --git a/script/upstream/package.json b/script/upstream/package.json index 76643aab7bc..d6dab3f44b3 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.1.23", + "version": "7.2.0", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 12b653118d0..d521dcb3c2b 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "7.1.23", + "version": "7.2.0", "publisher": "sst-dev", "repository": { "type": "git", From f0b34e4129812e37af24bf468dd3ef6538d9eca9 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 7 Apr 2026 15:25:10 -0400 Subject: [PATCH 29/55] fix(cli): use bun ./script path to fix module resolution in CI --- .../workflows/check-opencode-annotations.yml | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/check-opencode-annotations.yml diff --git a/.github/workflows/check-opencode-annotations.yml b/.github/workflows/check-opencode-annotations.yml new file mode 100644 index 00000000000..625eea153b3 --- /dev/null +++ b/.github/workflows/check-opencode-annotations.yml @@ -0,0 +1,32 @@ +name: Check opencode annotations + +on: + pull_request: + paths: + - "packages/opencode/**" + - "script/check-opencode-annotations.ts" + - ".github/workflows/check-opencode-annotations.yml" + workflow_dispatch: + +jobs: + check-annotations: + name: Check kilocode_change annotations + if: github.repository == 'Kilo-Org/kilocode' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + + - name: Check kilocode_change annotations in shared opencode files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if [ -n "$BASE_SHA" ]; then + bun ./script/check-opencode-annotations.ts --base "$BASE_SHA" + else + echo "No PR base SHA available (workflow_dispatch without PR context) — skipping." + fi From f08ad1ef74fcd9e74eb85481494c7c4374d1ba60 Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 7 Apr 2026 21:32:09 +0200 Subject: [PATCH 30/55] feat(agent-manager): show branch name as subtitle in worktree sidebar items (#8529) Display the git branch name beneath the worktree label when it differs from the display name. Aligns the local card branch style to match (10px, --text-weaker). --- .../agent-manager/AgentManagerApp.tsx | 6 ++++ .../webview-ui/agent-manager/WorktreeItem.tsx | 7 ++++- .../agent-manager/agent-manager.css | 29 +++++++++++++++---- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index dee52089681..c131f64681d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -793,6 +793,11 @@ const AgentManagerContent: Component = () => { return firstOrderedTitle(sessions, worktreeTabOrder()[wt.id], wt.branch) } + const worktreeSubtitle = (wt: WorktreeState): string | undefined => { + const label = worktreeLabel(wt) + return label !== wt.branch ? wt.branch : undefined + } + const isStaleWorktree = (worktreeId: string): boolean => staleWorktreeIds().has(worktreeId) /** True when any session in the given ID list is actively working (busy/retry and not blocked by permissions/questions). */ @@ -2322,6 +2327,7 @@ const AgentManagerContent: Component = () => { = (props) => {
- {/* Row 2: always rendered for consistent height; PR badge or skeleton */} + {/* Row 2: branch subtitle + PR badge */}
+ + {props.subtitle} + Date: Tue, 7 Apr 2026 15:39:04 -0400 Subject: [PATCH 31/55] feat(cli): add annotation check script and AGENTS.md docs --- AGENTS.md | 1 + script/check-opencode-annotations.ts | 168 +++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 script/check-opencode-annotations.ts diff --git a/AGENTS.md b/AGENTS.md index a8323860822..1d4c3f77eb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang - **Knip** (unused exports): `bun run knip` from `packages/kilo-vscode/`. CI runs this — all exported types/functions must be imported somewhere. Remove or unexport unused exports before pushing. - **Source links**: After adding or changing URLs in `packages/kilo-vscode/`, `packages/kilo-vscode/webview-ui/`, or `packages/opencode/src/`, run `bun run script/extract-source-links.ts` from the repo root and commit the updated `packages/kilo-docs/source-links.md`. CI runs this check — the build fails if the file is stale. - **kilocode_change check**: `bun run check-kilocode-change` from `packages/kilo-vscode/`. CI runs this — `kilocode_change` is a marker for upstream merge conflicts and must not appear in `packages/kilo-vscode/` or `packages/kilo-ui/` (these are entirely Kilo Code additions). Remove the markers before pushing. +- **opencode annotation check**: `bun run script/check-opencode-annotations.ts` from repo root. CI runs this on PRs touching `packages/opencode/` — every Kilo-specific change in shared opencode files must be annotated with `kilocode_change` markers. Exempt paths (no markers needed): `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`, and any path containing `kilocode` in the name. ## Products diff --git a/script/check-opencode-annotations.ts b/script/check-opencode-annotations.ts new file mode 100644 index 00000000000..314b399d460 --- /dev/null +++ b/script/check-opencode-annotations.ts @@ -0,0 +1,168 @@ +#!/usr/bin/env bun + +/** + * Verifies that every Kilo-specific change in shared packages/opencode/ files + * is annotated with a kilocode_change marker. + * + * Usage: + * bun run script/check-opencode-annotations.ts # diff against origin/main + * bun run script/check-opencode-annotations.ts --base # diff against + * + * A line is "covered" if it: + * - contains // kilocode_change (inline annotation) + * - falls inside a // kilocode_change start/end block (block annotation) + * - is in a file whose first non-empty line is (whole-file annotation) + * // kilocode_change - new file + * - is empty / whitespace-only (skipped) + * - is itself a marker line (auto-covered) + * + * Exempt paths (no markers needed — entirely Kilo-specific): + * - packages/opencode/src/kilocode/** + * - packages/opencode/test/kilocode/** + * - Any path containing "kilocode" in directory or filename + */ + +import { spawnSync } from "node:child_process" +import { readFileSync } from "node:fs" +import path from "node:path" + +const ROOT = path.resolve(import.meta.dir, "..") +const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx"]) + +const args = process.argv.slice(2) +const baseIdx = args.indexOf("--base") +const base = baseIdx !== -1 ? args[baseIdx + 1] : "origin/main" + +function run(cmd: string, args: string[]) { + const result = spawnSync(cmd, args, { cwd: ROOT, encoding: "utf8" }) + return result.stdout?.trim() ?? "" +} + +function changedFiles() { + const out = run("git", ["diff", "--name-only", "--diff-filter=AMRT", `${base}...HEAD`, "--", "packages/opencode"]) + return out ? out.split("\n").filter(Boolean) : [] +} + +function isExempt(file: string) { + const norm = file.replaceAll("\\", "/").toLowerCase() + return norm.split("/").some((part) => part.includes("kilocode")) +} + +function isSource(file: string) { + return SOURCE_EXTS.has(path.extname(file)) +} + +function addedLines(file: string): Set { + const diff = run("git", ["diff", "--unified=0", "--diff-filter=AMRT", `${base}...HEAD`, "--", file]) + const out = new Set() + for (const line of diff.split("\n")) { + const m = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/) + if (!m) continue + const start = Number(m[1]) + const count = m[2] !== undefined ? Number(m[2]) : 1 + for (let i = 0; i < count; i++) out.add(start + i) + } + return out +} + +function coveredLines(text: string): { lines: string[]; covered: Set } { + const lines = text.split(/\r?\n/) + const covered = new Set() + + // Whole-file annotation: first non-empty line is "// kilocode_change - new file" + const first = lines.find((x) => x.trim() !== "") + if (first?.match(/\/\/\s*kilocode_change\s*-\s*new\s*file\b/)) { + for (let i = 1; i <= lines.length; i++) covered.add(i) + return { lines, covered } + } + + let block = false + for (let i = 0; i < lines.length; i++) { + const n = i + 1 + const line = lines[i] ?? "" + + if (line.match(/\/\/\s*kilocode_change\s+start\b/)) { + block = true + covered.add(n) + continue + } + + if (line.match(/\/\/\s*kilocode_change\s+end\b/)) { + covered.add(n) + block = false + continue + } + + if (block) { + covered.add(n) + continue + } + + if (line.match(/\/\/\s*kilocode_change\b/)) covered.add(n) + } + + return { lines, covered } +} + +// --- main --- + +const files = changedFiles().filter((f) => !isExempt(f) && isSource(f)) + +if (files.length === 0) { + console.log("No shared opencode source files changed — nothing to check.") + process.exit(0) +} + +const violations: string[] = [] + +for (const file of files) { + const nums = addedLines(file) + if (nums.size === 0) continue + + const abs = path.join(ROOT, file) + const text = readFileSync(abs, "utf8") + const { lines, covered } = coveredLines(text) + + for (const n of nums) { + const line = lines[n - 1] ?? "" + const trim = line.trim() + if (!trim) continue + if (trim.match(/\/\/\s*kilocode_change\b/)) continue + if (!covered.has(n)) violations.push(` ${file}:${n}: ${trim}`) + } +} + +if (violations.length === 0) { + console.log("All shared opencode changes are annotated with kilocode_change markers.") + process.exit(0) +} + +console.error( + [ + "Unannotated Kilo changes found in shared opencode files:", + "", + ...violations, + "", + "Every Kilo-specific change in packages/opencode/ must be annotated.", + "", + "Inline (single line):", + " const url = Flag.KILO_MODELS_URL || 'https://models.dev' // kilocode_change", + "", + "Block (multiple lines):", + " // kilocode_change start", + " ...", + " // kilocode_change end", + "", + "New file:", + " // kilocode_change - new file", + "", + "Exempt paths (no markers needed):", + " - packages/opencode/src/kilocode/**", + " - packages/opencode/test/kilocode/**", + " - Any path containing 'kilocode' in the directory or filename", + "", + "See AGENTS.md for details.", + ].join("\n"), +) + +process.exit(1) From 2371b7478434c3bc435e1acee40c7b9db7c246bf Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 7 Apr 2026 15:41:29 -0400 Subject: [PATCH 32/55] revert: restore bun run script/ invocation (consistent with source-check-links) --- .github/workflows/check-opencode-annotations.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-opencode-annotations.yml b/.github/workflows/check-opencode-annotations.yml index 625eea153b3..b2fc705daea 100644 --- a/.github/workflows/check-opencode-annotations.yml +++ b/.github/workflows/check-opencode-annotations.yml @@ -26,7 +26,7 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | if [ -n "$BASE_SHA" ]; then - bun ./script/check-opencode-annotations.ts --base "$BASE_SHA" + bun run script/check-opencode-annotations.ts --base "$BASE_SHA" else echo "No PR base SHA available (workflow_dispatch without PR context) — skipping." fi From 1595eba59ad69145bcfc72f85797226022db4877 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 7 Apr 2026 22:10:14 +0200 Subject: [PATCH 33/55] feat(cli): track remote connection opened event in PostHog --- packages/kilo-telemetry/src/events.ts | 3 +++ packages/kilo-telemetry/src/telemetry.ts | 5 +++++ packages/opencode/src/kilo-sessions/kilo-sessions.ts | 2 ++ 3 files changed, 10 insertions(+) diff --git a/packages/kilo-telemetry/src/events.ts b/packages/kilo-telemetry/src/events.ts index ddfde6a9a81..c621da8dc0b 100644 --- a/packages/kilo-telemetry/src/events.ts +++ b/packages/kilo-telemetry/src/events.ts @@ -25,6 +25,9 @@ export enum TelemetryEvent { MCP_SERVER_CONNECTED = "MCP Server Connected", MCP_SERVER_ERROR = "MCP Server Error", + // Remote Events + REMOTE_CONNECTION_OPENED = "Remote Connection Opened", + // Auth Events AUTH_SUCCESS = "Auth Success", AUTH_LOGOUT = "Auth Logout", diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 5b353aadcab..5e4379892da 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -184,6 +184,11 @@ export namespace Telemetry { track(TelemetryEvent.MCP_SERVER_ERROR, { server, error }) } + // Remote + export function trackRemoteConnectionOpened() { + track(TelemetryEvent.REMOTE_CONNECTION_OPENED) + } + // Auth export function trackAuthSuccess(provider: string) { track(TelemetryEvent.AUTH_SUCCESS, { provider }) diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 76249cf9d5f..d45abad6926 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -17,6 +17,7 @@ import simpleGit from "simple-git" import { RemoteWS } from "@/kilo-sessions/remote-ws" import { RemoteSender } from "@/kilo-sessions/remote-sender" import { SessionStatus } from "@/session/status" +import { Telemetry } from "@kilocode/kilo-telemetry" export namespace KiloSessions { const log = Log.create({ service: "kilo-sessions" }) @@ -293,6 +294,7 @@ export namespace KiloSessions { remote = { conn, sender, heartbeat } log.info("remote connection enabled") + Telemetry.trackRemoteConnectionOpened() })().finally(() => { if (remoteSeq === seq) enabling = undefined }) From 6106b1275dfce56e81c1afa99f73b90fc967f365 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:19:39 +0000 Subject: [PATCH 34/55] feat(vscode): add open-in-editor icon to instruction files in Rules tab Add a go-to-file icon button next to each instruction file entry in the Agent Behaviour > Rules subtab, allowing users to open the file directly in the editor. Uses the established go-to-file icon pattern consistent with DiffPanel and PromptInput components. --- .../src/components/settings/AgentBehaviourTab.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx index 1b3eac83992..47c0ba3ccc0 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx @@ -1004,7 +1004,15 @@ const AgentBehaviourTab: Component = () => { > {path} - removeInstruction(index())} /> +
+ vscode.postMessage({ type: "openFile", filePath: path })} + /> + removeInstruction(index())} /> +
)} From 60bfeaf63078377d696de9dc989e5de05e372092 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 8 Apr 2026 09:22:38 +0200 Subject: [PATCH 35/55] feat(agent-manager): sync worktree branch from git worktree list (#8534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Piggyback on the existing `git worktree list --porcelain` call in probeWorktreePresence to detect branch changes (e.g. after checking out a PR branch). Zero additional git calls — the branch info was already returned but discarded. --- .../src/agent-manager/AgentManagerProvider.ts | 16 ++++++++++++---- .../kilo-vscode/src/agent-manager/GitOps.ts | 8 ++++---- .../src/agent-manager/GitStatsPoller.ts | 5 ++++- .../src/agent-manager/WorktreeStateManager.ts | 13 +++++++++++++ .../tests/unit/git-stats-poller.test.ts | 17 ++++++++++------- 5 files changed, 43 insertions(+), 16 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index d70103ab362..3554990404d 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -695,11 +695,11 @@ export class AgentManagerProvider implements Disposable { this.pushState() // Disk removal after state is clean — pollers no longer reference this worktree. try { - await manager.removeWorktree(worktree.path, worktree.branch) + await manager.removeWorktree(worktree.path, worktree.originalBranch ?? worktree.branch) } catch (error) { this.log(`Failed to remove worktree from disk: ${error}`) } - this.log(`Deleted worktree ${worktreeId} (${worktree.branch})`) + this.log(`Deleted worktree ${worktreeId} (${worktree.originalBranch ?? worktree.branch})`) return null } @@ -1444,12 +1444,20 @@ export class AgentManagerProvider implements Disposable { const entries = result.worktrees.filter((item) => ids.has(item.worktreeId)) if (entries.length === 0) return + // Sync branches from git worktree list (no extra git calls) + let branchChanged = false + for (const entry of entries) { + if (entry.branch && state.updateWorktreeBranch(entry.worktreeId, entry.branch)) { + branchChanged = true + } + } + const next = new Set(entries.filter((entry) => entry.missing).map((entry) => entry.worktreeId)) - const changed = + const staleChanged = next.size !== this.staleWorktreeIds.size || [...next].some((worktreeId) => !this.staleWorktreeIds.has(worktreeId)) this.staleWorktreeIds = next - if (changed) { + if (staleChanged || branchChanged) { this.pushState() } } diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index 9aa393513ce..dcd74e95987 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -130,14 +130,14 @@ export class GitOps { } /** Return the set of worktree paths for the repo, excluding bare entries. */ - async listWorktreePaths(cwd: string): Promise> { + async listWorktreePaths(cwd: string): Promise> { const raw = await this.raw(["worktree", "list", "--porcelain"], cwd) - const paths = new Set() + const result = new Map() for (const entry of parseWorktreeList(raw)) { if (entry.bare) continue - paths.add(normalizePath(entry.path)) + result.set(normalizePath(entry.path), entry.branch) } - return paths + return result } /** diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index 27260b2f059..301261d137e 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -26,6 +26,8 @@ export interface LocalStats { export interface WorktreePresence { worktreeId: string missing: boolean + /** Current branch from `git worktree list`, if available. */ + branch?: string } export interface WorktreePresenceResult { @@ -231,7 +233,8 @@ export class GitStatsPoller { () => false, ) const missing = !exists || !tracked.has(normalized) - return { worktreeId: wt.id, missing } + const branch = tracked.get(normalized) + return { worktreeId: wt.id, missing, branch } }), ) diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index e7c0ba50214..c41ce547aa8 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -32,6 +32,9 @@ export interface Worktree { prUrl?: string /** Cached PR state for correct badge color on reload (open/merged/closed/draft). */ prState?: string + /** Original branch created with the worktree, used for cleanup on deletion. + * Set automatically when `branch` is updated via live sync. */ + originalBranch?: string } /** @@ -169,6 +172,16 @@ export class WorktreeStateManager { return wt } + updateWorktreeBranch(id: string, branch: string): boolean { + const wt = this.worktrees.get(id) + if (!wt || wt.branch === branch) return false + if (!wt.originalBranch) wt.originalBranch = wt.branch + this.log(`Updated worktree ${id} branch: ${wt.branch} → ${branch}`) + wt.branch = branch + void this.save() + return true + } + updateWorktreeLabel(id: string, label: string): void { const wt = this.worktrees.get(id) if (!wt) return diff --git a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts index ef9ec16b823..53176ffccb9 100644 --- a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts +++ b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts @@ -3,7 +3,7 @@ import * as fs from "fs" import * as os from "os" import * as path from "path" import type { KiloClient } from "@kilocode/sdk/v2/client" -import { GitStatsPoller } from "../../src/agent-manager/GitStatsPoller" +import { GitStatsPoller, type WorktreePresenceResult } from "../../src/agent-manager/GitStatsPoller" import { GitOps } from "../../src/agent-manager/GitOps" import type { Worktree } from "../../src/agent-manager/WorktreeStateManager" @@ -128,7 +128,7 @@ describe("GitStatsPoller", () => { const wtPath = path.join(root, "wt-a") fs.mkdirSync(wtPath, { recursive: true }) - const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = [] + const presence: WorktreePresenceResult[] = [] const poller = new GitStatsPoller({ getWorktrees: () => [{ ...worktree("a"), path: wtPath }], @@ -154,7 +154,10 @@ describe("GitStatsPoller", () => { poller.stop() fs.rmSync(root, { recursive: true, force: true }) - expect(presence[0]).toEqual({ worktrees: [{ worktreeId: "a", missing: false }], degraded: false }) + expect(presence[0]).toEqual({ + worktrees: [{ worktreeId: "a", missing: false, branch: "branch-a" }], + degraded: false, + }) }) it("emits degraded probe when git worktree listing fails", async () => { @@ -162,7 +165,7 @@ describe("GitStatsPoller", () => { const wtPath = path.join(root, "wt-a") fs.mkdirSync(wtPath, { recursive: true }) - const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = [] + const presence: WorktreePresenceResult[] = [] const poller = new GitStatsPoller({ getWorktrees: () => [{ ...worktree("a"), path: wtPath }], @@ -199,7 +202,7 @@ describe("GitStatsPoller", () => { const calls: string[] = [] const emitted: Array> = [] - const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = [] + const presence: WorktreePresenceResult[] = [] const client = { worktree: { @@ -240,8 +243,8 @@ describe("GitStatsPoller", () => { expect(calls.some((cwd) => cwd === wtBPath)).toBe(false) expect(presence[0]).toEqual({ worktrees: [ - { worktreeId: "a", missing: false }, - { worktreeId: "b", missing: true }, + { worktreeId: "a", missing: false, branch: "branch-a" }, + { worktreeId: "b", missing: true, branch: undefined }, ], degraded: false, }) From 32c442497ff9d3e3e0a7b546bae06170ef76dfda Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:31:47 +0000 Subject: [PATCH 36/55] fix(vscode): use pencil-line icon for edit action in Rules tab The pencil icon more clearly conveys "edit this file" compared to the go-to-file icon. --- .../webview-ui/src/components/settings/AgentBehaviourTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx index 47c0ba3ccc0..5349789bae3 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx @@ -1008,7 +1008,7 @@ const AgentBehaviourTab: Component = () => { vscode.postMessage({ type: "openFile", filePath: path })} /> removeInstruction(index())} /> From 9e13f71a75dc7f9e18d9a70a9b2a5bae9474dc4b Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:51:50 +0000 Subject: [PATCH 37/55] fix(i18n): clarify built-in mode banner to avoid read-only confusion Re-apply PR #7720 which was reverted by a force push during the publish workflow. Also update uk, tr, and nl translations which were missed by the original translation update in PR #8086. Closes #8558 (partial) --- packages/kilo-vscode/webview-ui/src/i18n/en.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/nl.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/tr.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/uk.ts | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index cdc8808c581..ae418d88169 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1175,7 +1175,8 @@ export const dict = { "settings.agentBehaviour.editMode.prompt": "System Prompt", "settings.agentBehaviour.editMode.save": "Done", "settings.agentBehaviour.editMode.back": "Back to list", - "settings.agentBehaviour.editMode.native": "Built-in mode (read-only definition)", + "settings.agentBehaviour.editMode.native": + "This is a built-in mode. Its base definition cannot be changed, but you can configure overrides below.", "settings.agentBehaviour.editMode.promptOverride": "Custom prompt override for this built-in mode", "settings.autoApprove.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index b3a648f5ab9..32e70ebc7a1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1377,7 +1377,8 @@ export const dict = { "settings.agentBehaviour.editMode": "Modus bewerken", "settings.agentBehaviour.editMode.back": "Terug naar lijst", "settings.agentBehaviour.editMode.description": "Beschrijving", - "settings.agentBehaviour.editMode.native": "Ingebouwde modus (alleen-lezen definitie)", + "settings.agentBehaviour.editMode.native": + "Dit is een ingebouwde modus. De basisdefinitie kan niet worden gewijzigd, maar u kunt hieronder overrides configureren.", "settings.agentBehaviour.editMode.prompt": "Systeemprompt", "settings.agentBehaviour.editMode.promptOverride": "Aangepaste systeemprompt voor deze ingebouwde modus", "settings.agentBehaviour.editMode.save": "Klaar", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 9728ca2defe..996c68cd5ad 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1370,7 +1370,8 @@ export const dict = { "settings.agentBehaviour.editMode": "Modu Düzenle", "settings.agentBehaviour.editMode.back": "Listeye dön", "settings.agentBehaviour.editMode.description": "Açıklama", - "settings.agentBehaviour.editMode.native": "Yerleşik mod (salt okunur tanım)", + "settings.agentBehaviour.editMode.native": + "Bu yerleşik bir moddur. Temel tanımı değiştirilemez, ancak aşağıdan geçersiz kılmaları yapılandırabilirsiniz.", "settings.agentBehaviour.editMode.prompt": "Sistem İstemi", "settings.agentBehaviour.editMode.promptOverride": "Bu yerleşik mod için özel sistem istemi geçersiz kılma", "settings.agentBehaviour.editMode.save": "Tamam", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 44d43c7073a..1d7a576b582 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1371,7 +1371,8 @@ export const dict = { "settings.agentBehaviour.editMode": "Редагувати режим", "settings.agentBehaviour.editMode.back": "Назад до списку", "settings.agentBehaviour.editMode.description": "Опис", - "settings.agentBehaviour.editMode.native": "Вбудований режим (визначення лише для читання)", + "settings.agentBehaviour.editMode.native": + "Це вбудований режим. Його базове визначення неможливо змінити, але ви можете налаштувати перевизначення нижче.", "settings.agentBehaviour.editMode.prompt": "Системний запит", "settings.agentBehaviour.editMode.promptOverride": "Власне перевизначення системного запиту для цього вбудованого режиму", From 17b2355a26b2f6ce22e2781180fcb723e3ac298c Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:56:38 +0000 Subject: [PATCH 38/55] fix(vscode): fall back to generic "Try Model" for long model names Recover PR #7981 (force-pushed by publish workflow on April 1, 2026). When the suggested model name exceeds 30 characters (after stripping sub-provider prefix), fall back to the generic "Try Model" label to prevent an oversized notification button. Adds tryModelGeneric i18n key for the fallback label across all 18 locales. See #8558 --- .../src/components/chat/KiloNotifications.tsx | 10 ++++++++-- packages/kilo-vscode/webview-ui/src/i18n/ar.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/br.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/bs.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/da.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/de.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/en.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/es.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/fr.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/ja.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/ko.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/nl.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/no.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/pl.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/ru.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/th.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/tr.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/zh.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/zht.ts | 1 + 19 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/KiloNotifications.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/KiloNotifications.tsx index e0d01ef86e6..06d410d1ed4 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/KiloNotifications.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/KiloNotifications.tsx @@ -62,12 +62,16 @@ export const KiloNotifications: Component = () => { return true }) + const MAX_NAME = 30 + const suggestedName = createMemo(() => { const suggestion = suggestedModel() if (!suggestion) return undefined const model = provider.findModel(suggestion) if (!model?.name) return undefined - return stripSubProviderPrefix(model.name) + const name = stripSubProviderPrefix(model.name) + if (name.length > MAX_NAME) return undefined + return name }) const handleTryModel = () => { @@ -98,7 +102,9 @@ export const KiloNotifications: Component = () => {
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 018648fff11..1911d9160d2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1334,4 +1334,5 @@ export const dict = { "notifications.action.next": "التالي", "notifications.action.close": "إغلاق", "notifications.action.tryModel": "جرّب {{model}}", + "notifications.action.tryModelGeneric": "جرّب النموذج", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index f53d7ee3106..723bd0ace71 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1363,4 +1363,5 @@ export const dict = { "notifications.action.next": "Próximo", "notifications.action.close": "Fechar", "notifications.action.tryModel": "Experimentar {{model}}", + "notifications.action.tryModelGeneric": "Experimentar modelo", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 804deedc915..414701e0653 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1359,4 +1359,5 @@ export const dict = { "notifications.action.next": "Sljedeći", "notifications.action.close": "Zatvori", "notifications.action.tryModel": "Probaj {{model}}", + "notifications.action.tryModelGeneric": "Probaj model", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 0f85dd66ead..c7a0ee2bb4f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1350,4 +1350,5 @@ export const dict = { "notifications.action.next": "Næste", "notifications.action.close": "Luk", "notifications.action.tryModel": "Prøv {{model}}", + "notifications.action.tryModelGeneric": "Prøv model", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index c0ce3dcc640..139cc84bfa5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1377,4 +1377,5 @@ export const dict = { "notifications.action.next": "Weiter", "notifications.action.close": "Schließen", "notifications.action.tryModel": "{{model}} ausprobieren", + "notifications.action.tryModelGeneric": "Modell ausprobieren", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index cdc8808c581..64fb332609d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1360,4 +1360,5 @@ export const dict = { "notifications.action.next": "Next", "notifications.action.close": "Close", "notifications.action.tryModel": "Try {{model}}", + "notifications.action.tryModelGeneric": "Try Model", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 83ef47af004..b05fadfa265 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1366,4 +1366,5 @@ export const dict = { "notifications.action.next": "Siguiente", "notifications.action.close": "Cerrar", "notifications.action.tryModel": "Probar {{model}}", + "notifications.action.tryModelGeneric": "Probar modelo", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 451e4dc37e5..d9ee62fe576 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1382,4 +1382,5 @@ export const dict = { "notifications.action.next": "Suivant", "notifications.action.close": "Fermer", "notifications.action.tryModel": "Essayer {{model}}", + "notifications.action.tryModelGeneric": "Essayer le modèle", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index a7457c19d22..2466dd4acab 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1349,4 +1349,5 @@ export const dict = { "notifications.action.next": "次へ", "notifications.action.close": "閉じる", "notifications.action.tryModel": "{{model}}を試す", + "notifications.action.tryModelGeneric": "モデルを試す", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 1c0de33a563..2ebaca8dedc 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1337,4 +1337,5 @@ export const dict = { "notifications.action.next": "다음", "notifications.action.close": "닫기", "notifications.action.tryModel": "{{model}} 시도", + "notifications.action.tryModelGeneric": "모델 시도", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index b3a648f5ab9..f7e082e2819 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1355,6 +1355,7 @@ export const dict = { "notifications.action.next": "Volgende", "notifications.action.close": "Sluiten", "notifications.action.tryModel": "Probeer {{model}}", + "notifications.action.tryModelGeneric": "Probeer model", // Missing translations - English fallbacks until translated "settings.agentBehaviour.createMode": "Nieuwe modus aanmaken", "settings.agentBehaviour.createMode.button": "Aanmaken", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index d397d75f5a8..f99649fca0f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1348,4 +1348,5 @@ export const dict = { "notifications.action.next": "Neste", "notifications.action.close": "Lukk", "notifications.action.tryModel": "Prøv {{model}}", + "notifications.action.tryModelGeneric": "Prøv modell", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index c5283783fb5..896cbff05d0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1358,4 +1358,5 @@ export const dict = { "notifications.action.next": "Następny", "notifications.action.close": "Zamknij", "notifications.action.tryModel": "Wypróbuj {{model}}", + "notifications.action.tryModelGeneric": "Wypróbuj model", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 6685607d233..2d8a1bb2cc3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1357,4 +1357,5 @@ export const dict = { "notifications.action.next": "Далее", "notifications.action.close": "Закрыть", "notifications.action.tryModel": "Попробовать {{model}}", + "notifications.action.tryModelGeneric": "Попробовать модель", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index dbd2efc8fa0..cffeb9fd8b4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1333,4 +1333,5 @@ export const dict = { "notifications.action.next": "ถัดไป", "notifications.action.close": "ปิด", "notifications.action.tryModel": "ลองใช้ {{model}}", + "notifications.action.tryModelGeneric": "ลองใช้โมเดล", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 9728ca2defe..7759972656c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1347,6 +1347,7 @@ export const dict = { "notifications.action.next": "Sonraki", "notifications.action.close": "Kapat", "notifications.action.tryModel": "Dene {{model}}", + "notifications.action.tryModelGeneric": "Modeli Dene", // Missing translations - English fallbacks until translated "profile.switchingAccount": "Hesap değiştiriliyor…", "settings.agentBehaviour.createMode": "Yeni Mod Oluştur", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index e2527847aef..7cc9ef99319 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1308,4 +1308,5 @@ export const dict = { "notifications.action.next": "下一个", "notifications.action.close": "关闭", "notifications.action.tryModel": "尝试 {{model}}", + "notifications.action.tryModelGeneric": "尝试模型", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 016313e9b0b..71a73f5ee6a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1311,4 +1311,5 @@ export const dict = { "notifications.action.next": "下一個", "notifications.action.close": "關閉", "notifications.action.tryModel": "嘗試 {{model}}", + "notifications.action.tryModelGeneric": "嘗試模型", } satisfies Partial> From f3dee356afb098aa16f6c6c7b8a95595756c47e9 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:19:10 +0000 Subject: [PATCH 39/55] fix(vscode): add missing uk translation for tryModelGeneric --- packages/kilo-vscode/webview-ui/src/i18n/uk.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 44d43c7073a..5dae7ec8116 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1348,6 +1348,7 @@ export const dict = { "notifications.action.next": "Далі", "notifications.action.close": "Закрити", "notifications.action.tryModel": "Спробувати {{model}}", + "notifications.action.tryModelGeneric": "Спробувати модель", // Missing translations - English fallbacks until translated "profile.switchingAccount": "Перемикання акаунту…", "settings.agentBehaviour.createMode": "Створити новий режим", From 634cec88b141caf546f4dbd6843aaf9bbfe7e2e6 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 8 Apr 2026 10:34:41 +0200 Subject: [PATCH 40/55] fix(vscode): show stop button during rate limit retry state (#8552) The PromptInput isBusy() check only matched 'busy' status, hiding the stop button and disabling Escape-to-abort when the session was in 'retry' state (e.g. rate limited). Changed to match any non-idle status so users can always abort. --- .../kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index feaca6ac761..3326f9872e6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -244,7 +244,7 @@ export const PromptInput: Component = (props) => { window.addEventListener("compactSession", onCompact) onCleanup(() => window.removeEventListener("compactSession", onCompact)) - const isBusy = () => session.status() === "busy" + const isBusy = () => session.status() !== "idle" const isDisabled = () => !server.isConnected() const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0 const canSend = () => hasInput() && !isDisabled() && !props.blocked?.() From d815b2118d791ebf6b8839da9d108dc3934a5395 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:38:34 +0000 Subject: [PATCH 41/55] feat(vscode): add fuzzy search to model selector - Add subsequence fuzzy matching for model search - Support searching by provider name - Normalize spaces to dashes for better matching (e.g., 'gpt 5.4' matches 'gpt-5.4') Fixes #8404 --- .../src/components/shared/ModelSelector.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index 1ae56f28f2b..cd9dc341383 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -34,6 +34,16 @@ import { ModelPreview } from "./ModelPreview" // Row / group key helpers — single source of truth for key formatting // --------------------------------------------------------------------------- +function fuzzyMatch(query: string, target: string): boolean { + const q = query.toLowerCase().replace(/\s+/g, "") + const t = target.toLowerCase().replace(/\s+/g, "") + let qi = 0 + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) qi++ + } + return qi === q.length +} + const CLEAR_KEY = "clear" const FAVORITES_KEY = "favorites" const RECOMMENDED_KEY = "recommended" @@ -167,11 +177,11 @@ export const ModelSelectorBase: Component = (props) => { // Flat filtered list for keyboard navigation const filtered = createMemo(() => { - const q = debouncedSearch().toLowerCase() + const q = debouncedSearch() if (!q) { return visibleModels() } - return visibleModels().filter((m) => m.name.toLowerCase().includes(q)) + return visibleModels().filter((m) => fuzzyMatch(q, m.name) || fuzzyMatch(q, m.id) || fuzzyMatch(q, m.providerName)) }) // Live set of favorited keys — drives star icon visual state (filled vs outline). From b8714986902b8704a98f47fc271eeadfe59232d1 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 8 Apr 2026 11:46:40 +0300 Subject: [PATCH 42/55] style(cli): add kilocode markers in toast --- packages/opencode/src/cli/cmd/tui/ui/toast.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx index 32b8deac14d..cbe2eb8550c 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx @@ -60,6 +60,7 @@ function init() { const { duration, ...currentToast } = parsedOptions setStore("currentToast", currentToast) if (timeoutHandle) clearTimeout(timeoutHandle) + // kilocode_change start timeoutHandle = null if (duration && duration > 0) { timeoutHandle = setTimeout(() => { @@ -73,6 +74,7 @@ function init() { timeoutHandle = null setStore("currentToast", null) }, + // kilocode_change end error: (err: any) => { if (err instanceof Error) return toast.show({ From bb8b807dff8363be39b9411f567e618ede94d558 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 8 Apr 2026 11:46:42 +0300 Subject: [PATCH 43/55] docs(cli): add KILO_CONFIG_DIR to command paths --- packages/opencode/src/kilocode/skills/kilo-config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/skills/kilo-config.md b/packages/opencode/src/kilocode/skills/kilo-config.md index 70289d08dcd..96861a41e9e 100644 --- a/packages/opencode/src/kilocode/skills/kilo-config.md +++ b/packages/opencode/src/kilocode/skills/kilo-config.md @@ -205,7 +205,7 @@ Toggle notifications, Toggle animations, Toggle diff wrapping, Toggle sidebar (` | Project | `./kilo.json`, `./.kilo/kilo.json` | | Global | `~/.config/kilo/kilo.json` | | Managed | Linux: `/etc/kilo/kilo.json`, macOS: `/Library/Application Support/kilo/kilo.json`, Windows: `%ProgramData%\kilo\kilo.json` (enterprise, highest priority) | -| Commands | `.kilo/command/*.md` (project), `~/.config/kilo/command/*.md` (global) | +| Commands | `.kilo/command/*.md` (project), `~/.config/kilo/command/*.md` (global), `KILO_CONFIG_DIR/command/*.md` if set | | Agents | `.kilo/agent/*.md` (project), `~/.config/kilo/agent/*.md` (global) | | Skills | `.kilo/skill/*/SKILL.md`, `.kilo/skills/*/SKILL.md` | | Instructions | `AGENTS.md`, `.kilo/instructions.md`, glob patterns from `instructions` | From e0e116e9ccdaae0118931312b12450e756e8f8c6 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 8 Apr 2026 11:47:05 +0300 Subject: [PATCH 44/55] docs(cli): add workflows section --- packages/opencode/src/kilocode/skills/kilo-config.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/opencode/src/kilocode/skills/kilo-config.md b/packages/opencode/src/kilocode/skills/kilo-config.md index 96861a41e9e..572d1d23018 100644 --- a/packages/opencode/src/kilocode/skills/kilo-config.md +++ b/packages/opencode/src/kilocode/skills/kilo-config.md @@ -41,6 +41,10 @@ System prompt for this agent. `mode` values: `primary` = selectable as main agent, `subagent` = only via Task tool, `all` = both. +## Workflows (legacy) + +Markdown files in `.kilo/workflows/` or `.kilocode/workflows/` (project-level) and `~/.kilo/workflows/` or `~/.kilocode/workflows/` (global). These are automatically converted to commands at startup. The filename (minus `.md`) becomes the command name. Project workflows override global ones with the same name. + ## Permissions Scalar form applies to all patterns. Object form maps glob patterns to actions. Evaluated top-to-bottom; first match wins. From b5c756ef72b1ca621167a103c39097a67cbac21f Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 8 Apr 2026 11:47:24 +0300 Subject: [PATCH 45/55] style(cli): add kilocode markers on command block --- packages/opencode/src/config/config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f7cb7268915..73defd0732f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -596,7 +596,8 @@ export namespace Config { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse command ${item}` - if (warnings) warnings.push({ path: item, message }) // kilocode_change + // kilocode_change start + if (warnings) warnings.push({ path: item, message }) try { const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) @@ -605,6 +606,7 @@ export namespace Config { } log.error("failed to load command", { command: item, err }) return undefined + // kilocode_change end }) if (!md) continue From cef00b9df3b2408ffb9b481f5dd673c286f0eb6b Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 8 Apr 2026 11:47:50 +0300 Subject: [PATCH 46/55] style(cli): add kilocode markers on agent block --- packages/opencode/src/config/config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 73defd0732f..4d0a6e453a8 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -655,7 +655,8 @@ export namespace Config { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse agent ${item}` - if (warnings) warnings.push({ path: item, message }) // kilocode_change + // kilocode_change start + if (warnings) warnings.push({ path: item, message }) try { const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) @@ -664,6 +665,7 @@ export namespace Config { } log.error("failed to load agent", { agent: item, err }) return undefined + // kilocode_change end }) if (!md) continue From 0a2c10956e1e31a0084dc0d18a2ed6a82bcb9d55 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 8 Apr 2026 11:48:10 +0300 Subject: [PATCH 47/55] style(cli): add kilocode markers on mode block --- packages/opencode/src/config/config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 4d0a6e453a8..d4cba10d1a1 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -715,7 +715,8 @@ export namespace Config { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse mode ${item}` - if (warnings) warnings.push({ path: item, message }) // kilocode_change + // kilocode_change start + if (warnings) warnings.push({ path: item, message }) try { const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) @@ -724,6 +725,7 @@ export namespace Config { } log.error("failed to load mode", { mode: item, err }) return undefined + // kilocode_change end }) if (!md) continue From 49cabd99c395dcdb85e3dd228c608e6c72c48b4d Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 8 Apr 2026 10:49:23 +0200 Subject: [PATCH 48/55] feat(agent-manager): add expand/collapse all button to inline diff panel (#8562) --- .../webview-ui/agent-manager/DiffPanel.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 3c00d20d5cd..af67780fe9a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -307,6 +307,11 @@ export const DiffPanel: Component = (props) => { sendAllToChat() } + const handleExpandAll = () => { + const allOpen = open().length === props.diffs.length + setOpen(allOpen ? [] : props.diffs.map((d) => d.file)) + } + const totals = createMemo(() => ({ files: props.diffs.length, additions: props.diffs.reduce((sum, diff) => sum + diff.additions, 0), @@ -354,6 +359,28 @@ export const DiffPanel: Component = (props) => {
+ 0}> + + + + Date: Wed, 8 Apr 2026 11:05:56 +0200 Subject: [PATCH 49/55] fix(vscode): trim whitespace-only search queries in model selector --- .../webview-ui/src/components/shared/ModelSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index cd9dc341383..4c99925df50 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -177,7 +177,7 @@ export const ModelSelectorBase: Component = (props) => { // Flat filtered list for keyboard navigation const filtered = createMemo(() => { - const q = debouncedSearch() + const q = debouncedSearch().trim() if (!q) { return visibleModels() } From 4b1e81b113356cf211c3994af8cc1a39c2840bc8 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 8 Apr 2026 11:10:58 +0200 Subject: [PATCH 50/55] fix(vscode): use word-boundary matching instead of fuzzy match in model selector Port the word-boundary matching algorithm from the legacy word-boundary-fzf.ts. This splits text at camelCase transitions and delimiters, so 'clso' matches 'Claude Sonnet' (Cl+So) and multi-word queries like 'gpt 5' require each fragment to match independently. --- .../src/components/shared/ModelSelector.tsx | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index 4c99925df50..3dc1b2dbbaf 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -34,14 +34,36 @@ import { ModelPreview } from "./ModelPreview" // Row / group key helpers — single source of truth for key formatting // --------------------------------------------------------------------------- -function fuzzyMatch(query: string, target: string): boolean { - const q = query.toLowerCase().replace(/\s+/g, "") - const t = target.toLowerCase().replace(/\s+/g, "") - let qi = 0 - for (let ti = 0; ti < t.length && qi < q.length; ti++) { - if (t[ti] === q[qi]) qi++ +// Word boundary matching ported from legacy word-boundary-fzf.ts. +// Splits at camelCase transitions and common delimiters so that e.g. +// "clso" matches "Claude Sonnet" (Cl + So), "gpt-5" splits into ["gpt","5"]. +const WORD_BOUNDARY = /(?=[A-Z])|[[\]_.:\s/\\(){}-]+/ + +function acronymMatch(text: string, query: string): boolean { + const words = text + .split(WORD_BOUNDARY) + .filter((w) => w.length > 0) + .map((w) => w.toLowerCase()) + + const attempt = (wi: number, qi: number): boolean => { + if (qi === query.length) return true + if (wi >= words.length) return false + const word = words[wi]! + let consumed = 0 + while (qi + consumed < query.length && consumed < word.length && word[consumed] === query[qi + consumed]) consumed++ + if (consumed > 0 && attempt(wi + 1, qi + consumed)) return true + return attempt(wi + 1, qi) } - return qi === q.length + + return attempt(0, 0) +} + +function searchMatch(query: string, text: string): boolean { + const q = query.toLowerCase().trim() + if (!q) return true + const parts = q.split(WORD_BOUNDARY).filter((w) => w.length > 0) + if (parts.length === 0) return true + return parts.every((p) => acronymMatch(text, p)) } const CLEAR_KEY = "clear" @@ -181,7 +203,9 @@ export const ModelSelectorBase: Component = (props) => { if (!q) { return visibleModels() } - return visibleModels().filter((m) => fuzzyMatch(q, m.name) || fuzzyMatch(q, m.id) || fuzzyMatch(q, m.providerName)) + return visibleModels().filter( + (m) => searchMatch(q, m.name) || searchMatch(q, m.id) || searchMatch(q, m.providerName), + ) }) // Live set of favorited keys — drives star icon visual state (filled vs outline). From d771f29a6069353bfdf1c52ede3513e3b1600c90 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Wed, 8 Apr 2026 11:19:32 +0200 Subject: [PATCH 51/55] refactor(vscode): extract search matching into testable utility Move word-boundary matching logic from ModelSelector.tsx into webview-ui/src/utils/search-match.ts with 37 unit tests ported from the legacy word-boundary-fzf.ts test suite. --- .../tests/unit/search-match.test.ts | 200 ++++++++++++++++++ .../src/components/shared/ModelSelector.tsx | 33 +-- .../webview-ui/src/utils/search-match.ts | 59 ++++++ 3 files changed, 260 insertions(+), 32 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/search-match.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/utils/search-match.ts diff --git a/packages/kilo-vscode/tests/unit/search-match.test.ts b/packages/kilo-vscode/tests/unit/search-match.test.ts new file mode 100644 index 00000000000..c12cf433abc --- /dev/null +++ b/packages/kilo-vscode/tests/unit/search-match.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect } from "bun:test" +import { acronymMatch, searchMatch } from "../../webview-ui/src/utils/search-match" + +// --------------------------------------------------------------------------- +// acronymMatch — low-level word-boundary matching +// --------------------------------------------------------------------------- + +describe("acronymMatch", () => { + describe("basic word boundary matching", () => { + it("matches at word start", () => { + expect(acronymMatch("fool org", "foo")).toBe(true) + expect(acronymMatch("the fool", "foo")).toBe(true) + }) + + it("does not match arbitrary substrings", () => { + expect(acronymMatch("faoboc", "foo")).toBe(false) + expect(acronymMatch("barfoo", "foo")).toBe(false) + }) + + it("matches prefix of a single word", () => { + expect(acronymMatch("foobar", "foob")).toBe(true) + }) + + it("matches exact word", () => { + expect(acronymMatch("test", "test")).toBe(true) + expect(acronymMatch("testing", "test")).toBe(true) + expect(acronymMatch("the test", "test")).toBe(true) + }) + }) + + describe("word separators", () => { + it("recognizes space", () => { + expect(acronymMatch("hello world", "wor")).toBe(true) + }) + + it("recognizes hyphen", () => { + expect(acronymMatch("hello-world", "wor")).toBe(true) + }) + + it("recognizes underscore", () => { + expect(acronymMatch("hello_world", "wor")).toBe(true) + }) + + it("recognizes slash", () => { + expect(acronymMatch("hello/world", "wor")).toBe(true) + }) + + it("recognizes dot", () => { + expect(acronymMatch("hello.world", "wor")).toBe(true) + }) + + it("recognizes parentheses", () => { + expect(acronymMatch("Grok Code Fast 1 (free)", "free")).toBe(true) + }) + }) + + describe("acronym matching", () => { + it("matches acronyms from word starts", () => { + expect(acronymMatch("Claude Sonnet", "clso")).toBe(true) + }) + + it("matches partial acronyms", () => { + expect(acronymMatch("Claude Sonnet 3.5", "cls")).toBe(true) + }) + + it("matches direct and acronym", () => { + expect(acronymMatch("clso tool", "clso")).toBe(true) + expect(acronymMatch("Claude Sonnet", "clso")).toBe(true) + }) + + it("does not match non-boundary acronym", () => { + expect(acronymMatch("aclbso", "clso")).toBe(false) + }) + }) + + describe("camelCase and PascalCase", () => { + it("recognizes camelCase boundary", () => { + expect(acronymMatch("gitRebase", "gr")).toBe(true) + }) + + it("matches PascalCase acronyms", () => { + expect(acronymMatch("NewFileCreation", "nfc")).toBe(true) + }) + + it("splits camelCase at uppercase transitions", () => { + expect(acronymMatch("parseMarkdownContent", "pmc")).toBe(true) + }) + + it("handles mixed case scenarios", () => { + expect(acronymMatch("gitRebase", "gitr")).toBe(true) + expect(acronymMatch("GitRebase", "gitr")).toBe(true) + }) + }) + + describe("backtracking", () => { + it("matches word that appears later in text", () => { + expect(acronymMatch("google gemini", "gemini")).toBe(true) + expect(acronymMatch("gemini pro", "gemini")).toBe(true) + expect(acronymMatch("google em emini", "gemini")).toBe(true) + }) + + it("matches partial word that appears later", () => { + expect(acronymMatch("Microsoft Copilot", "copilot")).toBe(true) + expect(acronymMatch("GitHub Copilot", "copilot")).toBe(true) + }) + + it("still respects word boundaries with backtracking", () => { + expect(acronymMatch("google gemini", "gemini")).toBe(true) + expect(acronymMatch("googlegemini", "gemini")).toBe(false) + }) + }) + + describe("edge cases", () => { + it("handles empty text", () => { + expect(acronymMatch("", "foo")).toBe(false) + }) + + it("handles empty query", () => { + expect(acronymMatch("foo", "")).toBe(true) + }) + + it("handles special characters in text", () => { + expect(acronymMatch("foo-bar", "foob")).toBe(true) + }) + }) +}) + +// --------------------------------------------------------------------------- +// searchMatch — high-level search with trimming and multi-word support +// --------------------------------------------------------------------------- + +describe("searchMatch", () => { + describe("empty and whitespace queries", () => { + it("returns true for empty query", () => { + expect(searchMatch("", "anything")).toBe(true) + }) + + it("returns true for whitespace-only query", () => { + expect(searchMatch(" ", "anything")).toBe(true) + }) + }) + + describe("case insensitivity", () => { + it("matches case-insensitively", () => { + expect(searchMatch("foo", "Foo Bar")).toBe(true) + expect(searchMatch("foo", "FOO BAZ")).toBe(true) + expect(searchMatch("FoO", "foo qux")).toBe(true) + }) + }) + + describe("trimming", () => { + it("trims leading spaces", () => { + expect(searchMatch(" foo", "foo bar")).toBe(true) + }) + + it("trims trailing spaces", () => { + expect(searchMatch("foo ", "foo bar")).toBe(true) + }) + + it("trims spaces on both sides", () => { + expect(searchMatch(" foo ", "foo bar")).toBe(true) + }) + }) + + describe("multi-word queries", () => { + it("matches when all words present", () => { + expect(searchMatch("claude sonnet", "Claude Sonnet 3.5")).toBe(true) + }) + + it("does not match when any word missing", () => { + expect(searchMatch("claude sonnet", "Claude Opus")).toBe(false) + expect(searchMatch("claude sonnet", "GPT Sonnet")).toBe(false) + }) + }) + + describe("real-world model search", () => { + it("finds model with hyphen in query", () => { + expect(searchMatch("gpt-5", "OpenAI: gpt-5 mini")).toBe(true) + expect(searchMatch("gpt-5", "OpenAI: gpt-4")).toBe(false) + }) + + it("finds model when hyphen omitted from query", () => { + expect(searchMatch("gpt5", "OpenAI: gpt-5 mini")).toBe(true) + }) + + it("finds all models with trailing hyphen", () => { + expect(searchMatch("gpt-", "OpenAI: gpt-5 mini")).toBe(true) + expect(searchMatch("gpt-", "OpenAI: gpt-4")).toBe(true) + expect(searchMatch("gpt-", "Anthropic: claude-3")).toBe(false) + }) + + it("matches file paths", () => { + expect(searchMatch("code", "src/services/code-index/manager.ts")).toBe(true) + }) + + it("matches mode selector options by label+value", () => { + expect(searchMatch("cod", "Code code Write code")).toBe(true) + }) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index 3dc1b2dbbaf..dc7b8191361 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -29,43 +29,12 @@ import { sanitizeName, } from "./model-selector-utils" import { ModelPreview } from "./ModelPreview" +import { searchMatch } from "../../utils/search-match" // --------------------------------------------------------------------------- // Row / group key helpers — single source of truth for key formatting // --------------------------------------------------------------------------- -// Word boundary matching ported from legacy word-boundary-fzf.ts. -// Splits at camelCase transitions and common delimiters so that e.g. -// "clso" matches "Claude Sonnet" (Cl + So), "gpt-5" splits into ["gpt","5"]. -const WORD_BOUNDARY = /(?=[A-Z])|[[\]_.:\s/\\(){}-]+/ - -function acronymMatch(text: string, query: string): boolean { - const words = text - .split(WORD_BOUNDARY) - .filter((w) => w.length > 0) - .map((w) => w.toLowerCase()) - - const attempt = (wi: number, qi: number): boolean => { - if (qi === query.length) return true - if (wi >= words.length) return false - const word = words[wi]! - let consumed = 0 - while (qi + consumed < query.length && consumed < word.length && word[consumed] === query[qi + consumed]) consumed++ - if (consumed > 0 && attempt(wi + 1, qi + consumed)) return true - return attempt(wi + 1, qi) - } - - return attempt(0, 0) -} - -function searchMatch(query: string, text: string): boolean { - const q = query.toLowerCase().trim() - if (!q) return true - const parts = q.split(WORD_BOUNDARY).filter((w) => w.length > 0) - if (parts.length === 0) return true - return parts.every((p) => acronymMatch(text, p)) -} - const CLEAR_KEY = "clear" const FAVORITES_KEY = "favorites" const RECOMMENDED_KEY = "recommended" diff --git a/packages/kilo-vscode/webview-ui/src/utils/search-match.ts b/packages/kilo-vscode/webview-ui/src/utils/search-match.ts new file mode 100644 index 00000000000..dfcc6ca0167 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/utils/search-match.ts @@ -0,0 +1,59 @@ +/** + * Word-boundary matching for model/provider search. + * + * Splits text at camelCase transitions and common delimiters so that e.g. + * "clso" matches "Claude Sonnet" (Cl + So) and "gpt5" matches "gpt-5". + * Multi-word queries like "claude sonnet" require every fragment to match + * independently. + * + * Ported from legacy word-boundary-fzf.ts. + */ + +// Split at positions before uppercase letters (camelCase/PascalCase) +// and at common delimiters: hyphen, underscore, dot, colon, whitespace, +// forward/back slash, brackets, parentheses. +const WORD_BOUNDARY = /(?=[A-Z])|[[\]_.:\s/\\(){}-]+/ + +/** + * Match a single query fragment against text using word-boundary acronym + * matching. Each character in `query` must match the start of a word in + * `text`, consuming consecutive characters from the same word before moving + * to the next. + * + * Examples: + * - acronymMatch("Claude Sonnet", "clso") → true (Cl + So) + * - acronymMatch("gitRebase", "gr") → true (git + Rebase) + * - acronymMatch("faoboc", "foo") → false (no word boundary) + */ +export function acronymMatch(text: string, query: string): boolean { + const words = text + .split(WORD_BOUNDARY) + .filter((w) => w.length > 0) + .map((w) => w.toLowerCase()) + + const attempt = (wi: number, qi: number): boolean => { + if (qi === query.length) return true + if (wi >= words.length) return false + const word = words[wi]! + let consumed = 0 + while (qi + consumed < query.length && consumed < word.length && word[consumed] === query[qi + consumed]) consumed++ + if (consumed > 0 && attempt(wi + 1, qi + consumed)) return true + return attempt(wi + 1, qi) + } + + return attempt(0, 0) +} + +/** + * High-level search: trims and lowercases the query, splits multi-word + * queries at word boundaries, and requires every fragment to match. + * + * Returns `true` when `query` is empty/whitespace-only. + */ +export function searchMatch(query: string, text: string): boolean { + const q = query.toLowerCase().trim() + if (!q) return true + const parts = q.split(WORD_BOUNDARY).filter((w) => w.length > 0) + if (parts.length === 0) return true + return parts.every((p) => acronymMatch(text, p)) +} From 60c91b51132d7a4bdfd7da886f52bf3a205d2a86 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 8 Apr 2026 12:59:33 +0200 Subject: [PATCH 52/55] fix(vscode): dispose git processes on panel close to prevent orphans on Windows (#8572) Add AbortController to GitOps that kills in-flight child processes when dispose() is called. Thread the abort signal through both simple-git and child_process.spawn so spawned git processes are terminated immediately on Windows (TerminateProcess) and Unix (SIGTERM). Wire gitOps.dispose() into all three owners: DiffViewerProvider, AgentManagerProvider, and KiloProvider. --- .../kilo-vscode/src/DiffViewerProvider.ts | 1 + packages/kilo-vscode/src/KiloProvider.ts | 4 + .../src/agent-manager/AgentManagerProvider.ts | 1 + .../kilo-vscode/src/agent-manager/GitOps.ts | 34 +++++++- .../kilo-vscode/tests/unit/git-ops.test.ts | 81 +++++++++++++++++++ 5 files changed, 119 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/src/DiffViewerProvider.ts b/packages/kilo-vscode/src/DiffViewerProvider.ts index f9f143f7467..67466a0fa8a 100644 --- a/packages/kilo-vscode/src/DiffViewerProvider.ts +++ b/packages/kilo-vscode/src/DiffViewerProvider.ts @@ -212,6 +212,7 @@ export class DiffViewerProvider implements vscode.Disposable { public dispose(): void { this.stopDiffPolling() + this.gitOps.dispose() this.panel?.dispose() this.outputChannel.dispose() } diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 481284eec01..6888caf5d19 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -171,6 +171,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private pendingFollowup: Followup | null = null /** Worktree diff stats poller for the sidebar badge — reuses GitStatsPoller (local stats only) */ private statsPoller: GitStatsPoller | null = null + private statsGitOps: GitOps | null = null private cachedStats: unknown = null /** Optional interceptor called before the standard message handler. @@ -3148,7 +3149,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private startStatsPolling(): void { this.statsPoller?.stop() + this.statsGitOps?.dispose() const git = new GitOps({ log: () => {} }) + this.statsGitOps = git this.statsPoller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => getWorkspaceRoot(), @@ -3176,6 +3179,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper */ dispose(): void { this.statsPoller?.stop() + this.statsGitOps?.dispose() this.unsubscribeEvent?.() this.unsubscribeState?.() this.unsubscribeNotificationDismiss?.() diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 3554990404d..8ebb82f9a89 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -1918,6 +1918,7 @@ export class AgentManagerProvider implements Disposable { public dispose(): void { this.stopDiffPolling() this.statsPoller.stop() + this.gitOps.dispose() this.prBridge.poller.stop() this.terminalManager.dispose() this.panel?.dispose() diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index dcd74e95987..6c43f64d1e4 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -62,19 +62,45 @@ export function nonInteractiveEnv(): NodeJS.ProcessEnv { export class GitOps { private readonly log: (...args: unknown[]) => void private readonly runGit: (args: string[], cwd: string) => Promise + private readonly controller = new AbortController() + + get disposed(): boolean { + return this.controller.signal.aborted + } constructor(options: GitOpsOptions) { this.log = options.log this.runGit = options.runGit ?? ((args, cwd) => - simpleGit(cwd) + simpleGit(cwd, { abort: this.controller.signal }) .raw(args) .then((out) => out.trim())) } + dispose(): void { + if (!this.controller.signal.aborted) { + this.controller.abort() + } + } + private raw(args: string[], cwd: string): Promise { - return this.runGit(args, cwd) + const signal = this.controller.signal + if (signal.aborted) return Promise.reject(new Error("GitOps disposed")) + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error("GitOps disposed")) + signal.addEventListener("abort", onAbort, { once: true }) + this.runGit(args, cwd).then( + (value) => { + signal.removeEventListener("abort", onAbort) + resolve(value) + }, + (err) => { + signal.removeEventListener("abort", onAbort) + reject(err) + }, + ) + }) } /** Return the name of the currently checked-out branch, or `"HEAD"` if detached. */ @@ -335,10 +361,14 @@ export class GitOps { } private exec(args: string[], cwd: string, options?: ExecOptions): Promise { + if (this.controller.signal.aborted) { + return Promise.resolve({ code: 1, stdout: "", stderr: "GitOps disposed" }) + } return new Promise((resolve) => { const child = spawn("git", args, { cwd, env: options?.env, + signal: this.controller.signal, stdio: ["pipe", "pipe", "pipe"], }) diff --git a/packages/kilo-vscode/tests/unit/git-ops.test.ts b/packages/kilo-vscode/tests/unit/git-ops.test.ts index eff6a667259..64f0b78c85a 100644 --- a/packages/kilo-vscode/tests/unit/git-ops.test.ts +++ b/packages/kilo-vscode/tests/unit/git-ops.test.ts @@ -8,6 +8,10 @@ function ops(handler: (args: string[], cwd: string) => Promise): GitOps return new GitOps({ log: () => undefined, runGit: handler }) } +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + function runGit(cwd: string, args: string[]): string { const result = Bun.spawnSync({ cmd: ["git", ...args], @@ -475,4 +479,81 @@ describe("GitOps", () => { }) }) }) + + describe("dispose", () => { + it("aborts in-flight runGit calls quickly", async () => { + let resolved = false + const git = new GitOps({ + log: () => undefined, + runGit: async () => { + await sleep(5000) + resolved = true + return "should not reach" + }, + }) + + const start = Date.now() + const pending = git.currentBranch("/repo") + git.dispose() + await pending + const elapsed = Date.now() - start + expect(elapsed).toBeLessThan(500) + expect(resolved).toBe(false) + }) + + it("causes subsequent runGit calls to fail immediately", async () => { + let called = false + const git = new GitOps({ + log: () => undefined, + runGit: async () => { + called = true + return "ok" + }, + }) + git.dispose() + + // currentBranch swallows errors — should return "" without calling runGit + const result = await git.currentBranch("/repo") + expect(result).toBe("") + expect(called).toBe(false) + }) + + it("reports disposed state", () => { + const git = ops(async () => "ok") + expect(git.disposed).toBe(false) + git.dispose() + expect(git.disposed).toBe(true) + }) + + it("kills in-flight exec (spawn) processes", async () => { + await withRepo(async (cwd) => { + const git = new GitOps({ log: () => undefined }) + await fs.writeFile(nodePath.join(cwd, "a.txt"), "one\n", "utf8") + runGit(cwd, ["add", "-A"]) + runGit(cwd, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"]) + await fs.writeFile(nodePath.join(cwd, "a.txt"), "two\n", "utf8") + + const branch = runGit(cwd, ["branch", "--show-current"]) || "HEAD" + const pending = git.buildWorktreePatch(cwd, branch) + // Give spawn a moment to start, then dispose + await sleep(10) + git.dispose() + + // Should either reject or return (but process should be killed) + try { + await pending + } catch { + // expected — aborted + } + expect(git.disposed).toBe(true) + }) + }) + + it("is safe to call multiple times", () => { + const git = ops(async () => "ok") + git.dispose() + git.dispose() + expect(git.disposed).toBe(true) + }) + }) }) From 850826a2bea731dc6db04996d55acd8836b8cbc9 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 8 Apr 2026 13:05:06 +0200 Subject: [PATCH 53/55] fix(cli): align /local-review diff with agent manager diff viewer (#8574) The /local-review command used git diff base...HEAD (triple-dot) which only captured committed changes. The diff viewer uses merge-base + two-dot diff against the working tree. This mismatch caused /local-review to show 'nothing to review' when changes were uncommitted. Now computes merge-base explicitly and diffs the working tree against it, matching WorktreeDiff behavior. Also includes untracked files. --- .../opencode/src/kilocode/review/review.ts | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/kilocode/review/review.ts b/packages/opencode/src/kilocode/review/review.ts index 7d216082366..3e1aad0e144 100644 --- a/packages/opencode/src/kilocode/review/review.ts +++ b/packages/opencode/src/kilocode/review/review.ts @@ -396,12 +396,25 @@ export namespace Review { log.info("getting branch changes", { baseBranch: base }) - // git diff base...HEAD shows all changes on current branch since diverging - // Using triple-dot to get merge-base comparison - const result = await $`git -c core.quotepath=false diff ${base}...HEAD`.cwd(Instance.directory).quiet().nothrow() + // Compute merge-base explicitly, then diff working tree against it. + // This matches WorktreeDiff (the diff viewer) and includes uncommitted + // changes + untracked files — unlike `git diff base...HEAD` which only + // shows committed differences. + const ancestor = await $`git merge-base HEAD ${base}`.cwd(Instance.directory).quiet().nothrow() + if (ancestor.exitCode !== 0) { + log.warn("git merge-base failed", { + exitCode: ancestor.exitCode, + stderr: ancestor.stderr.toString(), + baseBranch: base, + }) + return { files: [], raw: "" } + } + const hash = ancestor.stdout.toString().trim() + + // Two-dot diff against working tree: includes staged, unstaged, and committed changes since merge-base + const result = await $`git -c core.quotepath=false diff ${hash}`.cwd(Instance.directory).quiet().nothrow() if (result.exitCode !== 0) { - // May fail if on base branch or no common ancestor log.warn("git diff failed", { exitCode: result.exitCode, stderr: result.stderr.toString(), @@ -413,6 +426,23 @@ export namespace Review { const raw = result.stdout.toString() const parsed = parseDiff(raw) + // Include untracked files (same as WorktreeDiff) so new files show up in the review + const untracked = await $`git ls-files --others --exclude-standard`.cwd(Instance.directory).quiet().nothrow() + if (untracked.exitCode === 0) { + const paths = untracked.stdout.toString().trim() + if (paths) { + const existing = new Set(parsed.files.map((f) => f.path)) + for (const file of paths.split("\n")) { + if (!file || existing.has(file)) continue + parsed.files.push({ + path: file, + status: "added", + hunks: [], + }) + } + } + } + log.info("parsed branch changes", { baseBranch: base, fileCount: parsed.files.length, From c7bebd225ee89e6374831cb5ea7431b3cf1ce7d2 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 8 Apr 2026 13:16:52 +0200 Subject: [PATCH 54/55] feat(agent-manager): add user-defined sections for organizing worktrees (#8225) Implement collapsible, color-coded sections in the Agent Manager sidebar. Sections can be created, renamed, colored, deleted, and reordered via Move Up/Down context menu. Worktrees are assigned via context menu or drag-and-drop onto section headers. - Section CRUD in WorktreeStateManager with JSON persistence - worktreeOrder normalization on load (backfills missing section IDs) - setWorktreeOrder filters to top-level only (sections + ungrouped worktrees) - moveSection operates on visible top-level model, not raw array - createDroppable sections as worktree drop targets - sectionAwareDetector skips home section for within-section reorder - Multi-version worktree groups move together - Auto-rename on creation with double-rAF focus - 76 tests (26 state manager + 15 helpers + 35 arch) --- .../src/agent-manager/AgentManagerProvider.ts | 7 + .../src/agent-manager/WorktreeStateManager.ts | 156 +++++++++- .../src/agent-manager/section-handler.ts | 21 ++ .../kilo-vscode/src/agent-manager/types.ts | 51 +++- .../tests/unit/agent-manager-arch.test.ts | 1 + .../tests/unit/section-helpers.test.ts | 114 +++++++ .../unit/worktree-state-sections.test.ts | 286 ++++++++++++++++++ .../agent-manager/AgentManagerApp.tsx | 243 ++++++++------- .../agent-manager/SectionHeader.tsx | 152 ++++++++++ .../webview-ui/agent-manager/WorktreeItem.tsx | 53 +++- .../agent-manager/agent-manager.css | 145 +++++++++ .../agent-manager/constrain-drag-x.ts | 21 ++ .../webview-ui/agent-manager/i18n/ar.ts | 11 + .../webview-ui/agent-manager/i18n/br.ts | 11 + .../webview-ui/agent-manager/i18n/bs.ts | 11 + .../webview-ui/agent-manager/i18n/da.ts | 11 + .../webview-ui/agent-manager/i18n/de.ts | 11 + .../webview-ui/agent-manager/i18n/en.ts | 11 + .../webview-ui/agent-manager/i18n/es.ts | 11 + .../webview-ui/agent-manager/i18n/fr.ts | 11 + .../webview-ui/agent-manager/i18n/ja.ts | 11 + .../webview-ui/agent-manager/i18n/ko.ts | 11 + .../webview-ui/agent-manager/i18n/nl.ts | 11 + .../webview-ui/agent-manager/i18n/no.ts | 11 + .../webview-ui/agent-manager/i18n/pl.ts | 11 + .../webview-ui/agent-manager/i18n/ru.ts | 11 + .../webview-ui/agent-manager/i18n/th.ts | 11 + .../webview-ui/agent-manager/i18n/tr.ts | 11 + .../webview-ui/agent-manager/i18n/uk.ts | 11 + .../webview-ui/agent-manager/i18n/zh.ts | 11 + .../webview-ui/agent-manager/i18n/zht.ts | 11 + .../agent-manager/section-colors.ts | 22 ++ .../webview-ui/agent-manager/section-dnd.ts | 35 +++ .../agent-manager/section-helpers.ts | 62 ++++ .../webview-ui/src/types/messages.ts | 61 ++++ 35 files changed, 1522 insertions(+), 117 deletions(-) create mode 100644 packages/kilo-vscode/src/agent-manager/section-handler.ts create mode 100644 packages/kilo-vscode/tests/unit/section-helpers.test.ts create mode 100644 packages/kilo-vscode/tests/unit/worktree-state-sections.test.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/constrain-drag-x.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/section-colors.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/section-dnd.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 8ebb82f9a89..bebd61406a2 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -6,6 +6,7 @@ import { getErrorMessage } from "../kilo-provider-utils" import { isAbsolutePath } from "../path-utils" import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager" import { WorktreeStateManager, remoteRef } from "./WorktreeStateManager" +import { handleSection } from "./section-handler" import { chooseBaseBranch, normalizeBaseBranch } from "./base-branch" import { GitStatsPoller, type WorktreePresenceResult } from "./GitStatsPoller" import { PRStatusBridge } from "./pr-status-bridge" @@ -341,6 +342,7 @@ export class AgentManagerProvider implements Disposable { this.state?.setSessionsCollapsed(m.collapsed) return null } + if (this.handleSection(m)) return null if (m.type === "agentManager.setReviewDiffStyle") { this.state?.setReviewDiffStyle(m.style) return null @@ -1488,6 +1490,7 @@ export class AgentManagerProvider implements Disposable { type: "agentManager.state", worktrees, sessions: state.getSessions(), + sections: state.getSections(), staleWorktreeIds, tabOrder: state.getTabOrder(), worktreeOrder: state.getWorktreeOrder(), @@ -1911,6 +1914,10 @@ export class AgentManagerProvider implements Disposable { ) } + private handleSection(m: AgentManagerInMessage): boolean { + return handleSection(this.state, m, () => this.pushState()) + } + public postMessage(message: unknown): void { this.panel?.postMessage(message) } diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index c41ce547aa8..8078dad5b2c 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -35,6 +35,18 @@ export interface Worktree { /** Original branch created with the worktree, used for cleanup on deletion. * Set automatically when `branch` is updated via live sync. */ originalBranch?: string + /** Section this worktree belongs to, or undefined for ungrouped. */ + sectionId?: string +} + +export interface Section { + id: string + name: string + /** Color label (e.g. "Red", "Blue") mapped to VS Code theme CSS vars at render time, or null for default. */ + color: string | null + /** Position among top-level sidebar children (interleaved with ungrouped worktrees). */ + order: number + collapsed: boolean } /** @@ -55,6 +67,7 @@ export interface ManagedSession { interface StateFile { worktrees: Record> sessions: Record> + sections?: Record> tabOrder?: Record worktreeOrder?: string[] sessionsCollapsed?: boolean @@ -76,6 +89,7 @@ export class WorktreeStateManager { private readonly file: string private worktrees = new Map() private sessions = new Map() + private sections = new Map() private tabOrder: Record = {} private worktreeOrder: string[] = [] private collapsed = false @@ -284,7 +298,128 @@ export class WorktreeStateManager { } setWorktreeOrder(order: string[]): void { - this.worktreeOrder = order + const top = new Set() + for (const sec of this.sections.values()) top.add(sec.id) + for (const wt of this.worktrees.values()) { + if (!wt.sectionId) top.add(wt.id) + } + this.worktreeOrder = order.filter((id) => top.has(id)) + void this.save() + } + + // --------------------------------------------------------------------------- + // Sections + // --------------------------------------------------------------------------- + + getSections(): Section[] { + return [...this.sections.values()] + } + + getSection(id: string): Section | undefined { + return this.sections.get(id) + } + + addSection(name: string, color: string | null, worktreeIds?: string[]): Section { + const id = generateId("sec") + const order = this.worktreeOrder.length + const sec: Section = { id, name, color, order, collapsed: false } + this.sections.set(id, sec) + this.worktreeOrder.push(id) + if (worktreeIds) { + for (const wtId of worktreeIds) { + const wt = this.worktrees.get(wtId) + if (wt) { + wt.sectionId = id + // Remove from top-level worktreeOrder since it's now inside a section + const idx = this.worktreeOrder.indexOf(wtId) + if (idx !== -1) this.worktreeOrder.splice(idx, 1) + } + } + } + this.log(`Added section ${id}: "${name}"`) + void this.save() + return sec + } + + renameSection(id: string, name: string): void { + const sec = this.sections.get(id) + if (!sec || !name) return + sec.name = name + this.log(`Renamed section ${id} to "${name}"`) + void this.save() + } + + setSectionColor(id: string, color: string | null): void { + const sec = this.sections.get(id) + if (!sec) return + sec.color = color + void this.save() + } + + toggleSection(id: string): void { + const sec = this.sections.get(id) + if (!sec) return + sec.collapsed = !sec.collapsed + void this.save() + } + + deleteSection(id: string): void { + if (!this.sections.delete(id)) return + // Ungroup all worktrees in this section — do NOT delete them + for (const wt of this.worktrees.values()) { + if (wt.sectionId === id) { + wt.sectionId = undefined + if (!this.worktreeOrder.includes(wt.id)) this.worktreeOrder.push(wt.id) + } + } + // Remove from sidebar order + const idx = this.worktreeOrder.indexOf(id) + if (idx !== -1) this.worktreeOrder.splice(idx, 1) + this.log(`Deleted section ${id}, ungrouped its worktrees`) + void this.save() + } + + moveSection(id: string, dir: -1 | 1): void { + const top = this.worktreeOrder.filter((item) => { + if (this.sections.has(item)) return true + const wt = this.worktrees.get(item) + return !!wt && !wt.sectionId + }) + const idx = top.indexOf(id) + const next = idx + dir + if (idx === -1 || next < 0 || next >= top.length) return + const target = top[next]! + const result = [...this.worktreeOrder] + const fi = result.indexOf(id) + if (fi === -1 || result.indexOf(target) === -1) return + result.splice(fi, 1) + const insertAt = result.indexOf(target) + (dir === 1 ? 1 : 0) + result.splice(insertAt, 0, id) + this.worktreeOrder = result + void this.save() + } + + moveToSection(worktreeIds: string[], sectionId: string | null): void { + // Expand to include all multi-version siblings (same groupId) + const expanded = new Set(worktreeIds) + for (const wtId of worktreeIds) { + const wt = this.worktrees.get(wtId) + if (!wt?.groupId) continue + for (const sibling of this.worktrees.values()) { + if (sibling.groupId === wt.groupId) expanded.add(sibling.id) + } + } + for (const wtId of expanded) { + const wt = this.worktrees.get(wtId) + if (!wt) continue + wt.sectionId = sectionId ?? undefined + if (sectionId) { + const idx = this.worktreeOrder.indexOf(wtId) + if (idx !== -1) this.worktreeOrder.splice(idx, 1) + } else { + if (!this.worktreeOrder.includes(wtId)) this.worktreeOrder.push(wtId) + } + } void this.save() } @@ -343,6 +478,7 @@ export class WorktreeStateManager { const data = JSON.parse(content) as StateFile this.worktrees.clear() this.sessions.clear() + this.sections.clear() this.tabOrder = {} this.worktreeOrder = [] this.reviewDiffStyle = "unified" @@ -358,12 +494,23 @@ export class WorktreeStateManager { for (const [id, s] of Object.entries(data.sessions ?? {})) { this.sessions.set(id, { id, ...s }) } + for (const [id, sec] of Object.entries(data.sections ?? {})) { + this.sections.set(id, { id, ...sec }) + } if (data.tabOrder) { this.tabOrder = data.tabOrder } if (data.worktreeOrder) { this.worktreeOrder = data.worktreeOrder } + // Normalize: ensure all section IDs and ungrouped worktree IDs are in worktreeOrder + const present = new Set(this.worktreeOrder) + for (const id of this.sections.keys()) { + if (!present.has(id)) this.worktreeOrder.push(id) + } + for (const wt of this.worktrees.values()) { + if (!wt.sectionId && !present.has(wt.id)) this.worktreeOrder.push(wt.id) + } this.collapsed = data.sessionsCollapsed ?? false if (data.reviewDiffStyle === "split") { this.reviewDiffStyle = "split" @@ -433,6 +580,13 @@ export class WorktreeStateManager { const { id: _, ...rest } = s data.sessions[id] = rest } + if (this.sections.size > 0) { + data.sections = {} + for (const [id, sec] of this.sections) { + const { id: _, ...rest } = sec + data.sections[id] = rest + } + } if (Object.keys(this.tabOrder).length > 0) { data.tabOrder = this.tabOrder } diff --git a/packages/kilo-vscode/src/agent-manager/section-handler.ts b/packages/kilo-vscode/src/agent-manager/section-handler.ts new file mode 100644 index 00000000000..624a23eb10e --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/section-handler.ts @@ -0,0 +1,21 @@ +import type { WorktreeStateManager } from "./WorktreeStateManager" +import type { AgentManagerInMessage } from "./types" + +/** Handle section CRUD messages. Returns true if handled. */ +export function handleSection( + state: WorktreeStateManager | undefined, + m: AgentManagerInMessage, + push: () => void, +): boolean { + if (!state) return false + if (m.type === "agentManager.createSection") state.addSection(m.name, m.color ?? null, m.worktreeIds) + else if (m.type === "agentManager.renameSection") state.renameSection(m.sectionId, m.name) + else if (m.type === "agentManager.deleteSection") state.deleteSection(m.sectionId) + else if (m.type === "agentManager.setSectionColor") state.setSectionColor(m.sectionId, m.color) + else if (m.type === "agentManager.toggleSectionCollapsed") state.toggleSection(m.sectionId) + else if (m.type === "agentManager.moveToSection") state.moveToSection(m.worktreeIds, m.sectionId) + else if (m.type === "agentManager.moveSection") state.moveSection(m.sectionId, m.dir) + else return false + push() + return true +} diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 6c116034638..264f1f46107 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -8,7 +8,7 @@ */ import type { FileDiff } from "@kilocode/sdk/v2/client" -import type { Worktree, ManagedSession } from "./WorktreeStateManager" +import type { Worktree, ManagedSession, Section } from "./WorktreeStateManager" import type { WorktreeStats, LocalStats } from "./GitStatsPoller" import type { ApplyConflict } from "./GitOps" import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import" @@ -118,6 +118,7 @@ interface StateMessage { type: "agentManager.state" worktrees: Worktree[] sessions: ManagedSession[] + sections?: Section[] staleWorktreeIds?: string[] tabOrder?: Record worktreeOrder?: string[] @@ -523,6 +524,47 @@ interface ContinueInWorktreeIn { sessionId: string } +interface CreateSectionIn { + type: "agentManager.createSection" + name: string + color?: string + worktreeIds?: string[] +} + +interface RenameSectionIn { + type: "agentManager.renameSection" + sectionId: string + name: string +} + +interface DeleteSectionIn { + type: "agentManager.deleteSection" + sectionId: string +} + +interface SetSectionColorIn { + type: "agentManager.setSectionColor" + sectionId: string + color: string | null +} + +interface ToggleSectionCollapsedIn { + type: "agentManager.toggleSectionCollapsed" + sectionId: string +} + +interface MoveToSectionIn { + type: "agentManager.moveToSection" + worktreeIds: string[] + sectionId: string | null +} + +interface MoveSectionIn { + type: "agentManager.moveSection" + sectionId: string + dir: -1 | 1 +} + /** All messages the Agent Manager expects from the webview (onMessage input). */ export type AgentManagerInMessage = | CreateWorktreeIn @@ -570,3 +612,10 @@ export type AgentManagerInMessage = | ClearSessionIn | AbortIn | ContinueInWorktreeIn + | CreateSectionIn + | RenameSectionIn + | DeleteSectionIn + | SetSectionColorIn + | ToggleSectionCollapsedIn + | MoveToSectionIn + | MoveSectionIn 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 65a20a1af4f..69c88db63ee 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -31,6 +31,7 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/BranchSelect.tsx"), path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"), + path.join(ROOT, "webview-ui/agent-manager/SectionHeader.tsx"), ] const TSX_FILE = TSX_FILES[0] const PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts") diff --git a/packages/kilo-vscode/tests/unit/section-helpers.test.ts b/packages/kilo-vscode/tests/unit/section-helpers.test.ts new file mode 100644 index 00000000000..8a16ebf78bc --- /dev/null +++ b/packages/kilo-vscode/tests/unit/section-helpers.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "bun:test" +import { buildTopLevelItems, isGrouped, isGroupStart, isGroupEnd } from "../../webview-ui/agent-manager/section-helpers" +import type { WorktreeState, SectionState } from "../../webview-ui/src/types/messages" + +function wt(id: string, opts: Partial = {}): WorktreeState { + return { + id, + branch: `branch-${id}`, + path: `/tmp/${id}`, + parentBranch: "main", + createdAt: "2024-01-01", + ...opts, + } +} + +function sec(id: string, order: number, opts: Partial = {}): SectionState { + return { id, name: `Section ${id}`, color: null, order, collapsed: false, ...opts } +} + +describe("buildTopLevelItems", () => { + it("returns flat worktree list when no sections", () => { + const all = [wt("a"), wt("b"), wt("c")] + const result = buildTopLevelItems([], [], all, []) + expect(result).toHaveLength(3) + expect(result.every((r) => r.kind === "worktree")).toBe(true) + }) + + it("interleaves sections and worktrees per order", () => { + const s1 = sec("s1", 0) + const w1 = wt("w1") + const s2 = sec("s2", 1) + const result = buildTopLevelItems([s1, s2], [w1], [w1], ["s1", "w1", "s2"]) + expect(result).toHaveLength(3) + expect(result[0]).toEqual({ kind: "section", section: s1 }) + expect(result[1]).toEqual({ kind: "worktree", wt: w1 }) + expect(result[2]).toEqual({ kind: "section", section: s2 }) + }) + + it("appends unordered sections and worktrees at the end", () => { + const s1 = sec("s1", 0) + const s2 = sec("s2", 1) + const w1 = wt("w1") + const w2 = wt("w2") + // Only s1 is in the order array + const result = buildTopLevelItems([s1, s2], [w1, w2], [w1, w2], ["s1", "w1"]) + expect(result).toHaveLength(4) + expect(result[0]).toEqual({ kind: "section", section: s1 }) + expect(result[1]).toEqual({ kind: "worktree", wt: w1 }) + // unordered items appended + expect(result[2]).toEqual({ kind: "section", section: s2 }) + expect(result[3]).toEqual({ kind: "worktree", wt: w2 }) + }) + + it("skips duplicate ids in order array", () => { + const s1 = sec("s1", 0) + const w1 = wt("w1") + const result = buildTopLevelItems([s1], [w1], [w1], ["s1", "w1", "s1", "w1"]) + expect(result).toHaveLength(2) + }) +}) + +describe("isGrouped", () => { + it("returns true when groupId is set", () => { + expect(isGrouped(wt("a", { groupId: "g1" }))).toBe(true) + }) + + it("returns false when groupId is undefined", () => { + expect(isGrouped(wt("a"))).toBe(false) + }) + + it("returns false when groupId is empty string", () => { + expect(isGrouped(wt("a", { groupId: "" }))).toBe(false) + }) +}) + +describe("isGroupStart", () => { + const list = [wt("a", { groupId: "g1" }), wt("b", { groupId: "g1" }), wt("c", { groupId: "g2" }), wt("d")] + + it("returns true for first item in group", () => { + expect(isGroupStart(list[0]!, 0, list)).toBe(true) + }) + + it("returns false for middle/end item in same group", () => { + expect(isGroupStart(list[1]!, 1, list)).toBe(false) + }) + + it("returns true when previous item has different groupId", () => { + expect(isGroupStart(list[2]!, 2, list)).toBe(true) + }) + + it("returns false for ungrouped worktree", () => { + expect(isGroupStart(list[3]!, 3, list)).toBe(false) + }) +}) + +describe("isGroupEnd", () => { + const list = [wt("a", { groupId: "g1" }), wt("b", { groupId: "g1" }), wt("c", { groupId: "g2" }), wt("d")] + + it("returns false for start/middle of group", () => { + expect(isGroupEnd(list[0]!, 0, list)).toBe(false) + }) + + it("returns true for last item in group when next has different groupId", () => { + expect(isGroupEnd(list[1]!, 1, list)).toBe(true) + }) + + it("returns true for last item in list with groupId", () => { + expect(isGroupEnd(list[2]!, 2, list)).toBe(true) + }) + + it("returns false for ungrouped worktree", () => { + expect(isGroupEnd(list[3]!, 3, list)).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/worktree-state-sections.test.ts b/packages/kilo-vscode/tests/unit/worktree-state-sections.test.ts new file mode 100644 index 00000000000..e83ea943dc4 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/worktree-state-sections.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test" +import * as fs from "fs" +import * as path from "path" +import * as os from "os" +import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" + +describe("WorktreeStateManager sections", () => { + let root: string + let mgr: WorktreeStateManager + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "wtsm-sec-")) + fs.mkdirSync(path.join(root, ".kilo"), { recursive: true }) + mgr = new WorktreeStateManager(root, () => {}) + }) + + afterEach(async () => { + await mgr.flush() + fs.rmSync(root, { recursive: true, force: true }) + }) + + describe("addSection", () => { + it("creates a section with correct defaults", () => { + const sec = mgr.addSection("Backend", "Blue") + expect(sec.id).toMatch(/^sec-/) + expect(sec.name).toBe("Backend") + expect(sec.color).toBe("Blue") + expect(sec.collapsed).toBe(false) + expect(mgr.getWorktreeOrder()).toContain(sec.id) + }) + + it("adds section id to worktreeOrder", () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + mgr.setWorktreeOrder([wt.id]) + const sec = mgr.addSection("Infra", null) + expect(mgr.getWorktreeOrder()).toEqual([wt.id, sec.id]) + }) + + it("moves specified worktrees into the section", () => { + const wt1 = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + const wt2 = mgr.addWorktree({ branch: "b", path: "/tmp/b", parentBranch: "main" }) + mgr.setWorktreeOrder([wt1.id, wt2.id]) + + const sec = mgr.addSection("Group", "Red", [wt1.id]) + expect(mgr.getWorktree(wt1.id)?.sectionId).toBe(sec.id) + expect(mgr.getWorktree(wt2.id)?.sectionId).toBeUndefined() + // wt1 removed from top-level order, wt2 remains + expect(mgr.getWorktreeOrder()).not.toContain(wt1.id) + expect(mgr.getWorktreeOrder()).toContain(wt2.id) + }) + }) + + describe("renameSection", () => { + it("updates the name", () => { + const sec = mgr.addSection("Old", null) + mgr.renameSection(sec.id, "New") + expect(mgr.getSection(sec.id)?.name).toBe("New") + }) + + it("rejects empty string", () => { + const sec = mgr.addSection("Keep", null) + mgr.renameSection(sec.id, "") + expect(mgr.getSection(sec.id)?.name).toBe("Keep") + }) + + it("is a no-op for unknown id", () => { + mgr.renameSection("nonexistent", "Foo") + expect(mgr.getSections()).toHaveLength(0) + }) + }) + + describe("setSectionColor", () => { + it("updates color", () => { + const sec = mgr.addSection("X", null) + mgr.setSectionColor(sec.id, "Green") + expect(mgr.getSection(sec.id)?.color).toBe("Green") + }) + + it("accepts null for default", () => { + const sec = mgr.addSection("X", "Red") + mgr.setSectionColor(sec.id, null) + expect(mgr.getSection(sec.id)?.color).toBeNull() + }) + }) + + describe("toggleSection", () => { + it("flips collapsed state", () => { + const sec = mgr.addSection("T", null) + expect(sec.collapsed).toBe(false) + + mgr.toggleSection(sec.id) + expect(mgr.getSection(sec.id)?.collapsed).toBe(true) + + mgr.toggleSection(sec.id) + expect(mgr.getSection(sec.id)?.collapsed).toBe(false) + }) + }) + + describe("deleteSection", () => { + it("removes section and ungroups its worktrees", () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + const sec = mgr.addSection("Del", null, [wt.id]) + expect(mgr.getWorktree(wt.id)?.sectionId).toBe(sec.id) + + mgr.deleteSection(sec.id) + expect(mgr.getSection(sec.id)).toBeUndefined() + // worktree still exists but sectionId cleared + expect(mgr.getWorktree(wt.id)).toBeTruthy() + expect(mgr.getWorktree(wt.id)?.sectionId).toBeUndefined() + }) + + it("removes section from worktreeOrder", () => { + const sec = mgr.addSection("Gone", null) + expect(mgr.getWorktreeOrder()).toContain(sec.id) + mgr.deleteSection(sec.id) + expect(mgr.getWorktreeOrder()).not.toContain(sec.id) + }) + + it("is a no-op for unknown id", () => { + mgr.deleteSection("nonexistent") + expect(mgr.getSections()).toHaveLength(0) + }) + }) + + describe("moveToSection", () => { + it("sets sectionId and removes from worktreeOrder", () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + mgr.setWorktreeOrder([wt.id]) + const sec = mgr.addSection("Target", null) + + mgr.moveToSection([wt.id], sec.id) + expect(mgr.getWorktree(wt.id)?.sectionId).toBe(sec.id) + expect(mgr.getWorktreeOrder()).not.toContain(wt.id) + }) + + it("ungroups worktrees with null sectionId and adds back to order", () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + const sec = mgr.addSection("Temp", null, [wt.id]) + expect(mgr.getWorktreeOrder()).not.toContain(wt.id) + + mgr.moveToSection([wt.id], null) + expect(mgr.getWorktree(wt.id)?.sectionId).toBeUndefined() + expect(mgr.getWorktreeOrder()).toContain(wt.id) + }) + + it("expands to multi-version siblings with same groupId", () => { + const wt1 = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main", groupId: "g1" }) + const wt2 = mgr.addWorktree({ branch: "b", path: "/tmp/b", parentBranch: "main", groupId: "g1" }) + const wt3 = mgr.addWorktree({ branch: "c", path: "/tmp/c", parentBranch: "main" }) + mgr.setWorktreeOrder([wt1.id, wt2.id, wt3.id]) + const sec = mgr.addSection("Multi", null) + + // Move only wt1 — wt2 should follow because of shared groupId + mgr.moveToSection([wt1.id], sec.id) + expect(mgr.getWorktree(wt1.id)?.sectionId).toBe(sec.id) + expect(mgr.getWorktree(wt2.id)?.sectionId).toBe(sec.id) + expect(mgr.getWorktree(wt3.id)?.sectionId).toBeUndefined() + }) + + it("does not duplicate in worktreeOrder when ungrouping already-present id", () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + mgr.setWorktreeOrder([wt.id]) + // Ungroup when already in order + mgr.moveToSection([wt.id], null) + const count = mgr.getWorktreeOrder().filter((id) => id === wt.id).length + expect(count).toBe(1) + }) + }) + + describe("getSections", () => { + it("returns all sections", () => { + mgr.addSection("A", null) + mgr.addSection("B", "Red") + mgr.addSection("C", "Blue") + expect(mgr.getSections()).toHaveLength(3) + }) + + it("returns empty array when no sections", () => { + expect(mgr.getSections()).toEqual([]) + }) + }) + + describe("moveSection", () => { + it("moves a section up within mixed top-level order", () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + mgr.setWorktreeOrder([wt.id]) + const a = mgr.addSection("A", null) + const b = mgr.addSection("B", null) + expect(mgr.getWorktreeOrder()).toEqual([wt.id, a.id, b.id]) + mgr.moveSection(b.id, -1) + expect(mgr.getWorktreeOrder()).toEqual([wt.id, b.id, a.id]) + }) + + it("moves a section down within mixed top-level order", () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + mgr.setWorktreeOrder([wt.id]) + const a = mgr.addSection("A", null) + const b = mgr.addSection("B", null) + expect(mgr.getWorktreeOrder()).toEqual([wt.id, a.id, b.id]) + mgr.moveSection(a.id, 1) + expect(mgr.getWorktreeOrder()).toEqual([wt.id, b.id, a.id]) + }) + + it("is a no-op at boundaries", () => { + const a = mgr.addSection("A", null) + const b = mgr.addSection("B", null) + mgr.moveSection(a.id, -1) + expect(mgr.getWorktreeOrder()).toEqual([a.id, b.id]) + mgr.moveSection(b.id, 1) + expect(mgr.getWorktreeOrder()).toEqual([a.id, b.id]) + }) + + it("does not change section membership", () => { + const wt1 = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + const wt2 = mgr.addWorktree({ branch: "b", path: "/tmp/b", parentBranch: "main" }) + const a = mgr.addSection("A", null, [wt1.id, wt2.id]) + const b = mgr.addSection("B", null) + mgr.moveSection(b.id, -1) + expect(mgr.getWorktree(wt1.id)?.sectionId).toBe(a.id) + expect(mgr.getWorktree(wt2.id)?.sectionId).toBe(a.id) + }) + + it("persists reordered sections across save/load", async () => { + const a = mgr.addSection("A", null) + const b = mgr.addSection("B", null) + mgr.moveSection(b.id, -1) + await mgr.flush() + await mgr.save() + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + expect(loaded.getWorktreeOrder()).toEqual([b.id, a.id]) + }) + }) + + describe("persistence", () => { + it("saves and loads sections", async () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + const sec = mgr.addSection("Persist", "Green", [wt.id]) + mgr.toggleSection(sec.id) + await mgr.flush() + await mgr.save() + + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + + const secs = loaded.getSections() + expect(secs).toHaveLength(1) + const first = secs[0]! + expect(first.name).toBe("Persist") + expect(first.color).toBe("Green") + expect(first.collapsed).toBe(true) + expect(loaded.getWorktree(wt.id)?.sectionId).toBe(sec.id) + }) + + it("normalizes worktreeOrder on load to include missing section IDs", async () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + const sec = mgr.addSection("S", null) + await mgr.flush() + await mgr.save() + // Simulate stale data: manually remove section from worktreeOrder + const file = path.join(root, ".kilo", "agent-manager.json") + const data = JSON.parse(fs.readFileSync(file, "utf-8")) + data.worktreeOrder = [wt.id] // section ID missing + fs.writeFileSync(file, JSON.stringify(data)) + + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + expect(loaded.getWorktreeOrder()).toContain(sec.id) + expect(loaded.getWorktreeOrder()).toContain(wt.id) + }) + + it("normalizes worktreeOrder on load to include missing ungrouped worktrees", async () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + await mgr.flush() + await mgr.save() + const file = path.join(root, ".kilo", "agent-manager.json") + const data = JSON.parse(fs.readFileSync(file, "utf-8")) + data.worktreeOrder = [] // worktree ID missing + fs.writeFileSync(file, JSON.stringify(data)) + + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + expect(loaded.getWorktreeOrder()).toContain(wt.id) + }) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index c131f64681d..726a3c5c4ee 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -7,7 +7,6 @@ import { createSignal, createMemo, createEffect, - createRoot, on, onMount, onCleanup, @@ -37,6 +36,7 @@ import type { PRStatus, AgentManagerPRStatusMessage, ManagedSessionState, + SectionState, SessionInfo, BranchInfo, } from "../src/types/messages" @@ -47,9 +47,8 @@ import { SortableProvider, closestCenter, createSortable, - useDragDropContext, } from "@thisbeyond/solid-dnd" -import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" import { ThemeProvider } from "@kilocode/kilo-ui/theme" import { DialogProvider, useDialog } from "@kilocode/kilo-ui/context/dialog" import { Dialog } from "@kilocode/kilo-ui/dialog" @@ -92,6 +91,11 @@ import { groupApplyConflicts } from "./apply-conflicts" import type { ReviewComment } from "./review-comments" import { BranchSelect } from "./BranchSelect" import { WorktreeItem } from "./WorktreeItem" +import SectionHeader from "./SectionHeader" +import { randomColor } from "./section-colors" +import { buildTopLevelItems, isGrouped, isGroupStart, isGroupEnd, type TopLevelItem } from "./section-helpers" +import { sectionAwareDetector } from "./section-dnd" +import { ConstrainDragXAxis } from "./constrain-drag-x" import { mergeWorktreeDiffs } from "./diff-state" import "./agent-manager.css" import "./agent-manager-review.css" @@ -198,7 +202,6 @@ function useTabScroll(activeTabs: Accessor, activeId: Accessor { const id = activeId() const el = ref() @@ -325,6 +328,7 @@ const AgentManagerContent: Component = () => { const [localSessionIDs, setLocalSessionIDs] = createSignal(persisted?.localSessionIDs ?? []) const [sidebarWidth, setSidebarWidth] = createSignal(persisted?.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH) const [sessionsCollapsed, setSessionsCollapsed] = createSignal(false) + const [sections, setSections] = createSignal([]) // rAF coalescing for resize handlers — at most one signal write per frame let sidebarRaf: number | undefined @@ -616,6 +620,8 @@ const AgentManagerContent: Component = () => { // Sidebar worktree order (persisted to extension state) const [sidebarWorktreeOrder, setSidebarWorktreeOrder] = createSignal([]) const [draggingWorktree, setDraggingWorktree] = createSignal() + const [renamingSection, setRenamingSection] = createSignal(null) + let pendingNewSection = false const addPendingTab = () => { const id = `${PENDING_PREFIX}${++pendingCounter}` @@ -861,23 +867,23 @@ const AgentManagerContent: Component = () => { return result }) - /** Check if this worktree is part of a group. */ - const isGrouped = (wt: WorktreeState) => !!wt.groupId - - /** Check if this is the first item in its group. */ - const isGroupStart = (wt: WorktreeState, idx: number) => { - if (!wt.groupId) return false - const list = sortedWorktrees() - if (idx === 0) return true - return list[idx - 1]?.groupId !== wt.groupId - } - - /** Check if this is the last item in its group. */ - const isGroupEnd = (wt: WorktreeState, idx: number) => { - if (!wt.groupId) return false - const list = sortedWorktrees() - if (idx === list.length - 1) return true - return list[idx + 1]?.groupId !== wt.groupId + const worktreesInSection = (id: string) => sortedWorktrees().filter((wt) => wt.sectionId === id) + const ungrouped = createMemo(() => sortedWorktrees().filter((wt) => !wt.sectionId)) + const topLevelItems = createMemo((): TopLevelItem[] => + buildTopLevelItems(sections(), ungrouped(), sortedWorktrees(), sidebarWorktreeOrder()), + ) + const moveToSection = (ids: string[], sec: string | null) => + vscode.postMessage({ type: "agentManager.moveToSection", worktreeIds: ids, sectionId: sec }) + const moveSection = (sectionId: string, dir: -1 | 1) => + vscode.postMessage({ type: "agentManager.moveSection", sectionId, dir }) + const newSection = (ids?: string[]) => { + pendingNewSection = true + vscode.postMessage({ + type: "agentManager.createSection", + name: t("agentManager.section.defaultName"), + color: randomColor(), + worktreeIds: ids, + }) } const scrollIntoView = (el: HTMLElement) => { @@ -1206,6 +1212,14 @@ const AgentManagerContent: Component = () => { // When not a git repo, also mark sessions as loaded since the Kilo // server won't connect to send the sessionsLoaded message. if (state.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true) + const prev = new Set(sections().map((s) => s.id)), + incoming = state.sections ?? [] + setSections(incoming) + if (pendingNewSection) { + pendingNewSection = false + const c = incoming.find((s) => !prev.has(s.id)) + if (c) setRenamingSection(c.id) + } if (state.tabOrder) setWorktreeTabOrder(state.tabOrder) if (state.worktreeOrder) setSidebarWorktreeOrder(state.worktreeOrder) if (state.reviewDiffStyle === "split" || state.reviewDiffStyle === "unified") { @@ -2145,6 +2159,11 @@ const AgentManagerContent: Component = () => { ))} + + newSection()}> + + {t("agentManager.worktree.newSection")} + @@ -2233,56 +2252,40 @@ const AgentManagerContent: Component = () => { setRenamingWt(null) } + const hasSections = createMemo(() => sections().length > 0) const wtIds = createMemo(() => sortedWorktrees().map((wt) => wt.id)) + const secIds = createMemo(() => new Set(sections().map((s) => s.id))) + const home = () => new Map(sortedWorktrees().map((w) => [w.id, w.sectionId] as const)) + const sectionAware = sectionAwareDetector(secIds, home) const onWtDragStart = (event: DragEvent) => { const id = event.draggable?.id if (typeof id === "string") setDraggingWorktree(id) document.body.classList.add("am-wt-dragging-active") } - const onWtDragOver = (event: DragEvent) => { const from = event.draggable?.id const to = event.droppable?.id if (typeof from !== "string" || typeof to !== "string") return + if (secIds().has(to)) return setSidebarWorktreeOrder((prev) => { - const current = applyTabOrder( - sortedWorktrees().map((wt) => ({ id: wt.id })), + const cur = applyTabOrder( + sortedWorktrees().map((w) => ({ id: w.id })), prev, - ).map((item) => item.id) - return reorderTabs(current, from, to) ?? prev + ).map((i) => i.id) + return reorderTabs(cur, from, to) ?? prev }) } - - const onWtDragEnd = () => { + const onWtDragEnd = (event: DragEvent) => { + const from = event.draggable?.id + const to = event.droppable?.id setDraggingWorktree(undefined) document.body.classList.remove("am-wt-dragging-active") - const order = sortedWorktrees().map((wt) => wt.id) - if (order.length > 0) { - vscode.postMessage({ type: "agentManager.setWorktreeOrder", order }) + if (typeof from === "string" && typeof to === "string" && secIds().has(to)) { + moveToSection([from], to) + return } - } - - /** Lock drag movement to the Y axis (vertical-only worktree dragging). */ - const ConstrainDragXAxis: Component = () => { - const ctx = useDragDropContext() - if (!ctx) return null - const [ - , - { onDragStart: onStart, onDragEnd: onEnd, addTransformer: add, removeTransformer: remove }, - ] = ctx - const xform: Transformer = { id: "constrain-x-axis", order: 100, callback: (t) => ({ ...t, x: 0 }) } - const dispose = createRoot((dispose) => { - onStart(({ draggable }) => { - if (draggable) add("draggables", draggable.id as string, xform) - }) - onEnd(({ draggable }) => { - if (draggable) remove("draggables", draggable.id as string, xform.id) - }) - return dispose - }) - onCleanup(dispose) - return null + vscode.postMessage({ type: "agentManager.setWorktreeOrder", order: sidebarWorktreeOrder() }) } return ( @@ -2290,33 +2293,35 @@ const AgentManagerContent: Component = () => { onDragStart={onWtDragStart} onDragEnd={onWtDragEnd} onDragOver={onWtDragOver} - collisionDetector={closestCenter} + collisionDetector={sectionAware} > - - {(wt, idx) => { - const sessions = createMemo(() => managedSessions().filter((ms) => ms.worktreeId === wt.id)) - const navHint = () => { - const flat = [ - LOCAL as string, - ...sortedWorktrees().map((w) => w.id), - ...unassignedSessions().map((s) => s.id), - ] - const active = selection() ?? session.currentSessionID() ?? "" - return adjacentHint( + {(() => { + const renderWt = ( + wt: WorktreeState, + idx: () => number, + inSection?: boolean, + list?: WorktreeState[], + ) => { + const wtSessions = createMemo(() => + managedSessions().filter((ms) => ms.worktreeId === wt.id), + ) + const navHint = () => + adjacentHint( wt.id, - active, - flat, + selection() ?? session.currentSessionID() ?? "", + [ + LOCAL as string, + ...sortedWorktrees().map((w) => w.id), + ...unassignedSessions().map((s) => s.id), + ], kb().previousSession ?? "", kb().nextSession ?? "", ) - } - const groupSize = () => { - if (!wt.groupId) return 0 - return sortedWorktrees().filter((w) => w.groupId === wt.groupId).length - } + const groupSize = () => + !wt.groupId ? 0 : sortedWorktrees().filter((w) => w.groupId === wt.groupId).length const sortable = createSortable(wt.id) void sortable return ( @@ -2327,52 +2332,27 @@ const AgentManagerContent: Component = () => { - vscode.postMessage({ type: "agentManager.openPR", worktreeId: wt.id }) - } + sections={sections()} + currentSectionId={wt.sectionId} + onMoveToSection={(secId) => moveToSection([wt.id], secId)} + onMoveToNewSection={() => newSection()} onClick={() => { if (pendingDelete() === wt.id) { confirmDeleteWorktree(wt.id) @@ -2386,17 +2366,60 @@ const AgentManagerContent: Component = () => { onCommitRename={() => commitRename(wt.id)} onCancelRename={cancelRename} onRemoveStale={() => confirmRemoveStaleWorktree(wt.id)} - onCopyPath={() => - vscode.postMessage({ type: "agentManager.copyToClipboard", text: wt.path }) - } + onCopyPath={() => navigator.clipboard.writeText(wt.path)} onOpen={() => vscode.postMessage({ type: "agentManager.openWorktree", worktreeId: wt.id }) } />
) - }} - + } + if (hasSections()) { + const post = vscode.postMessage.bind(vscode) + return ( + + {(item, idx) => { + if (item.kind === "section") { + const sec = item.section + const members = createMemo(() => worktreesInSection(sec.id)) + return ( + setRenamingSection(null)} + onToggle={() => + post({ type: "agentManager.toggleSectionCollapsed", sectionId: sec.id }) + } + onRename={(name) => + post({ type: "agentManager.renameSection", sectionId: sec.id, name }) + } + onDelete={() => post({ type: "agentManager.deleteSection", sectionId: sec.id })} + onSetColor={(color) => + post({ type: "agentManager.setSectionColor", sectionId: sec.id, color }) + } + isFirst={idx() === 0} + isLast={idx() === topLevelItems().length - 1} + onMoveUp={() => moveSection(sec.id, -1)} + onMoveDown={() => moveSection(sec.id, 1)} + > + +
+ + {(wt, wtIdx) => renderWt(wt, wtIdx, true, members())} + +
+
+
+ ) + } + return renderWt(item.wt, idx) + }} +
+ ) + } + return {(wt, idx) => renderWt(wt, idx)} + })()} {(() => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx new file mode 100644 index 00000000000..34dbedb6d21 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx @@ -0,0 +1,152 @@ +import { Component, Show, createEffect, createSignal, type JSX } from "solid-js" +import { createDroppable } from "@thisbeyond/solid-dnd" +import { ContextMenu } from "@kilocode/kilo-ui/context-menu" +import { Icon } from "@kilocode/kilo-ui/icon" +import type { SectionState } from "../src/types/messages" +import { SECTION_COLORS, colorCss } from "./section-colors" +import { useLanguage } from "../src/context/language" + +interface Props { + section: SectionState + count: number + children?: JSX.Element + /** When true, auto-enter rename mode (e.g. after creation). */ + autoRename?: boolean + onToggle: () => void + onRename: (name: string) => void + onDelete: () => void + onSetColor: (color: string | null) => void + /** Called when rename ends (commit or cancel) so parent clears autoRename. */ + onRenameEnd?: () => void + onMoveUp?: () => void + onMoveDown?: () => void + isFirst?: boolean + isLast?: boolean +} + +const SectionHeader: Component = (props) => { + const { t } = useLanguage() + const [renaming, setRenaming] = createSignal(false) + const [value, setValue] = createSignal("") + + const border = () => colorCss(props.section.color) ?? "var(--vscode-panel-border)" + + const startRename = () => { + setValue(props.section.name) + setRenaming(true) + } + + const commit = () => { + const trimmed = value().trim() + setRenaming(false) + props.onRenameEnd?.() + if (trimmed && trimmed !== props.section.name) { + props.onRename(trimmed) + } + } + + const cancel = () => { + setRenaming(false) + props.onRenameEnd?.() + } + + createEffect(() => { + if (props.autoRename && !renaming()) startRename() + }) + + const handleClick = (e: MouseEvent) => { + if (e.button !== 0 || renaming()) return + props.onToggle() + } + + const droppable = createDroppable(props.section.id) + + return ( +
+ + +
+ + setValue(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") commit() + if (e.key === "Escape") cancel() + }} + onBlur={commit} + onClick={(e) => e.stopPropagation()} + ref={(el) => + requestAnimationFrame(() => + requestAnimationFrame(() => { + el.focus() + el.select() + }), + ) + } + /> + } + > + {props.section.name} + +
+ {props.count} +
+ + + + + {t("agentManager.section.rename")} + + + + {t("agentManager.section.setColor")} +
+ props.onSetColor(null)} class="am-color-grid-item"> + + + {SECTION_COLORS.map((c) => ( + props.onSetColor(c.label)} class="am-color-grid-item"> + + + ))} +
+
+ + props.onMoveUp?.()} disabled={props.isFirst}> + + {t("agentManager.section.moveUp")} + + props.onMoveDown?.()} disabled={props.isLast}> + + {t("agentManager.section.moveDown")} + + + + + {t("agentManager.section.delete")} + +
+
+
+ {props.children} +
+ ) +} + +export default SectionHeader diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx index d2fd93465ef..693710eaf28 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx @@ -2,7 +2,7 @@ * Sidebar worktree item with inline delete confirmation, HoverCard, rename, and stats. * Extracted from AgentManagerApp for reuse and visual-regression testing via Storybook. */ -import { Component, Show, createSignal } from "solid-js" +import { Component, For, Show, createSignal } from "solid-js" import { Icon } from "@kilocode/kilo-ui/icon" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Spinner } from "@kilocode/kilo-ui/spinner" @@ -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 } from "../src/types/messages" +import type { WorktreeState, WorktreeGitStats, PRStatus, SectionState } from "../src/types/messages" +import { colorCss } from "./section-colors" import { useLanguage } from "../src/context/language" import { formatRelativeDate } from "../src/utils/date" @@ -30,8 +31,8 @@ interface WorktreeItemProps { /** Whether an agent session on this worktree is actively working (shows spinner instead of branch icon). */ working: boolean stale: boolean - /** 1-indexed shortcut number shown as ⌘2, ⌘3, etc. Pass 0 or >9 to hide. */ - shortcut: number + /** 1-indexed shortcut number shown as ⌘2, ⌘3, etc. Pass 0, >9, or undefined to hide. */ + shortcut?: number stats?: WorktreeGitStats /** Navigation hint text shown in the hover card (e.g. "⌘⌥↑"). */ navHint?: string @@ -57,6 +58,14 @@ interface WorktreeItemProps { pr?: PRStatus | null /** Callback when the PR badge is clicked. */ onOpenPR?: () => void + /** Available sections for the "Move to Section" submenu. */ + sections?: SectionState[] + /** ID of the section this worktree currently belongs to (for disabling current item). */ + currentSectionId?: string + /** Move this worktree to a section (or null for ungrouped). */ + onMoveToSection?: (sectionId: string | null) => void + /** Move this worktree to a new section. */ + onMoveToNewSection?: () => void onClick: () => void onDelete: (e: MouseEvent) => void @@ -234,7 +243,9 @@ export const WorktreeItem: Component = (props) => { {t("agentManager.worktree.confirmDelete")}
- = 2 && props.shortcut <= MAX_SHORTCUT}> + = 2 && props.shortcut <= MAX_SHORTCUT} + > {isMac ? "⌘" : "Ctrl+"} {props.shortcut} @@ -451,6 +462,38 @@ export const WorktreeItem: Component = (props) => { {t("agentManager.worktree.copyPath")} + + props.onMoveToNewSection?.()}> + + {t("agentManager.worktree.newSection")} + + 0}> + + props.onMoveToSection?.(null)}> + + + + {t("agentManager.worktree.ungrouped")} + + + {(sec) => ( + props.onMoveToSection?.(sec.id)}> + + } + > + + + {sec.name} + + )} + + 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 648a3ccbaca..688eefd69ed 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -627,6 +627,151 @@ button.am-section-toggle:hover .am-section-label { opacity: 0; } +/* User-defined sections — collapsible, color-coded groups */ + +.am-section-group { + margin-top: 2px; + border-left: 2px solid var(--section-color, var(--vscode-panel-border)); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + transition: border-color 120ms; +} + +.am-section-group-drop { + background: color-mix(in srgb, var(--section-color) 10%, transparent); + border-color: var(--section-color); + border-style: solid; +} + +.am-section-group-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 10px 5px 6px; + cursor: pointer; + user-select: none; + border-radius: var(--radius-sm); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.3px; + text-transform: uppercase; + color: var(--text-weak); +} + +.am-section-group-header:hover { + background: var(--surface-interactive-hover, var(--vscode-list-hoverBackground)); +} + +.am-section-group-left { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + overflow: hidden; +} + +.am-section-group-chevron { + flex-shrink: 0; + opacity: 0.7; + transition: transform 120ms ease; +} + +.am-section-group-chevron-collapsed { + transform: rotate(-90deg); +} + +.am-section-group-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.am-section-group-count { + flex-shrink: 0; + font-size: 10px; + color: var(--text-weaker); + min-width: 14px; + text-align: center; +} + +.am-section-group-body { + padding-left: 4px; +} + +.am-section-group-rename { + background: var(--input-base, var(--vscode-input-background, #3c3c3c)); + color: var(--vscode-input-foreground); + border: 1px solid var(--border-focus, var(--vscode-focusBorder, #007fd4)); + border-radius: var(--radius-sm); + padding: 1px 4px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.3px; + text-transform: uppercase; + outline: none; + width: 100%; + min-width: 0; +} + +/* Color picker grid in section context menu */ + +.am-color-grid { + display: flex; + flex-wrap: wrap; + gap: 2px; + padding: 4px 8px; +} + +.am-color-grid-item { + padding: 3px !important; + border-radius: 4px !important; + min-height: unset !important; +} + +.am-color-swatch { + display: block; + width: 16px; + height: 16px; + border-radius: 50%; + flex-shrink: 0; + border: 1.5px solid transparent; +} + +.am-color-swatch-active { + border-color: var(--vscode-foreground); + box-shadow: 0 0 0 1px var(--vscode-editor-background); +} + +.am-color-swatch-default { + background: var(--vscode-panel-border); + position: relative; +} + +.am-color-swatch-default::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 70%; + height: 1.5px; + background: var(--text-weaker); + transform: translate(-50%, -50%) rotate(-45deg); +} + +.am-color-swatch-sm { + width: 10px; + height: 10px; +} + +.am-icon-flip { + transform: rotate(180deg); +} + +/* Context menu danger item (delete section) */ + +.am-ctx-menu-danger { + color: var(--text-error, var(--vscode-errorForeground, #f44)); +} + /* Grouped worktrees — visual grouping with header and left accent */ .am-wt-group-header { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/constrain-drag-x.ts b/packages/kilo-vscode/webview-ui/agent-manager/constrain-drag-x.ts new file mode 100644 index 00000000000..3c9ed79504c --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/constrain-drag-x.ts @@ -0,0 +1,21 @@ +import { type Component, createRoot, onCleanup } from "solid-js" +import { useDragDropContext, type Transformer } from "@thisbeyond/solid-dnd" + +/** Lock drag movement to the Y axis (vertical-only worktree dragging). */ +export const ConstrainDragXAxis: Component = () => { + const ctx = useDragDropContext() + if (!ctx) return null + const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = ctx + const xform: Transformer = { id: "constrain-x-axis", order: 100, callback: (t) => ({ ...t, x: 0 }) } + const dispose = createRoot((d) => { + onDragStart(({ draggable }) => { + if (draggable) addTransformer("draggables", draggable.id as string, xform) + }) + onDragEnd(({ draggable }) => { + if (draggable) removeTransformer("draggables", draggable.id as string, xform.id) + }) + return d + }) + onCleanup(dispose) + return null +} 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 461c4288671..9c2d75384ec 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "نسخ المسار", "agentManager.worktree.openInVscode": "فتح في VS Code", "agentManager.worktree.rename": "إعادة تسمية", + "agentManager.worktree.moveToSection": "نقل إلى القسم", + "agentManager.worktree.newSection": "قسم جديد", + "agentManager.worktree.ungrouped": "غير مجمع", + "agentManager.section.rename": "إعادة تسمية القسم", + "agentManager.section.setColor": "تعيين اللون", + "agentManager.section.delete": "حذف القسم", + "agentManager.section.defaultColor": "افتراضي", + "agentManager.section.defaultName": "قسم جديد", + "agentManager.section.moveUp": "تحريك لأعلى", + "agentManager.section.moveDown": "تحريك لأسفل", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "الفرع", "agentManager.hoverCard.base": "الأساس", "agentManager.hoverCard.sessions": "الجلسات", 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 a2a5531c4b4..b8f8c9fcc46 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Copiar Caminho", "agentManager.worktree.openInVscode": "Abrir no VS Code", "agentManager.worktree.rename": "Renomear", + "agentManager.worktree.moveToSection": "Mover para a Seção", + "agentManager.worktree.newSection": "Nova Seção", + "agentManager.worktree.ungrouped": "Não Agrupado", + "agentManager.section.rename": "Renomear Seção", + "agentManager.section.setColor": "Definir Cor", + "agentManager.section.delete": "Excluir Seção", + "agentManager.section.defaultColor": "Padrão", + "agentManager.section.defaultName": "Nova Seção", + "agentManager.section.moveUp": "Mover para Cima", + "agentManager.section.moveDown": "Mover para Baixo", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessões", 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 d92d7434157..9a778861061 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Kopiraj putanju", "agentManager.worktree.openInVscode": "Otvori u VS Code", "agentManager.worktree.rename": "Preimenuj", + "agentManager.worktree.moveToSection": "Premjesti u sekciju", + "agentManager.worktree.newSection": "Nova sekcija", + "agentManager.worktree.ungrouped": "Bez grupe", + "agentManager.section.rename": "Preimenuj sekciju", + "agentManager.section.setColor": "Postavi boju", + "agentManager.section.delete": "Izbriši sekciju", + "agentManager.section.defaultColor": "Zadano", + "agentManager.section.defaultName": "Nova sekcija", + "agentManager.section.moveUp": "Pomjeri gore", + "agentManager.section.moveDown": "Pomjeri dolje", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Baza", "agentManager.hoverCard.sessions": "Sesije", 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 b6b7b692229..e5036de5057 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Kopier sti", "agentManager.worktree.openInVscode": "Åbn i VS Code", "agentManager.worktree.rename": "Omdøb", + "agentManager.worktree.moveToSection": "Flyt til sektion", + "agentManager.worktree.newSection": "Ny sektion", + "agentManager.worktree.ungrouped": "Ugrupperet", + "agentManager.section.rename": "Omdøb sektion", + "agentManager.section.setColor": "Indstil farve", + "agentManager.section.delete": "Slet sektion", + "agentManager.section.defaultColor": "Standard", + "agentManager.section.defaultName": "Ny sektion", + "agentManager.section.moveUp": "Flyt op", + "agentManager.section.moveDown": "Flyt ned", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessioner", 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 bc6a0e0f5bc..2d26633d9d7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Pfad kopieren", "agentManager.worktree.openInVscode": "In VS Code öffnen", "agentManager.worktree.rename": "Umbenennen", + "agentManager.worktree.moveToSection": "In Bereich verschieben", + "agentManager.worktree.newSection": "Neuer Bereich", + "agentManager.worktree.ungrouped": "Nicht gruppiert", + "agentManager.section.rename": "Bereich umbenennen", + "agentManager.section.setColor": "Farbe festlegen", + "agentManager.section.delete": "Bereich löschen", + "agentManager.section.defaultColor": "Standard", + "agentManager.section.defaultName": "Neuer Bereich", + "agentManager.section.moveUp": "Nach oben verschieben", + "agentManager.section.moveDown": "Nach unten verschieben", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Basis", "agentManager.hoverCard.sessions": "Sitzungen", 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 a4ed3a190e4..dab774f0af9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -20,6 +20,17 @@ export const dict = { "agentManager.worktree.copyPath": "Copy Path", "agentManager.worktree.openInVscode": "Open in VS Code", "agentManager.worktree.rename": "Rename", + "agentManager.worktree.moveToSection": "Move to Section", + "agentManager.worktree.newSection": "New Section", + "agentManager.worktree.ungrouped": "Ungrouped", + "agentManager.section.rename": "Rename Section", + "agentManager.section.setColor": "Set Color", + "agentManager.section.delete": "Delete Section", + "agentManager.section.defaultColor": "Default", + "agentManager.section.defaultName": "New Section", + "agentManager.section.moveUp": "Move Up", + "agentManager.section.moveDown": "Move Down", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Base", 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 576d2c6e11a..161e7c8347a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Copiar ruta", "agentManager.worktree.openInVscode": "Abrir en VS Code", "agentManager.worktree.rename": "Renombrar", + "agentManager.worktree.moveToSection": "Mover a la sección", + "agentManager.worktree.newSection": "Nueva sección", + "agentManager.worktree.ungrouped": "Desagrupado", + "agentManager.section.rename": "Renombrar sección", + "agentManager.section.setColor": "Establecer color", + "agentManager.section.delete": "Eliminar sección", + "agentManager.section.defaultColor": "Predeterminado", + "agentManager.section.defaultName": "Nueva sección", + "agentManager.section.moveUp": "Mover hacia arriba", + "agentManager.section.moveDown": "Mover hacia abajo", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sesiones", 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 3f880892387..9f14a9440da 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Copier le chemin", "agentManager.worktree.openInVscode": "Ouvrir dans VS Code", "agentManager.worktree.rename": "Renommer", + "agentManager.worktree.moveToSection": "Déplacer vers la section", + "agentManager.worktree.newSection": "Nouvelle section", + "agentManager.worktree.ungrouped": "Non groupé", + "agentManager.section.rename": "Renommer la section", + "agentManager.section.setColor": "Définir la couleur", + "agentManager.section.delete": "Supprimer la section", + "agentManager.section.defaultColor": "Par défaut", + "agentManager.section.defaultName": "Nouvelle section", + "agentManager.section.moveUp": "Déplacer vers le haut", + "agentManager.section.moveDown": "Déplacer vers le bas", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCHE", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessions", 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 44c226ad37c..867dd44a3e5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "パスをコピー", "agentManager.worktree.openInVscode": "VS Codeで開く", "agentManager.worktree.rename": "名前を変更", + "agentManager.worktree.moveToSection": "セクションに移動", + "agentManager.worktree.newSection": "新しいセクション", + "agentManager.worktree.ungrouped": "グループなし", + "agentManager.section.rename": "セクションの名前変更", + "agentManager.section.setColor": "色の設定", + "agentManager.section.delete": "セクションの削除", + "agentManager.section.defaultColor": "デフォルト", + "agentManager.section.defaultName": "新しいセクション", + "agentManager.section.moveUp": "上に移動", + "agentManager.section.moveDown": "下に移動", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "ブランチ", "agentManager.hoverCard.base": "ベース", "agentManager.hoverCard.sessions": "セッション", 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 8258caba61a..f3fa9011a0b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "경로 복사", "agentManager.worktree.openInVscode": "VS Code에서 열기", "agentManager.worktree.rename": "이름 바꾸기", + "agentManager.worktree.moveToSection": "섹션으로 이동", + "agentManager.worktree.newSection": "새 섹션", + "agentManager.worktree.ungrouped": "그룹 해제됨", + "agentManager.section.rename": "섹션 이름 바꾸기", + "agentManager.section.setColor": "색상 설정", + "agentManager.section.delete": "섹션 삭제", + "agentManager.section.defaultColor": "기본값", + "agentManager.section.defaultName": "새 섹션", + "agentManager.section.moveUp": "위로 이동", + "agentManager.section.moveDown": "아래로 이동", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "브랜치", "agentManager.hoverCard.base": "베이스", "agentManager.hoverCard.sessions": "세션", 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 40394720d21..00da3f0282d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -20,6 +20,17 @@ export const dict = { "agentManager.worktree.copyPath": "Pad kopiëren", "agentManager.worktree.openInVscode": "Openen in VS Code", "agentManager.worktree.rename": "Hernoemen", + "agentManager.worktree.moveToSection": "Verplaatsen naar sectie", + "agentManager.worktree.newSection": "Nieuwe sectie", + "agentManager.worktree.ungrouped": "Niet gegroepeerd", + "agentManager.section.rename": "Sectie hernoemen", + "agentManager.section.setColor": "Kleur instellen", + "agentManager.section.delete": "Sectie verwijderen", + "agentManager.section.defaultColor": "Standaard", + "agentManager.section.defaultName": "Nieuwe sectie", + "agentManager.section.moveUp": "Omhoog verplaatsen", + "agentManager.section.moveDown": "Omlaag verplaatsen", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Basis", 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 1a67eac546f..dab80f396f1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Kopier sti", "agentManager.worktree.openInVscode": "Åpne i VS Code", "agentManager.worktree.rename": "Gi nytt navn", + "agentManager.worktree.moveToSection": "Flytt til seksjon", + "agentManager.worktree.newSection": "Ny seksjon", + "agentManager.worktree.ungrouped": "Ugruppert", + "agentManager.section.rename": "Gi seksjon nytt navn", + "agentManager.section.setColor": "Angi farge", + "agentManager.section.delete": "Slett seksjon", + "agentManager.section.defaultColor": "Standard", + "agentManager.section.defaultName": "Ny seksjon", + "agentManager.section.moveUp": "Flytt opp", + "agentManager.section.moveDown": "Flytt ned", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Økter", 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 57a71417611..e91b0ac2526 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Kopiuj ścieżkę", "agentManager.worktree.openInVscode": "Otwórz w VS Code", "agentManager.worktree.rename": "Zmień nazwę", + "agentManager.worktree.moveToSection": "Przenieś do sekcji", + "agentManager.worktree.newSection": "Nowa sekcja", + "agentManager.worktree.ungrouped": "Niezgrupowane", + "agentManager.section.rename": "Zmień nazwę sekcji", + "agentManager.section.setColor": "Ustaw kolor", + "agentManager.section.delete": "Usuń sekcję", + "agentManager.section.defaultColor": "Domyślny", + "agentManager.section.defaultName": "Nowa sekcja", + "agentManager.section.moveUp": "Przenieś w górę", + "agentManager.section.moveDown": "Przenieś w dół", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Baza", "agentManager.hoverCard.sessions": "Sesje", 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 5748502ce48..5113cea6b47 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Копировать путь", "agentManager.worktree.openInVscode": "Открыть в VS Code", "agentManager.worktree.rename": "Переименовать", + "agentManager.worktree.moveToSection": "Переместить в раздел", + "agentManager.worktree.newSection": "Новый раздел", + "agentManager.worktree.ungrouped": "Без группы", + "agentManager.section.rename": "Переименовать раздел", + "agentManager.section.setColor": "Задать цвет", + "agentManager.section.delete": "Удалить раздел", + "agentManager.section.defaultColor": "По умолчанию", + "agentManager.section.defaultName": "Новый раздел", + "agentManager.section.moveUp": "Переместить вверх", + "agentManager.section.moveDown": "Переместить вниз", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "ВЕТКА", "agentManager.hoverCard.base": "Основа", "agentManager.hoverCard.sessions": "Сессии", 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 62c833e0b70..61bf7c4ea13 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "คัดลอกเส้นทาง", "agentManager.worktree.openInVscode": "เปิดใน VS Code", "agentManager.worktree.rename": "เปลี่ยนชื่อ", + "agentManager.worktree.moveToSection": "ย้ายไปที่ส่วน", + "agentManager.worktree.newSection": "ส่วนใหม่", + "agentManager.worktree.ungrouped": "ไม่ได้จัดกลุ่ม", + "agentManager.section.rename": "เปลี่ยนชื่อส่วน", + "agentManager.section.setColor": "ตั้งค่าสี", + "agentManager.section.delete": "ลบส่วน", + "agentManager.section.defaultColor": "ค่าเริ่มต้น", + "agentManager.section.defaultName": "ส่วนใหม่", + "agentManager.section.moveUp": "เลื่อนขึ้น", + "agentManager.section.moveDown": "เลื่อนลง", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "ฐาน", "agentManager.hoverCard.sessions": "เซสชัน", 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 10ce0c20985..aa25ee92f25 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -20,6 +20,17 @@ export const dict = { "agentManager.worktree.copyPath": "Yolu Kopyala", "agentManager.worktree.openInVscode": "VS Code'da Aç", "agentManager.worktree.rename": "Yeniden Adlandır", + "agentManager.worktree.moveToSection": "Bölüme Taşı", + "agentManager.worktree.newSection": "Yeni Bölüm", + "agentManager.worktree.ungrouped": "Gruplandırılmamış", + "agentManager.section.rename": "Bölümü Yeniden Adlandır", + "agentManager.section.setColor": "Renk Ayarla", + "agentManager.section.delete": "Bölümü Sil", + "agentManager.section.defaultColor": "Varsayılan", + "agentManager.section.defaultName": "Yeni Bölüm", + "agentManager.section.moveUp": "Yukarı Taşı", + "agentManager.section.moveDown": "Aşağı Taşı", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "DAL", "agentManager.hoverCard.base": "Temel", 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 ae8f2cd143f..9ccde643c3f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -20,6 +20,17 @@ export const dict = { "agentManager.worktree.copyPath": "Копіювати шлях", "agentManager.worktree.openInVscode": "Відкрити у VS Code", "agentManager.worktree.rename": "Перейменувати", + "agentManager.worktree.moveToSection": "Перемістити до розділу", + "agentManager.worktree.newSection": "Новий розділь", + "agentManager.worktree.ungrouped": "Без групи", + "agentManager.section.rename": "Перейменувати розділ", + "agentManager.section.setColor": "Встановити колір", + "agentManager.section.delete": "Видалити розділ", + "agentManager.section.defaultColor": "За замовчуванням", + "agentManager.section.defaultName": "Новий розділ", + "agentManager.section.moveUp": "Перемістити вгору", + "agentManager.section.moveDown": "Перемістити вниз", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "ГІЛКА", "agentManager.hoverCard.base": "База", 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 fd8f3a5071d..046a0bc4e0f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "复制路径", "agentManager.worktree.openInVscode": "在 VS Code 中打开", "agentManager.worktree.rename": "重命名", + "agentManager.worktree.moveToSection": "移动到分区", + "agentManager.worktree.newSection": "新分区", + "agentManager.worktree.ungrouped": "未分组", + "agentManager.section.rename": "重命名分区", + "agentManager.section.setColor": "设置颜色", + "agentManager.section.delete": "删除分区", + "agentManager.section.defaultColor": "默认", + "agentManager.section.defaultName": "新分区", + "agentManager.section.moveUp": "上移", + "agentManager.section.moveDown": "下移", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "分支", "agentManager.hoverCard.base": "基础", "agentManager.hoverCard.sessions": "会话", 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 0906b6f73f0..ed02e907a6c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "複製路徑", "agentManager.worktree.openInVscode": "在 VS Code 中打開", "agentManager.worktree.rename": "重新命名", + "agentManager.worktree.moveToSection": "移動到分區", + "agentManager.worktree.newSection": "新分區", + "agentManager.worktree.ungrouped": "未分組", + "agentManager.section.rename": "重新命名分區", + "agentManager.section.setColor": "設定顏色", + "agentManager.section.delete": "刪除分區", + "agentManager.section.defaultColor": "預設", + "agentManager.section.defaultName": "新分區", + "agentManager.section.moveUp": "上移", + "agentManager.section.moveDown": "下移", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "分支", "agentManager.hoverCard.base": "基底", "agentManager.hoverCard.sessions": "工作階段", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/section-colors.ts b/packages/kilo-vscode/webview-ui/agent-manager/section-colors.ts new file mode 100644 index 00000000000..2d988b95601 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/section-colors.ts @@ -0,0 +1,22 @@ +/** Section color palette using VS Code theme CSS variables for theme-adaptive colors. */ +export const SECTION_COLORS = [ + { label: "Red", css: "var(--vscode-terminal-ansiRed)" }, + { label: "Orange", css: "var(--vscode-charts-orange)" }, + { label: "Yellow", css: "var(--vscode-terminal-ansiYellow)" }, + { label: "Green", css: "var(--vscode-terminal-ansiGreen)" }, + { label: "Cyan", css: "var(--vscode-terminal-ansiCyan)" }, + { label: "Blue", css: "var(--vscode-charts-blue)" }, + { label: "Purple", css: "var(--vscode-charts-purple)" }, + { label: "Magenta", css: "var(--vscode-terminal-ansiMagenta)" }, +] as const + +/** Map a stored color label to its CSS variable string. Returns undefined for null/unknown labels. */ +export function colorCss(label: string | null): string | undefined { + if (!label) return undefined + return SECTION_COLORS.find((c) => c.label === label)?.css +} + +/** Pick a random color label for new sections. */ +export function randomColor(): string { + return SECTION_COLORS[Math.floor(Math.random() * SECTION_COLORS.length)]!.label +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/section-dnd.ts b/packages/kilo-vscode/webview-ui/agent-manager/section-dnd.ts new file mode 100644 index 00000000000..d625f795ca8 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/section-dnd.ts @@ -0,0 +1,35 @@ +/** + * Section drag-and-drop helpers. Separated from section-helpers.ts to avoid + * pulling solid-dnd into test environments. + */ +import { closestCenter } from "@thisbeyond/solid-dnd" +import type { CollisionDetector } from "@thisbeyond/solid-dnd" + +/** + * Collision detector that prioritizes section drop zones when a worktree is + * dragged (checks bounding box, not just center). Skips the worktree's home + * section so within-section reorder works. Falls back to closestCenter. + * + * @param secIds Accessor for all section IDs + * @param home Accessor for worktree ID → its sectionId (or undefined if ungrouped) + */ +export function sectionAwareDetector( + secIds: () => Set, + home: () => Map, +): CollisionDetector { + return (draggable, droppables, ctx) => { + const secs = secIds() + const id = draggable.id as string + const mySection = home().get(id) + if (!secs.has(id)) { + const pt = draggable.transformed.center + for (const d of droppables) { + if (!secs.has(d.id as string)) continue + if (d.id === mySection) continue + const { top, bottom, left, right } = d.layout + if (pt.x >= left && pt.x <= right && pt.y >= top && pt.y <= bottom) return d + } + } + return closestCenter(draggable, droppables, ctx) + } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts b/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts new file mode 100644 index 00000000000..6b15ab6ef64 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts @@ -0,0 +1,62 @@ +/** + * Section computation helpers for the agent manager sidebar. + * Pure functions — no solid-dnd dependency so they remain testable. + */ +import type { WorktreeState, SectionState } from "../src/types/messages" + +export type TopLevelItem = { kind: "section"; section: SectionState } | { kind: "worktree"; wt: WorktreeState } + +/** Check if this worktree is part of a multi-version group. */ +export const isGrouped = (wt: WorktreeState) => !!wt.groupId + +/** Check if this is the first item in its group within a given list. */ +export const isGroupStart = (wt: WorktreeState, idx: number, list: WorktreeState[]) => { + if (!wt.groupId) return false + if (idx === 0) return true + return list[idx - 1]?.groupId !== wt.groupId +} + +/** Check if this is the last item in its group within a given list. */ +export const isGroupEnd = (wt: WorktreeState, idx: number, list: WorktreeState[]) => { + if (!wt.groupId) return false + if (idx === list.length - 1) return true + return list[idx + 1]?.groupId !== wt.groupId +} + +/** + * Build the interleaved list of sections and ungrouped worktrees + * ordered by sidebarWorktreeOrder. + */ +export function buildTopLevelItems( + secs: SectionState[], + ungrouped: WorktreeState[], + all: WorktreeState[], + order: string[], +): TopLevelItem[] { + if (secs.length === 0) { + return all.map((wt) => ({ kind: "worktree" as const, wt })) + } + const secMap = new Map(secs.map((s) => [s.id, s])) + const wtMap = new Map(ungrouped.map((wt) => [wt.id, wt])) + const result: TopLevelItem[] = [] + const placed = new Set() + + for (const id of order) { + if (placed.has(id)) continue + placed.add(id) + const sec = secMap.get(id) + if (sec) { + result.push({ kind: "section", section: sec }) + continue + } + const wt = wtMap.get(id) + if (wt) result.push({ kind: "worktree", wt }) + } + for (const sec of secs) { + if (!placed.has(sec.id)) result.push({ kind: "section", section: sec }) + } + for (const wt of ungrouped) { + if (!placed.has(wt.id)) result.push({ kind: "worktree", wt }) + } + return result +} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 2f36feec95a..b894c9a36bf 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -805,6 +805,17 @@ export interface WorktreeState { prUrl?: string /** Cached PR state for correct badge color on reload (open/merged/closed/draft). */ prState?: string + /** Section this worktree belongs to, or undefined for ungrouped. */ + sectionId?: string +} + +export interface SectionState { + id: string + name: string + /** Color label (e.g. "Red", "Blue") or null for default. */ + color: string | null + order: number + collapsed: boolean } // --------------------------------------------------------------------------- @@ -885,6 +896,7 @@ export interface AgentManagerStateMessage { type: "agentManager.state" worktrees: WorktreeState[] sessions: ManagedSessionState[] + sections?: SectionState[] staleWorktreeIds?: string[] tabOrder?: Record worktreeOrder?: string[] @@ -2162,6 +2174,48 @@ export interface ContinueInWorktreeRequest { sessionId: string } +// Section CRUD messages (webview → extension) +export interface CreateSectionRequest { + type: "agentManager.createSection" + name: string + color?: string + worktreeIds?: string[] +} + +export interface RenameSectionRequest { + type: "agentManager.renameSection" + sectionId: string + name: string +} + +export interface DeleteSectionRequest { + type: "agentManager.deleteSection" + sectionId: string +} + +export interface SetSectionColorRequest { + type: "agentManager.setSectionColor" + sectionId: string + color: string | null +} + +export interface ToggleSectionCollapsedRequest { + type: "agentManager.toggleSectionCollapsed" + sectionId: string +} + +export interface MoveToSectionRequest { + type: "agentManager.moveToSection" + worktreeIds: string[] + sectionId: string | null +} + +export interface MoveSectionRequest { + type: "agentManager.moveSection" + sectionId: string + dir: -1 | 1 +} + export type ContinueInWorktreeStatus = | "capturing" | "creating" @@ -2307,6 +2361,13 @@ export type WebviewMessage = | ToggleFavoriteRequest | RequestFavoritesMessage | ContinueInWorktreeRequest + | CreateSectionRequest + | RenameSectionRequest + | DeleteSectionRequest + | SetSectionColorRequest + | ToggleSectionCollapsedRequest + | MoveToSectionRequest + | MoveSectionRequest // ============================================ // VS Code API type From 5de991a8076a9c9410e5cb167f9e6da42163db82 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 8 Apr 2026 13:38:55 +0200 Subject: [PATCH 55/55] feat(ui): add copy button for assistant messages in kilo-ui (#8578) The kilo-ui TextPartDisplay was missing a copy button for assistant responses. Add one that mirrors the existing user message copy button, shown left-aligned with minimal margin on the last text part of a completed turn. --- .../kilo-ui/src/components/message-part.css | 12 +++++++ .../kilo-ui/src/components/message-part.tsx | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index 307ae9fccdd..802bea6c7f9 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -19,6 +19,18 @@ height: 20px; } } + + [data-slot="assistant-copy-wrapper"] { + display: flex; + align-items: center; + justify-content: flex-start; + margin-top: 2px; + + [data-component="icon-button"] { + width: 20px; + height: 20px; + } + } } /* Prevent long title/path from hiding the collapsible expand arrow */ diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 187a6a2df21..663945716bb 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -1155,6 +1155,7 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() { PART_MAPPING["text"] = function TextPartDisplay(props) { const data = useData() + const i18n = useI18n() const part = () => props.part as TextPart const displayText = () => (part().text ?? "").trim() @@ -1166,6 +1167,21 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { return props.turnDiffSummary }) + const showCopy = createMemo(() => { + if (props.message.role !== "assistant") return false + if (props.showAssistantCopyPartID === null) return false + return props.showAssistantCopyPartID === part().id + }) + const [copied, setCopied] = createSignal(false) + + const handleCopy = async () => { + const content = displayText() + if (!content) return + await navigator.clipboard.writeText(content) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + const handleMarkdownClick = (e: MouseEvent) => { if (!data.openFile) return const target = e.target @@ -1200,6 +1216,24 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
+ +
+ + e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} + /> + +
+
{(render) => (