From e432f7c009dfd95a7f092287be5d3cb1e05cb62a Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Tue, 7 Apr 2026 10:42:39 -0700 Subject: [PATCH] feat(hooks): display hook system messages in UI (#24616) --- packages/cli/src/ui/AppContainer.tsx | 18 +++++++++++++++++- .../src/ui/components/HistoryItemDisplay.tsx | 1 + .../src/ui/components/messages/InfoMessage.tsx | 5 +++++ packages/cli/src/ui/types.ts | 1 + packages/core/src/config/config.ts | 2 +- packages/core/src/hooks/hookEventHandler.ts | 9 +++++++++ packages/core/src/hooks/hookRunner.test.ts | 17 +++++++++++++++-- packages/core/src/hooks/hookRunner.ts | 6 +++++- packages/core/src/hooks/types.ts | 2 ++ packages/core/src/utils/events.ts | 16 ++++++++++++++++ 10 files changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index a0d995f323..e61cada6b5 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -36,9 +36,11 @@ import { type ConfirmationRequest, type PermissionConfirmationRequest, type QuotaStats, + MessageType, + StreamingState, + type HistoryItemInfo, } from './types.js'; import { checkPermissions } from './hooks/atCommandProcessor.js'; -import { MessageType, StreamingState } from './types.js'; import { ToolActionsProvider } from './contexts/ToolActionsContext.js'; import { MouseProvider } from './contexts/MouseContext.js'; import { ScrollProvider } from './contexts/ScrollProvider.js'; @@ -51,6 +53,7 @@ import { type UserTierId, type GeminiUserTier, type UserFeedbackPayload, + type HookSystemMessagePayload, type AgentDefinition, type ApprovalMode, IdeClient, @@ -2111,7 +2114,19 @@ Logging in with Google... Restarting Gemini CLI to continue. } }; + const handleHookSystemMessage = (payload: HookSystemMessagePayload) => { + historyManager.addItem( + { + type: MessageType.INFO, + text: payload.message, + source: payload.hookName, + } as HistoryItemInfo, + Date.now(), + ); + }; + coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback); + coreEvents.on(CoreEvent.HookSystemMessage, handleHookSystemMessage); // Flush any messages that happened during startup before this component // mounted. @@ -2119,6 +2134,7 @@ Logging in with Google... Restarting Gemini CLI to continue. return () => { coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback); + coreEvents.off(CoreEvent.HookSystemMessage, handleHookSystemMessage); }; }, [historyManager]); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index cd978b7952..0ceb70f8d7 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -134,6 +134,7 @@ export const HistoryItemDisplay: React.FC = ({ = ({ text, secondaryText, + source, icon, color, marginBottom, @@ -40,6 +42,9 @@ export const InfoMessage: React.FC = ({ {index === text.split('\n').length - 1 && secondaryText && ( {secondaryText} )} + {index === text.split('\n').length - 1 && source && ( + [{source}] + )} ))} diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 88215a1221..6fbc3151d8 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -174,6 +174,7 @@ export type HistoryItemInfo = HistoryItemBase & { type: 'info'; text: string; secondaryText?: string; + source?: string; icon?: string; color?: string; marginBottom?: number; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index efb3e296df..a36d3b7a02 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -908,8 +908,8 @@ export class Config implements McpContext, AgentLoopContext { private readonly acceptRawOutputRisk: boolean; private readonly dynamicModelConfiguration: boolean; private pendingIncludeDirectories: string[]; - private readonly enableHooks: boolean; private readonly enableHooksUI: boolean; + private readonly enableHooks: boolean; private hooks: { [K in HookEventName]?: HookDefinition[] } | undefined; private projectHooks: diff --git a/packages/core/src/hooks/hookEventHandler.ts b/packages/core/src/hooks/hookEventHandler.ts index e7b970875c..24dd77d76e 100644 --- a/packages/core/src/hooks/hookEventHandler.ts +++ b/packages/core/src/hooks/hookEventHandler.ts @@ -458,6 +458,15 @@ export class HookEventHandler { ); logHookCall(this.context.config, hookCallEvent); + + // Emit structured system message event for UI display + if (result.output?.systemMessage && result.outputFormat === 'json') { + coreEvents.emitHookSystemMessage({ + hookName, + eventName, + message: result.output.systemMessage, + }); + } } // Log individual errors diff --git a/packages/core/src/hooks/hookRunner.test.ts b/packages/core/src/hooks/hookRunner.test.ts index 56576ac354..9cee6575fe 100644 --- a/packages/core/src/hooks/hookRunner.test.ts +++ b/packages/core/src/hooks/hookRunner.test.ts @@ -204,7 +204,11 @@ describe('HookRunner', () => { }; it('should execute command hook successfully', async () => { - const mockOutput = { decision: 'allow', reason: 'All good' }; + const mockOutput = { + decision: 'allow', + reason: 'All good', + format: 'json', + }; // Mock successful execution mockSpawn.mockStdoutOn.mockImplementation( @@ -623,6 +627,7 @@ describe('HookRunner', () => { hookSpecificOutput: { additionalContext: 'Context from hook 1', }, + format: 'json', }; let hookCallCount = 0; @@ -803,6 +808,7 @@ describe('HookRunner', () => { expect(result.success).toBe(true); expect(result.exitCode).toBe(0); // Should convert plain text to structured output + expect(result.outputFormat).toBe('text'); expect(result.output).toEqual({ decision: 'allow', systemMessage: invalidJson, @@ -835,6 +841,7 @@ describe('HookRunner', () => { ); expect(result.success).toBe(true); + expect(result.outputFormat).toBe('text'); expect(result.output).toEqual({ decision: 'allow', systemMessage: malformedJson, @@ -868,6 +875,7 @@ describe('HookRunner', () => { expect(result.success).toBe(false); expect(result.exitCode).toBe(1); + expect(result.outputFormat).toBe('text'); expect(result.output).toEqual({ decision: 'allow', systemMessage: `Warning: ${invalidJson}`, @@ -901,6 +909,7 @@ describe('HookRunner', () => { expect(result.success).toBe(false); expect(result.exitCode).toBe(2); + expect(result.outputFormat).toBe('text'); expect(result.output).toEqual({ decision: 'deny', reason: invalidJson, @@ -936,7 +945,11 @@ describe('HookRunner', () => { }); it('should handle double-encoded JSON string', async () => { - const mockOutput = { decision: 'allow', reason: 'All good' }; + const mockOutput = { + decision: 'allow', + reason: 'All good', + format: 'json', + }; const doubleEncodedJson = JSON.stringify(JSON.stringify(mockOutput)); mockSpawn.mockStdoutOn.mockImplementation( diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index 6147dca8eb..812deafcbe 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -447,6 +447,7 @@ export class HookRunner { // Parse output let output: HookOutput | undefined; + let outputFormat: 'json' | 'text' | undefined; const textToParse = stdout.trim() || stderr.trim(); if (textToParse) { @@ -460,6 +461,7 @@ export class HookRunner { if (parsed && typeof parsed === 'object') { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion output = parsed as HookOutput; + outputFormat = 'json'; } } catch { // Not JSON, convert plain text to structured output @@ -467,6 +469,7 @@ export class HookRunner { textToParse, exitCode || EXIT_CODE_SUCCESS, ); + outputFormat = 'text'; } } @@ -475,6 +478,7 @@ export class HookRunner { eventName, success: exitCode === EXIT_CODE_SUCCESS, output, + outputFormat, stdout, stderr, exitCode: exitCode || EXIT_CODE_SUCCESS, @@ -523,7 +527,7 @@ export class HookRunner { exitCode: number, ): HookOutput { if (exitCode === EXIT_CODE_SUCCESS) { - // Success - treat as system message or additional context + // Success return { decision: 'allow', systemMessage: text, diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index 11dbe874e5..418dcde03e 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -734,6 +734,8 @@ export interface HookExecutionResult { exitCode?: number; duration: number; error?: Error; + /** The format of the output provided by the hook */ + outputFormat?: 'json' | 'text'; } /** diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts index bf3d997da1..9548146f9d 100644 --- a/packages/core/src/utils/events.ts +++ b/packages/core/src/utils/events.ts @@ -109,6 +109,13 @@ export interface HookEndPayload extends HookPayload { success: boolean; } +/** + * Payload for the 'hook-system-message' event. + */ +export interface HookSystemMessagePayload extends HookPayload { + message: string; +} + /** * Payload for the 'retry-attempt' event. */ @@ -183,6 +190,7 @@ export enum CoreEvent { SettingsChanged = 'settings-changed', HookStart = 'hook-start', HookEnd = 'hook-end', + HookSystemMessage = 'hook-system-message', AgentsRefreshed = 'agents-refreshed', AdminSettingsChanged = 'admin-settings-changed', RetryAttempt = 'retry-attempt', @@ -217,6 +225,7 @@ export interface CoreEvents extends ExtensionEvents { [CoreEvent.SettingsChanged]: never[]; [CoreEvent.HookStart]: [HookStartPayload]; [CoreEvent.HookEnd]: [HookEndPayload]; + [CoreEvent.HookSystemMessage]: [HookSystemMessagePayload]; [CoreEvent.AgentsRefreshed]: never[]; [CoreEvent.AdminSettingsChanged]: never[]; [CoreEvent.RetryAttempt]: [RetryAttemptPayload]; @@ -339,6 +348,13 @@ export class CoreEventEmitter extends EventEmitter { this.emit(CoreEvent.HookEnd, payload); } + /** + * Notifies subscribers that a hook has provided a system message. + */ + emitHookSystemMessage(payload: HookSystemMessagePayload): void { + this.emit(CoreEvent.HookSystemMessage, payload); + } + /** * Notifies subscribers that agents have been refreshed. */