mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
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:
@@ -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>>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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" } })
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" } })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user