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

View File

@@ -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]);

View File

@@ -134,6 +134,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
<InfoMessage
text={itemForDisplay.text}
secondaryText={itemForDisplay.secondaryText}
source={itemForDisplay.source}
icon={itemForDisplay.icon}
color={itemForDisplay.color}
marginBottom={itemForDisplay.marginBottom}

View File

@@ -12,6 +12,7 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface InfoMessageProps {
text: string;
secondaryText?: string;
source?: string;
icon?: string;
color?: string;
marginBottom?: number;
@@ -20,6 +21,7 @@ interface InfoMessageProps {
export const InfoMessage: React.FC<InfoMessageProps> = ({
text,
secondaryText,
source,
icon,
color,
marginBottom,
@@ -40,6 +42,9 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({
{index === text.split('\n').length - 1 && secondaryText && (
<Text color={theme.text.secondary}> {secondaryText}</Text>
)}
{index === text.split('\n').length - 1 && source && (
<Text color={theme.text.secondary}> [{source}]</Text>
)}
</Text>
))}
</Box>

View File

@@ -174,6 +174,7 @@ export type HistoryItemInfo = HistoryItemBase & {
type: 'info';
text: string;
secondaryText?: string;
source?: string;
icon?: string;
color?: string;
marginBottom?: number;

View File

@@ -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:

View File

@@ -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

View File

@@ -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(

View File

@@ -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,

View File

@@ -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';
}
/**

View File

@@ -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<CoreEvents> {
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.
*/