diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 9f5991241b..72faba0af6 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -37,6 +37,7 @@ import { } from "./kilo-provider-utils" import { MarketplaceService } from "./services/marketplace" import { resolveProjectDirectory } from "./project-directory" +import { getBusySessionCount, seedSessionStatuses } from "./session-status" type KiloProviderOptions = { projectDirectory?: string | null @@ -71,6 +72,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private trackedSessionIds: Set = new Set() private syncedChildSessions: Set = new Set() + /** Tracks the latest status for each session, used to warn before destructive config operations. */ + private sessionStatusMap = new Map() /** Per-session directory overrides (e.g., worktree paths registered by AgentManagerProvider). */ private sessionDirectories = new Map() /** Project ID for the current workspace, used to filter out sessions from other repositories. */ @@ -250,6 +253,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper type: "profileData", data: profileData, }) + + // Seed session status map so the Settings panel knows about already-running sessions. + // Must run after webview is ready (postMessage is a no-op before that). + void this.seedSessionStatusMap() } // legacy-migration start @@ -841,6 +848,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return event.type !== "message.part.updated" && event.type !== "message.part.delta" } + // session.status must always pass through — even for sessions not tracked by this + // KiloProvider instance. The Settings panel is a separate provider with no tracked + // sessions, but it needs session.status to populate sessionStatusMap and allStatusMap + // for the busy-session warning on Save. + if (event.type === "session.status") return true + return this.trackedSessionIds.has(sessionId) }, (event) => { @@ -899,7 +912,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await this.syncWebviewState("initializeConnection") await this.flushPendingSessionRefresh("initializeConnection") - // Fetch providers, agents, skills, config, and notifications in parallel + // Fetch providers, agents, skills, config, notifications, and session statuses in parallel await Promise.all([ this.fetchAndSendProviders(), this.fetchAndSendAgents(), @@ -907,6 +920,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.fetchAndSendCommands(), this.fetchAndSendConfig(), this.fetchAndSendNotifications(), + this.seedSessionStatusMap(), ]) this.sendNotificationSettings() @@ -1529,6 +1543,33 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + /** + * Seed sessionStatusMap with current session statuses on connect. + * Without this, the Settings panel (which has no tracked sessions) would see + * busyCount() = 0 for sessions that were already running before it opened. + */ + private async seedSessionStatusMap(): Promise { + if (!this.client || this.connectionState !== "connected") return + const dir = this.getWorkspaceDirectory() + await seedSessionStatuses(this.client, dir, this.sessionStatusMap, (msg) => this.postMessage(msg)) + } + + /** + * Fetch the latest merged config and push it as configUpdated. + * Called when global.config.updated SSE fires (config changed without a full dispose). + */ + private async fetchAndSendConfigUpdated(): Promise { + if (!this.client || this.connectionState !== "connected") return + try { + const dir = this.getWorkspaceDirectory() + const { data: config } = await this.client.config.get({ directory: dir }, { throwOnError: true }) + this.cachedConfigMessage = { type: "configLoaded", config } + this.postMessage({ type: "configUpdated", config }) + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to fetch config after update:", error) + } + } + /** * Fetch Kilo news/notifications and send to webview. * Uses the cached message pattern so the webview gets data immediately on refresh. @@ -1827,6 +1868,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) } + /** Returns the number of sessions currently in "busy" state. */ + private getBusySessionCount(): number { + return getBusySessionCount(this.sessionStatusMap) + } + /** * Handle config update request from the webview. * Applies a partial config update via the global config endpoint, then pushes @@ -1845,33 +1891,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { await this.client.global.config.update({ config: partial }, { throwOnError: true }) - // global.config.update only resets the global config cache — the - // per-instance merged config (Config.state) is still stale. Force a - // full instance disposal so the next config.get re-merges all layers. - await this.client.global.dispose({ throwOnError: true }) - // Re-fetch the full merged config (global + project + all layers) so the // webview receives the complete resolved config, not just global-only data. + // 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 message = { - type: "configUpdated", - config: merged, - } this.cachedConfigMessage = { type: "configLoaded", config: merged } - this.postMessage(message) + this.postMessage({ type: "configUpdated", config: merged }) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to update config:", error) this.postMessage({ type: "error", message: getErrorMessage(error) || "Failed to update config", }) - // Send configUpdated with the last known good config so the webview - // decrements its pendingUpdates counter and reverts the optimistic state. - if (this.cachedConfigMessage) { - this.postMessage({ type: "configUpdated", config: (this.cachedConfigMessage as { config: unknown }).config }) - } } finally { this.pending-- } @@ -2567,6 +2601,19 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // let a foreign session through if it was accidentally tracked. if (isEventFromForeignProject(event, this.projectID)) return + // session.status events pass the onEventFiltered pre-filter for all providers (see line 842), + // so this runs on every KiloProvider instance — including the Settings panel which has no + // tracked sessions. Update sessionStatusMap and forward to webview before the + // trackedSessionIds guard so the Settings panel's allStatusMap stays current for the + // busy-session warning on Save. + if (event.type === "session.status") { + const sid = event.properties.sessionID + this.sessionStatusMap.set(sid, event.properties.status.type) + const msg = mapSSEEventToWebviewMessage(event, sid) + if (msg) this.postMessage(msg) + return + } + // Extract sessionID from the event const sessionID = this.extractSessionID(event) @@ -2586,6 +2633,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } + // Config was updated without a full dispose (e.g. permission-only save). + // Fetch and push the updated config so the Settings panel reflects the change. + if (event.type === "global.config.updated") { + void this.fetchAndSendConfigUpdated() + return + } + // Forward relevant events to webview // Side effects that must happen before the webview message is sent if (event.type === "session.created" && !this.currentSession) { @@ -2934,6 +2988,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.trackedSessionIds.clear() this.syncedChildSessions.clear() this.sessionDirectories.clear() + this.sessionStatusMap.clear() this.ignoreController?.dispose() this.marketplace?.dispose() } diff --git a/packages/kilo-vscode/src/session-status.ts b/packages/kilo-vscode/src/session-status.ts new file mode 100644 index 0000000000..6e086f1519 --- /dev/null +++ b/packages/kilo-vscode/src/session-status.ts @@ -0,0 +1,41 @@ +import type { KiloClient, SessionStatus } from "@kilocode/sdk/v2/client" + +/** + * Returns the number of sessions currently in "busy" state. + * Used to warn users before operations that will interrupt running sessions. + */ +export function getBusySessionCount(map: Map): number { + let count = 0 + for (const status of map.values()) { + if (status === "busy") count++ + } + return count +} + +/** + * Fetch all current session statuses and seed the provided map + webview. + * Called on connect so the Settings panel knows about already-running sessions + * without waiting for the next session.status SSE event. + */ +export async function seedSessionStatuses( + client: KiloClient, + dir: string, + map: Map, + post: (msg: unknown) => void, +): Promise { + try { + const result = await client.session.status({ directory: dir }) + if (!result.data) return + for (const [sid, info] of Object.entries(result.data) as [string, SessionStatus][]) { + map.set(sid, info.type) + post({ + type: "sessionStatus", + sessionID: sid, + status: info.type, + ...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}), + }) + } + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to seed session statuses:", error) + } +} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/AutoApproveTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/AutoApproveTab.tsx index de07923cfb..751dd2a1e6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AutoApproveTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AutoApproveTab.tsx @@ -135,8 +135,8 @@ function wildcardAction(rule: PermissionRule | undefined, fallback: PermissionLe function exceptions(rule: PermissionRule | undefined): Array<{ pattern: string; action: PermissionLevel }> { if (!rule || typeof rule === "string") return [] return Object.entries(rule) - .filter(([key]) => key !== "*") - .map(([pattern, action]) => ({ pattern, action })) + .filter(([key, action]) => key !== "*" && action !== null) + .map(([pattern, action]) => ({ pattern, action: action as PermissionLevel })) } function toolTitle(id: string): string { @@ -184,14 +184,14 @@ const AutoApproveTab: Component = () => { updateConfig({ permission: { [tool]: level } }) return } - const obj: Record = { "*": level } + const obj: Record = { "*": level } for (const exc of excs) obj[exc.pattern] = exc.action updateConfig({ permission: { [tool]: obj } }) } const setException = (tool: string, pattern: string, level: PermissionLevel) => { const current = ruleFor(tool) - const base: Record = + const base: Record = typeof current === "string" ? { "*": current } : { ...(current ?? {}) } base[pattern] = level updateConfig({ permission: { [tool]: base } }) @@ -199,7 +199,7 @@ const AutoApproveTab: Component = () => { const addException = (tool: string, pattern: string) => { const current = ruleFor(tool) - const base: Record = + const base: Record = typeof current === "string" ? { "*": current } : { ...(current ?? {}) } base[pattern] = "allow" updateConfig({ permission: { [tool]: base } }) @@ -208,34 +208,10 @@ const AutoApproveTab: Component = () => { const removeException = (tool: string, pattern: string) => { const current = ruleFor(tool) if (!current || typeof current === "string") return - const rebuilt: Record = {} - for (const [k, v] of Object.entries(current)) { - if (k !== pattern) rebuilt[k] = v - } - const keys = Object.keys(rebuilt) - const fallback = defaultFor(tool) - const value: PermissionRule = - keys.length === 0 ? fallback : keys.length === 1 && keys[0] === "*" ? rebuilt["*"]! : rebuilt - // patchJsonc only sets keys present in the patch — it won't remove the deleted key - // from the JSONC file. To work around this, first set the tool to a plain string - // (which replaces the entire JSONC node), then immediately send the rebuilt object - // in a second call only when necessary. - // Both messages are dispatched synchronously before any reactive flush, so the - // second call always operates on the value we just computed — not on stale signal - // state — avoiding a race condition. - // Ideally when keys.length === 0 we'd remove the tool key entirely so it - // inherits the global default, but that requires backend support for null - // delete sentinels (tracked in #6625). - const wildcard = rebuilt["*"] ?? fallback - // Single call covers string and collapsed-to-string cases - updateConfig({ permission: { [tool]: wildcard } }) - // Only send the second call when the result must remain an object - if (typeof value === "object") { - // This runs synchronously in the same microtask tick; the first updateConfig - // queues a JSONC node replacement, and this one immediately overwrites it with - // the full object — no intervening reactive update occurs. - updateConfig({ permission: { [tool]: value } }) - } + // Send a single patch with null for the deleted key. + // null is a delete sentinel: patchJsonc removes the key from the JSONC file, + // stripNulls removes it from the optimistic UI. + updateConfig({ permission: { [tool]: { [pattern]: null } } }) } return ( diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx index 54a626cf1b..40f55833c7 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx @@ -1,8 +1,12 @@ -import { Component, createSignal, createEffect, on } from "solid-js" +import { Component, createSignal, createEffect, on, Show } from "solid-js" import { Icon } from "@kilocode/kilo-ui/icon" import { Tabs } from "@kilocode/kilo-ui/tabs" +import { Button } from "@kilocode/kilo-ui/button" +import { showToast } from "@kilocode/kilo-ui/toast" import { useVSCode } from "../../context/vscode" import { useLanguage } from "../../context/language" +import { useConfig } from "../../context/config" +import { useSession } from "../../context/session" import ProvidersTab from "./ProvidersTab" import AgentBehaviourTab from "./AgentBehaviourTab" import AutoApproveTab from "./AutoApproveTab" @@ -29,8 +33,30 @@ const Settings: Component = (props) => { const server = useServer() const language = useLanguage() const vscode = useVSCode() + const { isDirty, saveConfig, discardConfig } = useConfig() + const session = useSession() const [active, setActive] = createSignal(props.tab ?? "providers") + const busyCount = () => Object.values(session.allStatusMap()).filter((s) => s.type === "busy").length + + const handleSave = () => { + const busy = busyCount() + if (busy === 0) { + saveConfig() + return + } + const msg = busy === 1 ? language.t("settings.saveBar.warning.one") : language.t("settings.saveBar.warning.many") + showToast({ + variant: "error", + title: msg, + persistent: true, + actions: [ + { label: language.t("settings.saveBar.saveAnyway"), onClick: saveConfig }, + { label: language.t("settings.saveBar.cancel"), onClick: "dismiss" }, + ], + }) + } + // Sync when the parent changes the tab prop (e.g. via navigate message) createEffect( on( @@ -191,6 +217,30 @@ const Settings: Component = (props) => { /> + + {/* Save bar — visible when there are unsaved config changes */} + +
+ + {language.t("settings.saveBar.unsavedChanges")} + + + +
+
) } diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index 6a5d6738d5..66474bd262 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -2,6 +2,10 @@ * Config context * Manages backend configuration state (permissions, agents, providers, etc.) * and exposes an updateConfig method to apply partial updates. + * + * Changes are accumulated in a local draft and only sent to the extension + * when saveConfig() is called. This allows batching multiple settings + * changes into a single write (which triggers disposeAll on the CLI). */ import { createContext, useContext, createSignal, onCleanup, ParentComponent, Accessor } from "solid-js" @@ -11,7 +15,10 @@ import type { Config, ExtensionMessage } from "../types/messages" interface ConfigContextValue { config: Accessor loading: Accessor + isDirty: Accessor updateConfig: (partial: Partial) => void + saveConfig: () => void + discardConfig: () => void } function isRecord(value: unknown): value is Record { @@ -52,39 +59,44 @@ export const ConfigProvider: ParentComponent = (props) => { const [config, setConfig] = createSignal({}) const [loading, setLoading] = createSignal(true) - - // Race-condition guard: track how many updateConfig calls are in-flight. - // - // Why this is needed: - // When the user picks a new dropdown value, updateConfig() optimistically - // updates local state and sends an "updateConfig" message to the extension. - // The extension writes the change, then sends back "configUpdated". - // However, the CLI backend may emit a "global.disposed" SSE event as part - // of its config-reload cycle, causing KiloProvider to call fetchAndSendConfig() - // which may return the *old* config (before the write is committed) and send - // a "configLoaded" message. Without this guard, that stale "configLoaded" - // would overwrite the optimistic state, causing a visible flash/revert. - // - // Solution: increment pendingUpdates on each updateConfig() call and - // decrement on each "configUpdated" response. Discard any "configLoaded" - // message that arrives while pendingUpdates > 0. - const [pendingUpdates, setPendingUpdates] = createSignal(0) + const [draft, setDraft] = createSignal>({}) + const [isDirty, setIsDirty] = createSignal(false) + // Last config received from the server — used to revert on discard + const [saved, setSaved] = createSignal({}) + // True while a saveConfig() write is in-flight — used to clear draft on success + // and to guard against stale configLoaded messages overwriting optimistic state. + let saving = false // Register handler immediately (not in onMount) so we never miss // a configLoaded message that arrives before the DOM mount. const unsubscribe = vscode.onMessage((message: ExtensionMessage) => { if (message.type === "configLoaded") { - // Only apply if no update is in flight — a stale configLoaded must not - // overwrite the optimistically-updated state (see pendingUpdates above). - if (pendingUpdates() === 0) { - setConfig(message.config) - setLoading(false) - } + // Skip if a save is in-flight — a stale configLoaded must not overwrite + // the optimistically-updated state while the write is being confirmed. + if (saving) return + setConfig(message.config) + setSaved(message.config) + setLoading(false) return } if (message.type === "configUpdated") { - setConfig(message.config) - setPendingUpdates((n) => Math.max(0, n - 1)) + if (saving) { + // This configUpdated is the confirmation of our saveConfig() write. + // Clear the draft now that the server has confirmed the write. + saving = false + setDraft({}) + setIsDirty(false) + setConfig(message.config) + } else { + // configUpdated from a different source (e.g. PermissionDock save). + // Re-apply the draft on top so pending settings changes are preserved. + if (isDirty()) { + setConfig(stripNulls(deepMerge(message.config, draft()))) + } else { + setConfig(message.config) + } + } + setSaved(message.config) return } }) @@ -114,16 +126,33 @@ export const ConfigProvider: ParentComponent = (props) => { function updateConfig(partial: Partial) { // Optimistically update local state with deep merge + null stripping setConfig((prev) => stripNulls(deepMerge(prev, partial))) - // Track this in-flight update so stale configLoaded messages are ignored - setPendingUpdates((n) => n + 1) - // Send to extension for persistence - vscode.postMessage({ type: "updateConfig", config: partial }) + // Accumulate in draft — will be sent on saveConfig() + setDraft((prev) => deepMerge(prev as Config, partial)) + setIsDirty(true) + } + + function saveConfig() { + const changes = draft() + if (Object.keys(changes).length === 0) return + // Don't clear draft/isDirty yet — wait for configUpdated confirmation. + // If the write fails, the save bar stays visible so the user can retry. + saving = true + vscode.postMessage({ type: "updateConfig", config: changes }) + } + + function discardConfig() { + setConfig(saved()) + setDraft({}) + setIsDirty(false) } const value: ConfigContextValue = { config, loading, + isDirty, updateConfig, + saveConfig, + discardConfig, } return {props.children} diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 9fe2312355..f52383504a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1126,4 +1126,11 @@ export const dict = { "task.todos.progress": "{{done}}/{{total}} مهام مكتملة", "task.todos.allDone": "{{count}} مهام مكتملة", + "settings.saveBar.unsavedChanges": "تغييرات غير محفوظة", + "settings.saveBar.discard": "تجاهل", + "settings.saveBar.save": "حفظ", + "settings.saveBar.warning.one": "جلسة واحدة تعمل وستتوقف", + "settings.saveBar.warning.many": "عدة جلسات تعمل وستتوقف", + "settings.saveBar.saveAnyway": "حفظ على أي حال", + "settings.saveBar.cancel": "إلغاء", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 846305faac..22b51f6a7f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1149,4 +1149,11 @@ export const dict = { "task.todos.progress": "{{done}}/{{total}} tarefas concluídas", "task.todos.allDone": "{{count}} tarefas concluídas", + "settings.saveBar.unsavedChanges": "Alterações não salvas", + "settings.saveBar.discard": "Descartar", + "settings.saveBar.save": "Salvar", + "settings.saveBar.warning.one": "Uma sessão está em execução e será interrompida", + "settings.saveBar.warning.many": "Várias sessões estão em execução e serão interrompidas", + "settings.saveBar.saveAnyway": "Salvar mesmo assim", + "settings.saveBar.cancel": "Cancelar", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index b904554de8..1b22de39b7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1147,4 +1147,11 @@ export const dict = { "task.todos.progress": "{{done}}/{{total}} zadataka završeno", "task.todos.allDone": "{{count}} zadataka završeno", + "settings.saveBar.unsavedChanges": "Nespremljene promjene", + "settings.saveBar.discard": "Odbaci", + "settings.saveBar.save": "Spremi", + "settings.saveBar.warning.one": "Jedna sesija je pokrenuta i bit će prekinuta", + "settings.saveBar.warning.many": "Nekoliko sesija je pokrenuto i bit će prekinuto", + "settings.saveBar.saveAnyway": "Spremi svejedno", + "settings.saveBar.cancel": "Otkaži", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index f08609d6fe..d0c34bf36a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1141,4 +1141,11 @@ export const dict = { "task.todos.progress": "{{done}}/{{total}} opgaver udført", "task.todos.allDone": "{{count}} opgaver udført", + "settings.saveBar.unsavedChanges": "Ikke-gemte ændringer", + "settings.saveBar.discard": "Kassér", + "settings.saveBar.save": "Gem", + "settings.saveBar.warning.one": "En session kører og vil blive afbrudt", + "settings.saveBar.warning.many": "Flere sessioner kører og vil blive afbrudt", + "settings.saveBar.saveAnyway": "Gem alligevel", + "settings.saveBar.cancel": "Annuller", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 9c46a074c7..4855734614 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1162,4 +1162,11 @@ export const dict = { "task.todos.progress": "{{done}}/{{total}} Aufgaben erledigt", "task.todos.allDone": "{{count}} Aufgaben erledigt", + "settings.saveBar.unsavedChanges": "Nicht gespeicherte Änderungen", + "settings.saveBar.discard": "Verwerfen", + "settings.saveBar.save": "Speichern", + "settings.saveBar.warning.one": "Eine Sitzung läuft und wird unterbrochen", + "settings.saveBar.warning.many": "Mehrere Sitzungen laufen und werden unterbrochen", + "settings.saveBar.saveAnyway": "Trotzdem speichern", + "settings.saveBar.cancel": "Abbrechen", } 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 49706a8811..6605d67967 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1152,4 +1152,12 @@ export const dict = { "task.todos.progress": "{{done}}/{{total}} to-dos done", "task.todos.allDone": "{{count}} to-dos done", + + "settings.saveBar.unsavedChanges": "Unsaved changes", + "settings.saveBar.discard": "Discard", + "settings.saveBar.save": "Save", + "settings.saveBar.warning.one": "One session is running and will be interrupted", + "settings.saveBar.warning.many": "Several sessions are running and will be interrupted", + "settings.saveBar.saveAnyway": "Save anyway", + "settings.saveBar.cancel": "Cancel", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index daa2e53c2d..f3f91c95ed 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1152,4 +1152,11 @@ export const dict = { "task.todos.progress": "{{done}}/{{total}} tareas completadas", "task.todos.allDone": "{{count}} tareas completadas", + "settings.saveBar.unsavedChanges": "Cambios sin guardar", + "settings.saveBar.discard": "Descartar", + "settings.saveBar.save": "Guardar", + "settings.saveBar.warning.one": "Una sesión está en ejecución y se interrumpirá", + "settings.saveBar.warning.many": "Varias sesiones están en ejecución y se interrumpirán", + "settings.saveBar.saveAnyway": "Guardar de todas formas", + "settings.saveBar.cancel": "Cancelar", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 00eb6298ab..03b250ab3c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1162,4 +1162,11 @@ export const dict = { "task.todos.progress": "{{done}}/{{total}} tâches terminées", "task.todos.allDone": "{{count}} tâches terminées", + "settings.saveBar.unsavedChanges": "Modifications non enregistrées", + "settings.saveBar.discard": "Ignorer", + "settings.saveBar.save": "Enregistrer", + "settings.saveBar.warning.one": "Une session est en cours et sera interrompue", + "settings.saveBar.warning.many": "Plusieurs sessions sont en cours et seront interrompues", + "settings.saveBar.saveAnyway": "Enregistrer quand même", + "settings.saveBar.cancel": "Annuler", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 5c866e894d..34673c9ded 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1138,4 +1138,11 @@ export const dict = { "task.todos.progress": "{{done}}/{{total}} タスク完了", "task.todos.allDone": "{{count}} タスク完了", + "settings.saveBar.unsavedChanges": "未保存の変更", + "settings.saveBar.discard": "破棄", + "settings.saveBar.save": "保存", + "settings.saveBar.warning.one": "1つのセッションが実行中で中断されます", + "settings.saveBar.warning.many": "複数のセッションが実行中で中断されます", + "settings.saveBar.saveAnyway": "それでも保存", + "settings.saveBar.cancel": "キャンセル", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 8abd8c6f09..a6efd91b80 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1129,4 +1129,11 @@ export const dict = { "task.todos.progress": "{{done}}/{{total}} 할 일 완료", "task.todos.allDone": "{{count}} 할 일 완료", + "settings.saveBar.unsavedChanges": "저장되지 않은 변경 사항", + "settings.saveBar.discard": "취소", + "settings.saveBar.save": "저장", + "settings.saveBar.warning.one": "하나의 세션이 실행 중이며 중단됩니다", + "settings.saveBar.warning.many": "여러 세션이 실행 중이며 중단됩니다", + "settings.saveBar.saveAnyway": "그래도 저장", + "settings.saveBar.cancel": "취소", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index a109985ed9..a6548b04ef 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1139,4 +1139,11 @@ export const dict = { "task.todos.progress": "{{done}}/{{total}} oppgaver fullført", "task.todos.allDone": "{{count}} oppgaver fullført", + "settings.saveBar.unsavedChanges": "Ulagrede endringer", + "settings.saveBar.discard": "Forkast", + "settings.saveBar.save": "Lagre", + "settings.saveBar.warning.one": "En økt kjører og vil bli avbrutt", + "settings.saveBar.warning.many": "Flere økter kjører og vil bli avbrutt", + "settings.saveBar.saveAnyway": "Lagre uansett", + "settings.saveBar.cancel": "Avbryt", } 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 34059206b7..9c6693f7b5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1145,4 +1145,11 @@ export const dict = { "task.todos.progress": "{{done}}/{{total}} zadań ukończono", "task.todos.allDone": "{{count}} zadań ukończono", + "settings.saveBar.unsavedChanges": "Niezapisane zmiany", + "settings.saveBar.discard": "Odrzuć", + "settings.saveBar.save": "Zapisz", + "settings.saveBar.warning.one": "Jedna sesja jest uruchomiona i zostanie przerwana", + "settings.saveBar.warning.many": "Kilka sesji jest uruchomionych i zostanie przerwanych", + "settings.saveBar.saveAnyway": "Zapisz mimo to", + "settings.saveBar.cancel": "Anuluj", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index be48f722b9..56345bb25a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1144,4 +1144,11 @@ export const dict = { "task.todos.progress": "{{done}}/{{total}} задач выполнено", "task.todos.allDone": "{{count}} задач выполнено", + "settings.saveBar.unsavedChanges": "Несохранённые изменения", + "settings.saveBar.discard": "Отменить", + "settings.saveBar.save": "Сохранить", + "settings.saveBar.warning.one": "Один сеанс выполняется и будет прерван", + "settings.saveBar.warning.many": "Несколько сеансов выполняются и будут прерваны", + "settings.saveBar.saveAnyway": "Сохранить в любом случае", + "settings.saveBar.cancel": "Отмена", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 343fd3f8cf..57db640555 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1125,4 +1125,11 @@ export const dict = { "task.todos.progress": "{{done}}/{{total}} งานเสร็จแล้ว", "task.todos.allDone": "{{count}} งานเสร็จแล้ว", + "settings.saveBar.unsavedChanges": "การเปลี่ยนแปลงที่ยังไม่ได้บันทึก", + "settings.saveBar.discard": "ยกเลิก", + "settings.saveBar.save": "บันทึก", + "settings.saveBar.warning.one": "มีเซสชันหนึ่งกำลังทำงานและจะถูกขัดจังหวะ", + "settings.saveBar.warning.many": "มีหลายเซสชันกำลังทำงานและจะถูกขัดจังหวะ", + "settings.saveBar.saveAnyway": "บันทึกต่อไป", + "settings.saveBar.cancel": "ยกเลิก", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 3c6bc6b7aa..97324a73af 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1109,4 +1109,11 @@ export const dict = { "task.todos.progress": "{{done}}/{{total}} 个待办已完成", "task.todos.allDone": "{{count}} 个待办已完成", + "settings.saveBar.unsavedChanges": "未保存的更改", + "settings.saveBar.discard": "放弃", + "settings.saveBar.save": "保存", + "settings.saveBar.warning.one": "一个会话正在运行,将被中断", + "settings.saveBar.warning.many": "多个会话正在运行,将被中断", + "settings.saveBar.saveAnyway": "仍然保存", + "settings.saveBar.cancel": "取消", } 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 ed38217a15..0fa4c6df5d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1110,4 +1110,11 @@ export const dict = { "task.todos.progress": "{{done}}/{{total}} 個待辦已完成", "task.todos.allDone": "{{count}} 個待辦已完成", + "settings.saveBar.unsavedChanges": "未儲存的變更", + "settings.saveBar.discard": "捨棄", + "settings.saveBar.save": "儲存", + "settings.saveBar.warning.one": "一個工作階段正在執行,將被中斷", + "settings.saveBar.warning.many": "多個工作階段正在執行,將被中斷", + "settings.saveBar.saveAnyway": "仍然儲存", + "settings.saveBar.cancel": "取消", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 7e8dd7b4df..3342fc977f 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -201,7 +201,10 @@ const ConfigWrapper: ParentComponent<{ config?: Config }> = (props) => { const value = { config: () => props.config!, loading: () => false, + isDirty: () => false, updateConfig: noop, + saveConfig: noop, + discardConfig: noop, } return {props.children} } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index b37145347c..d34e6ad86c 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -302,7 +302,8 @@ export interface ModelSelection { export type PermissionLevel = "allow" | "ask" | "deny" -export type PermissionRule = PermissionLevel | Record +/** null in a PermissionRule object is a delete sentinel — removes the key from the config */ +export type PermissionRule = PermissionLevel | Record export type PermissionConfig = Partial> diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 797561edff..210d680e9c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -82,7 +82,9 @@ export namespace Config { return merged } - export const state = Instance.state(async () => { + // kilocode_change start — capture init so resetState() can invalidate the cache entry + const stateInit = async () => { + // kilocode_change end const auth = await Auth.all() // This ensures Opencode native configs always take precedence over legacy Kilocode configs @@ -339,7 +341,10 @@ export namespace Config { directories, deps, } - }) + } + // kilocode_change start — create state from named init so resetState() can invalidate it + export const state = Instance.state(stateInit) + // kilocode_change end export async function waitForDependencies() { const deps = await state().then((x) => x.deps) @@ -699,7 +704,8 @@ export namespace Config { export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote]) export type Mcp = z.infer - export const PermissionAction = z.enum(["ask", "allow", "deny"]).meta({ + export const PermissionAction = z.enum(["ask", "allow", "deny"]).nullable().meta({ + // kilocode_change - nullable allows null as a delete sentinel ref: "PermissionActionConfig", }) export type PermissionAction = z.infer @@ -1590,9 +1596,24 @@ export namespace Config { // kilocode_change start — skip dispose when caller opts out (e.g. permission-only saves) await global.reset() - if (!dispose) return next; - // kilocode_change end + if (!dispose) { + // Reset Config.state for all instances so the next Config.get() call re-reads + // from disk and re-merges all layers (global + project + workspace) in the + // correct precedence order. This avoids the stale-cache problem without the + // precedence bug that would occur if we merged the global patch directly into + // the already-resolved config (which includes project overrides). + Instance.resetStateEntry(stateInit) + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Event.ConfigUpdated.type, + properties: {}, + }, + }) + return next + } + // kilocode_change end void Instance.disposeAll() .catch(() => undefined) @@ -1606,7 +1627,6 @@ export namespace Config { }) }) - return next } diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 52954b3fbd..e267a96620 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -54,8 +54,13 @@ export namespace PermissionNext { }) continue } + // null is a delete sentinel — skip it (it only appears in patches, not in stored config) + if (value === null) continue ruleset.push( - ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })), + // Filter out null entries (delete sentinels) — they don't represent real rules + ...Object.entries(value) + .filter(([, action]) => action !== null) + .map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action: action as Action })), ) } return ruleset @@ -94,7 +99,7 @@ export namespace PermissionNext { continue } - if (existing === undefined) { + if (existing === undefined || existing === null) { // Use object format to avoid replacing existing granular rules // when merged via updateGlobal (e.g. { read: "allow" } would wipe // { read: { "*": "ask", "src/*": "allow" } }) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index df44a3a229..2dd6b13f93 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -118,6 +118,16 @@ export const Instance = { cache.delete(Instance.directory) emit(Instance.directory) }, + /** + * Reset a specific state entry for all instances without running dispose callbacks. + * Used to invalidate config-derived caches (e.g. Config.state) after a no-dispose + * config write, so the next Config.get() re-reads from disk with correct precedence. + */ + resetStateEntry(init: (...args: any[]) => any) { + for (const dir of cache.keys()) { + State.resetEntry(dir, init) + } + }, async disposeAll() { if (disposal.all) return disposal.all diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index a9dce565b5..c00824e295 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -28,6 +28,16 @@ export namespace State { } } + /** + * Remove a specific state entry without running its dispose callback. + * The next call to the accessor will re-initialize from scratch. + * Used to invalidate config-derived caches (e.g. Config.state) without + * triggering a full Instance.dispose() that would kill running sessions. + */ + export function resetEntry(key: string, init: (...args: any[]) => any) { + recordsByKey.get(key)?.delete(init) + } + export async function dispose(key: string) { const entries = recordsByKey.get(key) if (!entries) return diff --git a/packages/opencode/src/server/event.ts b/packages/opencode/src/server/event.ts index 49325b2bb6..48d40ef6a5 100644 --- a/packages/opencode/src/server/event.ts +++ b/packages/opencode/src/server/event.ts @@ -4,4 +4,7 @@ import z from "zod" export const Event = { Connected: BusEvent.define("server.connected", z.object({})), Disposed: BusEvent.define("global.disposed", z.object({})), + // kilocode_change start — emitted when config is updated without a full dispose + ConfigUpdated: BusEvent.define("global.config.updated", z.object({})), + // kilocode_change end } diff --git a/packages/opencode/test/permission/next.toConfig.test.ts b/packages/opencode/test/permission/next.toConfig.test.ts index e4c1bac79b..9b9f68701e 100644 --- a/packages/opencode/test/permission/next.toConfig.test.ts +++ b/packages/opencode/test/permission/next.toConfig.test.ts @@ -86,3 +86,25 @@ test("toConfig - mixed scalar-only and rule-capable permissions", () => { bash: { "npm *": "allow" }, }) }) + +// Tests for null delete sentinel handling (null = "remove this key from config") + +test("fromConfig - null entries in PermissionObject are skipped", () => { + const config = { bash: { "*": "ask" as const, "npm *": null } } + const rules = PermissionNext.fromConfig(config) + // null is a delete sentinel — only the non-null entry should produce a rule + expect(rules).toEqual([{ permission: "bash", pattern: "*", action: "ask" }]) +}) + +test("fromConfig - null top-level PermissionRule is skipped", () => { + const config = { bash: null } + const rules = PermissionNext.fromConfig(config) + expect(rules).toEqual([]) +}) + +test("toConfig - null existing entry is treated as absent (new rule wins)", () => { + // If result[permission] is null (shouldn't happen in practice but defensive), + // the new rule should be written as a fresh object entry. + const result = PermissionNext.toConfig([{ permission: "bash", pattern: "npm *", action: "allow" }]) + expect(result).toEqual({ bash: { "npm *": "allow" } }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 58861cb7dd..6d14806b3d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -68,6 +68,13 @@ export type EventGlobalDisposed = { } } +export type EventGlobalConfigUpdated = { + type: "global.config.updated" + properties: { + [key: string]: unknown + } +} + export type EventLspClientDiagnostics = { type: "lsp.client.diagnostics" properties: { @@ -989,6 +996,7 @@ export type Event = | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed + | EventGlobalConfigUpdated | EventLspClientDiagnostics | EventLspUpdated | EventFileEdited @@ -1067,7 +1075,7 @@ export type ServerConfig = { cors?: Array } -export type PermissionActionConfig = "ask" | "allow" | "deny" +export type PermissionActionConfig = "ask" | "allow" | "deny" | null export type PermissionObjectConfig = { [key: string]: PermissionActionConfig