Przeglądaj źródła

feat: add notifications when skills are added or removed (#4888)

Introduces a ConfigChangeNotifier service that tracks configuration changes and displays VSCode notifications when skills are added or removed from global or project configurations. Includes i18n support for 25 languages and type definitions for context configuration changes.
Chris Hasson 1 miesiąc temu
rodzic
commit
334328de5f

+ 5 - 0
.changeset/polite-results-attend.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": minor
+---
+
+Show notifications when skills are added or removed from the project or global config

+ 25 - 0
packages/types/src/context-config.ts

@@ -0,0 +1,25 @@
+// kilocode_change - new file
+
+/**
+ * All discoverable configuration types that affect agent behavior.
+ * These are things that can be discovered from .kilocode directories.
+ */
+export type ContextConfigType = "skill" | "workflow" | "command" | "rule" | "mcp"
+
+/**
+ * Represents a single configuration change (added or removed).
+ */
+export interface ContextConfigChange {
+	/** What kind of configType this is */
+	configType: ContextConfigType
+	/** Whether this item was added or removed */
+	changeType: "added" | "removed"
+	/** Name/identifier of the item */
+	name: string
+	/** Where was it discovered: global (~/.kilocode) or project (.kilocode) */
+	source: "global" | "project"
+	/** Optional mode for mode-specific configs (e.g., 'code', 'architect') */
+	mode?: string
+	/** Optional file path for "click to open" functionality */
+	filePath?: string
+}

+ 1 - 0
packages/types/src/index.ts

@@ -1,5 +1,6 @@
 export * from "./api.js"
 export * from "./auto-purge.js" // kilocode_change
+export * from "./context-config.js" // kilocode_change
 export * from "./cloud.js"
 export * from "./codebase-index.js"
 export * from "./context-management.js"

+ 4 - 0
src/i18n/locales/ar/kilocode.json

@@ -207,5 +207,9 @@
 			"invalidApiKey": "مفتاح API الخاص بـ OpenAI غير صالح أو لا يملك صلاحية الوصول إلى Realtime API. يرجى التحقق من مفتاح API الخاص بك والمحاولة مرة أخرى.",
 			"unknown": "فشل الاتصال. يرجى التحقق من الإعدادات والمحاولة مرة أخرى."
 		}
+	},
+	"configDiscovery": {
+		"added": "رمز كيلو: تم إضافة {{source}} {{configType}}: {{name}}{{modeStr}}",
+		"removed": "كود كيلوجرام: تمت إزالة {{source}} {{configType}}: {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/ca/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "La vostra clau de l'API d'OpenAI no és vàlida o no té accés a l'API en temps real. Si us plau, comproveu la vostra clau de l'API i torneu-ho a provar.",
 			"unknown": "Ha fallat la connexió. Comproveu la configuració i torneu-ho a provar."
 		}
+	},
+	"configDiscovery": {
+		"removed": "Kilo Codi: Eliminat {{source}} {{configType}}: {{name}}{{modeStr}}",
+		"added": "Codi Kilo: Afegit {{source}} {{configType}}: {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/cs/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "Váš OpenAI API klíč je neplatný nebo nemá přístup k Realtime API. Zkontrolujte prosím svůj API klíč a zkuste to znovu.",
 			"unknown": "Připojení se nezdařilo. Zkontrolujte prosím svou konfiguraci a zkuste to znovu."
 		}
+	},
+	"configDiscovery": {
+		"added": "Kód kilo: Přidáno {{source}} {{configType}}: {{name}}{{modeStr}}",
+		"removed": "Kód Kilo: Odstraněno {{source}} {{configType}}: {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/de/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "Ihr OpenAI API-Schlüssel ist ungültig oder hat keinen Zugriff auf die Realtime API. Bitte überprüfen Sie Ihren API-Schlüssel und versuchen Sie es erneut.",
 			"unknown": "Verbindung fehlgeschlagen. Bitte überprüfen Sie Ihre Konfiguration und versuchen Sie es erneut."
 		}
