mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 12:11:09 +00:00
remove hooks experimental and refactor hook Config
This commit is contained in:
parent
1b1a029fd7
commit
5221002831
33 changed files with 722 additions and 322 deletions
|
|
@ -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* () {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -82,7 +82,6 @@ export async function handleQwenAuth(
|
|||
acp: undefined,
|
||||
experimentalAcp: undefined,
|
||||
experimentalLsp: undefined,
|
||||
experimentalHooks: undefined,
|
||||
extensions: [],
|
||||
listExtensions: undefined,
|
||||
openaiLogging: undefined,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: 'ユーザー',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: 'Пользователь',
|
||||
|
|
|
|||
|
|
@ -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: '用户',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||
exportCommand,
|
||||
extensionsCommand,
|
||||
helpCommand,
|
||||
...(this.config?.getEnableHooks() ? [hooksCommand] : []),
|
||||
hooksCommand,
|
||||
await ideCommand(),
|
||||
initCommand,
|
||||
languageCommand,
|
||||
|
|
|
|||
124
packages/cli/src/ui/components/hooks/HooksDisabledStep.test.tsx
Normal file
124
packages/cli/src/ui/components/hooks/HooksDisabledStep.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
84
packages/cli/src/ui/components/hooks/HooksDisabledStep.tsx
Normal file
84
packages/cli/src/ui/components/hooks/HooksDisabledStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue