Skip to content

Commit e432f7c

Browse files
authored
feat(hooks): display hook system messages in UI (#24616)
1 parent 846051f commit e432f7c

File tree

10 files changed

+72
-5
lines changed

10 files changed

+72
-5
lines changed

packages/cli/src/ui/AppContainer.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@ import {
3636
type ConfirmationRequest,
3737
type PermissionConfirmationRequest,
3838
type QuotaStats,
39+
MessageType,
40+
StreamingState,
41+
type HistoryItemInfo,
3942
} from './types.js';
4043
import { checkPermissions } from './hooks/atCommandProcessor.js';
41-
import { MessageType, StreamingState } from './types.js';
4244
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
4345
import { MouseProvider } from './contexts/MouseContext.js';
4446
import { ScrollProvider } from './contexts/ScrollProvider.js';
@@ -51,6 +53,7 @@ import {
5153
type UserTierId,
5254
type GeminiUserTier,
5355
type UserFeedbackPayload,
56+
type HookSystemMessagePayload,
5457
type AgentDefinition,
5558
type ApprovalMode,
5659
IdeClient,
@@ -2111,14 +2114,27 @@ Logging in with Google... Restarting Gemini CLI to continue.
21112114
}
21122115
};
21132116

2117+
const handleHookSystemMessage = (payload: HookSystemMessagePayload) => {
2118+
historyManager.addItem(
2119+
{
2120+
type: MessageType.INFO,
2121+
text: payload.message,
2122+
source: payload.hookName,
2123+
} as HistoryItemInfo,
2124+
Date.now(),
2125+
);
2126+
};
2127+
21142128
coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback);
2129+
coreEvents.on(CoreEvent.HookSystemMessage, handleHookSystemMessage);
21152130

21162131
// Flush any messages that happened during startup before this component
21172132
// mounted.
21182133
coreEvents.drainBacklogs();
21192134

21202135
return () => {
21212136
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
2137+
coreEvents.off(CoreEvent.HookSystemMessage, handleHookSystemMessage);
21222138
};
21232139
}, [historyManager]);
21242140

packages/cli/src/ui/components/HistoryItemDisplay.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
134134
<InfoMessage
135135
text={itemForDisplay.text}
136136
secondaryText={itemForDisplay.secondaryText}
137+
source={itemForDisplay.source}
137138
icon={itemForDisplay.icon}
138139
color={itemForDisplay.color}
139140
marginBottom={itemForDisplay.marginBottom}