+	},
+	"configDiscovery": {
+		"added": "Kilo-Code: Hinzugefügt {{source}} {{configType}}: {{name}}{{modeStr}}",
+		"removed": "Kilo-Code: Entfernt {{source}} {{configType}}: {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/en/kilocode.json

@@ -2,6 +2,10 @@
 	"info": {
 		"settings_imported": "Settings imported successfully."
 	},
+	"configDiscovery": {
+		"added": "Kilo Code: Added {{source}} {{configType}}: {{name}}{{modeStr}}",
+		"removed": "Kilo Code: Removed {{source}} {{configType}}: {{name}}{{modeStr}}"
+	},
 	"userFeedback": {
 		"message_update_failed": "Failed to update message",
 		"no_checkpoint_found": "No checkpoint found before this message",

+ 4 - 0
src/i18n/locales/es/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "Tu clave de API de OpenAI es inválida o no tiene acceso a la API de Realtime. Por favor verifica tu clave de API e inténtalo de nuevo.",
 			"unknown": "Error de conexión. Por favor, verifica tu configuración e inténtalo de nuevo."
 		}
+	},
+	"configDiscovery": {
+		"added": "Código Kilo: Se ha añadido {{source}} {{configType}}: {{name}}{{modeStr}}",
+		"removed": "Código Kilo: Removido {{source}} {{configType}}: {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/fr/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "Votre clé API OpenAI est invalide ou n'a pas accès à l'API Realtime. Veuillez vérifier votre clé API et réessayer.",
 			"unknown": "Échec de la connexion. Veuillez vérifier votre configuration et réessayer."
 		}
+	},
+	"configDiscovery": {
+		"removed": "Code Kilo : Supprimé {{source}} {{configType}} : {{name}}{{modeStr}}",
+		"added": "Code Kilo : Ajouté {{source}} {{configType}} : {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/hi/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "आपकी OpenAI API key अमान्य है या इसमें Realtime API तक पहुंच नहीं है। कृपया अपनी API key जांचें और पुनः प्रयास करें।",
 			"unknown": "कनेक्शन असफल हुआ। कृपया अपनी कॉन्फ़िगरेशन जांचें और पुनः प्रयास करें।"
 		}
+	},
+	"configDiscovery": {
+		"added": "किलो कोड: जोड़ा गया {{source}} {{configType}}: {{name}}{{modeStr}}",
+		"removed": "किलो कोड: हटाया गया {{source}} {{configType}}: {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/id/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "Kunci API OpenAI Anda tidak valid atau tidak memiliki akses ke Realtime API. Silakan periksa kunci API Anda dan coba lagi.",
 			"unknown": "Koneksi gagal. Silakan periksa konfigurasi Anda dan coba lagi."
 		}
+	},
+	"configDiscovery": {
+		"added": "Kilo Kode: Ditambahkan {{source}} {{configType}}: {{name}}{{modeStr}}",
+		"removed": "Kilo Kode: Dihapus {{source}} {{configType}}: {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/it/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "La tua chiave API OpenAI non è valida o non ha accesso alla Realtime API. Controlla la tua chiave API e riprova.",
 			"unknown": "Connessione fallita. Controlla la tua configurazione e riprova."
 		}
+	},
+	"configDiscovery": {
+		"removed": "Codice Kilo: Rimosso {{source}} {{configType}}: {{name}}{{modeStr}}",
+		"added": "Kilo Codice: Aggiunto {{source}} {{configType}}: {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/ja/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "OpenAI APIキーが無効であるか、Realtime APIへのアクセス権限がありません。APIキーを確認して再度お試しください。",
 			"unknown": "接続に失敗しました。設定を確認してもう一度お試しください。"
 		}
+	},
+	"configDiscovery": {
+		"added": "Kiloコード: {{source}} {{configType}}: {{name}}{{modeStr}} を追加しました。",
+		"removed": "Kiloコード: {{source}} {{configType}}: {{name}}{{modeStr}} を削除しました"
 	}
 }

+ 4 - 0
src/i18n/locales/ko/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "OpenAI API 키가 유효하지 않거나 Realtime API에 대한 액세스 권한이 없습니다. API 키를 확인하고 다시 시도해 주세요.",
 			"unknown": "연결에 실패했습니다. 설정을 확인한 후 다시 시도해 주세요."
 		}
+	},
+	"configDiscovery": {
+		"added": "킬로 코드: {{source}} {{configType}}: {{name}}{{modeStr}} 추가됨",
+		"removed": "Kilo 코드: 제거됨 {{source}} {{configType}}: {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/nl/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "Uw OpenAI API-sleutel is ongeldig of heeft geen toegang tot de Realtime API. Controleer uw API-sleutel en probeer het opnieuw.",
 			"unknown": "Verbinding mislukt. Controleer uw configuratie en probeer het opnieuw."
 		}
