Save changes on Settings update (#7312)

* feat(vscode): track session status for busy-session warnings

* feat(vscode): warn before saving settings when sessions are running

* feat(vscode): add draft state and save/discard to config context

* feat(vscode): Save button on Settings panel

* fix(vscode): pass remove permission commands correctly to backend

* feat(vscode): handle permission removals

* fix(vscode): detect other sessions in settings panel

* feat(vscode): translations

* feat(vscode): refresh config state on auto-approve tab

* fix(vscode): formatting and unit tests

* fix: reset Config.state cache on no-dispose config write

* fix(vscode): Detect sessions opened before Settings tab is opened

* fix(vscode): Removed previous fix changes
This commit is contained in:
Imanol Maiztegui
2026-03-19 22:12:09 +01:00
committed by GitHub
parent 75aba4cf51
commit f336949a08
30 changed files with 435 additions and 89 deletions
+71 -16
View File
@@ -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<string> = new Set()
private syncedChildSessions: Set<string> = new Set()
/** Tracks the latest status for each session, used to warn before destructive config operations. */
private sessionStatusMap = new Map<string, SessionStatus["type"]>()
/** Per-session directory overrides (e.g., worktree paths registered by AgentManagerProvider). */
private sessionDirectories = new Map<string, string>()
/** 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<void> {
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<void> {
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()
}
@@ -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<string, SessionStatus["type"]>): 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<string, SessionStatus["type"]>,
post: (msg: unknown) => void,
): Promise<void> {
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)
}
}
@@ -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<string, PermissionLevel> = { "*": level }
const obj: Record<string, PermissionLevel | null> = { "*": 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<string, PermissionLevel> =
const base: Record<string, PermissionLevel | null> =
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<string, PermissionLevel> =
const base: Record<string, PermissionLevel | null> =
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<string, PermissionLevel> = {}
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 (
@@ -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<SettingsProps> = (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<SettingsProps> = (props) => {
/>
</Tabs.Content>
</Tabs>
{/* Save bar — visible when there are unsaved config changes */}
<Show when={isDirty()}>
<div
style={{
display: "flex",
"align-items": "center",
"justify-content": "flex-end",
gap: "8px",
padding: "8px 16px",
"border-top": "1px solid var(--border-weak-base)",
}}
>
<span style={{ "font-size": "12px", color: "var(--foreground-secondary)", "margin-right": "auto" }}>
{language.t("settings.saveBar.unsavedChanges")}
</span>
<Button variant="ghost" size="small" onClick={discardConfig}>
{language.t("settings.saveBar.discard")}
</Button>
<Button variant="primary" size="small" onClick={handleSave}>
{language.t("settings.saveBar.save")}
</Button>
</div>
</Show>
</div>
)
}
@@ -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<Config>
loading: Accessor<boolean>
isDirty: Accessor<boolean>
updateConfig: (partial: Partial<Config>) => void
saveConfig: () => void
discardConfig: () => void
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -52,39 +59,44 @@ export const ConfigProvider: ParentComponent = (props) => {
const [config, setConfig] = createSignal<Config>({})
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<Partial<Config>>({})
const [isDirty, setIsDirty] = createSignal(false)
// Last config received from the server — used to revert on discard
const [saved, setSaved] = createSignal<Config>({})
// 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<Config>) {
// 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 <ConfigContext.Provider value={value}>{props.children}</ConfigContext.Provider>
@@ -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": "إلغاء",
}
@@ -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",
}
@@ -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",
}
@@ -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",
}
@@ -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<Record<Keys, string>>
@@ -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",
}
@@ -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",
}
@@ -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",
}
@@ -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": "キャンセル",
}
@@ -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": "취소",
}
@@ -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<Record<Keys, string>>
@@ -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",
}
@@ -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": "Отмена",
}
@@ -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": "ยกเลิก",
}
@@ -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<Record<Keys, string>>
@@ -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<Record<Keys, string>>
@@ -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 <ConfigContext.Provider value={value}>{props.children}</ConfigContext.Provider>
}
@@ -302,7 +302,8 @@ export interface ModelSelection {
export type PermissionLevel = "allow" | "ask" | "deny"
export type PermissionRule = PermissionLevel | Record<string, PermissionLevel>
/** null in a PermissionRule object is a delete sentinel — removes the key from the config */
export type PermissionRule = PermissionLevel | Record<string, PermissionLevel | null>
export type PermissionConfig = Partial<Record<string, PermissionRule>>
+26 -6
View File
@@ -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<typeof Mcp>
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<typeof PermissionAction>
@@ -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
}
+7 -2
View File
@@ -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" } })
+10
View File
@@ -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
+10
View File
@@ -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
+3
View File
@@ -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
}
@@ -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" } })
})
+9 -1
View File
@@ -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<string>
}
export type PermissionActionConfig = "ask" | "allow" | "deny"
export type PermissionActionConfig = "ask" | "allow" | "deny" | null
export type PermissionObjectConfig = {
[key: string]: PermissionActionConfig