remove hooks experimental and refactor hook Config

This commit is contained in:
DennisYu07 2026-04-01 11:50:23 +08:00
parent 1b1a029fd7
commit 5221002831
33 changed files with 722 additions and 322 deletions

View file

@ -441,7 +441,7 @@ describe('Session', () => {
.fn()
.mockReturnValue(ApprovalMode.DEFAULT);
mockConfig.getPermissionManager = vi.fn().mockReturnValue(null);
mockConfig.getEnableHooks = vi.fn().mockReturnValue(true);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
mockConfig.getMessageBus = vi.fn().mockReturnValue({});
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
(async function* () {

View file

@ -711,7 +711,7 @@ export class Session implements SessionContext {
injectPermissionRulesIfMissing(confirmationDetails, pmCtx);
const messageBus = this.config.getMessageBus?.();
const hooksEnabled = this.config.getEnableHooks?.() ?? false;
const hooksEnabled = !this.config.getDisableAllHooks?.();
let hookHandled = false;
if (hooksEnabled && messageBus) {

View file

@ -82,7 +82,6 @@ export async function handleQwenAuth(
acp: undefined,
experimentalAcp: undefined,
experimentalLsp: undefined,
experimentalHooks: undefined,
extensions: [],
listExtensions: undefined,
openaiLogging: undefined,

View file

@ -128,7 +128,6 @@ export interface CliArgs {
acp: boolean | undefined;
experimentalAcp: boolean | undefined;
experimentalLsp: boolean | undefined;
experimentalHooks: boolean | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
openaiLogging: boolean | undefined;
@ -352,12 +351,6 @@ export async function parseArguments(): Promise<CliArgs> {
'Enable experimental LSP (Language Server Protocol) feature for code intelligence',
default: false,
})
.option('experimental-hooks', {
type: 'boolean',
description:
'Enable experimental hooks feature for lifecycle event customization',
default: false,
})
.option('channel', {
type: 'string',
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
@ -1121,9 +1114,7 @@ export async function loadCliConfig(
format: outputSettingsFormat,
},
hooks: settings.hooks,
hooksConfig: settings.hooksConfig,
enableHooks:
argv.experimentalHooks === true || settings.hooksConfig?.enabled === true,
disableAllHooks: settings.disableAllHooks ?? false,
channel: argv.channel,
// Precedence: explicit CLI flag > settings file > default(true).
// NOTE: do NOT set a yargs default for `chat-recording`, otherwise argv will

View file

@ -1404,38 +1404,15 @@ const SETTINGS_SCHEMA = {
},
},
hooksConfig: {
type: 'object',
label: 'Hooks Config',
disableAllHooks: {
type: 'boolean',
label: 'Disable All Hooks',
category: 'Advanced',
requiresRestart: false,
default: {},
requiresRestart: true,
default: false,
description:
'Hook configurations for intercepting and customizing agent behavior.',
'Temporarily disable all hooks without deleting configurations. Default is false (hooks enabled).',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable Hooks',
category: 'Advanced',
requiresRestart: true,
default: true,
description:
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
showInDialog: false,
},
disabled: {
type: 'array',
label: 'Disabled Hooks',
category: 'Advanced',
requiresRestart: false,
default: [] as string[],
description:
'List of hook names (commands) that should be disabled. Hooks in this list will not execute even if configured.',
showInDialog: false,
mergeStrategy: MergeStrategy.UNION,
},
},
},
hooks: {

View file

@ -506,7 +506,6 @@ describe('gemini.tsx main function kitty protocol', () => {
authType: undefined,
maxSessionTurns: undefined,
experimentalLsp: undefined,
experimentalHooks: undefined,
channel: undefined,
chatRecording: undefined,
sessionId: undefined,

View file

@ -623,6 +623,19 @@ export default {
'No hook config selected': 'Keine Hook-Konfiguration ausgewählt',
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.':
'Um diesen Hook zu ändern oder zu entfernen, bearbeiten Sie settings.json direkt oder fragen Sie Qwen.',
// Hooks - Disabled Step
'Hook Configuration - Disabled': 'Hook-Konfiguration - Deaktiviert',
'All hooks are currently disabled. You have {{count}} that are not running.':
'Alle Hooks sind derzeit deaktiviert. Sie haben {{count}} die nicht ausgeführt werden.',
'{{count}} configured hook': '{{count}} konfigurierter Hook',
'{{count}} configured hooks': '{{count}} konfigurierte Hooks',
'When hooks are disabled:': 'Wenn Hooks deaktiviert sind:',
'No hook commands will execute': 'Keine Hook-Befehle werden ausgeführt',
'StatusLine will not be displayed': 'StatusLine wird nicht angezeigt',
'Tool operations will proceed without hook validation':
'Tool-Operationen werden ohne Hook-Validierung fortgesetzt',
'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.':
'Um Hooks wieder zu aktivieren, entfernen Sie "disableAllHooks" aus settings.json oder fragen Sie Qwen Code.',
// Hooks - Source
Project: 'Projekt',
User: 'Benutzer',

View file

@ -696,6 +696,19 @@ export default {
'No hook config selected': 'No hook config selected',
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.':
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.',
// Hooks - Disabled Step
'Hook Configuration - Disabled': 'Hook Configuration - Disabled',
'All hooks are currently disabled. You have {{count}} that are not running.':
'All hooks are currently disabled. You have {{count}} that are not running.',
'{{count}} configured hook': '{{count}} configured hook',
'{{count}} configured hooks': '{{count}} configured hooks',
'When hooks are disabled:': 'When hooks are disabled:',
'No hook commands will execute': 'No hook commands will execute',
'StatusLine will not be displayed': 'StatusLine will not be displayed',
'Tool operations will proceed without hook validation':
'Tool operations will proceed without hook validation',
'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.':
'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.',
// Hooks - Source
Project: 'Project',
User: 'User',

View file

@ -409,6 +409,19 @@ export default {
'No hook config selected': 'フック設定が選択されていません',
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.':
'このフックを変更または削除するには、settings.json を直接編集するか、Qwen に尋ねてください。',
// Hooks - Disabled Step
'Hook Configuration - Disabled': 'フック設定 - 無効',
'All hooks are currently disabled. You have {{count}} that are not running.':
'すべてのフックは現在無効です。{{count}} が実行されていません。',
'{{count}} configured hook': '{{count}} 個の設定されたフック',
'{{count}} configured hooks': '{{count}} 個の設定されたフック',
'When hooks are disabled:': 'フックが無効な場合:',
'No hook commands will execute': 'フックコマンドは実行されません',
'StatusLine will not be displayed': 'StatusLine は表示されません',
'Tool operations will proceed without hook validation':
'ツール操作はフック検証なしで続行されます',
'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.':
'フックを再有効化するには、settings.json から "disableAllHooks" を削除するか、Qwen Code に尋ねてください。',
// Hooks - Source
Project: 'プロジェクト',
User: 'ユーザー',

View file

@ -629,6 +629,19 @@ export default {
'No hook config selected': 'Nenhuma configuração de hook selecionada',
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.':
'Para modificar ou remover este hook, edite settings.json diretamente ou pergunte ao Qwen.',
// Hooks - Disabled Step
'Hook Configuration - Disabled': 'Configuração de Hook - Desativado',
'All hooks are currently disabled. You have {{count}} that are not running.':
'Todos os hooks estão desativados. Você tem {{count}} que não estão em execução.',
'{{count}} configured hook': '{{count}} hook configurado',
'{{count}} configured hooks': '{{count}} hooks configurados',
'When hooks are disabled:': 'Quando os hooks estão desativados:',
'No hook commands will execute': 'Nenhum comando de hook será executado',
'StatusLine will not be displayed': 'StatusLine não será exibido',
'Tool operations will proceed without hook validation':
'As operações de ferramentas prosseguirão sem validação de hook',
'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.':
'Para reativar os hooks, remova "disableAllHooks" do settings.json ou pergunte ao Qwen Code.',
// Hooks - Source
Project: 'Projeto',
User: 'Usuário',

View file

@ -634,6 +634,19 @@ export default {
'No hook config selected': 'Конфигурация хука не выбрана',
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.':
'Чтобы изменить или удалить этот хук, отредактируйте settings.json напрямую или спросите Qwen.',
// Hooks - Disabled Step
'Hook Configuration - Disabled': 'Конфигурация хуков - Отключено',
'All hooks are currently disabled. You have {{count}} that are not running.':
'Все хуки в данный момент отключены. У вас {{count}} не выполняются.',
'{{count}} configured hook': '{{count}} настроенный хук',
'{{count}} configured hooks': '{{count}} настроенных хуков',
'When hooks are disabled:': 'Когда хуки отключены:',
'No hook commands will execute': 'Никакие команды хуков не будут выполняться',
'StatusLine will not be displayed': 'StatusLine не будет отображаться',
'Tool operations will proceed without hook validation':
'Операции инструментов будут выполняться без проверки хуков',
'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.':
'Чтобы снова включить хуки, удалите "disableAllHooks" из settings.json или спросите Qwen Code.',
// Hooks - Source
Project: 'Проект',
User: 'Пользователь',

View file

@ -660,6 +660,19 @@ export default {
'No hook config selected': '未选择 Hook 配置',
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.':
'要修改或删除此 Hook请直接编辑 settings.json 或询问 Qwen。',
// Hooks - Disabled Step
'Hook Configuration - Disabled': 'Hook 配置 - 已禁用',
'All hooks are currently disabled. You have {{count}} that are not running.':
'所有 Hook 当前已禁用。您有 {{count}} 未运行。',
'{{count}} configured hook': '{{count}} 个已配置的 Hook',
'{{count}} configured hooks': '{{count}} 个已配置的 Hook',
'When hooks are disabled:': '当 Hook 被禁用时:',
'No hook commands will execute': '不会执行任何 Hook 命令',
'StatusLine will not be displayed': '不会显示状态栏',
'Tool operations will proceed without hook validation':
'工具操作将在没有 Hook 验证的情况下继续',
'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.':
'要重新启用 Hook请从 settings.json 中删除 "disableAllHooks" 或询问 Qwen Code。',
// Hooks - Source
Project: '项目',
User: '用户',

View file

@ -121,7 +121,7 @@ describe('BuiltinCommandLoader', () => {
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(true),
getUseModelRouter: () => false,
getEnableHooks: vi.fn().mockReturnValue(true),
getDisableAllHooks: vi.fn().mockReturnValue(false),
} as unknown as Config;
restoreCommandMock.mockReturnValue({
@ -207,18 +207,19 @@ describe('BuiltinCommandLoader', () => {
expect(modelCmd?.name).toBe('model');
});
it('should include hooks command when enableHooks is true', async () => {
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const hooksCmd = commands.find((c) => c.name === 'hooks');
expect(hooksCmd).toBeDefined();
});
it('should always include hooks command regardless of disableAllHooks', async () => {
// When disableAllHooks is false
const loader1 = new BuiltinCommandLoader(mockConfig);
const commands1 = await loader1.loadCommands(new AbortController().signal);
const hooksCmd1 = commands1.find((c) => c.name === 'hooks');
expect(hooksCmd1).toBeDefined();
it('should exclude hooks command when enableHooks is false', async () => {
(mockConfig.getEnableHooks as Mock).mockReturnValue(false);
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const hooksCmd = commands.find((c) => c.name === 'hooks');
expect(hooksCmd).toBeUndefined();
// When disableAllHooks is true - hooks command should still be available
// (it will show a disabled state page in the UI instead of hiding the command)
(mockConfig.getDisableAllHooks as Mock).mockReturnValue(true);
const loader2 = new BuiltinCommandLoader(mockConfig);
const commands2 = await loader2.loadCommands(new AbortController().signal);
const hooksCmd2 = commands2.find((c) => c.name === 'hooks');
expect(hooksCmd2).toBeDefined();
});
});

View file

@ -80,7 +80,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
exportCommand,
extensionsCommand,
helpCommand,
...(this.config?.getEnableHooks() ? [hooksCommand] : []),
hooksCommand,
await ideCommand(),
initCommand,
languageCommand,

View file

@ -0,0 +1,124 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from 'ink-testing-library';
import { HooksDisabledStep } from './HooksDisabledStep.js';
// Mock i18n module
vi.mock('../../../i18n/index.js', () => ({
t: vi.fn((key: string, options?: { count?: string }) => {
// Handle pluralization
if (key === '{{count}} configured hook' && options?.count) {
return `${options.count} configured hook`;
}
if (key === '{{count}} configured hooks' && options?.count) {
return `${options.count} configured hooks`;
}
// Handle interpolation for main message
if (
key ===
'All hooks are currently disabled. You have {{count}} that are not running.' &&
options?.count
) {
return `All hooks are currently disabled. You have ${options.count} that are not running.`;
}
return key;
}),
}));
// Mock semantic-colors
vi.mock('../../semantic-colors.js', () => ({
theme: {
text: {
primary: 'white',
secondary: 'gray',
},
status: {
warning: 'yellow',
error: 'red',
success: 'green',
},
},
}));
describe('HooksDisabledStep', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render disabled title', () => {
const { lastFrame } = render(
<HooksDisabledStep configuredHooksCount={2} />,
);
expect(lastFrame()).toContain('Hook Configuration - Disabled');
});
it('should show configured hooks count', () => {
const { lastFrame } = render(
<HooksDisabledStep configuredHooksCount={2} />,
);
expect(lastFrame()).toContain('2 configured hooks');
});
it('should show singular form for single hook', () => {
const { lastFrame } = render(
<HooksDisabledStep configuredHooksCount={1} />,
);
expect(lastFrame()).toContain('1 configured hook');
});
it('should show zero hooks message', () => {
const { lastFrame } = render(
<HooksDisabledStep configuredHooksCount={0} />,
);
expect(lastFrame()).toContain('0 configured hooks');
});
it('should show explanation items', () => {
const { lastFrame } = render(
<HooksDisabledStep configuredHooksCount={2} />,
);
const output = lastFrame();
expect(output).toContain('When hooks are disabled:');
expect(output).toContain('No hook commands will execute');
expect(output).toContain('StatusLine will not be displayed');
expect(output).toContain(
'Tool operations will proceed without hook validation',
);
});
it('should show re-enable instructions', () => {
const { lastFrame } = render(
<HooksDisabledStep configuredHooksCount={2} />,
);
expect(lastFrame()).toContain('To re-enable hooks');
expect(lastFrame()).toContain('disableAllHooks');
expect(lastFrame()).toContain('settings.json');
});
it('should show Esc hint', () => {
const { lastFrame } = render(
<HooksDisabledStep configuredHooksCount={2} />,
);
expect(lastFrame()).toContain('Esc to close');
});
it('should handle large hook counts', () => {
const { lastFrame } = render(
<HooksDisabledStep configuredHooksCount={100} />,
);
expect(lastFrame()).toContain('100 configured hooks');
});
});

View file

@ -0,0 +1,84 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { t } from '../../../i18n/index.js';
interface HooksDisabledStepProps {
configuredHooksCount: number;
}
export function HooksDisabledStep({
configuredHooksCount,
}: HooksDisabledStepProps): React.JSX.Element {
// Get the correct plural/singular form
const hooksText =
configuredHooksCount === 1
? t('{{count}} configured hook', { count: String(configuredHooksCount) })
: t('{{count}} configured hooks', {
count: String(configuredHooksCount),
});
return (
<Box flexDirection="column" paddingX={1}>
{/* Title */}
<Box marginBottom={1}>
<Text bold color={theme.status.warning}>
{t('Hook Configuration - Disabled')}
</Text>
</Box>
{/* Main message */}
<Box marginBottom={1}>
<Text color={theme.text.primary}>
{t(
'All hooks are currently disabled. You have {{count}} that are not running.',
{
count: hooksText,
},
)}
</Text>
</Box>
{/* Explanation */}
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
{t('When hooks are disabled:')}
</Text>
<Box>
<Text color={theme.text.secondary}>
{` · ${t('No hook commands will execute')}`}
</Text>
</Box>
<Box>
<Text color={theme.text.secondary}>
{` · ${t('StatusLine will not be displayed')}`}
</Text>
</Box>
<Box>
<Text color={theme.text.secondary}>
{` · ${t('Tool operations will proceed without hook validation')}`}
</Text>
</Box>
</Box>
{/* How to re-enable */}
<Box marginBottom={1}>
<Text color={theme.text.secondary}>
{t(
'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.',
)}
</Text>
</Box>
{/* Footer hint */}
<Box marginTop={1}>
<Text color={theme.text.secondary}>{t('Esc to close')}</Text>
</Box>
</Box>
);
}

View file

@ -4,9 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HooksManagementDialog } from './HooksManagementDialog.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import type { Key } from '../../contexts/KeypressContext.js';
// Mock useKeypress
vi.mock('../../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
const mockedUseKeypress = vi.mocked(useKeypress);
// Mock i18n module
vi.mock('../../../i18n/index.js', () => ({
@ -18,6 +27,20 @@ vi.mock('../../../i18n/index.js', () => ({
if (key === '{{count}} hooks configured' && options?.count) {
return `${options.count} hooks configured`;
}
if (key === '{{count}} configured hook' && options?.count) {
return `${options.count} configured hook`;
}
if (key === '{{count}} configured hooks' && options?.count) {
return `${options.count} configured hooks`;
}
// Handle interpolation for disabled message
if (
key ===
'All hooks are currently disabled. You have {{count}} that are not running.' &&
options?.count
) {
return `All hooks are currently disabled. You have ${options.count} that are not running.`;
}
return key;
}),
}));
@ -35,6 +58,7 @@ vi.mock('../../contexts/ConfigContext.js', async (importOriginal) => {
...actual,
useConfig: vi.fn(() => ({
getExtensions: vi.fn(() => []),
getDisableAllHooks: vi.fn(() => false),
})),
};
});
@ -62,6 +86,7 @@ vi.mock('../../semantic-colors.js', () => ({
status: {
success: 'green',
error: 'red',
warning: 'yellow',
},
border: {
default: 'gray',
@ -82,46 +107,183 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
};
});
// Helper to create a key object
function createKey(name: string, sequence = ''): Key {
return {
name,
sequence,
ctrl: false,
meta: false,
shift: false,
paste: false,
};
}
describe('HooksManagementDialog', () => {
const mockOnClose = vi.fn();
let keypressHandler: ((key: Key) => void) | null = null;
beforeEach(() => {
vi.clearAllMocks();
keypressHandler = null;
// Mock useKeypress to capture the handler
mockedUseKeypress.mockImplementation((handler) => {
keypressHandler = handler;
});
});
it('should render loading state initially', () => {
const { lastFrame } = renderWithProviders(
<HooksManagementDialog onClose={mockOnClose} />,
);
expect(lastFrame()).toContain('Loading hooks');
afterEach(() => {
keypressHandler = null;
});
it('should render with border', async () => {
const { lastFrame, unmount } = renderWithProviders(
<HooksManagementDialog onClose={mockOnClose} />,
);
describe('Initial rendering', () => {
it('should render loading state initially', () => {
const { lastFrame } = renderWithProviders(
<HooksManagementDialog onClose={mockOnClose} />,
);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(lastFrame()).toContain('Loading hooks');
});
// The dialog should have a border (rendered as box-drawing characters)
const output = lastFrame();
expect(output).toBeTruthy();
it('should render with border', async () => {
const { lastFrame, unmount } = renderWithProviders(
<HooksManagementDialog onClose={mockOnClose} />,
);
unmount();
await new Promise((resolve) => setTimeout(resolve, 100));
// The dialog should have a border (rendered as box-drawing characters)
const output = lastFrame();
expect(output).toBeTruthy();
unmount();
});
it('should handle empty hooks list gracefully', async () => {
const { lastFrame, unmount } = renderWithProviders(
<HooksManagementDialog onClose={mockOnClose} />,
);
await new Promise((resolve) => setTimeout(resolve, 100));
const output = lastFrame();
// Should show 0 hooks configured when no hooks are configured
expect(output).toContain('0 hooks configured');
unmount();
});
});
it('should handle empty hooks list gracefully', async () => {
const { lastFrame, unmount } = renderWithProviders(
<HooksManagementDialog onClose={mockOnClose} />,
);
describe('Keyboard navigation - HOOKS_LIST step', () => {
it('should register keypress handler with isActive: true', async () => {
renderWithProviders(<HooksManagementDialog onClose={mockOnClose} />);
await new Promise((resolve) => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
const output = lastFrame();
// Should show 0 hooks configured when no hooks are configured
expect(output).toContain('0 hooks configured');
expect(mockedUseKeypress).toHaveBeenCalled();
const options = mockedUseKeypress.mock.calls[0][1];
expect(options).toEqual({ isActive: true });
});
unmount();
it('should close dialog on Escape key', async () => {
renderWithProviders(<HooksManagementDialog onClose={mockOnClose} />);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(keypressHandler).not.toBeNull();
keypressHandler!(createKey('escape', '\x1b'));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('should navigate up and down with arrow keys', async () => {
const { lastFrame, unmount } = renderWithProviders(
<HooksManagementDialog onClose={mockOnClose} />,
);
await new Promise((resolve) => setTimeout(resolve, 100));
// Initial state - first item selected
let output = lastFrame();
expect(output).toContain('');
// Press down - should move selection
keypressHandler!(createKey('down'));
output = lastFrame();
// Press up - should move back
keypressHandler!(createKey('up'));
output = lastFrame();
unmount();
});
it('should not go above first item when pressing up', async () => {
const { unmount } = renderWithProviders(
<HooksManagementDialog onClose={mockOnClose} />,
);
await new Promise((resolve) => setTimeout(resolve, 100));
// Press up multiple times from first item
keypressHandler!(createKey('up'));
keypressHandler!(createKey('up'));
keypressHandler!(createKey('up'));
// Should still be at first item (no crash)
unmount();
});
});
describe('Keyboard navigation - HOOKS_DISABLED step', () => {
it('should show disabled state when disableAllHooks is true', async () => {
// Override the mock for this test
const configContext = await import('../../contexts/ConfigContext.js');
vi.mocked(configContext.useConfig).mockReturnValue({
getExtensions: vi.fn(() => []),
getDisableAllHooks: vi.fn(() => true),
} as unknown as ReturnType<typeof configContext.useConfig>);
const { lastFrame, unmount } = renderWithProviders(
<HooksManagementDialog onClose={mockOnClose} />,
);
await new Promise((resolve) => setTimeout(resolve, 100));
const output = lastFrame();
expect(output).toContain('Hook Configuration - Disabled');
unmount();
});
it('should close dialog on Escape key when hooks are disabled', async () => {
const configContext = await import('../../contexts/ConfigContext.js');
vi.mocked(configContext.useConfig).mockReturnValue({
getExtensions: vi.fn(() => []),
getDisableAllHooks: vi.fn(() => true),
} as unknown as ReturnType<typeof configContext.useConfig>);
renderWithProviders(<HooksManagementDialog onClose={mockOnClose} />);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(keypressHandler).not.toBeNull();
keypressHandler!(createKey('escape', '\x1b'));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
describe('Loading and error states', () => {
it('should allow Escape to close during loading state', () => {
renderWithProviders(<HooksManagementDialog onClose={mockOnClose} />);
// Don't wait for loading to complete
expect(keypressHandler).not.toBeNull();
keypressHandler!(createKey('escape', '\x1b'));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -26,6 +26,7 @@ import { HOOKS_MANAGEMENT_STEPS } from './types.js';
import { HooksListStep } from './HooksListStep.js';
import { HookDetailStep } from './HookDetailStep.js';
import { HookConfigDetailStep } from './HookConfigDetailStep.js';
import { HooksDisabledStep } from './HooksDisabledStep.js';
import {
DISPLAY_HOOK_EVENTS,
getTranslatedSourceDisplayMap,
@ -111,8 +112,13 @@ export function HooksManagementDialog({
const { columns: width } = useTerminalSize();
const boxWidth = width - 4;
// Check if hooks are disabled
const disableAllHooks = config?.getDisableAllHooks() ?? false;
const [navigationStack, setNavigationStack] = useState<string[]>([
HOOKS_MANAGEMENT_STEPS.HOOKS_LIST,
disableAllHooks
? HOOKS_MANAGEMENT_STEPS.HOOKS_DISABLED
: HOOKS_MANAGEMENT_STEPS.HOOKS_LIST,
]);
const [selectedHookIndex, setSelectedHookIndex] = useState<number>(-1);
const [selectedConfigIndex, setSelectedConfigIndex] = useState<number>(-1);
@ -148,6 +154,12 @@ export function HooksManagementDialog({
}
switch (currentStep) {
case HOOKS_MANAGEMENT_STEPS.HOOKS_DISABLED:
if (key.name === 'escape') {
onClose();
}
break;
case HOOKS_MANAGEMENT_STEPS.HOOKS_LIST:
if (key.name === 'up') {
setListSelectedIndex((prev) => Math.max(0, prev - 1));
@ -339,8 +351,19 @@ export function HooksManagementDialog({
return null;
}, [selectedHook, selectedConfigIndex]);
// Calculate total configured hooks count
const configuredHooksCount = useMemo(
() => hooks.reduce((sum, hook) => sum + hook.configs.length, 0),
[hooks],
);
// Render based on current step
const renderContent = () => {
// Show disabled state first (before loading check)
if (currentStep === HOOKS_MANAGEMENT_STEPS.HOOKS_DISABLED) {
return <HooksDisabledStep configuredHooksCount={configuredHooksCount} />;
}
if (isLoading) {
return (
<Box flexDirection="column" paddingX={1}>

View file

@ -185,21 +185,10 @@ export function getTranslatedSourceDisplayMap(): Record<
/**
* List of hook events to display in the UI
* Automatically synced with HookEventName enum from core
*/
export const DISPLAY_HOOK_EVENTS: HookEventName[] = [
HookEventName.Stop,
HookEventName.PreToolUse,
HookEventName.PostToolUse,
HookEventName.PostToolUseFailure,
HookEventName.Notification,
HookEventName.UserPromptSubmit,
HookEventName.SessionStart,
HookEventName.SessionEnd,
HookEventName.SubagentStart,
HookEventName.SubagentStop,
HookEventName.PreCompact,
HookEventName.PermissionRequest,
];
export const DISPLAY_HOOK_EVENTS: HookEventName[] =
Object.values(HookEventName);
/**
* Create empty hook event display info

View file

@ -7,5 +7,7 @@
export { HooksManagementDialog } from './HooksManagementDialog.js';
export { HooksListStep } from './HooksListStep.js';
export { HookDetailStep } from './HookDetailStep.js';
export { HookConfigDetailStep } from './HookConfigDetailStep.js';
export { HooksDisabledStep } from './HooksDisabledStep.js';
export * from './types.js';
export * from './constants.js';

View file

@ -44,6 +44,7 @@ export interface HookConfigDisplayInfo {
* Hook management dialog step names
*/
export const HOOKS_MANAGEMENT_STEPS = {
HOOKS_DISABLED: 'hooks_disabled',
HOOKS_LIST: 'hooks_list',
HOOK_DETAIL: 'hook_detail',
HOOK_CONFIG_DETAIL: 'hook_config_detail',

View file

@ -79,7 +79,7 @@ export const useAttentionNotifications = ({
// Fire idle_prompt notification hook when entering idle state
if (config && !idleNotificationSentRef.current) {
const messageBus = config.getMessageBus();
const hooksEnabled = config.getEnableHooks();
const hooksEnabled = !config.getDisableAllHooks();
if (hooksEnabled && messageBus) {
fireNotificationHook(
messageBus,

View file

@ -69,7 +69,7 @@ const mockConfig = {
getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }),
getChatRecordingService: () => undefined,
getMessageBus: vi.fn().mockReturnValue(undefined),
getEnableHooks: vi.fn().mockReturnValue(false),
getDisableAllHooks: vi.fn().mockReturnValue(true),
getHookSystem: vi.fn().mockReturnValue(undefined),
getDebugLogger: vi.fn().mockReturnValue({
debug: vi.fn(),