+	},
+	"configDiscovery": {
+		"added": "Kilo Code: Toegevoegd {{source}} {{configType}}: {{name}}{{modeStr}}",
+		"removed": "Kilo code: Verwijderd {{source}} {{configType}}: {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/pl/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "Twój klucz API OpenAI jest nieprawidłowy lub nie ma dostępu do API Realtime. Sprawdź swój klucz API i spróbuj ponownie.",
 			"unknown": "Połączenie nie powiodło się. Sprawdź konfigurację i spróbuj ponownie."
 		}
+	},
+	"configDiscovery": {
+		"added": "Kilo Code: Dodano {{source}} {{configType}}: {{name}}{{modeStr}}",
+		"removed": "Kilo Kod: Usunięto {{source}} {{configType}}: {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/pt-BR/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "Sua chave da API OpenAI é inválida ou não tem acesso à API Realtime. Por favor, verifique sua chave da API e tente novamente.",
 			"unknown": "Falha na conexão. Verifique sua configuração e tente novamente."
 		}
+	},
+	"configDiscovery": {
+		"added": "Código Kilo: Adicionado {{source}} {{configType}}: {{name}}{{modeStr}}",
+		"removed": "Código Kilo: Removido {{source}} {{configType}}: {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/ru/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "Ваш API-ключ OpenAI недействителен или не имеет доступа к Realtime API. Пожалуйста, проверьте ваш API-ключ и попробуйте снова.",
 			"unknown": "Подключение не удалось. Пожалуйста, проверьте настройки и повторите попытку."
 		}
+	},
+	"configDiscovery": {
+		"added": "Код Кило: Добавлен {{source}} {{configType}}: {{name}}{{modeStr}}",
+		"removed": "Код Kilo: Удалено {{source}} {{configType}}: {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/th/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "คีย์ OpenAI API ของคุณไม่ถูกต้องหรือไม่มีสิทธิ์เข้าถึง Realtime API กรุณาตรวจสอบคีย์ API ของคุณและลองใหม่อีกครั้ง",
 			"unknown": "การเชื่อมต่อล้มเหลว กรุณาตรวจสอบการกำหนดค่าของคุณและลองใหม่อีกครั้ง"
 		}
+	},
+	"configDiscovery": {
+		"added": "Kilo Code: เพิ่ม {{source}} {{configType}}: {{name}}{{modeStr}}",
+		"removed": "Kilo Code: ลบ {{source}} {{configType}}: {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/tr/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "OpenAI API anahtarınız geçersiz veya Realtime API'ye erişim izni bulunmuyor. Lütfen API anahtarınızı kontrol edin ve tekrar deneyin.",
 			"unknown": "Bağlantı başarısız oldu. Lütfen yapılandırmanızı kontrol edin ve tekrar deneyin."
 		}
+	},
+	"configDiscovery": {
+		"added": "Kilo Kodu: {{source}} {{configType}}: {{name}}{{modeStr}} eklendi.",
+		"removed": "Kilo Kodu: Kaldırıldı {{source}} {{configType}}: {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/uk/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "Ваш API ключ OpenAI недійсний або не має доступу до Realtime API. Будь ласка, перевірте ваш API ключ і спробуйте знову.",
 			"unknown": "Підключення не вдалося. Будь ласка, перевірте налаштування та спробуйте ще раз."
 		}
+	},
+	"configDiscovery": {
+		"removed": "Код одиниці: Видалено {{source}} {{configType}}: {{name}}{{modeStr}}",
+		"added": "Код Кіло: Додано {{source}} {{configType}}: {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/vi/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "Khóa API OpenAI của bạn không hợp lệ hoặc không có quyền truy cập vào Realtime API. Vui lòng kiểm tra khóa API và thử lại.",
 			"unknown": "Kết nối thất bại. Vui lòng kiểm tra cấu hình của bạn và thử lại."
 		}
+	},
+	"configDiscovery": {
+		"added": "Mã Kilo: Đã thêm {{source}} {{configType}}: {{name}}{{modeStr}}",
+		"removed": "Mã Kilo: Đã xóa {{source}} {{configType}}: {{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/zh-CN/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "您的 OpenAI API 密钥无效或无权访问实时 API。请检查您的 API 密钥并重试。",
 			"unknown": "连接失败。请检查您的配置并重试。"
 		}
+	},
+	"configDiscovery": {
+		"added": "Kilo 代码:添加了{{source}} {{configType}}:{{name}}{{modeStr}}",
+		"removed": "Kilo代码:已移除{{source}} {{configType}}:{{name}}{{modeStr}}"
 	}
 }

