From 2cb33b2f764b19b4c7ce3ce1f9f3359542a4656d Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Mon, 5 Jan 2026 15:12:51 -0800 Subject: [PATCH] Agent Skills: Implement /skills reload (#15865) --- packages/cli/src/config/config.ts | 6 + packages/cli/src/config/settings.test.ts | 1 + packages/cli/src/config/settings.ts | 1 + packages/cli/src/ui/AppContainer.tsx | 14 ++ .../cli/src/ui/commands/skillsCommand.test.ts | 177 +++++++++++++++++- packages/cli/src/ui/commands/skillsCommand.ts | 116 +++++++++++- .../cli/src/ui/contexts/UIStateContext.tsx | 1 + packages/core/src/config/config.test.ts | 104 ++++++++++ packages/core/src/config/config.ts | 39 +++- packages/core/src/tools/tool-registry.ts | 9 + packages/core/src/utils/events.ts | 9 + 11 files changed, 468 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index e08a50893f..1aee75940b 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -726,6 +726,12 @@ export async function loadCliConfig( hooks: settings.hooks || {}, projectHooks: projectHooks || {}, onModelChange: (model: string) => saveModelChange(loadedSettings, model), + onReload: async () => { + const refreshedSettings = loadSettings(cwd); + return { + disabledSkills: refreshedSettings.merged.skills?.disabled, + }; + }, }); } diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 133b50daae..df3fdbe9ea 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -114,6 +114,7 @@ vi.mock('./extension.js'); const mockCoreEvents = vi.hoisted(() => ({ emitFeedback: vi.fn(), + emitSettingsChanged: vi.fn(), })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index f347a04be2..5cba3dd637 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -534,6 +534,7 @@ export class LoadedSettings { setNestedProperty(settingsFile.originalSettings, key, value); this._merged = this.computeMergedSettings(); saveSettings(settingsFile); + coreEvents.emitSettingsChanged(); } } diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ce5654b443..f352556b06 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -188,6 +188,7 @@ export const AppContainer = (props: AppContainerProps) => { const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] = useState(false); const [historyRemountKey, setHistoryRemountKey] = useState(0); + const [settingsNonce, setSettingsNonce] = useState(0); const [updateInfo, setUpdateInfo] = useState(null); const [isTrustedFolder, setIsTrustedFolder] = useState( isWorkspaceTrusted(settings.merged).isTrusted, @@ -368,6 +369,17 @@ export const AppContainer = (props: AppContainerProps) => { }; }, [config]); + useEffect(() => { + const handleSettingsChanged = () => { + setSettingsNonce((prev) => prev + 1); + }; + + coreEvents.on(CoreEvent.SettingsChanged, handleSettingsChanged); + return () => { + coreEvents.off(CoreEvent.SettingsChanged, handleSettingsChanged); + }; + }, []); + const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } = useConsoleMessages(); @@ -1546,6 +1558,7 @@ Logging in with Google... Restarting Gemini CLI to continue. bannerData, bannerVisible, terminalBackgroundColor: config.getTerminalBackground(), + settingsNonce, }), [ isThemeDialogOpen, @@ -1638,6 +1651,7 @@ Logging in with Google... Restarting Gemini CLI to continue. bannerData, bannerVisible, config, + settingsNonce, ], ); diff --git a/packages/cli/src/ui/commands/skillsCommand.test.ts b/packages/cli/src/ui/commands/skillsCommand.test.ts index 39339f8226..cba9c9ff4e 100644 --- a/packages/cli/src/ui/commands/skillsCommand.test.ts +++ b/packages/cli/src/ui/commands/skillsCommand.test.ts @@ -1,21 +1,22 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { skillsCommand } from './skillsCommand.js'; import { MessageType } from '../types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import type { CommandContext } from './types.js'; -import type { Config } from '@google/gemini-cli-core'; +import type { Config, SkillDefinition } from '@google/gemini-cli-core'; import { SettingScope, type LoadedSettings } from '../../config/settings.js'; describe('skillsCommand', () => { let context: CommandContext; beforeEach(() => { + vi.useFakeTimers(); const skills = [ { name: 'skill1', @@ -35,6 +36,7 @@ describe('skillsCommand', () => { config: { getSkillManager: vi.fn().mockReturnValue({ getAllSkills: vi.fn().mockReturnValue(skills), + getSkills: vi.fn().mockReturnValue(skills), getSkill: vi .fn() .mockImplementation( @@ -51,6 +53,11 @@ describe('skillsCommand', () => { }); }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + it('should add a SKILLS_LIST item to UI with descriptions by default', async () => { await skillsCommand.action!(context, ''); @@ -187,6 +194,170 @@ describe('skillsCommand', () => { }); }); + describe('reload', () => { + it('should reload skills successfully and show success message', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + // Make reload take some time so timer can fire + const reloadSkillsMock = vi.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + // @ts-expect-error Mocking reloadSkills + context.services.config.reloadSkills = reloadSkillsMock; + + const actionPromise = reloadCmd.action!(context, ''); + + // Initially, no pending item (flicker prevention) + expect(context.ui.setPendingItem).not.toHaveBeenCalled(); + + // Fast forward 100ms to trigger the pending item + await vi.advanceTimersByTimeAsync(100); + expect(context.ui.setPendingItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Reloading agent skills...', + }), + ); + + // Fast forward another 100ms (reload complete), but pending item should stay + await vi.advanceTimersByTimeAsync(100); + expect(context.ui.setPendingItem).not.toHaveBeenCalledWith(null); + + // Fast forward to reach 500ms total + await vi.advanceTimersByTimeAsync(300); + await actionPromise; + + expect(reloadSkillsMock).toHaveBeenCalled(); + expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Agent skills reloaded successfully.', + }), + expect.any(Number), + ); + }); + + it('should show new skills count after reload', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + const reloadSkillsMock = vi.fn().mockImplementation(async () => { + const skillManager = context.services.config!.getSkillManager(); + vi.mocked(skillManager.getSkills).mockReturnValue([ + { name: 'skill1' }, + { name: 'skill2' }, + { name: 'skill3' }, + ] as SkillDefinition[]); + }); + // @ts-expect-error Mocking reloadSkills + context.services.config.reloadSkills = reloadSkillsMock; + + await reloadCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Agent skills reloaded successfully. 1 newly available skill.', + }), + expect.any(Number), + ); + }); + + it('should show removed skills count after reload', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + const reloadSkillsMock = vi.fn().mockImplementation(async () => { + const skillManager = context.services.config!.getSkillManager(); + vi.mocked(skillManager.getSkills).mockReturnValue([ + { name: 'skill1' }, + ] as SkillDefinition[]); + }); + // @ts-expect-error Mocking reloadSkills + context.services.config.reloadSkills = reloadSkillsMock; + + await reloadCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Agent skills reloaded successfully. 1 skill no longer available.', + }), + expect.any(Number), + ); + }); + + it('should show both added and removed skills count after reload', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + const reloadSkillsMock = vi.fn().mockImplementation(async () => { + const skillManager = context.services.config!.getSkillManager(); + vi.mocked(skillManager.getSkills).mockReturnValue([ + { name: 'skill2' }, // skill1 removed, skill3 added + { name: 'skill3' }, + ] as SkillDefinition[]); + }); + // @ts-expect-error Mocking reloadSkills + context.services.config.reloadSkills = reloadSkillsMock; + + await reloadCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Agent skills reloaded successfully. 1 newly available skill and 1 skill no longer available.', + }), + expect.any(Number), + ); + }); + + it('should show error if configuration is missing', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + context.services.config = null; + + await reloadCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Could not retrieve configuration.', + }), + expect.any(Number), + ); + }); + + it('should show error if reload fails', async () => { + const reloadCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'reload', + )!; + const error = new Error('Reload failed'); + const reloadSkillsMock = vi.fn().mockImplementation(async () => { + await new Promise((_, reject) => setTimeout(() => reject(error), 200)); + }); + // @ts-expect-error Mocking reloadSkills + context.services.config.reloadSkills = reloadSkillsMock; + + const actionPromise = reloadCmd.action!(context, ''); + await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(400); + await actionPromise; + + expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Failed to reload skills: Reload failed', + }), + expect.any(Number), + ); + }); + }); + describe('completions', () => { it('should provide completions for disable (only enabled skills)', async () => { const disableCmd = skillsCommand.subCommands!.find( diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index e3cbc568a1..156516e9ea 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -10,7 +10,11 @@ import { type SlashCommandActionReturn, CommandKind, } from './types.js'; -import { MessageType, type HistoryItemSkillsList } from '../types.js'; +import { + MessageType, + type HistoryItemSkillsList, + type HistoryItemInfo, +} from '../types.js'; import { SettingScope } from '../../config/settings.js'; async function listAction( @@ -104,7 +108,7 @@ async function disableAction( context.ui.addItem( { type: MessageType.INFO, - text: `Skill "${skillName}" disabled in ${scope} settings. Restart required to take effect.`, + text: `Skill "${skillName}" disabled in ${scope} settings. Use "/skills reload" for it to take effect.`, }, Date.now(), ); @@ -148,12 +152,107 @@ async function enableAction( context.ui.addItem( { type: MessageType.INFO, - text: `Skill "${skillName}" enabled in ${scope} settings. Restart required to take effect.`, + text: `Skill "${skillName}" enabled in ${scope} settings. Use "/skills reload" for it to take effect.`, }, Date.now(), ); } +async function reloadAction( + context: CommandContext, +): Promise { + const config = context.services.config; + if (!config) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: 'Could not retrieve configuration.', + }, + Date.now(), + ); + return; + } + + const skillManager = config.getSkillManager(); + const beforeNames = new Set(skillManager.getSkills().map((s) => s.name)); + + const startTime = Date.now(); + let pendingItemSet = false; + const pendingTimeout = setTimeout(() => { + context.ui.setPendingItem({ + type: MessageType.INFO, + text: 'Reloading agent skills...', + }); + pendingItemSet = true; + }, 100); + + try { + await config.reloadSkills(); + + clearTimeout(pendingTimeout); + if (pendingItemSet) { + // If we showed the pending item, make sure it stays for at least 500ms + // total to avoid a "flicker" where it appears and immediately disappears. + const elapsed = Date.now() - startTime; + const minVisibleDuration = 500; + if (elapsed < minVisibleDuration) { + await new Promise((resolve) => + setTimeout(resolve, minVisibleDuration - elapsed), + ); + } + context.ui.setPendingItem(null); + } + + const afterSkills = skillManager.getSkills(); + const afterNames = new Set(afterSkills.map((s) => s.name)); + + const added = afterSkills.filter((s) => !beforeNames.has(s.name)); + const removedCount = [...beforeNames].filter( + (name) => !afterNames.has(name), + ).length; + + let successText = 'Agent skills reloaded successfully.'; + const details: string[] = []; + + if (added.length > 0) { + details.push( + `${added.length} newly available skill${added.length > 1 ? 's' : ''}`, + ); + } + if (removedCount > 0) { + details.push( + `${removedCount} skill${removedCount > 1 ? 's' : ''} no longer available`, + ); + } + + if (details.length > 0) { + successText += ` ${details.join(' and ')}.`; + } + + context.ui.addItem( + { + type: 'info', + text: successText, + icon: '✓ ', + color: 'green', + } as HistoryItemInfo, + Date.now(), + ); + } catch (error) { + clearTimeout(pendingTimeout); + if (pendingItemSet) { + context.ui.setPendingItem(null); + } + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Failed to reload skills: ${error instanceof Error ? error.message : String(error)}`, + }, + Date.now(), + ); + } +} + function disableCompletion( context: CommandContext, partialArg: string, @@ -185,7 +284,7 @@ function enableCompletion( export const skillsCommand: SlashCommand = { name: 'skills', description: - 'List, enable, or disable Gemini CLI agent skills. Usage: /skills [list | disable | enable ]', + 'List, enable, disable, or reload Gemini CLI agent skills. Usage: /skills [list | disable | enable | reload]', kind: CommandKind.BUILT_IN, autoExecute: false, subCommands: [ @@ -210,6 +309,13 @@ export const skillsCommand: SlashCommand = { action: enableAction, completion: enableCompletion, }, + { + name: 'reload', + description: + 'Reload the list of discovered skills. Usage: /skills reload', + kind: CommandKind.BUILT_IN, + action: reloadAction, + }, ], action: listAction, }; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index c0f0eb0c2e..d9f74e59e0 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -138,6 +138,7 @@ export interface UIState { bannerVisible: boolean; customDialog: React.ReactNode | null; terminalBackgroundColor: TerminalBackgroundColor; + settingsNonce: number; } export const UIStateContext = createContext(null); diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index ea357d690a..e16ef982fc 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -33,6 +33,8 @@ import { RipGrepTool, canUseRipgrep } from '../tools/ripGrep.js'; import { logRipgrepFallback } from '../telemetry/loggers.js'; import { RipgrepFallbackEvent } from '../telemetry/types.js'; import { ToolRegistry } from '../tools/tool-registry.js'; +import { ACTIVATE_SKILL_TOOL_NAME } from '../tools/tool-names.js'; +import type { SkillDefinition } from '../skills/skillLoader.js'; import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js'; import { DEFAULT_GEMINI_MODEL, @@ -57,6 +59,7 @@ vi.mock('fs', async (importOriginal) => { vi.mock('../tools/tool-registry', () => { const ToolRegistryMock = vi.fn(); ToolRegistryMock.prototype.registerTool = vi.fn(); + ToolRegistryMock.prototype.unregisterTool = vi.fn(); ToolRegistryMock.prototype.discoverAllTools = vi.fn(); ToolRegistryMock.prototype.sortTools = vi.fn(); ToolRegistryMock.prototype.getAllTools = vi.fn(() => []); // Mock methods if needed @@ -104,6 +107,7 @@ vi.mock('../core/client.js', () => ({ GeminiClient: vi.fn().mockImplementation(() => ({ initialize: vi.fn().mockResolvedValue(undefined), stripThoughtsFromHistory: vi.fn(), + isInitialized: vi.fn().mockReturnValue(false), })), })); @@ -1978,4 +1982,104 @@ describe('Config JIT Initialization', () => { expect(ContextManager).not.toHaveBeenCalled(); expect(config.getUserMemory()).toBe('Initial Memory'); }); + + describe('reloadSkills', () => { + it('should refresh disabledSkills and re-register ActivateSkillTool when skills exist', async () => { + const mockOnReload = vi.fn().mockResolvedValue({ + disabledSkills: ['skill2'], + }); + const params: ConfigParameters = { + sessionId: 'test-session', + targetDir: '/tmp/test', + debugMode: false, + model: 'test-model', + cwd: '/tmp/test', + skillsSupport: true, + onReload: mockOnReload, + }; + + config = new Config(params); + await config.initialize(); + + const skillManager = config.getSkillManager(); + const toolRegistry = config.getToolRegistry(); + + vi.spyOn(skillManager, 'discoverSkills').mockResolvedValue(undefined); + vi.spyOn(skillManager, 'setDisabledSkills'); + vi.spyOn(toolRegistry, 'registerTool'); + vi.spyOn(toolRegistry, 'unregisterTool'); + + const mockSkills = [{ name: 'skill1' }]; + vi.spyOn(skillManager, 'getSkills').mockReturnValue( + mockSkills as SkillDefinition[], + ); + + await config.reloadSkills(); + + expect(mockOnReload).toHaveBeenCalled(); + expect(skillManager.setDisabledSkills).toHaveBeenCalledWith(['skill2']); + expect(toolRegistry.registerTool).toHaveBeenCalled(); + expect(toolRegistry.unregisterTool).not.toHaveBeenCalledWith( + ACTIVATE_SKILL_TOOL_NAME, + ); + }); + + it('should unregister ActivateSkillTool when no skills exist after reload', async () => { + const params: ConfigParameters = { + sessionId: 'test-session', + targetDir: '/tmp/test', + debugMode: false, + model: 'test-model', + cwd: '/tmp/test', + skillsSupport: true, + }; + + config = new Config(params); + await config.initialize(); + + const skillManager = config.getSkillManager(); + const toolRegistry = config.getToolRegistry(); + + vi.spyOn(skillManager, 'discoverSkills').mockResolvedValue(undefined); + vi.spyOn(toolRegistry, 'registerTool'); + vi.spyOn(toolRegistry, 'unregisterTool'); + + vi.spyOn(skillManager, 'getSkills').mockReturnValue([]); + + await config.reloadSkills(); + + expect(toolRegistry.unregisterTool).toHaveBeenCalledWith( + ACTIVATE_SKILL_TOOL_NAME, + ); + }); + + it('should clear disabledSkills when onReload returns undefined for them', async () => { + const mockOnReload = vi.fn().mockResolvedValue({ + disabledSkills: undefined, + }); + const params: ConfigParameters = { + sessionId: 'test-session', + targetDir: '/tmp/test', + debugMode: false, + model: 'test-model', + cwd: '/tmp/test', + skillsSupport: true, + onReload: mockOnReload, + }; + + config = new Config(params); + // Initially set some disabled skills + // @ts-expect-error - accessing private + config.disabledSkills = ['skill1']; + await config.initialize(); + + const skillManager = config.getSkillManager(); + vi.spyOn(skillManager, 'discoverSkills').mockResolvedValue(undefined); + vi.spyOn(skillManager, 'setDisabledSkills'); + + await config.reloadSkills(); + + expect(skillManager.setDisabledSkills).toHaveBeenCalledWith([]); + }); + }); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 3d9aba2bb8..aceea0efef 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -356,6 +356,7 @@ export interface ConfigParameters { disabledSkills?: string[]; experimentalJitContext?: boolean; onModelChange?: (model: string) => void; + onReload?: () => Promise<{ disabledSkills?: string[] }>; } export class Config { @@ -479,10 +480,13 @@ export class Config { private experimentsPromise: Promise | undefined; private hookSystem?: HookSystem; private readonly onModelChange: ((model: string) => void) | undefined; + private readonly onReload: + | (() => Promise<{ disabledSkills?: string[] }>) + | undefined; private readonly enableAgents: boolean; private readonly skillsSupport: boolean; - private readonly disabledSkills: string[]; + private disabledSkills: string[]; private readonly experimentalJitContext: boolean; private contextManager?: ContextManager; @@ -643,6 +647,7 @@ export class Config { this.projectHooks = params.projectHooks; this.experiments = params.experiments; this.onModelChange = params.onModelChange; + this.onReload = params.onReload; if (params.contextFileName) { setGeminiMdFilename(params.contextFileName); @@ -1520,6 +1525,38 @@ export class Config { return this.skillsSupport; } + /** + * Reloads skills by re-discovering them from extensions and local directories. + */ + async reloadSkills(): Promise { + if (!this.skillsSupport) { + return; + } + + if (this.onReload) { + const refreshed = await this.onReload(); + this.disabledSkills = refreshed.disabledSkills ?? []; + } + + await this.getSkillManager().discoverSkills( + this.storage, + this.getExtensions(), + ); + this.getSkillManager().setDisabledSkills(this.disabledSkills); + + // Re-register ActivateSkillTool to update its schema with the newly discovered skills + if (this.getSkillManager().getSkills().length > 0) { + this.getToolRegistry().registerTool( + new ActivateSkillTool(this, this.messageBus), + ); + } else { + this.getToolRegistry().unregisterTool(ActivateSkillTool.Name); + } + + // Notify the client that system instructions might need updating + await this.updateSystemInstructionIfInitialized(); + } + isInteractive(): boolean { return this.interactive; } diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 18c30c5f76..6179e5a068 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -225,6 +225,15 @@ export class ToolRegistry { this.allKnownTools.set(tool.name, tool); } + /** + * Unregisters a tool definition by name. + * + * @param name - The name of the tool to unregister. + */ + unregisterTool(name: string): void { + this.allKnownTools.delete(name); + } + /** * Sorts tools as: * 1. Built in tools. diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts index 402b59940c..aebc1901a2 100644 --- a/packages/core/src/utils/events.ts +++ b/packages/core/src/utils/events.ts @@ -74,6 +74,7 @@ export enum CoreEvent { Output = 'output', MemoryChanged = 'memory-changed', ExternalEditorClosed = 'external-editor-closed', + SettingsChanged = 'settings-changed', } export interface CoreEvents { @@ -83,6 +84,7 @@ export interface CoreEvents { [CoreEvent.Output]: [OutputPayload]; [CoreEvent.MemoryChanged]: [MemoryChangedPayload]; [CoreEvent.ExternalEditorClosed]: never[]; + [CoreEvent.SettingsChanged]: never[]; } type EventBacklogItem = { @@ -163,6 +165,13 @@ export class CoreEventEmitter extends EventEmitter { this.emit(CoreEvent.ModelChanged, payload); } + /** + * Notifies subscribers that settings have been modified. + */ + emitSettingsChanged(): void { + this.emit(CoreEvent.SettingsChanged); + } + /** * Flushes buffered messages. Call this immediately after primary UI listener * subscribes.