mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 12:04:56 -07:00
feat(hooks): display hook system messages in UI (#24616)
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user