+ 4 - 0
src/i18n/locales/zh-TW/kilocode.json

@@ -203,5 +203,9 @@
 			"invalidApiKey": "您的 OpenAI API 密钥无效或没有访问 Realtime API 的权限。请检查您的 API 密钥并重试。",
 			"unknown": "连接失败。请检查您的配置并重试。"
 		}
+	},
+	"configDiscovery": {
+		"removed": "Kilo代码:已删除{{source}}{{configType}}:{{name}}{{modeStr}}",
+		"added": "Kilo代码:已添加{{source}} {{configType}}:{{name}}{{modeStr}}"
 	}
 }

+ 64 - 0
src/services/config/ConfigChangeNotifier.spec.ts

@@ -0,0 +1,64 @@
+// kilocode_change - new file
+
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import type { ClineProvider } from "../../core/webview/ClineProvider"
+import { ConfigChangeNotifier } from "./ConfigChangeNotifier"
+
+const { mockShowInformationMessage } = vi.hoisted(() => ({
+	mockShowInformationMessage: vi.fn(),
+}))
+
+vi.mock("vscode", () => ({
+	window: { showInformationMessage: mockShowInformationMessage },
+}))
+
+vi.mock("../../i18n", () => ({
+	t: vi.fn((key, vars) => `${key} ${JSON.stringify(vars)}`),
+}))
+
+describe("ConfigChangeNotifier", () => {
+	let mockProvider: ClineProvider
+	let notifier: ConfigChangeNotifier
+
+	beforeEach(() => {
+		vi.clearAllMocks()
+		mockProvider = { cwd: "/test" } as unknown as ClineProvider
+		notifier = new ConfigChangeNotifier(mockProvider)
+	})
+
+	it("should skip initial discovery without notifying", async () => {
+		await notifier.notifyIfChanged("skill", [{ name: "test-skill", source: "global" }])
+		expect(mockShowInformationMessage).not.toHaveBeenCalled()
+	})
+
+	it("should detect added configurations", async () => {
+		await notifier.notifyIfChanged("skill", [{ name: "existing-skill", source: "global" }])
+		await notifier.notifyIfChanged("skill", [
+			{ name: "existing-skill", source: "global" },
+			{ name: "new-skill", source: "global" },
+		])
+		expect(mockShowInformationMessage).toHaveBeenCalledTimes(1)
+		expect(mockShowInformationMessage).toHaveBeenCalledWith(expect.stringContaining("new-skill"))
+	})
+
+	it("should detect removed configurations", async () => {
+		await notifier.notifyIfChanged("skill", [
+			{ name: "existing-skill", source: "global" },
+			{ name: "removed-skill", source: "global" },
+		])
+		await notifier.notifyIfChanged("skill", [{ name: "existing-skill", source: "global" }])
+		expect(mockShowInformationMessage).toHaveBeenCalledTimes(1)
+		expect(mockShowInformationMessage).toHaveBeenCalledWith(expect.stringContaining("removed-skill"))
+	})
+
+	it("should track different config types separately", async () => {
+		await notifier.notifyIfChanged("skill", [{ name: "skill-a", source: "global" }])
+		await notifier.notifyIfChanged("workflow", [{ name: "workflow-a", source: "global" }])
+		await notifier.notifyIfChanged("skill", [
+			{ name: "skill-a", source: "global" },
+			{ name: "skill-b", source: "global" },
+		])
+		expect(mockShowInformationMessage).toHaveBeenCalledTimes(1)
+		expect(mockShowInformationMessage).toHaveBeenCalledWith(expect.stringContaining("skill-b"))
+	})
+})

+ 96 - 0
src/services/config/ConfigChangeNotifier.ts

