feat(hooks): display hook system messages in UI (#24616)

This commit is contained in:
Michael Bleigh
2026-04-07 10:42:39 -07:00
committed by GitHub
parent 846051f716
commit e432f7c009
10 changed files with 72 additions and 5 deletions
+17 -1
View File
@@ -36,9 +36,11 @@ import {
type ConfirmationRequest, type ConfirmationRequest,
type PermissionConfirmationRequest, type PermissionConfirmationRequest,
type QuotaStats, type QuotaStats,
MessageType,
StreamingState,
type HistoryItemInfo,
} from './types.js'; } from './types.js';
import { checkPermissions } from './hooks/atCommandProcessor.js'; import { checkPermissions } from './hooks/atCommandProcessor.js';
import { MessageType, StreamingState } from './types.js';
import { ToolActionsProvider } from './contexts/ToolActionsContext.js'; import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
import { MouseProvider } from './contexts/MouseContext.js'; import { MouseProvider } from './contexts/MouseContext.js';
import { ScrollProvider } from './contexts/ScrollProvider.js'; import { ScrollProvider } from './contexts/ScrollProvider.js';
@@ -51,6 +53,7 @@ import {
type UserTierId, type UserTierId,
type GeminiUserTier, type GeminiUserTier,
type UserFeedbackPayload, type UserFeedbackPayload,
type HookSystemMessagePayload,
type AgentDefinition, type AgentDefinition,
type ApprovalMode, type ApprovalMode,
IdeClient, 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.UserFeedback, handleUserFeedback);
coreEvents.on(CoreEvent.HookSystemMessage, handleHookSystemMessage);
// Flush any messages that happened during startup before this component // Flush any messages that happened during startup before this component
// mounted. // mounted.
@@ -2119,6 +2134,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
return () => { return () => {
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback); coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
coreEvents.off(CoreEvent.HookSystemMessage, handleHookSystemMessage);
}; };
}, [historyManager]); }, [historyManager]);
@@ -134,6 +134,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
<InfoMessage <InfoMessage
text={itemForDisplay.text} text={itemForDisplay.text}
secondaryText={itemForDisplay.secondaryText} secondaryText={itemForDisplay.secondaryText}
source={itemForDisplay.source}
icon={itemForDisplay.icon} icon={itemForDisplay.icon}
color={itemForDisplay.color} color={itemForDisplay.color}
marginBottom={itemForDisplay.marginBottom} marginBottom={itemForDisplay.marginBottom}
@@ -12,6 +12,7 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface InfoMessageProps { interface InfoMessageProps {
text: string; text: string;
secondaryText?: string; secondaryText?: string;
source?: string;
icon?: string; icon?: string;
color?: string; color?: string;
marginBottom?: number; marginBottom?: number;
@@ -20,6 +21,7 @@ interface InfoMessageProps {
export const InfoMessage: React.FC<InfoMessageProps> = ({ export const InfoMessage: React.FC<InfoMessageProps> = ({
text, text,
secondaryText, secondaryText,
source,
icon, icon,
color, color,
marginBottom, marginBottom,
@@ -40,6 +42,9 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({
{index === text.split('\n').length - 1 && secondaryText && ( {index === text.split('\n').length - 1 && secondaryText && (
<Text color={theme.text.secondary}> {secondaryText}</Text> <Text color={theme.text.secondary}> {secondaryText}</Text>
)} )}
{index === text.split('\n').length - 1 && source && (
<Text color={theme.text.secondary}> [{source}]</Text>
)}
</Text> </Text>
))} ))}
</Box> </Box>
+1
View File
@@ -174,6 +174,7 @@ export type HistoryItemInfo = HistoryItemBase & {
type: 'info'; type: 'info';
text: string; text: string;
secondaryText?: string; secondaryText?: string;
source?: string;
icon?: string; icon?: string;
color?: string; color?: string;
marginBottom?: number; marginBottom?: number;
+1 -1
View File
@@ -908,8 +908,8 @@ export class Config implements McpContext, AgentLoopContext {
private readonly acceptRawOutputRisk: boolean; private readonly acceptRawOutputRisk: boolean;
private readonly dynamicModelConfiguration: boolean; private readonly dynamicModelConfiguration: boolean;
private pendingIncludeDirectories: string[]; private pendingIncludeDirectories: string[];
private readonly enableHooks: boolean;
private readonly enableHooksUI: boolean; private readonly enableHooksUI: boolean;
private readonly enableHooks: boolean;
private hooks: { [K in HookEventName]?: HookDefinition[] } | undefined; private hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
private projectHooks: private projectHooks:
@@ -458,6 +458,15 @@ export class HookEventHandler {
); );
logHookCall(this.context.config, hookCallEvent); 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 // Log individual errors
+15 -2
View File
@@ -204,7 +204,11 @@ describe('HookRunner', () => {
}; };
it('should execute command hook successfully', async () => { 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 // Mock successful execution
mockSpawn.mockStdoutOn.mockImplementation( mockSpawn.mockStdoutOn.mockImplementation(
@@ -623,6 +627,7 @@ describe('HookRunner', () => {
hookSpecificOutput: { hookSpecificOutput: {
additionalContext: 'Context from hook 1', additionalContext: 'Context from hook 1',
}, },
format: 'json',
}; };
let hookCallCount = 0; let hookCallCount = 0;
@@ -803,6 +808,7 @@ describe('HookRunner', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
// Should convert plain text to structured output // Should convert plain text to structured output
expect(result.outputFormat).toBe('text');
expect(result.output).toEqual({ expect(result.output).toEqual({
decision: 'allow', decision: 'allow',
systemMessage: invalidJson, systemMessage: invalidJson,
@@ -835,6 +841,7 @@ describe('HookRunner', () => {
); );
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.outputFormat).toBe('text');
expect(result.output).toEqual({ expect(result.output).toEqual({
decision: 'allow', decision: 'allow',
systemMessage: malformedJson, systemMessage: malformedJson,
@@ -868,6 +875,7 @@ describe('HookRunner', () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
expect(result.outputFormat).toBe('text');
expect(result.output).toEqual({ expect(result.output).toEqual({
decision: 'allow', decision: 'allow',
systemMessage: `Warning: ${invalidJson}`, systemMessage: `Warning: ${invalidJson}`,
@@ -901,6 +909,7 @@ describe('HookRunner', () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.exitCode).toBe(2); expect(result.exitCode).toBe(2);
expect(result.outputFormat).toBe('text');
expect(result.output).toEqual({ expect(result.output).toEqual({
decision: 'deny', decision: 'deny',
reason: invalidJson, reason: invalidJson,
@@ -936,7 +945,11 @@ describe('HookRunner', () => {
}); });
it('should handle double-encoded JSON string', async () => { 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)); const doubleEncodedJson = JSON.stringify(JSON.stringify(mockOutput));
mockSpawn.mockStdoutOn.mockImplementation( mockSpawn.mockStdoutOn.mockImplementation(
+5 -1
View File
@@ -447,6 +447,7 @@ export class HookRunner {
// Parse output // Parse output
let output: HookOutput | undefined; let output: HookOutput | undefined;
let outputFormat: 'json' | 'text' | undefined;
const textToParse = stdout.trim() || stderr.trim(); const textToParse = stdout.trim() || stderr.trim();
if (textToParse) { if (textToParse) {
@@ -460,6 +461,7 @@ export class HookRunner {
if (parsed && typeof parsed === 'object') { if (parsed && typeof parsed === 'object') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
output = parsed as HookOutput; output = parsed as HookOutput;
outputFormat = 'json';
} }
} catch { } catch {
// Not JSON, convert plain text to structured output // Not JSON, convert plain text to structured output
@@ -467,6 +469,7 @@ export class HookRunner {
textToParse, textToParse,
exitCode || EXIT_CODE_SUCCESS, exitCode || EXIT_CODE_SUCCESS,
); );
outputFormat = 'text';
} }
} }
@@ -475,6 +478,7 @@ export class HookRunner {
eventName, eventName,
success: exitCode === EXIT_CODE_SUCCESS, success: exitCode === EXIT_CODE_SUCCESS,
output, output,
outputFormat,
stdout, stdout,
stderr, stderr,
exitCode: exitCode || EXIT_CODE_SUCCESS, exitCode: exitCode || EXIT_CODE_SUCCESS,
@@ -523,7 +527,7 @@ export class HookRunner {
exitCode: number, exitCode: number,
): HookOutput { ): HookOutput {
if (exitCode === EXIT_CODE_SUCCESS) { if (exitCode === EXIT_CODE_SUCCESS) {
// Success - treat as system message or additional context // Success
return { return {
decision: 'allow', decision: 'allow',
systemMessage: text, systemMessage: text,
+2
View File
@@ -734,6 +734,8 @@ export interface HookExecutionResult {
exitCode?: number; exitCode?: number;
duration: number; duration: number;
error?: Error; error?: Error;
/** The format of the output provided by the hook */
outputFormat?: 'json' | 'text';
} }
/** /**
+16
View File
@@ -109,6 +109,13 @@ export interface HookEndPayload extends HookPayload {
success: boolean; success: boolean;
} }
/**
* Payload for the 'hook-system-message' event.
*/
export interface HookSystemMessagePayload extends HookPayload {
message: string;
}
/** /**
* Payload for the 'retry-attempt' event. * Payload for the 'retry-attempt' event.
*/ */
@@ -183,6 +190,7 @@ export enum CoreEvent {
SettingsChanged = 'settings-changed', SettingsChanged = 'settings-changed',
HookStart = 'hook-start', HookStart = 'hook-start',
HookEnd = 'hook-end', HookEnd = 'hook-end',
HookSystemMessage = 'hook-system-message',
AgentsRefreshed = 'agents-refreshed', AgentsRefreshed = 'agents-refreshed',
AdminSettingsChanged = 'admin-settings-changed', AdminSettingsChanged = 'admin-settings-changed',
RetryAttempt = 'retry-attempt', RetryAttempt = 'retry-attempt',
@@ -217,6 +225,7 @@ export interface CoreEvents extends ExtensionEvents {
[CoreEvent.SettingsChanged]: never[]; [CoreEvent.SettingsChanged]: never[];
[CoreEvent.HookStart]: [HookStartPayload]; [CoreEvent.HookStart]: [HookStartPayload];
[CoreEvent.HookEnd]: [HookEndPayload]; [CoreEvent.HookEnd]: [HookEndPayload];
[CoreEvent.HookSystemMessage]: [HookSystemMessagePayload];
[CoreEvent.AgentsRefreshed]: never[]; [CoreEvent.AgentsRefreshed]: never[];
[CoreEvent.AdminSettingsChanged]: never[]; [CoreEvent.AdminSettingsChanged]: never[];
[CoreEvent.RetryAttempt]: [RetryAttemptPayload]; [CoreEvent.RetryAttempt]: [RetryAttemptPayload];
@@ -339,6 +348,13 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
this.emit(CoreEvent.HookEnd, payload); 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. * Notifies subscribers that agents have been refreshed.
*/ */