packages/cli/src/ui/components/messages/InfoMessage.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
1212
interface InfoMessageProps {
1313
text: string;
1414
secondaryText?: string;
15+
source?: string;
1516
icon?: string;
1617
color?: string;
1718
marginBottom?: number;
@@ -20,6 +21,7 @@ interface InfoMessageProps {
2021
export const InfoMessage: React.FC<InfoMessageProps> = ({
2122
text,
2223
secondaryText,
24+
source,
2325
icon,
2426
color,
2527
marginBottom,
@@ -40,6 +42,9 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({
4042
{index === text.split('\n').length - 1 && secondaryText && (
4143
<Text color={theme.text.secondary}> {secondaryText}</Text>
4244
)}
45+
{index === text.split('\n').length - 1 && source && (
46+
<Text color={theme.text.secondary}> [{source}]</Text>
47+
)}
4348
</Text>
4449
))}
4550
</Box>

packages/cli/src/ui/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ export type HistoryItemInfo = HistoryItemBase & {
174174
type: 'info';
175175
text: string;
176176
secondaryText?: string;
177+
source?: string;
177178
icon?: string;
178179
color?: string;
179180
marginBottom?: number;

packages/core/src/config/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -908,8 +908,8 @@ export class Config implements McpContext, AgentLoopContext {
908908
private readonly acceptRawOutputRisk: boolean;
909909
private readonly dynamicModelConfiguration: boolean;
910910
private pendingIncludeDirectories: string[];
911-
private readonly enableHooks: boolean;
912911
private readonly enableHooksUI: boolean;
912+
private readonly enableHooks: boolean;
913913

914914
private hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
915915
private projectHooks:

packages/core/src/hooks/hookEventHandler.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,15 @@ export class HookEventHandler {
458458
);
459459

460460
logHookCall(this.context.config, hookCallEvent);
461+
462+
// Emit structured system message event for UI display
463+
if (result.output?.systemMessage && result.outputFormat === 'json') {
464+
coreEvents.emitHookSystemMessage({
465+
hookName,
466+
eventName,
467+
message: result.output.systemMessage,
468+
});
469+
}
461470
}
462471

463472
// Log individual errors

packages/core/src/hooks/hookRunner.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,11 @@ describe('HookRunner', () => {
204204
};
205205

206206
it('should execute command hook successfully', async () => {
207-
const mockOutput = { decision: 'allow', reason: 'All good' };
207+
const mockOutput = {
208+
decision: 'allow',
209+
reason: 'All good',
210+
format: 'json',
211+
};
208212

209213
// Mock successful execution
210214
mockSpawn.mockStdoutOn.mockImplementation(
@@ -623,6 +627,7 @@ describe('HookRunner', () => {
623627
hookSpecificOutput: {
624628
additionalContext: 'Context from hook 1',
625629
},
630+
format: 'json',
626631
};
627632

628633
let hookCallCount = 0;
@@ -803,6 +808,7 @@ describe('HookRunner', () => {
803808
expect(result.success).toBe(true);
804809
expect(result.exitCode).toBe(0);
805810
// Should convert plain text to structured output
811+
expect(result.outputFormat).toBe('text');
806812
expect(result.output).toEqual({
807813
decision: 'allow',
808814
systemMessage: invalidJson,
@@ -835,6 +841,7 @@ describe('HookRunner', () => {
835841
);
836842

837843
expect(result.success).toBe(true);
844+
expect(result.outputFormat).toBe('text');
838845
expect(result.output).toEqual({
839846
decision: 'allow',
840847
systemMessage: malformedJson,
@@ -868,6 +875,7 @@ describe('HookRunner', () => {
868875

869876
expect(result.success).toBe(false);
870877
expect(result.exitCode).toBe(1);
878+
expect(result.outputFormat).toBe('text');
871879
expect(result.output).toEqual({
872880
decision: 'allow',
873881
systemMessage: `Warning: ${invalidJson}`,
@@ -901,6 +909,7 @@ describe('HookRunner', () => {
901909

902910
expect(result.success).toBe(false);
903911
expect(result.exitCode).toBe(2);
912+
expect(result.outputFormat).toBe('text');
904913
expect(result.output).toEqual({
905914
decision: 'deny',
906915
reason: invalidJson,
@@ -936,7 +945,11 @@ describe('HookRunner', () => {
936945
});
937946

938947
it('should handle double-encoded JSON string', async () => {
939-
const mockOutput = { decision: 'allow', reason: 'All good' };
948+
const mockOutput = {
949+
decision: 'allow',
950+
reason: 'All good',
951+
format: 'json',
952+
};
940953
const doubleEncodedJson = JSON.stringify(JSON.stringify(mockOutput));
941954

942955
mockSpawn.mockStdoutOn.mockImplementation(

packages/core/src/hooks/hookRunner.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@ export class HookRunner {
447447

448448
// Parse output
449449
let output: HookOutput | undefined;
450+
let outputFormat: 'json' | 'text' | undefined;
450451

451452
const textToParse = stdout.trim() || stderr.trim();
452453
if (textToParse) {
@@ -460,13 +461,15 @@ export class HookRunner {
460461
if (parsed && typeof parsed === 'object') {
461462
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
462463
output = parsed as HookOutput;
464+
outputFormat = 'json';
463465
}
464466
} catch {
465467
// Not JSON, convert plain text to structured output
466468
output = this.convertPlainTextToHookOutput(
467469
textToParse,
468470
exitCode || EXIT_CODE_SUCCESS,
469471
);
472+
outputFormat = 'text';
470473
}
471474
}
472475

@@ -475,6 +478,7 @@ export class HookRunner {
475478
eventName,
476479
success: exitCode === EXIT_CODE_SUCCESS,
477480
output,
481+
outputFormat,
478482
stdout,
479483
stderr,
480484
exitCode: exitCode || EXIT_CODE_SUCCESS,
@@ -523,7 +527,7 @@ export class HookRunner {
523527
exitCode: number,
524528
): HookOutput {
525529
if (exitCode === EXIT_CODE_SUCCESS) {
526-
// Success - treat as system message or additional context
530+
// Success
527531
return {
528532
decision: 'allow',
529533
systemMessage: text,

packages/core/src/hooks/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,8 @@ export interface HookExecutionResult {
734734
exitCode?: number;
735735
duration: number;
736736
error?: Error;
737+
/** The format of the output provided by the hook */
738+
outputFormat?: 'json' | 'text';
737739
}
738740

739741
/**

packages/core/src/utils/events.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ export interface HookEndPayload extends HookPayload {
109109
success: boolean;
110110
}
111111

112+
/**
113+
* Payload for the 'hook-system-message' event.
114+
*/
115+
export interface HookSystemMessagePayload extends HookPayload {
116+
message: string;
117+
}
118+
112119
/**
113120
* Payload for the 'retry-attempt' event.
114121
*/
@@ -183,6 +190,7 @@ export enum CoreEvent {
183190
SettingsChanged = 'settings-changed',
184191
HookStart = 'hook-start',
185192
HookEnd = 'hook-end',
193+
HookSystemMessage = 'hook-system-message',
186194
AgentsRefreshed = 'agents-refreshed',
187195
AdminSettingsChanged = 'admin-settings-changed',
188196
RetryAttempt = 'retry-attempt',
@@ -217,6 +225,7 @@ export interface CoreEvents extends ExtensionEvents {
217225
[CoreEvent.SettingsChanged]: never[];
218226
[CoreEvent.HookStart]: [HookStartPayload];
219227
[CoreEvent.HookEnd]: [HookEndPayload];
228+
[CoreEvent.HookSystemMessage]: [HookSystemMessagePayload];
220229
[CoreEvent.AgentsRefreshed]: never[];
221230
[CoreEvent.AdminSettingsChanged]: never[];
222231
[CoreEvent.RetryAttempt]: [RetryAttemptPayload];
@@ -339,6 +348,13 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
339348
this.emit(CoreEvent.HookEnd, payload);
340349
}
341350

351+
/**
352+
* Notifies subscribers that a hook has provided a system message.
353+
*/
354+
emitHookSystemMessage(payload: HookSystemMessagePayload): void {
355+
this.emit(CoreEvent.HookSystemMessage, payload);
356+
}
357+
342358
/**
343359
* Notifies subscribers that agents have been refreshed.
344360
*/

0 commit comments

Comments
 (0)