@@ -0,0 +1,96 @@
+// kilocode_change - new file
+
+import * as vscode from "vscode"
+import type { ClineProvider } from "../../core/webview/ClineProvider"
+import type { ContextConfigChange, ContextConfigType } from "@roo-code/types"
+import { t } from "../../i18n"
+
+interface ConfigInput {
+	name: string
+	source: "global" | "project"
+	mode?: string
+	path?: string
+}
+
+export class ConfigChangeNotifier {
+	private providerRef: WeakRef<ClineProvider>
+	private previousKeys = new Map<ContextConfigType, Set<string>>()
+
+	constructor(provider: ClineProvider) {
+		this.providerRef = new WeakRef(provider)
+	}
+
+	async notifyIfChanged(configType: ContextConfigType, currentConfigs: ConfigInput[]): Promise<void> {
+		const currentKeys = new Set(currentConfigs.map((c) => `${c.source}:${c.mode || ""}:${c.name}`))
+
+		if (!this.previousKeys.has(configType)) {
+			this.previousKeys.set(configType, currentKeys)
+			return
+		}
+
+		const previousKeys = this.previousKeys.get(configType)!
+		const changes = this.detectChanges(previousKeys, currentKeys, currentConfigs, configType)
+
+		this.previousKeys.set(configType, currentKeys)
+
+		if (changes.length > 0) {
+			await this.showNotifications(changes)
+		}
+	}
+
+	private detectChanges(
+		previousKeys: Set<string>,
+		currentKeys: Set<string>,
+		currentConfigs: ConfigInput[],
+		configType: ContextConfigType,
+	): ContextConfigChange[] {
+		const changes: ContextConfigChange[] = []
+		const configsByKey = new Map(currentConfigs.map((c) => [`${c.source}:${c.mode || ""}:${c.name}`, c]))
+
+		for (const key of currentKeys) {
+			if (!previousKeys.has(key)) {
+				const config = configsByKey.get(key)!
+				changes.push({
+					configType,
+					changeType: "added",
+					name: config.name,
+					source: config.source,
+					mode: config.mode,
+					filePath: config.path,
+				})
+			}
+		}
+
+		for (const key of previousKeys) {
+			if (!currentKeys.has(key)) {
+				const parts = key.split(":")
+				changes.push({
+					configType,
+					changeType: "removed",
+					name: parts.slice(2).join(":"),
+					source: parts[0] as "global" | "project",
+					mode: parts[1] || undefined,
+				})
+			}
+		}
+
+		return changes
+	}
+
+	private async showNotifications(changes: ContextConfigChange[]): Promise<void> {
+		const provider = this.providerRef.deref()
+		if (!provider) return
+
+		for (const change of changes) {
+			vscode.window.showInformationMessage(this.formatMessage(change))
+		}
+	}
+
+	private formatMessage(change: ContextConfigChange): string {
+		const modeStr = change.mode ? ` (${change.mode} mode)` : ""
+		const sourceStr = change.source === "global" ? "global" : "project"
+		const key =
+			change.changeType === "added" ? "kilocode:configDiscovery.added" : "kilocode:configDiscovery.removed"
+		return t(key, { configType: change.configType, name: change.name, source: sourceStr, modeStr })
+	}
+}

+ 11 - 1
src/services/skills/SkillsManager.ts

@@ -8,6 +8,7 @@ import { getGlobalRooDirectory } from "../roo-config"
 import { directoryExists, fileExists } from "../roo-config"
 import { SkillMetadata, SkillContent } from "../../shared/skills"
 import { modes, getAllModes } from "../../shared/modes"
+import { ConfigChangeNotifier } from "../config/ConfigChangeNotifier" // kilocode_change
 
 // Re-export for convenience
 export type { SkillMetadata, SkillContent }
@@ -17,9 +18,11 @@ export class SkillsManager {
 	private providerRef: WeakRef<ClineProvider>
 	private disposables: vscode.Disposable[] = []
 	private isDisposed = false
+	private configChangeNotifier: ConfigChangeNotifier // kilocode_change
 
 	constructor(provider: ClineProvider) {
 		this.providerRef = new WeakRef(provider)
+		this.configChangeNotifier = new ConfigChangeNotifier(provider) // kilocode_change
 	}
 
 	async initialize(): Promise<void> {
@@ -41,6 +44,9 @@ export class SkillsManager {
 		for (const { dir, source, mode } of skillsDirs) {
 			await this.scanSkillsDirectory(dir, source, mode)
 		}
+
+		const currentSkills = Array.from(this.skills.values()) // kilocode_change
+		await this.configChangeNotifier.notifyIfChanged("skill", currentSkills) // kilocode_change
 	}
 
 	/**
@@ -299,7 +305,11 @@ export class SkillsManager {
 			return
 		}
 
-		const pattern = new vscode.RelativePattern(dirPath, "**/SKILL.md")
+		// kilocode_change start
+		// Watch for direct children (skill directories) being added/changed/deleted
+		// When anything changes, we'll rescan and look for SKILL.md files
+		const pattern = new vscode.RelativePattern(dirPath, "*")
+		// kilocode_change end
 		const watcher = vscode.workspace.createFileSystemWatcher(pattern)
 
 		watcher.onDidChange(async (uri) => {