mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
feat(ui): add visual indicators for hook execution (#15408)
This commit is contained in:
@@ -872,6 +872,10 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
Hooks in this list will not execute even if configured.
|
Hooks in this list will not execute even if configured.
|
||||||
- **Default:** `[]`
|
- **Default:** `[]`
|
||||||
|
|
||||||
|
- **`hooks.notifications`** (boolean):
|
||||||
|
- **Description:** Show visual indicators when hooks are executing.
|
||||||
|
- **Default:** `true`
|
||||||
|
|
||||||
- **`hooks.BeforeTool`** (array):
|
- **`hooks.BeforeTool`** (array):
|
||||||
- **Description:** Hooks that execute before tool execution. Can intercept,
|
- **Description:** Hooks that execute before tool execution. Can intercept,
|
||||||
validate, or modify tool calls.
|
validate, or modify tool calls.
|
||||||
|
|||||||
@@ -357,6 +357,15 @@ describe('SettingsSchema', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should have hooks.notifications setting in schema', () => {
|
||||||
|
const setting = getSettingsSchema().hooks.properties.notifications;
|
||||||
|
expect(setting).toBeDefined();
|
||||||
|
expect(setting.type).toBe('boolean');
|
||||||
|
expect(setting.category).toBe('Advanced');
|
||||||
|
expect(setting.default).toBe(true);
|
||||||
|
expect(setting.showInDialog).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('should have name and description in hook definitions', () => {
|
it('should have name and description in hook definitions', () => {
|
||||||
const hookDef = SETTINGS_SCHEMA_DEFINITIONS['HookDefinitionArray'];
|
const hookDef = SETTINGS_SCHEMA_DEFINITIONS['HookDefinitionArray'];
|
||||||
expect(hookDef).toBeDefined();
|
expect(hookDef).toBeDefined();
|
||||||
|
|||||||
@@ -1559,6 +1559,15 @@ const SETTINGS_SCHEMA = {
|
|||||||
},
|
},
|
||||||
mergeStrategy: MergeStrategy.UNION,
|
mergeStrategy: MergeStrategy.UNION,
|
||||||
},
|
},
|
||||||
|
notifications: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Hook Notifications',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: true,
|
||||||
|
description: 'Show visual indicators when hooks are executing.',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
BeforeTool: {
|
BeforeTool: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
label: 'Before Tool Hooks',
|
label: 'Before Tool Hooks',
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ vi.mock('./contexts/SessionContext.js');
|
|||||||
vi.mock('./components/shared/text-buffer.js');
|
vi.mock('./components/shared/text-buffer.js');
|
||||||
vi.mock('./hooks/useLogger.js');
|
vi.mock('./hooks/useLogger.js');
|
||||||
vi.mock('./hooks/useInputHistoryStore.js');
|
vi.mock('./hooks/useInputHistoryStore.js');
|
||||||
|
vi.mock('./hooks/useHookDisplayState.js');
|
||||||
|
|
||||||
// Mock external utilities
|
// Mock external utilities
|
||||||
vi.mock('../utils/events.js');
|
vi.mock('../utils/events.js');
|
||||||
@@ -171,6 +172,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
|
|||||||
import { useLogger } from './hooks/useLogger.js';
|
import { useLogger } from './hooks/useLogger.js';
|
||||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||||
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||||
|
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||||
import { measureElement } from 'ink';
|
import { measureElement } from 'ink';
|
||||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||||
@@ -243,6 +245,7 @@ describe('AppContainer State Management', () => {
|
|||||||
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
|
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
|
||||||
const mockedUseKeypress = useKeypress as Mock;
|
const mockedUseKeypress = useKeypress as Mock;
|
||||||
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
|
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
|
||||||
|
const mockedUseHookDisplayState = useHookDisplayState as Mock;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -363,6 +366,7 @@ describe('AppContainer State Management', () => {
|
|||||||
elapsedTime: '0.0s',
|
elapsedTime: '0.0s',
|
||||||
currentLoadingPhrase: '',
|
currentLoadingPhrase: '',
|
||||||
});
|
});
|
||||||
|
mockedUseHookDisplayState.mockReturnValue([]);
|
||||||
|
|
||||||
// Mock Config
|
// Mock Config
|
||||||
mockConfig = makeFakeConfig();
|
mockConfig = makeFakeConfig();
|
||||||
@@ -1874,6 +1878,21 @@ describe('AppContainer State Management', () => {
|
|||||||
expect(capturedUIState.currentModel).toBe('new-model');
|
expect(capturedUIState.currentModel).toBe('new-model');
|
||||||
unmount!();
|
unmount!();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('provides activeHooks from useHookDisplayState', async () => {
|
||||||
|
const mockHooks = [{ name: 'hook1', eventName: 'event1' }];
|
||||||
|
mockedUseHookDisplayState.mockReturnValue(mockHooks);
|
||||||
|
|
||||||
|
let unmount: () => void;
|
||||||
|
await act(async () => {
|
||||||
|
const result = renderAppContainer();
|
||||||
|
unmount = result.unmount;
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||||
|
|
||||||
|
expect(capturedUIState.activeHooks).toEqual(mockHooks);
|
||||||
|
unmount!();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Shell Interaction', () => {
|
describe('Shell Interaction', () => {
|
||||||
|
|||||||
@@ -123,9 +123,11 @@ import { useSettings } from './contexts/SettingsContext.js';
|
|||||||
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
|
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
|
||||||
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||||
import { useBanner } from './hooks/useBanner.js';
|
import { useBanner } from './hooks/useBanner.js';
|
||||||
|
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||||
const WARNING_PROMPT_DURATION_MS = 1000;
|
import {
|
||||||
const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
|
WARNING_PROMPT_DURATION_MS,
|
||||||
|
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||||
|
} from './constants.js';
|
||||||
|
|
||||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||||
return pendingHistoryItems.some((item) => {
|
return pendingHistoryItems.some((item) => {
|
||||||
@@ -189,6 +191,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
useState<boolean>(false);
|
useState<boolean>(false);
|
||||||
const [historyRemountKey, setHistoryRemountKey] = useState(0);
|
const [historyRemountKey, setHistoryRemountKey] = useState(0);
|
||||||
const [settingsNonce, setSettingsNonce] = useState(0);
|
const [settingsNonce, setSettingsNonce] = useState(0);
|
||||||
|
const activeHooks = useHookDisplayState();
|
||||||
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
|
||||||
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
|
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
|
||||||
isWorkspaceTrusted(settings.merged).isTrusted,
|
isWorkspaceTrusted(settings.merged).isTrusted,
|
||||||
@@ -1522,6 +1525,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
elapsedTime,
|
elapsedTime,
|
||||||
currentLoadingPhrase,
|
currentLoadingPhrase,
|
||||||
historyRemountKey,
|
historyRemountKey,
|
||||||
|
activeHooks,
|
||||||
messageQueue,
|
messageQueue,
|
||||||
queueErrorMessage,
|
queueErrorMessage,
|
||||||
showAutoAcceptIndicator,
|
showAutoAcceptIndicator,
|
||||||
@@ -1612,6 +1616,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
elapsedTime,
|
elapsedTime,
|
||||||
currentLoadingPhrase,
|
currentLoadingPhrase,
|
||||||
historyRemountKey,
|
historyRemountKey,
|
||||||
|
activeHooks,
|
||||||
messageQueue,
|
messageQueue,
|
||||||
queueErrorMessage,
|
queueErrorMessage,
|
||||||
showAutoAcceptIndicator,
|
showAutoAcceptIndicator,
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ vi.mock('./ContextSummaryDisplay.js', () => ({
|
|||||||
ContextSummaryDisplay: () => <Text>ContextSummaryDisplay</Text>,
|
ContextSummaryDisplay: () => <Text>ContextSummaryDisplay</Text>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('./HookStatusDisplay.js', () => ({
|
||||||
|
HookStatusDisplay: () => <Text>HookStatusDisplay</Text>,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('./AutoAcceptIndicator.js', () => ({
|
vi.mock('./AutoAcceptIndicator.js', () => ({
|
||||||
AutoAcceptIndicator: () => <Text>AutoAcceptIndicator</Text>,
|
AutoAcceptIndicator: () => <Text>AutoAcceptIndicator</Text>,
|
||||||
}));
|
}));
|
||||||
@@ -125,6 +129,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
|||||||
errorCount: 0,
|
errorCount: 0,
|
||||||
nightly: false,
|
nightly: false,
|
||||||
isTrustedFolder: true,
|
isTrustedFolder: true,
|
||||||
|
activeHooks: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
}) as UIState;
|
}) as UIState;
|
||||||
|
|
||||||
@@ -341,6 +346,17 @@ describe('Composer', () => {
|
|||||||
expect(lastFrame()).toContain('ContextSummaryDisplay');
|
expect(lastFrame()).toContain('ContextSummaryDisplay');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders HookStatusDisplay instead of ContextSummaryDisplay with active hooks', () => {
|
||||||
|
const uiState = createMockUIState({
|
||||||
|
activeHooks: [{ name: 'test-hook', eventName: 'before-agent' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { lastFrame } = renderComposer(uiState);
|
||||||
|
|
||||||
|
expect(lastFrame()).toContain('HookStatusDisplay');
|
||||||
|
expect(lastFrame()).not.toContain('ContextSummaryDisplay');
|
||||||
|
});
|
||||||
|
|
||||||
it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => {
|
it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => {
|
||||||
const uiState = createMockUIState({
|
const uiState = createMockUIState({
|
||||||
ctrlCPressedOnce: true,
|
ctrlCPressedOnce: true,
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
import { Box, useIsScreenReaderEnabled } from 'ink';
|
||||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||||
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
|
import { StatusDisplay } from './StatusDisplay.js';
|
||||||
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
|
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
|
||||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||||
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
||||||
@@ -17,7 +17,6 @@ import { Footer } from './Footer.js';
|
|||||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||||
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
|
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
|
||||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
|
||||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||||
@@ -43,7 +42,7 @@ export const Composer = () => {
|
|||||||
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
|
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
|
||||||
|
|
||||||
const isAlternateBuffer = useAlternateBuffer();
|
const isAlternateBuffer = useAlternateBuffer();
|
||||||
const { contextFileNames, showAutoAcceptIndicator } = uiState;
|
const { showAutoAcceptIndicator } = uiState;
|
||||||
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
|
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
|
||||||
const hideContextSummary =
|
const hideContextSummary =
|
||||||
suggestionsVisible && suggestionsPosition === 'above';
|
suggestionsVisible && suggestionsPosition === 'above';
|
||||||
@@ -92,38 +91,7 @@ export const Composer = () => {
|
|||||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||||
>
|
>
|
||||||
<Box marginRight={1}>
|
<Box marginRight={1}>
|
||||||
{process.env['GEMINI_SYSTEM_MD'] && (
|
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||||
<Text color={theme.status.error}>|⌐■_■| </Text>
|
|
||||||
)}
|
|
||||||
{uiState.ctrlCPressedOnce ? (
|
|
||||||
<Text color={theme.status.warning}>
|
|
||||||
Press Ctrl+C again to exit.
|
|
||||||
</Text>
|
|
||||||
) : uiState.warningMessage ? (
|
|
||||||
<Text color={theme.status.warning}>{uiState.warningMessage}</Text>
|
|
||||||
) : uiState.ctrlDPressedOnce ? (
|
|
||||||
<Text color={theme.status.warning}>
|
|
||||||
Press Ctrl+D again to exit.
|
|
||||||
</Text>
|
|
||||||
) : uiState.showEscapePrompt ? (
|
|
||||||
<Text color={theme.text.secondary}>Press Esc again to clear.</Text>
|
|
||||||
) : uiState.queueErrorMessage ? (
|
|
||||||
<Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>
|
|
||||||
) : (
|
|
||||||
!settings.merged.ui?.hideContextSummary &&
|
|
||||||
!hideContextSummary && (
|
|
||||||
<ContextSummaryDisplay
|
|
||||||
ideContext={uiState.ideContextState}
|
|
||||||
geminiMdFileCount={uiState.geminiMdFileCount}
|
|
||||||
contextFileNames={contextFileNames}
|
|
||||||
mcpServers={config.getMcpClientManager()?.getMcpServers() ?? {}}
|
|
||||||
blockedMcpServers={
|
|
||||||
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
|
|
||||||
}
|
|
||||||
skillCount={config.getSkillManager().getSkills().length}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box paddingTop={isNarrow ? 1 : 0}>
|
<Box paddingTop={isNarrow ? 1 : 0}>
|
||||||
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
|
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { render } from '../../test-utils/render.js';
|
import { render } from '../../test-utils/render.js';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
|
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
|
||||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||||
|
|
||||||
@@ -16,6 +16,11 @@ vi.mock('../hooks/useTerminalSize.js', () => ({
|
|||||||
|
|
||||||
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
const renderWithWidth = (
|
const renderWithWidth = (
|
||||||
width: number,
|
width: number,
|
||||||
props: React.ComponentProps<typeof ContextSummaryDisplay>,
|
props: React.ComponentProps<typeof ContextSummaryDisplay>,
|
||||||
@@ -26,6 +31,20 @@ const renderWithWidth = (
|
|||||||
|
|
||||||
describe('<ContextSummaryDisplay />', () => {
|
describe('<ContextSummaryDisplay />', () => {
|
||||||
const baseProps = {
|
const baseProps = {
|
||||||
|
geminiMdFileCount: 0,
|
||||||
|
contextFileNames: [],
|
||||||
|
mcpServers: {},
|
||||||
|
ideContext: {
|
||||||
|
workspaceState: {
|
||||||
|
openFiles: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
skillCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should render on a single line on a wide screen', () => {
|
||||||
|
const props = {
|
||||||
|
...baseProps,
|
||||||
geminiMdFileCount: 1,
|
geminiMdFileCount: 1,
|
||||||
contextFileNames: ['GEMINI.md'],
|
contextFileNames: ['GEMINI.md'],
|
||||||
mcpServers: { 'test-server': { command: 'test' } },
|
mcpServers: { 'test-server': { command: 'test' } },
|
||||||
@@ -34,40 +53,46 @@ describe('<ContextSummaryDisplay />', () => {
|
|||||||
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
|
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
skillCount: 1,
|
|
||||||
};
|
};
|
||||||
|
const { lastFrame, unmount } = renderWithWidth(120, props);
|
||||||
it('should render on a single line on a wide screen', () => {
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
const { lastFrame, unmount } = renderWithWidth(120, baseProps);
|
|
||||||
const output = lastFrame()!;
|
|
||||||
expect(output).toContain(
|
|
||||||
'1 open file (ctrl+g to view) | 1 GEMINI.md file | 1 MCP server | 1 skill',
|
|
||||||
);
|
|
||||||
expect(output).not.toContain('Using:');
|
|
||||||
// Check for absence of newlines
|
|
||||||
expect(output.includes('\n')).toBe(false);
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render on multiple lines on a narrow screen', () => {
|
it('should render on multiple lines on a narrow screen', () => {
|
||||||
const { lastFrame, unmount } = renderWithWidth(60, baseProps);
|
const props = {
|
||||||
const output = lastFrame()!;
|
...baseProps,
|
||||||
const expectedLines = [
|
geminiMdFileCount: 1,
|
||||||
' - 1 open file (ctrl+g to view)',
|
contextFileNames: ['GEMINI.md'],
|
||||||
' - 1 GEMINI.md file',
|
mcpServers: { 'test-server': { command: 'test' } },
|
||||||
' - 1 MCP server',
|
ideContext: {
|
||||||
' - 1 skill',
|
workspaceState: {
|
||||||
];
|
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
|
||||||
const actualLines = output.split('\n');
|
},
|
||||||
expect(actualLines).toEqual(expectedLines);
|
},
|
||||||
|
};
|
||||||
|
const { lastFrame, unmount } = renderWithWidth(60, props);
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should switch layout at the 80-column breakpoint', () => {
|
it('should switch layout at the 80-column breakpoint', () => {
|
||||||
|
const props = {
|
||||||
|
...baseProps,
|
||||||
|
geminiMdFileCount: 1,
|
||||||
|
contextFileNames: ['GEMINI.md'],
|
||||||
|
mcpServers: { 'test-server': { command: 'test' } },
|
||||||
|
ideContext: {
|
||||||
|
workspaceState: {
|
||||||
|
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// At 80 columns, should be on one line
|
// At 80 columns, should be on one line
|
||||||
const { lastFrame: wideFrame, unmount: unmountWide } = renderWithWidth(
|
const { lastFrame: wideFrame, unmount: unmountWide } = renderWithWidth(
|
||||||
80,
|
80,
|
||||||
baseProps,
|
props,
|
||||||
);
|
);
|
||||||
expect(wideFrame()!.includes('\n')).toBe(false);
|
expect(wideFrame()!.includes('\n')).toBe(false);
|
||||||
unmountWide();
|
unmountWide();
|
||||||
@@ -75,13 +100,12 @@ describe('<ContextSummaryDisplay />', () => {
|
|||||||
// At 79 columns, should be on multiple lines
|
// At 79 columns, should be on multiple lines
|
||||||
const { lastFrame: narrowFrame, unmount: unmountNarrow } = renderWithWidth(
|
const { lastFrame: narrowFrame, unmount: unmountNarrow } = renderWithWidth(
|
||||||
79,
|
79,
|
||||||
baseProps,
|
props,
|
||||||
);
|
);
|
||||||
expect(narrowFrame()!.includes('\n')).toBe(true);
|
expect(narrowFrame()!.includes('\n')).toBe(true);
|
||||||
expect(narrowFrame()!.split('\n').length).toBe(4);
|
expect(narrowFrame()!.split('\n').length).toBe(4);
|
||||||
unmountNarrow();
|
unmountNarrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not render empty parts', () => {
|
it('should not render empty parts', () => {
|
||||||
const props = {
|
const props = {
|
||||||
...baseProps,
|
...baseProps,
|
||||||
@@ -89,11 +113,14 @@ describe('<ContextSummaryDisplay />', () => {
|
|||||||
contextFileNames: [],
|
contextFileNames: [],
|
||||||
mcpServers: {},
|
mcpServers: {},
|
||||||
skillCount: 0,
|
skillCount: 0,
|
||||||
|
ideContext: {
|
||||||
|
workspaceState: {
|
||||||
|
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const { lastFrame, unmount } = renderWithWidth(60, props);
|
const { lastFrame, unmount } = renderWithWidth(60, props);
|
||||||
const expectedLines = [' - 1 open file (ctrl+g to view)'];
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
const actualLines = lastFrame()!.split('\n');
|
|
||||||
expect(actualLines).toEqual(expectedLines);
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render } from '../../test-utils/render.js';
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { HookStatusDisplay } from './HookStatusDisplay.js';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('<HookStatusDisplay />', () => {
|
||||||
|
it('should render a single executing hook', () => {
|
||||||
|
const props = {
|
||||||
|
activeHooks: [{ name: 'test-hook', eventName: 'BeforeAgent' }],
|
||||||
|
};
|
||||||
|
const { lastFrame, unmount } = render(<HookStatusDisplay {...props} />);
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render multiple executing hooks', () => {
|
||||||
|
const props = {
|
||||||
|
activeHooks: [
|
||||||
|
{ name: 'h1', eventName: 'BeforeAgent' },
|
||||||
|
{ name: 'h2', eventName: 'BeforeAgent' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { lastFrame, unmount } = render(<HookStatusDisplay {...props} />);
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render sequential hook progress', () => {
|
||||||
|
const props = {
|
||||||
|
activeHooks: [
|
||||||
|
{ name: 'step', eventName: 'BeforeAgent', index: 1, total: 3 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { lastFrame, unmount } = render(<HookStatusDisplay {...props} />);
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string if no active hooks', () => {
|
||||||
|
const props = { activeHooks: [] };
|
||||||
|
const { lastFrame, unmount } = render(<HookStatusDisplay {...props} />);
|
||||||
|
expect(lastFrame()).toBe('');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { Text } from 'ink';
|
||||||
|
import { theme } from '../semantic-colors.js';
|
||||||
|
import { type ActiveHook } from '../types.js';
|
||||||
|
|
||||||
|
interface HookStatusDisplayProps {
|
||||||
|
activeHooks: ActiveHook[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HookStatusDisplay: React.FC<HookStatusDisplayProps> = ({
|
||||||
|
activeHooks,
|
||||||
|
}) => {
|
||||||
|
if (activeHooks.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = activeHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||||
|
const displayNames = activeHooks.map((hook) => {
|
||||||
|
let name = hook.name;
|
||||||
|
if (hook.index && hook.total && hook.total > 1) {
|
||||||
|
name += ` (${hook.index}/${hook.total})`;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = `${label}: ${displayNames.join(', ')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text color={theme.status.warning} wrap="truncate">
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { render } from '../../test-utils/render.js';
|
||||||
|
import { Text } from 'ink';
|
||||||
|
import { StatusDisplay } from './StatusDisplay.js';
|
||||||
|
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
|
||||||
|
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||||
|
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||||
|
|
||||||
|
// Mock child components to simplify testing
|
||||||
|
vi.mock('./ContextSummaryDisplay.js', () => ({
|
||||||
|
ContextSummaryDisplay: (props: { skillCount: number }) => (
|
||||||
|
<Text>Mock Context Summary Display (Skills: {props.skillCount})</Text>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./HookStatusDisplay.js', () => ({
|
||||||
|
HookStatusDisplay: () => <Text>Mock Hook Status Display</Text>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Create mock context providers
|
||||||
|
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||||
|
({
|
||||||
|
ctrlCPressedOnce: false,
|
||||||
|
warningMessage: null,
|
||||||
|
ctrlDPressedOnce: false,
|
||||||
|
showEscapePrompt: false,
|
||||||
|
queueErrorMessage: null,
|
||||||
|
activeHooks: [],
|
||||||
|
ideContextState: null,
|
||||||
|
geminiMdFileCount: 0,
|
||||||
|
contextFileNames: [],
|
||||||
|
...overrides,
|
||||||
|
}) as UIState;
|
||||||
|
|
||||||
|
const createMockConfig = (overrides = {}) => ({
|
||||||
|
getMcpClientManager: vi.fn().mockImplementation(() => ({
|
||||||
|
getBlockedMcpServers: vi.fn(() => []),
|
||||||
|
getMcpServers: vi.fn(() => ({})),
|
||||||
|
})),
|
||||||
|
getSkillManager: vi.fn().mockImplementation(() => ({
|
||||||
|
getSkills: vi.fn(() => ['skill1', 'skill2']),
|
||||||
|
})),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMockSettings = (merged = {}) => ({
|
||||||
|
merged: {
|
||||||
|
hooks: { notifications: true },
|
||||||
|
ui: { hideContextSummary: false },
|
||||||
|
...merged,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
const renderStatusDisplay = (
|
||||||
|
props: { hideContextSummary: boolean } = { hideContextSummary: false },
|
||||||
|
uiState: UIState = createMockUIState(),
|
||||||
|
settings = createMockSettings(),
|
||||||
|
config = createMockConfig(),
|
||||||
|
) =>
|
||||||
|
render(
|
||||||
|
<ConfigContext.Provider value={config as any}>
|
||||||
|
<SettingsContext.Provider value={settings as any}>
|
||||||
|
<UIStateContext.Provider value={uiState}>
|
||||||
|
<StatusDisplay {...props} />
|
||||||
|
</UIStateContext.Provider>
|
||||||
|
</SettingsContext.Provider>
|
||||||
|
</ConfigContext.Provider>,
|
||||||
|
);
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
describe('StatusDisplay', () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
delete process.env['GEMINI_SYSTEM_MD'];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing by default if context summary is hidden via props', () => {
|
||||||
|
const { lastFrame } = renderStatusDisplay({ hideContextSummary: true });
|
||||||
|
expect(lastFrame()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ContextSummaryDisplay by default', () => {
|
||||||
|
const { lastFrame } = renderStatusDisplay();
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders system md indicator if env var is set', () => {
|
||||||
|
process.env['GEMINI_SYSTEM_MD'] = 'true';
|
||||||
|
const { lastFrame } = renderStatusDisplay();
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prioritizes Ctrl+C prompt over everything else (except system md)', () => {
|
||||||
|
const uiState = createMockUIState({
|
||||||
|
ctrlCPressedOnce: true,
|
||||||
|
warningMessage: 'Warning',
|
||||||
|
activeHooks: [{ name: 'hook', eventName: 'event' }],
|
||||||
|
});
|
||||||
|
const { lastFrame } = renderStatusDisplay(
|
||||||
|
{ hideContextSummary: false },
|
||||||
|
uiState,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders warning message', () => {
|
||||||
|
const uiState = createMockUIState({
|
||||||
|
warningMessage: 'This is a warning',
|
||||||
|
});
|
||||||
|
const { lastFrame } = renderStatusDisplay(
|
||||||
|
{ hideContextSummary: false },
|
||||||
|
uiState,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prioritizes warning over Ctrl+D', () => {
|
||||||
|
const uiState = createMockUIState({
|
||||||
|
warningMessage: 'Warning',
|
||||||
|
ctrlDPressedOnce: true,
|
||||||
|
});
|
||||||
|
const { lastFrame } = renderStatusDisplay(
|
||||||
|
{ hideContextSummary: false },
|
||||||
|
uiState,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Ctrl+D prompt', () => {
|
||||||
|
const uiState = createMockUIState({
|
||||||
|
ctrlDPressedOnce: true,
|
||||||
|
});
|
||||||
|
const { lastFrame } = renderStatusDisplay(
|
||||||
|
{ hideContextSummary: false },
|
||||||
|
uiState,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Escape prompt', () => {
|
||||||
|
const uiState = createMockUIState({
|
||||||
|
showEscapePrompt: true,
|
||||||
|
});
|
||||||
|
const { lastFrame } = renderStatusDisplay(
|
||||||
|
{ hideContextSummary: false },
|
||||||
|
uiState,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Queue Error Message', () => {
|
||||||
|
const uiState = createMockUIState({
|
||||||
|
queueErrorMessage: 'Queue Error',
|
||||||
|
});
|
||||||
|
const { lastFrame } = renderStatusDisplay(
|
||||||
|
{ hideContextSummary: false },
|
||||||
|
uiState,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders HookStatusDisplay when hooks are active', () => {
|
||||||
|
const uiState = createMockUIState({
|
||||||
|
activeHooks: [{ name: 'hook', eventName: 'event' }],
|
||||||
|
});
|
||||||
|
const { lastFrame } = renderStatusDisplay(
|
||||||
|
{ hideContextSummary: false },
|
||||||
|
uiState,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT render HookStatusDisplay if notifications are disabled in settings', () => {
|
||||||
|
const uiState = createMockUIState({
|
||||||
|
activeHooks: [{ name: 'hook', eventName: 'event' }],
|
||||||
|
});
|
||||||
|
const settings = createMockSettings({
|
||||||
|
hooks: { notifications: false },
|
||||||
|
});
|
||||||
|
const { lastFrame } = renderStatusDisplay(
|
||||||
|
{ hideContextSummary: false },
|
||||||
|
uiState,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides ContextSummaryDisplay if configured in settings', () => {
|
||||||
|
const settings = createMockSettings({
|
||||||
|
ui: { hideContextSummary: true },
|
||||||
|
});
|
||||||
|
const { lastFrame } = renderStatusDisplay(
|
||||||
|
{ hideContextSummary: false },
|
||||||
|
undefined,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { Text } from 'ink';
|
||||||
|
import { theme } from '../semantic-colors.js';
|
||||||
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
|
import { useConfig } from '../contexts/ConfigContext.js';
|
||||||
|
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
|
||||||
|
import { HookStatusDisplay } from './HookStatusDisplay.js';
|
||||||
|
|
||||||
|
interface StatusDisplayProps {
|
||||||
|
hideContextSummary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
||||||
|
hideContextSummary,
|
||||||
|
}) => {
|
||||||
|
const uiState = useUIState();
|
||||||
|
const settings = useSettings();
|
||||||
|
const config = useConfig();
|
||||||
|
|
||||||
|
if (process.env['GEMINI_SYSTEM_MD']) {
|
||||||
|
return <Text color={theme.status.error}>|⌐■_■| </Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.ctrlCPressedOnce) {
|
||||||
|
return (
|
||||||
|
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.warningMessage) {
|
||||||
|
return <Text color={theme.status.warning}>{uiState.warningMessage}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.ctrlDPressedOnce) {
|
||||||
|
return (
|
||||||
|
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.showEscapePrompt) {
|
||||||
|
return <Text color={theme.text.secondary}>Press Esc again to clear.</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.queueErrorMessage) {
|
||||||
|
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
uiState.activeHooks.length > 0 &&
|
||||||
|
(settings.merged.hooks?.notifications ?? true)
|
||||||
|
) {
|
||||||
|
return <HookStatusDisplay activeHooks={uiState.activeHooks} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings.merged.ui?.hideContextSummary && !hideContextSummary) {
|
||||||
|
return (
|
||||||
|
<ContextSummaryDisplay
|
||||||
|
ideContext={uiState.ideContextState}
|
||||||
|
geminiMdFileCount={uiState.geminiMdFileCount}
|
||||||
|
contextFileNames={uiState.contextFileNames}
|
||||||
|
mcpServers={config.getMcpClientManager()?.getMcpServers() ?? {}}
|
||||||
|
blockedMcpServers={
|
||||||
|
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
|
||||||
|
}
|
||||||
|
skillCount={config.getSkillManager().getSkills().length}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`<ContextSummaryDisplay /> > should not render empty parts 1`] = `" - 1 open file (ctrl+g to view)"`;
|
||||||
|
|
||||||
|
exports[`<ContextSummaryDisplay /> > should render on a single line on a wide screen 1`] = `" 1 open file (ctrl+g to view) | 1 GEMINI.md file | 1 MCP server | 1 skill"`;
|
||||||
|
|
||||||
|
exports[`<ContextSummaryDisplay /> > should render on multiple lines on a narrow screen 1`] = `
|
||||||
|
" - 1 open file (ctrl+g to view)
|
||||||
|
- 1 GEMINI.md file
|
||||||
|
- 1 MCP server
|
||||||
|
- 1 skill"
|
||||||
|
`;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`<HookStatusDisplay /> > should render a single executing hook 1`] = `"Executing Hook: test-hook"`;
|
||||||
|
|
||||||
|
exports[`<HookStatusDisplay /> > should render multiple executing hooks 1`] = `"Executing Hooks: h1, h2"`;
|
||||||
|
|
||||||
|
exports[`<HookStatusDisplay /> > should render sequential hook progress 1`] = `"Executing Hook: step (1/3)"`;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `"Mock Context Summary Display (Skills: 2)"`;
|
||||||
|
|
||||||
|
exports[`StatusDisplay > prioritizes Ctrl+C prompt over everything else (except system md) 1`] = `"Press Ctrl+C again to exit."`;
|
||||||
|
|
||||||
|
exports[`StatusDisplay > prioritizes warning over Ctrl+D 1`] = `"Warning"`;
|
||||||
|
|
||||||
|
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2)"`;
|
||||||
|
|
||||||
|
exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
|
||||||
|
|
||||||
|
exports[`StatusDisplay > renders Escape prompt 1`] = `"Press Esc again to clear."`;
|
||||||
|
|
||||||
|
exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `"Mock Hook Status Display"`;
|
||||||
|
|
||||||
|
exports[`StatusDisplay > renders Queue Error Message 1`] = `"Queue Error"`;
|
||||||
|
|
||||||
|
exports[`StatusDisplay > renders system md indicator if env var is set 1`] = `"|⌐■_■|"`;
|
||||||
|
|
||||||
|
exports[`StatusDisplay > renders warning message 1`] = `"This is a warning"`;
|
||||||
@@ -28,3 +28,6 @@ export const TOOL_STATUS = {
|
|||||||
|
|
||||||
// Maximum number of MCP resources to display per server before truncating
|
// Maximum number of MCP resources to display per server before truncating
|
||||||
export const MAX_MCP_RESOURCES_TO_SHOW = 10;
|
export const MAX_MCP_RESOURCES_TO_SHOW = 10;
|
||||||
|
|
||||||
|
export const WARNING_PROMPT_DURATION_MS = 1000;
|
||||||
|
export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
LoopDetectionConfirmationRequest,
|
LoopDetectionConfirmationRequest,
|
||||||
HistoryItemWithoutId,
|
HistoryItemWithoutId,
|
||||||
StreamingState,
|
StreamingState,
|
||||||
|
ActiveHook,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||||
@@ -96,6 +97,7 @@ export interface UIState {
|
|||||||
elapsedTime: number;
|
elapsedTime: number;
|
||||||
currentLoadingPhrase: string;
|
currentLoadingPhrase: string;
|
||||||
historyRemountKey: number;
|
historyRemountKey: number;
|
||||||
|
activeHooks: ActiveHook[];
|
||||||
messageQueue: string[];
|
messageQueue: string[];
|
||||||
queueErrorMessage: string | null;
|
queueErrorMessage: string | null;
|
||||||
showAutoAcceptIndicator: ApprovalMode;
|
showAutoAcceptIndicator: ApprovalMode;
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderHook } from '../../test-utils/render.js';
|
||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
|
import { useHookDisplayState } from './useHookDisplayState.js';
|
||||||
|
import {
|
||||||
|
coreEvents,
|
||||||
|
CoreEvent,
|
||||||
|
type HookStartPayload,
|
||||||
|
type HookEndPayload,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import { act } from 'react';
|
||||||
|
|
||||||
|
describe('useHookDisplayState', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
coreEvents.removeAllListeners(CoreEvent.HookStart);
|
||||||
|
coreEvents.removeAllListeners(CoreEvent.HookEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with empty hooks', () => {
|
||||||
|
const { result } = renderHook(() => useHookDisplayState());
|
||||||
|
expect(result.current).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add a hook when HookStart event is emitted', () => {
|
||||||
|
const { result } = renderHook(() => useHookDisplayState());
|
||||||
|
|
||||||
|
const payload: HookStartPayload = {
|
||||||
|
hookName: 'test-hook',
|
||||||
|
eventName: 'before-agent',
|
||||||
|
hookIndex: 1,
|
||||||
|
totalHooks: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
coreEvents.emitHookStart(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toHaveLength(1);
|
||||||
|
expect(result.current[0]).toMatchObject({
|
||||||
|
name: 'test-hook',
|
||||||
|
eventName: 'before-agent',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove a hook immediately if duration > 1s', () => {
|
||||||
|
const { result } = renderHook(() => useHookDisplayState());
|
||||||
|
|
||||||
|
const startPayload: HookStartPayload = {
|
||||||
|
hookName: 'test-hook',
|
||||||
|
eventName: 'before-agent',
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
coreEvents.emitHookStart(startPayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advance time by 1.1 seconds
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPayload: HookEndPayload = {
|
||||||
|
hookName: 'test-hook',
|
||||||
|
eventName: 'before-agent',
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
coreEvents.emitHookEnd(endPayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delay removal if duration < 1s', () => {
|
||||||
|
const { result } = renderHook(() => useHookDisplayState());
|
||||||
|
|
||||||
|
const startPayload: HookStartPayload = {
|
||||||
|
hookName: 'test-hook',
|
||||||
|
eventName: 'before-agent',
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
coreEvents.emitHookStart(startPayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advance time by only 100ms
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPayload: HookEndPayload = {
|
||||||
|
hookName: 'test-hook',
|
||||||
|
eventName: 'before-agent',
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
coreEvents.emitHookEnd(endPayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should still be present
|
||||||
|
expect(result.current).toHaveLength(1);
|
||||||
|
|
||||||
|
// Advance remaining time (900ms needed, let's go 950ms)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(950);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple hooks correctly', () => {
|
||||||
|
const { result } = renderHook(() => useHookDisplayState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
coreEvents.emitHookStart({ hookName: 'h1', eventName: 'e1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
coreEvents.emitHookStart({ hookName: 'h2', eventName: 'e1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toHaveLength(2);
|
||||||
|
|
||||||
|
// End h1 (total time 500ms -> needs 500ms delay)
|
||||||
|
act(() => {
|
||||||
|
coreEvents.emitHookEnd({
|
||||||
|
hookName: 'h1',
|
||||||
|
eventName: 'e1',
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// h1 still there
|
||||||
|
expect(result.current).toHaveLength(2);
|
||||||
|
|
||||||
|
// Advance 600ms. h1 should disappear. h2 has been running for 600ms.
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toHaveLength(1);
|
||||||
|
expect(result.current[0].name).toBe('h2');
|
||||||
|
|
||||||
|
// End h2 (total time 600ms -> needs 400ms delay)
|
||||||
|
act(() => {
|
||||||
|
coreEvents.emitHookEnd({
|
||||||
|
hookName: 'h2',
|
||||||
|
eventName: 'e1',
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toHaveLength(1);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle interleaved hooks with same name and event', () => {
|
||||||
|
const { result } = renderHook(() => useHookDisplayState());
|
||||||
|
const hook = { hookName: 'same-hook', eventName: 'same-event' };
|
||||||
|
|
||||||
|
// Start Hook 1 at t=0
|
||||||
|
act(() => {
|
||||||
|
coreEvents.emitHookStart(hook);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advance to t=500
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start Hook 2 at t=500
|
||||||
|
act(() => {
|
||||||
|
coreEvents.emitHookStart(hook);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toHaveLength(2);
|
||||||
|
expect(result.current[0].name).toBe('same-hook');
|
||||||
|
expect(result.current[1].name).toBe('same-hook');
|
||||||
|
|
||||||
|
// End Hook 1 at t=600 (Duration 600ms -> delay 400ms)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
coreEvents.emitHookEnd({ ...hook, success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both still visible (Hook 1 pending removal in 400ms)
|
||||||
|
expect(result.current).toHaveLength(2);
|
||||||
|
|
||||||
|
// Advance 400ms (t=1000). Hook 1 should be removed.
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toHaveLength(1);
|
||||||
|
|
||||||
|
// End Hook 2 at t=1100 (Duration: 1100 - 500 = 600ms -> delay 400ms)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
coreEvents.emitHookEnd({ ...hook, success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hook 2 still visible (pending removal in 400ms)
|
||||||
|
expect(result.current).toHaveLength(1);
|
||||||
|
|
||||||
|
// Advance 400ms (t=1500). Hook 2 should be removed.
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
coreEvents,
|
||||||
|
CoreEvent,
|
||||||
|
type HookStartPayload,
|
||||||
|
type HookEndPayload,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import { type ActiveHook } from '../types.js';
|
||||||
|
import { WARNING_PROMPT_DURATION_MS } from '../constants.js';
|
||||||
|
|
||||||
|
export const useHookDisplayState = () => {
|
||||||
|
const [activeHooks, setActiveHooks] = useState<ActiveHook[]>([]);
|
||||||
|
|
||||||
|
// Track start times independently of render state to calculate duration in event handlers
|
||||||
|
// Key: `${hookName}:${eventName}` -> Stack of StartTimes (FIFO)
|
||||||
|
const hookStartTimes = useRef<Map<string, number[]>>(new Map());
|
||||||
|
|
||||||
|
// Track active timeouts to clear them on unmount
|
||||||
|
const timeouts = useRef<Set<NodeJS.Timeout>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeTimeouts = timeouts.current;
|
||||||
|
const startTimes = hookStartTimes.current;
|
||||||
|
|
||||||
|
const handleHookStart = (payload: HookStartPayload) => {
|
||||||
|
const key = `${payload.hookName}:${payload.eventName}`;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Add start time to ref
|
||||||
|
if (!startTimes.has(key)) {
|
||||||
|
startTimes.set(key, []);
|
||||||
|
}
|
||||||
|
startTimes.get(key)!.push(now);
|
||||||
|
|
||||||
|
setActiveHooks((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
name: payload.hookName,
|
||||||
|
eventName: payload.eventName,
|
||||||
|
index: payload.hookIndex,
|
||||||
|
total: payload.totalHooks,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHookEnd = (payload: HookEndPayload) => {
|
||||||
|
const key = `${payload.hookName}:${payload.eventName}`;
|
||||||
|
const starts = startTimes.get(key);
|
||||||
|
const startTime = starts?.shift(); // Get the earliest start time for this hook type
|
||||||
|
|
||||||
|
// Cleanup empty arrays in map
|
||||||
|
if (starts && starts.length === 0) {
|
||||||
|
startTimes.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
// Default to immediate removal if start time not found (defensive)
|
||||||
|
const elapsed = startTime ? now - startTime : WARNING_PROMPT_DURATION_MS;
|
||||||
|
const remaining = WARNING_PROMPT_DURATION_MS - elapsed;
|
||||||
|
|
||||||
|
const removeHook = () => {
|
||||||
|
setActiveHooks((prev) => {
|
||||||
|
const index = prev.findIndex(
|
||||||
|
(h) =>
|
||||||
|
h.name === payload.hookName && h.eventName === payload.eventName,
|
||||||
|
);
|
||||||
|
if (index === -1) return prev;
|
||||||
|
const newHooks = [...prev];
|
||||||
|
newHooks.splice(index, 1);
|
||||||
|
return newHooks;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (remaining > 0) {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
removeHook();
|
||||||
|
activeTimeouts.delete(timeoutId);
|
||||||
|
}, remaining);
|
||||||
|
activeTimeouts.add(timeoutId);
|
||||||
|
} else {
|
||||||
|
removeHook();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
coreEvents.on(CoreEvent.HookStart, handleHookStart);
|
||||||
|
coreEvents.on(CoreEvent.HookEnd, handleHookEnd);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
coreEvents.off(CoreEvent.HookStart, handleHookStart);
|
||||||
|
coreEvents.off(CoreEvent.HookEnd, handleHookEnd);
|
||||||
|
// Clear all pending timeouts
|
||||||
|
activeTimeouts.forEach(clearTimeout);
|
||||||
|
activeTimeouts.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return activeHooks;
|
||||||
|
};
|
||||||
@@ -418,3 +418,10 @@ export interface ConfirmationRequest {
|
|||||||
export interface LoopDetectionConfirmationRequest {
|
export interface LoopDetectionConfirmationRequest {
|
||||||
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
|
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActiveHook {
|
||||||
|
name: string;
|
||||||
|
eventName: string;
|
||||||
|
index?: number;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const mockDebugLogger = vi.hoisted(() => ({
|
|||||||
// Mock coreEvents
|
// Mock coreEvents
|
||||||
const mockCoreEvents = vi.hoisted(() => ({
|
const mockCoreEvents = vi.hoisted(() => ({
|
||||||
emitFeedback: vi.fn(),
|
emitFeedback: vi.fn(),
|
||||||
|
emitHookStart: vi.fn(),
|
||||||
|
emitHookEnd: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../utils/debugLogger.js', () => ({
|
vi.mock('../utils/debugLogger.js', () => ({
|
||||||
@@ -158,8 +160,31 @@ describe('HookEventHandler', () => {
|
|||||||
tool_name: 'EditTool',
|
tool_name: 'EditTool',
|
||||||
tool_input: { file: 'test.txt' },
|
tool_input: { file: 'test.txt' },
|
||||||
}),
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Verify event emission via callbacks
|
||||||
|
const onHookStart = vi.mocked(mockHookRunner.executeHooksParallel).mock
|
||||||
|
.calls[0][3];
|
||||||
|
const onHookEnd = vi.mocked(mockHookRunner.executeHooksParallel).mock
|
||||||
|
.calls[0][4];
|
||||||
|
|
||||||
|
if (onHookStart) onHookStart(mockPlan[0].hookConfig, 0);
|
||||||
|
expect(mockCoreEvents.emitHookStart).toHaveBeenCalledWith({
|
||||||
|
hookName: './test.sh',
|
||||||
|
eventName: HookEventName.BeforeTool,
|
||||||
|
hookIndex: 1,
|
||||||
|
totalHooks: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onHookEnd) onHookEnd(mockPlan[0].hookConfig, mockResults[0]);
|
||||||
|
expect(mockCoreEvents.emitHookEnd).toHaveBeenCalledWith({
|
||||||
|
hookName: './test.sh',
|
||||||
|
eventName: HookEventName.BeforeTool,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(result).toBe(mockAggregated);
|
expect(result).toBe(mockAggregated);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -294,6 +319,8 @@ describe('HookEventHandler', () => {
|
|||||||
tool_input: toolInput,
|
tool_input: toolInput,
|
||||||
tool_response: toolResponse,
|
tool_response: toolResponse,
|
||||||
}),
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe(mockAggregated);
|
expect(result).toBe(mockAggregated);
|
||||||
@@ -352,6 +379,8 @@ describe('HookEventHandler', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
prompt,
|
prompt,
|
||||||
}),
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe(mockAggregated);
|
expect(result).toBe(mockAggregated);
|
||||||
@@ -415,6 +444,8 @@ describe('HookEventHandler', () => {
|
|||||||
notification_type: 'ToolPermission',
|
notification_type: 'ToolPermission',
|
||||||
details: { type: 'ToolPermission', title: 'Test Permission' },
|
details: { type: 'ToolPermission', title: 'Test Permission' },
|
||||||
}),
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe(mockAggregated);
|
expect(result).toBe(mockAggregated);
|
||||||
@@ -478,6 +509,8 @@ describe('HookEventHandler', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
source: 'startup',
|
source: 'startup',
|
||||||
}),
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe(mockAggregated);
|
expect(result).toBe(mockAggregated);
|
||||||
@@ -548,6 +581,8 @@ describe('HookEventHandler', () => {
|
|||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe(mockAggregated);
|
expect(result).toBe(mockAggregated);
|
||||||
@@ -591,6 +626,8 @@ describe('HookEventHandler', () => {
|
|||||||
hook_event_name: 'BeforeTool',
|
hook_event_name: 'BeforeTool',
|
||||||
timestamp: expect.any(String),
|
timestamp: expect.any(String),
|
||||||
}),
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { HookRunner } from './hookRunner.js';
|
|||||||
import type { HookAggregator, AggregatedHookResult } from './hookAggregator.js';
|
import type { HookAggregator, AggregatedHookResult } from './hookAggregator.js';
|
||||||
import { HookEventName } from './types.js';
|
import { HookEventName } from './types.js';
|
||||||
import type {
|
import type {
|
||||||
|
HookConfig,
|
||||||
HookInput,
|
HookInput,
|
||||||
BeforeToolInput,
|
BeforeToolInput,
|
||||||
AfterToolInput,
|
AfterToolInput,
|
||||||
@@ -507,17 +508,38 @@ export class HookEventHandler {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onHookStart = (config: HookConfig, index: number) => {
|
||||||
|
coreEvents.emitHookStart({
|
||||||
|
hookName: this.getHookName(config),
|
||||||
|
eventName,
|
||||||
|
hookIndex: index + 1,
|
||||||
|
totalHooks: plan.hookConfigs.length,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHookEnd = (config: HookConfig, result: HookExecutionResult) => {
|
||||||
|
coreEvents.emitHookEnd({
|
||||||
|
hookName: this.getHookName(config),
|
||||||
|
eventName,
|
||||||
|
success: result.success,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Execute hooks according to the plan's strategy
|
// Execute hooks according to the plan's strategy
|
||||||
const results = plan.sequential
|
const results = plan.sequential
|
||||||
? await this.hookRunner.executeHooksSequential(
|
? await this.hookRunner.executeHooksSequential(
|
||||||
plan.hookConfigs,
|
plan.hookConfigs,
|
||||||
eventName,
|
eventName,
|
||||||
input,
|
input,
|
||||||
|
onHookStart,
|
||||||
|
onHookEnd,
|
||||||
)
|
)
|
||||||
: await this.hookRunner.executeHooksParallel(
|
: await this.hookRunner.executeHooksParallel(
|
||||||
plan.hookConfigs,
|
plan.hookConfigs,
|
||||||
eventName,
|
eventName,
|
||||||
input,
|
input,
|
||||||
|
onHookStart,
|
||||||
|
onHookEnd,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Aggregate results
|
// Aggregate results
|
||||||
@@ -659,11 +681,18 @@ export class HookEventHandler {
|
|||||||
// Other common fields like decision/reason are handled by specific hook output classes
|
// Other common fields like decision/reason are handled by specific hook output classes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get hook name from config for display or telemetry
|
||||||
|
*/
|
||||||
|
private getHookName(config: HookConfig): string {
|
||||||
|
return config.name || config.command || 'unknown-command';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get hook name from execution result for telemetry
|
* Get hook name from execution result for telemetry
|
||||||
*/
|
*/
|
||||||
private getHookNameFromResult(result: HookExecutionResult): string {
|
private getHookNameFromResult(result: HookExecutionResult): string {
|
||||||
return result.hookConfig.command || 'unknown-command';
|
return this.getHookName(result.hookConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -434,6 +434,37 @@ describe('HookRunner', () => {
|
|||||||
expect(spawn).toHaveBeenCalledTimes(2);
|
expect(spawn).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should call onHookStart and onHookEnd callbacks', async () => {
|
||||||
|
const configs: HookConfig[] = [
|
||||||
|
{ name: 'hook1', type: HookType.Command, command: './hook1.sh' },
|
||||||
|
];
|
||||||
|
|
||||||
|
mockSpawn.mockProcessOn.mockImplementation(
|
||||||
|
(event: string, callback: (code: number) => void) => {
|
||||||
|
if (event === 'close') {
|
||||||
|
setImmediate(() => callback(0));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onStart = vi.fn();
|
||||||
|
const onEnd = vi.fn();
|
||||||
|
|
||||||
|
await hookRunner.executeHooksParallel(
|
||||||
|
configs,
|
||||||
|
HookEventName.BeforeTool,
|
||||||
|
mockInput,
|
||||||
|
onStart,
|
||||||
|
onEnd,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onStart).toHaveBeenCalledWith(configs[0], 0);
|
||||||
|
expect(onEnd).toHaveBeenCalledWith(
|
||||||
|
configs[0],
|
||||||
|
expect.objectContaining({ success: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle mixed success and failure', async () => {
|
it('should handle mixed success and failure', async () => {
|
||||||
const configs: HookConfig[] = [
|
const configs: HookConfig[] = [
|
||||||
{ type: HookType.Command, command: './hook1.sh' },
|
{ type: HookType.Command, command: './hook1.sh' },
|
||||||
@@ -498,6 +529,37 @@ describe('HookRunner', () => {
|
|||||||
expect(executionOrder).toEqual(['./hook1.sh', './hook2.sh']);
|
expect(executionOrder).toEqual(['./hook1.sh', './hook2.sh']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should call onHookStart and onHookEnd callbacks sequentially', async () => {
|
||||||
|
const configs: HookConfig[] = [
|
||||||
|
{ name: 'hook1', type: HookType.Command, command: './hook1.sh' },
|
||||||
|
{ name: 'hook2', type: HookType.Command, command: './hook2.sh' },
|
||||||
|
];
|
||||||
|
|
||||||
|
mockSpawn.mockProcessOn.mockImplementation(
|
||||||
|
(event: string, callback: (code: number) => void) => {
|
||||||
|
if (event === 'close') {
|
||||||
|
setImmediate(() => callback(0));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onStart = vi.fn();
|
||||||
|
const onEnd = vi.fn();
|
||||||
|
|
||||||
|
await hookRunner.executeHooksSequential(
|
||||||
|
configs,
|
||||||
|
HookEventName.BeforeTool,
|
||||||
|
mockInput,
|
||||||
|
onStart,
|
||||||
|
onEnd,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onStart).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onEnd).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onStart).toHaveBeenNthCalledWith(1, configs[0], 0);
|
||||||
|
expect(onStart).toHaveBeenNthCalledWith(2, configs[1], 1);
|
||||||
|
});
|
||||||
|
|
||||||
it('should continue execution even if a hook fails', async () => {
|
it('should continue execution even if a hook fails', async () => {
|
||||||
const configs: HookConfig[] = [
|
const configs: HookConfig[] = [
|
||||||
{ type: HookType.Command, command: './hook1.sh' },
|
{ type: HookType.Command, command: './hook1.sh' },
|
||||||
|
|||||||
@@ -105,10 +105,15 @@ export class HookRunner {
|
|||||||
hookConfigs: HookConfig[],
|
hookConfigs: HookConfig[],
|
||||||
eventName: HookEventName,
|
eventName: HookEventName,
|
||||||
input: HookInput,
|
input: HookInput,
|
||||||
|
onHookStart?: (config: HookConfig, index: number) => void,
|
||||||
|
onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void,
|
||||||
): Promise<HookExecutionResult[]> {
|
): Promise<HookExecutionResult[]> {
|
||||||
const promises = hookConfigs.map((config) =>
|
const promises = hookConfigs.map(async (config, index) => {
|
||||||
this.executeHook(config, eventName, input),
|
onHookStart?.(config, index);
|
||||||
);
|
const result = await this.executeHook(config, eventName, input);
|
||||||
|
onHookEnd?.(config, result);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
}
|
}
|
||||||
@@ -120,12 +125,17 @@ export class HookRunner {
|
|||||||
hookConfigs: HookConfig[],
|
hookConfigs: HookConfig[],
|
||||||
eventName: HookEventName,
|
eventName: HookEventName,
|
||||||
input: HookInput,
|
input: HookInput,
|
||||||
|
onHookStart?: (config: HookConfig, index: number) => void,
|
||||||
|
onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void,
|
||||||
): Promise<HookExecutionResult[]> {
|
): Promise<HookExecutionResult[]> {
|
||||||
const results: HookExecutionResult[] = [];
|
const results: HookExecutionResult[] = [];
|
||||||
let currentInput = input;
|
let currentInput = input;
|
||||||
|
|
||||||
for (const config of hookConfigs) {
|
for (let i = 0; i < hookConfigs.length; i++) {
|
||||||
|
const config = hookConfigs[i];
|
||||||
|
onHookStart?.(config, i);
|
||||||
const result = await this.executeHook(config, eventName, currentInput);
|
const result = await this.executeHook(config, eventName, currentInput);
|
||||||
|
onHookEnd?.(config, result);
|
||||||
results.push(result);
|
results.push(result);
|
||||||
|
|
||||||
// If the hook succeeded and has output, use it to modify the input for the next hook
|
// If the hook succeeded and has output, use it to modify the input for the next hook
|
||||||
|
|||||||
@@ -274,4 +274,35 @@ describe('CoreEventEmitter', () => {
|
|||||||
expect(listener).toHaveBeenCalledWith({ model: newModel });
|
expect(listener).toHaveBeenCalledWith({ model: newModel });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Hook Events', () => {
|
||||||
|
it('should emit HookStart event with correct payload using helper', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
events.on(CoreEvent.HookStart, listener);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
hookName: 'test-hook',
|
||||||
|
eventName: 'before-agent',
|
||||||
|
hookIndex: 1,
|
||||||
|
totalHooks: 1,
|
||||||
|
};
|
||||||
|
events.emitHookStart(payload);
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledWith(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit HookEnd event with correct payload using helper', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
events.on(CoreEvent.HookEnd, listener);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
hookName: 'test-hook',
|
||||||
|
eventName: 'before-agent',
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
events.emitHookEnd(payload);
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledWith(payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,6 +67,36 @@ export interface MemoryChangedPayload {
|
|||||||
fileCount: number;
|
fileCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base payload for hook-related events.
|
||||||
|
*/
|
||||||
|
export interface HookPayload {
|
||||||
|
hookName: string;
|
||||||
|
eventName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload for the 'hook-start' event.
|
||||||
|
*/
|
||||||
|
export interface HookStartPayload extends HookPayload {
|
||||||
|
/**
|
||||||
|
* The 1-based index of the current hook in the execution sequence.
|
||||||
|
* Used for progress indication (e.g. "Hook 1/3").
|
||||||
|
*/
|
||||||
|
hookIndex?: number;
|
||||||
|
/**
|
||||||
|
* The total number of hooks in the current execution sequence.
|
||||||
|
*/
|
||||||
|
totalHooks?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload for the 'hook-end' event.
|
||||||
|
*/
|
||||||
|
export interface HookEndPayload extends HookPayload {
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export enum CoreEvent {
|
export enum CoreEvent {
|
||||||
UserFeedback = 'user-feedback',
|
UserFeedback = 'user-feedback',
|
||||||
ModelChanged = 'model-changed',
|
ModelChanged = 'model-changed',
|
||||||
@@ -75,6 +105,8 @@ export enum CoreEvent {
|
|||||||
MemoryChanged = 'memory-changed',
|
MemoryChanged = 'memory-changed',
|
||||||
ExternalEditorClosed = 'external-editor-closed',
|
ExternalEditorClosed = 'external-editor-closed',
|
||||||
SettingsChanged = 'settings-changed',
|
SettingsChanged = 'settings-changed',
|
||||||
|
HookStart = 'hook-start',
|
||||||
|
HookEnd = 'hook-end',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoreEvents {
|
export interface CoreEvents {
|
||||||
@@ -85,6 +117,8 @@ export interface CoreEvents {
|
|||||||
[CoreEvent.MemoryChanged]: [MemoryChangedPayload];
|
[CoreEvent.MemoryChanged]: [MemoryChangedPayload];
|
||||||
[CoreEvent.ExternalEditorClosed]: never[];
|
[CoreEvent.ExternalEditorClosed]: never[];
|
||||||
[CoreEvent.SettingsChanged]: never[];
|
[CoreEvent.SettingsChanged]: never[];
|
||||||
|
[CoreEvent.HookStart]: [HookStartPayload];
|
||||||
|
[CoreEvent.HookEnd]: [HookEndPayload];
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventBacklogItem = {
|
type EventBacklogItem = {
|
||||||
@@ -172,6 +206,20 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
|
|||||||
this.emit(CoreEvent.SettingsChanged);
|
this.emit(CoreEvent.SettingsChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies subscribers that a hook execution has started.
|
||||||
|
*/
|
||||||
|
emitHookStart(payload: HookStartPayload): void {
|
||||||
|
this.emit(CoreEvent.HookStart, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies subscribers that a hook execution has ended.
|
||||||
|
*/
|
||||||
|
emitHookEnd(payload: HookEndPayload): void {
|
||||||
|
this.emit(CoreEvent.HookEnd, payload);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flushes buffered messages. Call this immediately after primary UI listener
|
* Flushes buffered messages. Call this immediately after primary UI listener
|
||||||
* subscribes.
|
* subscribes.
|
||||||
|
|||||||
@@ -1512,6 +1512,13 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Hook Notifications",
|
||||||
|
"description": "Show visual indicators when hooks are executing.",
|
||||||
|
"markdownDescription": "Show visual indicators when hooks are executing.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `true`",
|
||||||
|
"default": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"BeforeTool": {
|
"BeforeTool": {
|
||||||
"title": "Before Tool Hooks",
|
"title": "Before Tool Hooks",
|
||||||
"description": "Hooks that execute before tool execution. Can intercept, validate, or modify tool calls.",
|
"description": "Hooks that execute before tool execution. Can intercept, validate, or modify tool calls.",
|
||||||
|
|||||||
Reference in New Issue
Block a user