diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index c3b7cf748e..2f7b358f6e 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -64,9 +64,12 @@ import { loadEnvironment, migrateDeprecatedSettings, SettingScope, + saveSettings, + type SettingsFile, } from './settings.js'; import { FatalConfigError, GEMINI_DIR, Storage } from '@google/gemini-cli-core'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; +import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; const MOCK_WORKSPACE_DIR = '/mock/workspace'; // Use the (mocked) GEMINI_DIR for consistency @@ -96,6 +99,23 @@ vi.mock('fs', async (importOriginal) => { vi.mock('./extension.js'); +const mockCoreEvents = vi.hoisted(() => ({ + emitFeedback: vi.fn(), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + coreEvents: mockCoreEvents, + }; +}); + +vi.mock('../utils/commentJson.js', () => ({ + updateSettingsFilePreservingFormat: vi.fn(), +})); + vi.mock('strip-json-comments', () => ({ default: vi.fn((content) => content), })); @@ -2495,4 +2515,62 @@ describe('Settings Loading and Merging', () => { expect(setValueSpy).not.toHaveBeenCalled(); }); }); + + describe('saveSettings', () => { + it('should save settings using updateSettingsFilePreservingFormat', () => { + const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat); + const settingsFile = { + path: '/mock/settings.json', + settings: { ui: { theme: 'dark' } }, + originalSettings: { ui: { theme: 'dark' } }, + } as unknown as SettingsFile; + + saveSettings(settingsFile); + + expect(mockUpdateSettings).toHaveBeenCalledWith('/mock/settings.json', { + ui: { theme: 'dark' }, + }); + }); + + it('should create directory if it does not exist', () => { + const mockFsExistsSync = vi.mocked(fs.existsSync); + const mockFsMkdirSync = vi.mocked(fs.mkdirSync); + mockFsExistsSync.mockReturnValue(false); + + const settingsFile = { + path: '/mock/new/dir/settings.json', + settings: {}, + originalSettings: {}, + } as unknown as SettingsFile; + + saveSettings(settingsFile); + + expect(mockFsExistsSync).toHaveBeenCalledWith('/mock/new/dir'); + expect(mockFsMkdirSync).toHaveBeenCalledWith('/mock/new/dir', { + recursive: true, + }); + }); + + it('should emit error feedback if saving fails', () => { + const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat); + const error = new Error('Write failed'); + mockUpdateSettings.mockImplementation(() => { + throw error; + }); + + const settingsFile = { + path: '/mock/settings.json', + settings: {}, + originalSettings: {}, + } as unknown as SettingsFile; + + saveSettings(settingsFile); + + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'There was an error saving your latest settings changes.', + error, + ); + }); + }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 0835cdd178..2818c9a214 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -15,6 +15,7 @@ import { GEMINI_DIR, getErrorMessage, Storage, + coreEvents, } from '@google/gemini-cli-core'; import stripJsonComments from 'strip-json-comments'; import { DefaultLight } from '../ui/themes/default-light.js'; @@ -799,6 +800,10 @@ export function saveSettings(settingsFile: SettingsFile): void { settingsToSave as Record, ); } catch (error) { - console.error('Error saving user settings file:', error); + coreEvents.emitFeedback( + 'error', + 'There was an error saving your latest settings changes.', + error, + ); } } diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index d82ab2108c..da5d097c64 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -11,6 +11,7 @@ import type { SessionMetrics, AnyDeclarativeTool, AnyToolInvocation, + UserFeedbackPayload, } from '@google/gemini-cli-core'; import { executeToolCall, @@ -20,14 +21,32 @@ import { OutputFormat, uiTelemetryService, FatalInputError, + CoreEvent, } from '@google/gemini-cli-core'; import type { Part } from '@google/genai'; import { runNonInteractive } from './nonInteractiveCli.js'; -import { vi, type Mock, type MockInstance } from 'vitest'; +import { + describe, + it, + expect, + beforeEach, + afterEach, + vi, + type Mock, + type MockInstance, +} from 'vitest'; import type { LoadedSettings } from './config/settings.js'; // Mock core modules vi.mock('./ui/hooks/atCommandProcessor.js'); + +const mockCoreEvents = vi.hoisted(() => ({ + on: vi.fn(), + off: vi.fn(), + drainFeedbackBacklog: vi.fn(), + emit: vi.fn(), +})); + vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); @@ -48,6 +67,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { uiTelemetryService: { getMetrics: vi.fn(), }, + coreEvents: mockCoreEvents, }; }); @@ -70,6 +90,7 @@ describe('runNonInteractive', () => { let mockShutdownTelemetry: Mock; let consoleErrorSpy: MockInstance; let processStdoutSpy: MockInstance; + let processStderrSpy: MockInstance; let mockGeminiClient: { sendMessageStream: Mock; getChatRecordingService: Mock; @@ -87,6 +108,9 @@ describe('runNonInteractive', () => { processStdoutSpy = vi .spyOn(process.stdout, 'write') .mockImplementation(() => true); + processStderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code}) called`); }); @@ -1051,4 +1075,135 @@ describe('runNonInteractive', () => { ); expect(processStdoutSpy).toHaveBeenCalledWith('file.txt'); }); + + describe('CoreEvents Integration', () => { + it('subscribes to UserFeedback and drains backlog on start', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive( + mockConfig, + mockSettings, + 'test', + 'prompt-id-events', + ); + + expect(mockCoreEvents.on).toHaveBeenCalledWith( + CoreEvent.UserFeedback, + expect.any(Function), + ); + expect(mockCoreEvents.drainFeedbackBacklog).toHaveBeenCalledTimes(1); + }); + + it('unsubscribes from UserFeedback on finish', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive( + mockConfig, + mockSettings, + 'test', + 'prompt-id-events', + ); + + expect(mockCoreEvents.off).toHaveBeenCalledWith( + CoreEvent.UserFeedback, + expect.any(Function), + ); + }); + + it('logs to process.stderr when UserFeedback event is received', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive( + mockConfig, + mockSettings, + 'test', + 'prompt-id-events', + ); + + // Get the registered handler + const handler = mockCoreEvents.on.mock.calls.find( + (call: unknown[]) => call[0] === CoreEvent.UserFeedback, + )?.[1]; + expect(handler).toBeDefined(); + + // Simulate an event + const payload: UserFeedbackPayload = { + severity: 'error', + message: 'Test error message', + }; + handler(payload); + + expect(processStderrSpy).toHaveBeenCalledWith( + '[ERROR] Test error message\n', + ); + }); + + it('logs optional error object to process.stderr in debug mode', async () => { + vi.mocked(mockConfig.getDebugMode).mockReturnValue(true); + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive( + mockConfig, + mockSettings, + 'test', + 'prompt-id-events', + ); + + // Get the registered handler + const handler = mockCoreEvents.on.mock.calls.find( + (call: unknown[]) => call[0] === CoreEvent.UserFeedback, + )?.[1]; + expect(handler).toBeDefined(); + + // Simulate an event with error object + const errorObj = new Error('Original error'); + // Mock stack for deterministic testing + errorObj.stack = 'Error: Original error\n at test'; + const payload: UserFeedbackPayload = { + severity: 'warning', + message: 'Test warning message', + error: errorObj, + }; + handler(payload); + + expect(processStderrSpy).toHaveBeenCalledWith( + '[WARNING] Test warning message\n', + ); + expect(processStderrSpy).toHaveBeenCalledWith( + 'Error: Original error\n at test\n', + ); + }); + }); }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index f92b17e800..2d8d845114 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -8,6 +8,7 @@ import type { Config, ToolCallRequestInfo, CompletedToolCall, + UserFeedbackPayload, } from '@google/gemini-cli-core'; import { isSlashCommand } from './ui/utils/commandUtils.js'; import type { LoadedSettings } from './config/settings.js'; @@ -24,6 +25,8 @@ import { JsonStreamEventType, uiTelemetryService, debugLogger, + coreEvents, + CoreEvent, } from '@google/gemini-cli-core'; import type { Content, Part } from '@google/genai'; @@ -50,6 +53,18 @@ export async function runNonInteractive( debugMode: config.getDebugMode(), }); + const handleUserFeedback = (payload: UserFeedbackPayload) => { + const prefix = payload.severity.toUpperCase(); + process.stderr.write(`[${prefix}] ${payload.message}\n`); + if (payload.error && config.getDebugMode()) { + const errorToLog = + payload.error instanceof Error + ? payload.error.stack || payload.error.message + : String(payload.error); + process.stderr.write(`${errorToLog}\n`); + } + }; + const startTime = Date.now(); const streamFormatter = config.getOutputFormat() === OutputFormat.STREAM_JSON @@ -58,6 +73,9 @@ export async function runNonInteractive( try { consolePatcher.patch(); + coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback); + coreEvents.drainFeedbackBacklog(); + // Handle EPIPE errors when the output is piped to a command that closes early. process.stdout.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EPIPE') { @@ -287,6 +305,7 @@ export async function runNonInteractive( handleError(error, config); } finally { consolePatcher.cleanup(); + coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback); if (isTelemetrySdkInitialized()) { await shutdownTelemetry(config); } diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index d12dc918fc..5864437880 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -15,7 +15,29 @@ import { } from 'vitest'; import { render, cleanup } from 'ink-testing-library'; import { AppContainer } from './AppContainer.js'; -import { type Config, makeFakeConfig } from '@google/gemini-cli-core'; +import { + type Config, + makeFakeConfig, + CoreEvent, + type UserFeedbackPayload, +} from '@google/gemini-cli-core'; + +// Mock coreEvents +const mockCoreEvents = vi.hoisted(() => ({ + on: vi.fn(), + off: vi.fn(), + drainFeedbackBacklog: vi.fn(), + emit: vi.fn(), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + coreEvents: mockCoreEvents, + }; +}); import type { LoadedSettings } from '../config/settings.js'; import type { InitializationResult } from '../core/initializer.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; @@ -1293,4 +1315,73 @@ describe('AppContainer State Management', () => { expect(mockCloseModelDialog).toHaveBeenCalled(); }); }); + + describe('CoreEvents Integration', () => { + it('subscribes to UserFeedback and drains backlog on mount', () => { + render( + , + ); + + expect(mockCoreEvents.on).toHaveBeenCalledWith( + CoreEvent.UserFeedback, + expect.any(Function), + ); + expect(mockCoreEvents.drainFeedbackBacklog).toHaveBeenCalledTimes(1); + }); + + it('unsubscribes from UserFeedback on unmount', () => { + const { unmount } = render( + , + ); + + unmount(); + + expect(mockCoreEvents.off).toHaveBeenCalledWith( + CoreEvent.UserFeedback, + expect.any(Function), + ); + }); + + it('adds history item when UserFeedback event is received', () => { + render( + , + ); + + // Get the registered handler + const handler = mockCoreEvents.on.mock.calls.find( + (call: unknown[]) => call[0] === CoreEvent.UserFeedback, + )?.[1]; + expect(handler).toBeDefined(); + + // Simulate an event + const payload: UserFeedbackPayload = { + severity: 'error', + message: 'Test error message', + }; + handler(payload); + + expect(mockedUseHistory().addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + text: 'Test error message', + }), + expect.any(Number), + ); + }); + }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 28ebc47d71..c8de2a27ec 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -34,6 +34,7 @@ import { type IdeInfo, type IdeContext, type UserTierId, + type UserFeedbackPayload, DEFAULT_GEMINI_FLASH_MODEL, IdeClient, ideContextStore, @@ -44,6 +45,8 @@ import { recordExitFail, ShellExecutionService, debugLogger, + coreEvents, + CoreEvent, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; @@ -1066,6 +1069,53 @@ Logging in with Google... Please restart Gemini CLI to continue. stdout, ]); + useEffect(() => { + const handleUserFeedback = (payload: UserFeedbackPayload) => { + let type: MessageType; + switch (payload.severity) { + case 'error': + type = MessageType.ERROR; + break; + case 'warning': + type = MessageType.WARNING; + break; + case 'info': + type = MessageType.INFO; + break; + default: + throw new Error( + `Unexpected severity for user feedback: ${payload.severity}`, + ); + } + + historyManager.addItem( + { + type, + text: payload.message, + }, + Date.now(), + ); + + // If there is an attached error object, log it to the debug drawer. + if (payload.error) { + debugLogger.warn( + `[Feedback Details for "${payload.message}"]`, + payload.error, + ); + } + }; + + coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback); + + // Flush any messages that happened during startup before this component + // mounted. + coreEvents.drainFeedbackBacklog(); + + return () => { + coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback); + }; + }, [historyManager]); + const filteredConsoleMessages = useMemo(() => { if (config.getDebugMode()) { return consoleMessages; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e2248b0c73..42ced4457f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -64,6 +64,7 @@ export * from './utils/partUtils.js'; export * from './utils/promptIdContext.js'; export * from './utils/thoughtUtils.js'; export * from './utils/debugLogger.js'; +export * from './utils/events.js'; // Export services export * from './services/fileDiscoveryService.js'; diff --git a/packages/core/src/utils/events.test.ts b/packages/core/src/utils/events.test.ts new file mode 100644 index 0000000000..4a11263014 --- /dev/null +++ b/packages/core/src/utils/events.test.ts @@ -0,0 +1,159 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + CoreEventEmitter, + CoreEvent, + type UserFeedbackPayload, +} from './events.js'; + +describe('CoreEventEmitter', () => { + let events: CoreEventEmitter; + + beforeEach(() => { + events = new CoreEventEmitter(); + }); + + it('should emit feedback immediately when a listener is present', () => { + const listener = vi.fn(); + events.on(CoreEvent.UserFeedback, listener); + + const payload = { + severity: 'info' as const, + message: 'Test message', + }; + + events.emitFeedback(payload.severity, payload.message); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload)); + }); + + it('should buffer feedback when no listener is present', () => { + const listener = vi.fn(); + const payload = { + severity: 'warning' as const, + message: 'Buffered message', + }; + + // Emit while no listeners attached + events.emitFeedback(payload.severity, payload.message); + expect(listener).not.toHaveBeenCalled(); + + // Attach listener and drain + events.on(CoreEvent.UserFeedback, listener); + events.drainFeedbackBacklog(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload)); + }); + + it('should respect the backlog size limit and maintain FIFO order', () => { + const listener = vi.fn(); + const MAX_BACKLOG_SIZE = 10000; + + for (let i = 0; i < MAX_BACKLOG_SIZE + 10; i++) { + events.emitFeedback('info', `Message ${i}`); + } + + events.on(CoreEvent.UserFeedback, listener); + events.drainFeedbackBacklog(); + + expect(listener).toHaveBeenCalledTimes(MAX_BACKLOG_SIZE); + // Verify strictly that the FIRST call was Message 10 (0-9 dropped) + expect(listener.mock.calls[0][0]).toMatchObject({ message: 'Message 10' }); + // Verify strictly that the LAST call was Message 109 + expect(listener.mock.lastCall?.[0]).toMatchObject({ + message: `Message ${MAX_BACKLOG_SIZE + 9}`, + }); + }); + + it('should clear the backlog after draining', () => { + const listener = vi.fn(); + events.emitFeedback('error', 'Test error'); + + events.on(CoreEvent.UserFeedback, listener); + events.drainFeedbackBacklog(); + expect(listener).toHaveBeenCalledTimes(1); + + listener.mockClear(); + events.drainFeedbackBacklog(); + expect(listener).not.toHaveBeenCalled(); + }); + + it('should include optional error object in payload', () => { + const listener = vi.fn(); + events.on(CoreEvent.UserFeedback, listener); + + const error = new Error('Original error'); + events.emitFeedback('error', 'Something went wrong', error); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'error', + message: 'Something went wrong', + error, + }), + ); + }); + + it('should handle multiple listeners correctly', () => { + const listenerA = vi.fn(); + const listenerB = vi.fn(); + + events.on(CoreEvent.UserFeedback, listenerA); + events.on(CoreEvent.UserFeedback, listenerB); + + events.emitFeedback('info', 'Broadcast message'); + + expect(listenerA).toHaveBeenCalledTimes(1); + expect(listenerB).toHaveBeenCalledTimes(1); + }); + + it('should stop receiving events after off() is called', () => { + const listener = vi.fn(); + events.on(CoreEvent.UserFeedback, listener); + + events.emitFeedback('info', 'First message'); + expect(listener).toHaveBeenCalledTimes(1); + + events.off(CoreEvent.UserFeedback, listener); + events.emitFeedback('info', 'Second message'); + expect(listener).toHaveBeenCalledTimes(1); // Still 1 + }); + + it('should handle re-entrant feedback emission during draining safely', () => { + events.emitFeedback('info', 'Buffered 1'); + events.emitFeedback('info', 'Buffered 2'); + + const listener = vi.fn((payload: UserFeedbackPayload) => { + // When 'Buffered 1' is received, immediately emit another event. + if (payload.message === 'Buffered 1') { + events.emitFeedback('warning', 'Re-entrant message'); + } + }); + + events.on(CoreEvent.UserFeedback, listener); + events.drainFeedbackBacklog(); + + // Expectation with atomic snapshot: + // 1. loop starts with ['Buffered 1', 'Buffered 2'] + // 2. emits 'Buffered 1' + // 3. listener fires for 'Buffered 1', calls emitFeedback('Re-entrant') + // 4. emitFeedback sees listener attached, emits 'Re-entrant' synchronously + // 5. listener fires for 'Re-entrant' + // 6. loop continues, emits 'Buffered 2' + // 7. listener fires for 'Buffered 2' + + expect(listener).toHaveBeenCalledTimes(3); + expect(listener.mock.calls[0][0]).toMatchObject({ message: 'Buffered 1' }); + expect(listener.mock.calls[1][0]).toMatchObject({ + message: 'Re-entrant message', + }); + expect(listener.mock.calls[2][0]).toMatchObject({ message: 'Buffered 2' }); + }); +}); diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts new file mode 100644 index 0000000000..76038560d8 --- /dev/null +++ b/packages/core/src/utils/events.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'node:events'; + +/** + * Defines the severity level for user-facing feedback. + * This maps loosely to UI `MessageType` + */ +export type FeedbackSeverity = 'info' | 'warning' | 'error'; + +/** + * Payload for the 'user-feedback' event. + */ +export interface UserFeedbackPayload { + /** + * The severity level determines how the message is rendered in the UI + * (e.g. colored text, specific icon). + */ + severity: FeedbackSeverity; + /** + * The main message to display to the user in the chat history or stdout. + */ + message: string; + /** + * The original error object, if applicable. + * Listeners can use this to extract stack traces for debug logging + * or verbose output, while keeping the 'message' field clean for end users. + */ + error?: unknown; +} + +export enum CoreEvent { + UserFeedback = 'user-feedback', +} + +export class CoreEventEmitter extends EventEmitter { + private _feedbackBacklog: UserFeedbackPayload[] = []; + private static readonly MAX_BACKLOG_SIZE = 10000; + + constructor() { + super(); + } + + /** + * Sends actionable feedback to the user. + * Buffers automatically if the UI hasn't subscribed yet. + */ + emitFeedback( + severity: FeedbackSeverity, + message: string, + error?: unknown, + ): void { + const payload: UserFeedbackPayload = { severity, message, error }; + + if (this.listenerCount(CoreEvent.UserFeedback) === 0) { + if (this._feedbackBacklog.length >= CoreEventEmitter.MAX_BACKLOG_SIZE) { + this._feedbackBacklog.shift(); + } + this._feedbackBacklog.push(payload); + } else { + this.emit(CoreEvent.UserFeedback, payload); + } + } + + /** + * Flushes buffered messages. Call this immediately after primary UI listener + * subscribes. + */ + drainFeedbackBacklog(): void { + const backlog = [...this._feedbackBacklog]; + this._feedbackBacklog.length = 0; // Clear in-place + for (const payload of backlog) { + this.emit(CoreEvent.UserFeedback, payload); + } + } + + override on( + event: CoreEvent.UserFeedback, + listener: (payload: UserFeedbackPayload) => void, + ): this; + override on( + event: string | symbol, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + listener: (...args: any[]) => void, + ): this { + return super.on(event, listener); + } + + override off( + event: CoreEvent.UserFeedback, + listener: (payload: UserFeedbackPayload) => void, + ): this; + override off( + event: string | symbol, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + listener: (...args: any[]) => void, + ): this { + return super.off(event, listener); + } + + override emit( + event: CoreEvent.UserFeedback, + payload: UserFeedbackPayload, + ): boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + override emit(event: string | symbol, ...args: any[]): boolean { + return super.emit(event, ...args); + } +} + +export const coreEvents = new CoreEventEmitter();