From 5221002831ceed3037294374a3b8a910db87e500 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Wed, 1 Apr 2026 11:50:23 +0800 Subject: [PATCH] remove hooks experimental and refactor hook Config --- docs/users/features/hooks.md | 19 +- .../hook-integration/hooks.test.ts | 240 +++++++++--------- .../acp-integration/session/Session.test.ts | 2 +- .../src/acp-integration/session/Session.ts | 2 +- packages/cli/src/commands/auth/handler.ts | 1 - packages/cli/src/config/config.ts | 11 +- packages/cli/src/config/settingsSchema.ts | 35 +-- packages/cli/src/gemini.test.tsx | 1 - packages/cli/src/i18n/locales/de.js | 13 + packages/cli/src/i18n/locales/en.js | 13 + packages/cli/src/i18n/locales/ja.js | 13 + packages/cli/src/i18n/locales/pt.js | 13 + packages/cli/src/i18n/locales/ru.js | 13 + packages/cli/src/i18n/locales/zh.js | 13 + .../src/services/BuiltinCommandLoader.test.ts | 27 +- .../cli/src/services/BuiltinCommandLoader.ts | 2 +- .../hooks/HooksDisabledStep.test.tsx | 124 +++++++++ .../ui/components/hooks/HooksDisabledStep.tsx | 84 ++++++ .../hooks/HooksManagementDialog.test.tsx | 212 ++++++++++++++-- .../hooks/HooksManagementDialog.tsx | 25 +- .../cli/src/ui/components/hooks/constants.ts | 17 +- packages/cli/src/ui/components/hooks/index.ts | 2 + packages/cli/src/ui/components/hooks/types.ts | 1 + .../src/ui/hooks/useAttentionNotifications.ts | 2 +- .../cli/src/ui/hooks/useToolScheduler.test.ts | 2 +- packages/core/src/config/config.test.ts | 4 +- packages/core/src/config/config.ts | 33 +-- packages/core/src/core/client.test.ts | 6 +- packages/core/src/core/client.ts | 2 +- packages/core/src/core/coreToolScheduler.ts | 4 +- packages/core/src/hooks/hookRegistry.test.ts | 79 +++--- packages/core/src/hooks/hookRegistry.ts | 8 +- .../schemas/settings.schema.json | 21 +- 33 files changed, 722 insertions(+), 322 deletions(-) create mode 100644 packages/cli/src/ui/components/hooks/HooksDisabledStep.test.tsx create mode 100644 packages/cli/src/ui/components/hooks/HooksDisabledStep.tsx diff --git a/docs/users/features/hooks.md b/docs/users/features/hooks.md index 5d32118c3..99b9d6370 100644 --- a/docs/users/features/hooks.md +++ b/docs/users/features/hooks.md @@ -4,13 +4,18 @@ Qwen Code hooks provide a powerful mechanism for extending and customizing the behavior of the Qwen Code application. Hooks allow users to execute custom scripts or programs at specific points in the application lifecycle, such as before tool execution, after tool execution, at session start/end, and during other key events. -> **⚠️ EXPERIMENTAL FEATURE** -> -> Hooks are currently in an experimental stage. To enable hooks, start Qwen Code with the `--experimental-hooks` flag: -> -> ```bash -> qwen --experimental-hooks -> ``` +Hooks are enabled by default. You can temporarily disable all hooks by setting `disableAllHooks` to `true` in your settings file (at the top level, alongside `hooks`): + +```json +{ + "disableAllHooks": true, + "hooks": { + "PreToolUse": [...] + } +} +``` + +This disables all hooks without deleting their configurations. ## What are Hooks? diff --git a/integration-tests/hook-integration/hooks.test.ts b/integration-tests/hook-integration/hooks.test.ts index affb1670d..1be3c3139 100644 --- a/integration-tests/hook-integration/hooks.test.ts +++ b/integration-tests/hook-integration/hooks.test.ts @@ -44,7 +44,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-allow-decision', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -73,7 +73,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-allow-tool', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -108,7 +108,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-block-decision', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -137,7 +137,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-block-tool', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -184,7 +184,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-modify-prompt', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -214,7 +214,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-add-context', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -241,7 +241,7 @@ describe('Hooks System Integration', () => { it('should continue execution when hook times out', async () => { await rig.setup('ups-timeout', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -269,7 +269,7 @@ describe('Hooks System Integration', () => { it('should continue execution when hook exits with non-blocking error (exit code 1)', async () => { await rig.setup('ups-nonblocking-error', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -295,7 +295,7 @@ describe('Hooks System Integration', () => { it('should block execution when hook exits with blocking error (exit code 2)', async () => { await rig.setup('ups-blocking-error', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -320,7 +320,7 @@ describe('Hooks System Integration', () => { it('should continue execution when hook command is empty', async () => { await rig.setup('ups-missing-command', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -352,7 +352,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-correct-input', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -382,7 +382,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-system-message', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -414,7 +414,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-multi-one-blocks', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -452,7 +452,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-seq-first-blocks', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -486,7 +486,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-seq-second-blocks', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -525,7 +525,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-multi-all-allow', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -569,7 +569,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-multi-all-block', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -605,7 +605,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-multi-context', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -639,7 +639,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-error-with-block', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -673,7 +673,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-timeout-with-block', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -709,7 +709,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-multi-groups', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -750,7 +750,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-multi-groups-one-blocks', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -790,7 +790,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-multi-modify', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -827,7 +827,7 @@ describe('Hooks System Integration', () => { await rig.setup('ups-multi-system-msg', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -869,7 +869,7 @@ describe('Hooks System Integration', () => { await rig.setup('stop-allow', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -897,7 +897,7 @@ describe('Hooks System Integration', () => { await rig.setup('stop-allow-final', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -931,7 +931,7 @@ describe('Hooks System Integration', () => { await rig.setup('stop-block-decision', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -972,7 +972,7 @@ describe('Hooks System Integration', () => { await rig.setup('stop-block-custom-reason', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -1014,7 +1014,7 @@ describe('Hooks System Integration', () => { await rig.setup('stop-add-context', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -1044,7 +1044,7 @@ describe('Hooks System Integration', () => { await rig.setup('stop-multi-context', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -1077,7 +1077,7 @@ describe('Hooks System Integration', () => { it('should continue stopping when hook times out', async () => { await rig.setup('stop-timeout', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -1105,7 +1105,7 @@ describe('Hooks System Integration', () => { it('should continue stopping when hook has non-blocking error', async () => { await rig.setup('stop-error', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -1131,7 +1131,7 @@ describe('Hooks System Integration', () => { it('should continue stopping when hook command does not exist', async () => { await rig.setup('stop-missing-command', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -1162,7 +1162,7 @@ describe('Hooks System Integration', () => { await rig.setup('stop-system-message', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -1196,7 +1196,7 @@ describe('Hooks System Integration', () => { await rig.setup('stop-multi-one-blocks', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -1249,7 +1249,7 @@ describe('Hooks System Integration', () => { await rig.setup('stop-seq-first-blocks', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -1303,7 +1303,7 @@ describe('Hooks System Integration', () => { await rig.setup('stop-seq-second-blocks', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -1358,7 +1358,7 @@ describe('Hooks System Integration', () => { await rig.setup('stop-multi-all-allow', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -1402,7 +1402,7 @@ describe('Hooks System Integration', () => { await rig.setup('stop-multi-all-block', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -1457,7 +1457,7 @@ describe('Hooks System Integration', () => { await rig.setup('multi-sequential', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -1493,7 +1493,7 @@ describe('Hooks System Integration', () => { await rig.setup('multi-first-blocks', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -1532,7 +1532,7 @@ describe('Hooks System Integration', () => { await rig.setup('multi-passthrough', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -1569,7 +1569,7 @@ describe('Hooks System Integration', () => { await rig.setup('multi-parallel', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -1604,7 +1604,7 @@ describe('Hooks System Integration', () => { await rig.setup('multi-mixed', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -1641,7 +1641,7 @@ describe('Hooks System Integration', () => { await rig.setup('multi-or-logic', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -3265,7 +3265,7 @@ describe('Hooks System Integration', () => { await rig.setup('combined-both-hooks', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -3306,7 +3306,7 @@ describe('Hooks System Integration', () => { await rig.setup('combined-ups-sessionend', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -3348,7 +3348,7 @@ describe('Hooks System Integration', () => { await rig.setup('combined-three-hooks', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -3403,7 +3403,7 @@ describe('Hooks System Integration', () => { await rig.setup('combined-all-hooks', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Stop: [ { @@ -3474,7 +3474,7 @@ describe('Hooks System Integration', () => { await rig.setup('script-file-hook', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -3503,7 +3503,7 @@ describe('Hooks System Integration', () => { await rig.setup('script-file-block-hook', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { UserPromptSubmit: [ { @@ -3539,7 +3539,7 @@ describe('Hooks System Integration', () => { await rig.setup('permission-req-allow-basic', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PermissionRequest: [ { @@ -3580,7 +3580,7 @@ describe('Hooks System Integration', () => { await rig.setup('permission-req-allow-safe-tools', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PermissionRequest: [ { @@ -3612,7 +3612,7 @@ describe('Hooks System Integration', () => { await rig.setup('permission-req-deny-basic', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PermissionRequest: [ { @@ -3657,7 +3657,7 @@ describe('Hooks System Integration', () => { await rig.setup('permission-req-block-dangerous', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PermissionRequest: [ { @@ -3695,7 +3695,7 @@ describe('Hooks System Integration', () => { await rig.setup('permission-req-multi-allow', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PermissionRequest: [ { @@ -3736,7 +3736,7 @@ describe('Hooks System Integration', () => { await rig.setup('permission-req-sequential-allow', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PermissionRequest: [ { @@ -3774,7 +3774,7 @@ describe('Hooks System Integration', () => { await rig.setup('permission-req-multi-one-denies', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PermissionRequest: [ { @@ -3816,7 +3816,7 @@ describe('Hooks System Integration', () => { await rig.setup('permission-req-sequential-first-denies', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PermissionRequest: [ { @@ -3861,7 +3861,7 @@ describe('Hooks System Integration', () => { await rig.setup('permission-req-matcher-specific', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PermissionRequest: [ { @@ -3890,7 +3890,7 @@ describe('Hooks System Integration', () => { await rig.setup('permission-req-matcher-wildcard', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PermissionRequest: [ { @@ -3927,7 +3927,7 @@ describe('Hooks System Integration', () => { await rig.setup('subagent-start-basic', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { SubagentStart: [ { @@ -3958,7 +3958,7 @@ describe('Hooks System Integration', () => { await rig.setup('subagent-start-context', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { SubagentStart: [ { @@ -3989,7 +3989,7 @@ describe('Hooks System Integration', () => { await rig.setup('subagent-start-context-only', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { SubagentStart: [ { @@ -4019,7 +4019,7 @@ describe('Hooks System Integration', () => { await rig.setup('subagent-start-error', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { SubagentStart: [ { @@ -4054,7 +4054,7 @@ describe('Hooks System Integration', () => { await rig.setup('subagent-start-parallel', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { SubagentStart: [ { @@ -4102,7 +4102,7 @@ describe('Hooks System Integration', () => { await rig.setup('subagent-start-sequential', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { SubagentStart: [ { @@ -4151,7 +4151,7 @@ describe('Hooks System Integration', () => { await rig.setup('subagent-start-matcher-specific', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { SubagentStart: [ { @@ -4183,7 +4183,7 @@ describe('Hooks System Integration', () => { await rig.setup('subagent-start-matcher-wildcard', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { SubagentStart: [ { @@ -4222,7 +4222,7 @@ describe('Hooks System Integration', () => { await rig.setup('subagent-stop-basic', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { SubagentStop: [ { @@ -4254,7 +4254,7 @@ describe('Hooks System Integration', () => { await rig.setup('subagent-stop-block-once', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { SubagentStop: [ { @@ -4288,7 +4288,7 @@ describe('Hooks System Integration', () => { await rig.setup('subagent-stop-error', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { SubagentStop: [ { @@ -4323,7 +4323,7 @@ describe('Hooks System Integration', () => { await rig.setup('subagent-stop-parallel', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { SubagentStop: [ { @@ -4371,7 +4371,7 @@ describe('Hooks System Integration', () => { await rig.setup('subagent-stop-sequential', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { SubagentStop: [ { @@ -4420,7 +4420,7 @@ describe('Hooks System Integration', () => { await rig.setup('subagent-stop-matcher-specific', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { SubagentStop: [ { @@ -4452,7 +4452,7 @@ describe('Hooks System Integration', () => { await rig.setup('subagent-stop-matcher-wildcard', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { SubagentStop: [ { @@ -4490,7 +4490,7 @@ describe('Hooks System Integration', () => { 'echo \'{"additionalContext": "Idle prompt notification processed"}\''; await rig.setup('notification-idle-prompt', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Notification: [ { @@ -4521,7 +4521,7 @@ describe('Hooks System Integration', () => { 'echo \'{"additionalContext": "Second idle prompt notification"}\''; await rig.setup('notification-idle-prompt-multiple', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Notification: [ { @@ -4560,7 +4560,7 @@ describe('Hooks System Integration', () => { await rig.setup('notification-elication-dialog', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Notification: [ { @@ -4591,7 +4591,7 @@ describe('Hooks System Integration', () => { 'echo \'{"additionalContext": "Second elication dialog notification"}\''; await rig.setup('notification-elication-dialog-multiple', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Notification: [ { @@ -4625,7 +4625,7 @@ describe('Hooks System Integration', () => { it('should handle elication dialog notification with error', async () => { await rig.setup('notification-elication-dialog-error', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Notification: [ { @@ -4659,7 +4659,7 @@ describe('Hooks System Integration', () => { await rig.setup('notification-multiple-different', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Notification: [ { @@ -4695,7 +4695,7 @@ describe('Hooks System Integration', () => { it('should handle missing command gracefully', async () => { await rig.setup('notification-missing-command', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Notification: [ { @@ -4722,7 +4722,7 @@ describe('Hooks System Integration', () => { it('should handle non-executable command gracefully', async () => { await rig.setup('notification-non-executable', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Notification: [ { @@ -4749,7 +4749,7 @@ describe('Hooks System Integration', () => { it('should handle command with non-zero exit code gracefully', async () => { await rig.setup('notification-nonzero-exit', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Notification: [ { @@ -4776,7 +4776,7 @@ describe('Hooks System Integration', () => { it('should handle command timeout gracefully', async () => { await rig.setup('notification-timeout', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { Notification: [ { @@ -4814,7 +4814,7 @@ describe('Hooks System Integration', () => { await rig.setup('pretooluse-allow-decision', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreToolUse: [ { @@ -4845,7 +4845,7 @@ describe('Hooks System Integration', () => { await rig.setup('pretooluse-allow-with-context', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreToolUse: [ { @@ -4878,7 +4878,7 @@ describe('Hooks System Integration', () => { await rig.setup('pretooluse-block-decision', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreToolUse: [ { @@ -4918,7 +4918,7 @@ describe('Hooks System Integration', () => { await rig.setup('pretooluse-block-specific-tool', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreToolUse: [ { @@ -4957,7 +4957,7 @@ describe('Hooks System Integration', () => { await rig.setup('pretooluse-matcher-specific', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreToolUse: [ { @@ -4989,7 +4989,7 @@ describe('Hooks System Integration', () => { await rig.setup('pretooluse-matcher-wildcard', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreToolUse: [ { @@ -5021,7 +5021,7 @@ describe('Hooks System Integration', () => { await rig.setup('pretooluse-matcher-no-match', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreToolUse: [ { @@ -5052,7 +5052,7 @@ describe('Hooks System Integration', () => { it('should continue execution when hook exits with non-blocking error', async () => { await rig.setup('pretooluse-nonblocking-error', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreToolUse: [ { @@ -5080,7 +5080,7 @@ describe('Hooks System Integration', () => { it('should continue execution when hook command does not exist', async () => { await rig.setup('pretooluse-missing-command', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreToolUse: [ { @@ -5115,7 +5115,7 @@ describe('Hooks System Integration', () => { await rig.setup('pretooluse-multi-parallel', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreToolUse: [ { @@ -5154,7 +5154,7 @@ describe('Hooks System Integration', () => { await rig.setup('pretooluse-multi-sequential', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreToolUse: [ { @@ -5193,7 +5193,7 @@ describe('Hooks System Integration', () => { await rig.setup('pretooluse-multi-one-blocks', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreToolUse: [ { @@ -5232,7 +5232,7 @@ describe('Hooks System Integration', () => { await rig.setup('pretooluse-seq-first-blocks', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreToolUse: [ { @@ -5273,7 +5273,7 @@ describe('Hooks System Integration', () => { await rig.setup('pretooluse-multi-context', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreToolUse: [ { @@ -5318,7 +5318,7 @@ describe('Hooks System Integration', () => { await rig.setup('posttooluse-basic', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PostToolUse: [ { @@ -5351,7 +5351,7 @@ describe('Hooks System Integration', () => { await rig.setup('posttooluse-matcher-specific', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PostToolUse: [ { @@ -5383,7 +5383,7 @@ describe('Hooks System Integration', () => { await rig.setup('posttooluse-matcher-wildcard', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PostToolUse: [ { @@ -5415,7 +5415,7 @@ describe('Hooks System Integration', () => { await rig.setup('posttooluse-matcher-no-match', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PostToolUse: [ { @@ -5451,7 +5451,7 @@ describe('Hooks System Integration', () => { await rig.setup('posttooluse-multi-parallel', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PostToolUse: [ { @@ -5490,7 +5490,7 @@ describe('Hooks System Integration', () => { await rig.setup('posttooluse-multi-sequential', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PostToolUse: [ { @@ -5530,7 +5530,7 @@ describe('Hooks System Integration', () => { await rig.setup('posttooluse-multi-context', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PostToolUse: [ { @@ -5575,7 +5575,7 @@ describe('Hooks System Integration', () => { await rig.setup('posttoolusefailure-basic', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PostToolUseFailure: [ { @@ -5611,7 +5611,7 @@ describe('Hooks System Integration', () => { await rig.setup('posttoolusefailure-with-details', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PostToolUseFailure: [ { @@ -5650,7 +5650,7 @@ describe('Hooks System Integration', () => { await rig.setup('precompact-basic', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreCompact: [ { @@ -5686,7 +5686,7 @@ describe('Hooks System Integration', () => { await rig.setup('precompact-with-details', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreCompact: [ { @@ -5719,7 +5719,7 @@ describe('Hooks System Integration', () => { await rig.setup('precompact-context', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreCompact: [ { @@ -5752,7 +5752,7 @@ describe('Hooks System Integration', () => { await rig.setup('precompact-matcher-wildcard', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreCompact: [ { @@ -5784,7 +5784,7 @@ describe('Hooks System Integration', () => { await rig.setup('precompact-matcher-no-match', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreCompact: [ { @@ -5820,7 +5820,7 @@ describe('Hooks System Integration', () => { await rig.setup('precompact-multi-parallel', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreCompact: [ { @@ -5859,7 +5859,7 @@ describe('Hooks System Integration', () => { await rig.setup('precompact-multi-sequential', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreCompact: [ { @@ -5899,7 +5899,7 @@ describe('Hooks System Integration', () => { await rig.setup('precompact-multi-context', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreCompact: [ { @@ -5935,7 +5935,7 @@ describe('Hooks System Integration', () => { it('should continue execution when hook exits with error', async () => { await rig.setup('precompact-error', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreCompact: [ { @@ -5963,7 +5963,7 @@ describe('Hooks System Integration', () => { it('should continue execution when hook command does not exist', async () => { await rig.setup('precompact-missing-command', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreCompact: [ { @@ -5991,7 +5991,7 @@ describe('Hooks System Integration', () => { it('should handle hook timeout gracefully', async () => { await rig.setup('precompact-timeout', { settings: { - hooksConfig: { enabled: true }, + disableAllHooks: false, hooks: { PreCompact: [ { diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 4b5229321..8d1c80c5a 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -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* () { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index fd009dddf..515d31df8 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -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) { diff --git a/packages/cli/src/commands/auth/handler.ts b/packages/cli/src/commands/auth/handler.ts index 1d03e9860..4dc059d43 100644 --- a/packages/cli/src/commands/auth/handler.ts +++ b/packages/cli/src/commands/auth/handler.ts @@ -82,7 +82,6 @@ export async function handleQwenAuth( acp: undefined, experimentalAcp: undefined, experimentalLsp: undefined, - experimentalHooks: undefined, extensions: [], listExtensions: undefined, openaiLogging: undefined, diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index fcc33f76a..e1762254d 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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 { '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 diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d2cf5081c..3ee6575f3 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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: { diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index b9ddb97fa..c9c80f9e7 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -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, diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index cb3229a2b..1b5ff45d3 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -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', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 3178ea533..d86c31b84 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -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', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index ac5f59111..d384f2ac5 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -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: 'ユーザー', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 993cd8d8c..c6e07c508 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -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', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index bb7e8968f..96653c019 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -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: 'Пользователь', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index ad755b721..42c648db1 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -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: '用户', diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 43da3235c..b2fe4ef2e 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -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(); }); }); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index f379a39de..df4555d73 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -80,7 +80,7 @@ export class BuiltinCommandLoader implements ICommandLoader { exportCommand, extensionsCommand, helpCommand, - ...(this.config?.getEnableHooks() ? [hooksCommand] : []), + hooksCommand, await ideCommand(), initCommand, languageCommand, diff --git a/packages/cli/src/ui/components/hooks/HooksDisabledStep.test.tsx b/packages/cli/src/ui/components/hooks/HooksDisabledStep.test.tsx new file mode 100644 index 000000000..5c6e232b5 --- /dev/null +++ b/packages/cli/src/ui/components/hooks/HooksDisabledStep.test.tsx @@ -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( + , + ); + + expect(lastFrame()).toContain('Hook Configuration - Disabled'); + }); + + it('should show configured hooks count', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('2 configured hooks'); + }); + + it('should show singular form for single hook', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('1 configured hook'); + }); + + it('should show zero hooks message', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('0 configured hooks'); + }); + + it('should show explanation items', () => { + const { lastFrame } = render( + , + ); + + 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( + , + ); + + expect(lastFrame()).toContain('To re-enable hooks'); + expect(lastFrame()).toContain('disableAllHooks'); + expect(lastFrame()).toContain('settings.json'); + }); + + it('should show Esc hint', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Esc to close'); + }); + + it('should handle large hook counts', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('100 configured hooks'); + }); +}); diff --git a/packages/cli/src/ui/components/hooks/HooksDisabledStep.tsx b/packages/cli/src/ui/components/hooks/HooksDisabledStep.tsx new file mode 100644 index 000000000..84045f83f --- /dev/null +++ b/packages/cli/src/ui/components/hooks/HooksDisabledStep.tsx @@ -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 ( + + {/* Title */} + + + {t('Hook Configuration - Disabled')} + + + + {/* Main message */} + + + {t( + 'All hooks are currently disabled. You have {{count}} that are not running.', + { + count: hooksText, + }, + )} + + + + {/* Explanation */} + + + {t('When hooks are disabled:')} + + + + {` · ${t('No hook commands will execute')}`} + + + + + {` · ${t('StatusLine will not be displayed')}`} + + + + + {` · ${t('Tool operations will proceed without hook validation')}`} + + + + + {/* How to re-enable */} + + + {t( + 'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.', + )} + + + + {/* Footer hint */} + + {t('Esc to close')} + + + ); +} diff --git a/packages/cli/src/ui/components/hooks/HooksManagementDialog.test.tsx b/packages/cli/src/ui/components/hooks/HooksManagementDialog.test.tsx index 08189ff49..3e1d103db 100644 --- a/packages/cli/src/ui/components/hooks/HooksManagementDialog.test.tsx +++ b/packages/cli/src/ui/components/hooks/HooksManagementDialog.test.tsx @@ -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( - , - ); - - expect(lastFrame()).toContain('Loading hooks'); + afterEach(() => { + keypressHandler = null; }); - it('should render with border', async () => { - const { lastFrame, unmount } = renderWithProviders( - , - ); + describe('Initial rendering', () => { + it('should render loading state initially', () => { + const { lastFrame } = renderWithProviders( + , + ); - 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( + , + ); - 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( + , + ); + + 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( - , - ); + describe('Keyboard navigation - HOOKS_LIST step', () => { + it('should register keypress handler with isActive: true', async () => { + renderWithProviders(); - 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(); + + 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( + , + ); + + 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( + , + ); + + 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); + + const { lastFrame, unmount } = renderWithProviders( + , + ); + + 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); + + renderWithProviders(); + + 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(); + + // Don't wait for loading to complete + expect(keypressHandler).not.toBeNull(); + keypressHandler!(createKey('escape', '\x1b')); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx b/packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx index fd18da36b..ef1909ab6 100644 --- a/packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx +++ b/packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx @@ -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([ - HOOKS_MANAGEMENT_STEPS.HOOKS_LIST, + disableAllHooks + ? HOOKS_MANAGEMENT_STEPS.HOOKS_DISABLED + : HOOKS_MANAGEMENT_STEPS.HOOKS_LIST, ]); const [selectedHookIndex, setSelectedHookIndex] = useState(-1); const [selectedConfigIndex, setSelectedConfigIndex] = useState(-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 ; + } + if (isLoading) { return ( diff --git a/packages/cli/src/ui/components/hooks/constants.ts b/packages/cli/src/ui/components/hooks/constants.ts index e18bf569a..d0d1bd751 100644 --- a/packages/cli/src/ui/components/hooks/constants.ts +++ b/packages/cli/src/ui/components/hooks/constants.ts @@ -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 diff --git a/packages/cli/src/ui/components/hooks/index.ts b/packages/cli/src/ui/components/hooks/index.ts index d2bcdb933..0c2669ec7 100644 --- a/packages/cli/src/ui/components/hooks/index.ts +++ b/packages/cli/src/ui/components/hooks/index.ts @@ -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'; diff --git a/packages/cli/src/ui/components/hooks/types.ts b/packages/cli/src/ui/components/hooks/types.ts index c4d3d92ee..4a8a3217b 100644 --- a/packages/cli/src/ui/components/hooks/types.ts +++ b/packages/cli/src/ui/components/hooks/types.ts @@ -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', diff --git a/packages/cli/src/ui/hooks/useAttentionNotifications.ts b/packages/cli/src/ui/hooks/useAttentionNotifications.ts index 39d547ee1..5ef6f2913 100644 --- a/packages/cli/src/ui/hooks/useAttentionNotifications.ts +++ b/packages/cli/src/ui/hooks/useAttentionNotifications.ts @@ -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, diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index dc898d46c..db8c878bc 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -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(), diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index aefe25ea1..17d88b174 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -347,7 +347,7 @@ describe('Server Config (config.ts)', () => { const mockMessageBus = { request: vi.fn() }; const config = new Config({ ...baseParams, - enableHooks: true, + disableAllHooks: false, }); // Set messageBus using the setter config.setMessageBus(mockMessageBus as unknown as MessageBus); @@ -378,7 +378,7 @@ describe('Server Config (config.ts)', () => { it('should not fire notification hook when hooks are disabled', async () => { const config = new Config({ ...baseParams, - enableHooks: false, + disableAllHooks: true, }); const authType = AuthType.USE_GEMINI; const mockContentConfig = { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c2d131991..68e609bbd 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -418,12 +418,10 @@ export interface ConfigParameters { modelProvidersConfig?: ModelProvidersConfig; /** Multi-agent collaboration settings (Arena, Team, Swarm) */ agents?: AgentsCollabSettings; - /** Enable hook system for lifecycle events */ - enableHooks?: boolean; + /** Disable all hooks (default: false, hooks enabled) */ + disableAllHooks?: boolean; /** Hooks configuration from settings */ hooks?: Record; - /** Hooks config settings (enabled, disabled list) */ - hooksConfig?: Record; /** Warnings generated during configuration resolution */ warnings?: string[]; /** @@ -593,9 +591,8 @@ export class Config { private readonly eventEmitter?: EventEmitter; private readonly channel: string | undefined; private readonly defaultFileEncoding: FileEncodingType | undefined; - private readonly enableHooks: boolean; + private readonly disableAllHooks: boolean; private readonly hooks?: Record; - private readonly hooksConfig?: Record; private hookSystem?: HookSystem; private messageBus?: MessageBus; @@ -759,9 +756,8 @@ export class Config { enabledExtensionOverrides: this.overrideExtensions, isWorkspaceTrusted: this.isTrustedFolder(), }); - this.enableHooks = params.enableHooks ?? false; + this.disableAllHooks = params.disableAllHooks ?? false; this.hooks = params.hooks; - this.hooksConfig = params.hooksConfig; } /** @@ -786,7 +782,7 @@ export class Config { this.debugLogger.debug('Extension manager initialized'); // Initialize hook system if enabled - if (this.enableHooks) { + if (!this.disableAllHooks) { this.hookSystem = new HookSystem(this); await this.hookSystem.initialize(); this.debugLogger.debug('Hook system initialized'); @@ -1072,7 +1068,7 @@ export class Config { // Fire auth_success notification hook (supports both interactive & non-interactive) const messageBus = this.getMessageBus(); - const hooksEnabled = this.getEnableHooks(); + const hooksEnabled = !this.getDisableAllHooks(); if (hooksEnabled && messageBus) { fireNotificationHook( messageBus, @@ -1779,10 +1775,10 @@ export class Config { } /** - * Check if hooks are enabled. + * Check if all hooks are disabled. */ - getEnableHooks(): boolean { - return this.enableHooks; + getDisableAllHooks(): boolean { + return this.disableAllHooks; } /** @@ -1801,17 +1797,6 @@ export class Config { this.messageBus = messageBus; } - /** - * Get the list of disabled hook names. - * This is used by the HookRegistry to filter out disabled hooks. - */ - getDisabledHooks(): string[] { - const hooksConfig = this.hooksConfig; - if (!hooksConfig) return []; - const disabled = hooksConfig['disabled']; - return Array.isArray(disabled) ? (disabled as string[]) : []; - } - /** * Get project-level hooks configuration. * This is used by the HookRegistry to load project-specific hooks. diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 3c181ba8f..bd7e1663d 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -2415,7 +2415,7 @@ Other open files: request: vi.fn(), response: vi.fn(), }; - vi.spyOn(client['config'], 'getEnableHooks').mockReturnValue(true); + vi.spyOn(client['config'], 'getDisableAllHooks').mockReturnValue(false); vi.spyOn(client['config'], 'getMessageBus').mockReturnValue( mockMessageBus as unknown as ReturnType, ); @@ -2439,7 +2439,7 @@ Other open files: request: vi.fn(), response: vi.fn(), }; - vi.spyOn(client['config'], 'getEnableHooks').mockReturnValue(true); + vi.spyOn(client['config'], 'getDisableAllHooks').mockReturnValue(false); vi.spyOn(client['config'], 'getMessageBus').mockReturnValue( mockMessageBus as unknown as ReturnType, ); @@ -2463,7 +2463,7 @@ Other open files: request: vi.fn().mockResolvedValue({ modifiedPrompt: undefined }), response: vi.fn(), }; - vi.spyOn(client['config'], 'getEnableHooks').mockReturnValue(true); + vi.spyOn(client['config'], 'getDisableAllHooks').mockReturnValue(false); vi.spyOn(client['config'], 'getMessageBus').mockReturnValue( mockMessageBus as unknown as ReturnType, ); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 43c6f556f..404830a96 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -463,7 +463,7 @@ export class GeminiClient { } // Fire UserPromptSubmit hook through MessageBus (only if hooks are enabled) - const hooksEnabled = this.config.getEnableHooks(); + const hooksEnabled = !this.config.getDisableAllHooks(); const messageBus = this.config.getMessageBus(); if ( messageType !== SendMessageType.Retry && diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 959c86e99..3161ddf75 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -1009,7 +1009,7 @@ export class CoreToolScheduler { const messageBus = this.config.getMessageBus() as | MessageBus | undefined; - const hooksEnabled = this.config.getEnableHooks(); + const hooksEnabled = !this.config.getDisableAllHooks(); if (hooksEnabled && messageBus) { const permissionMode = String(this.config.getApprovalMode()); @@ -1326,7 +1326,7 @@ export class CoreToolScheduler { // Get MessageBus for hook execution const messageBus = this.config.getMessageBus() as MessageBus | undefined; - const hooksEnabled = this.config.getEnableHooks(); + const hooksEnabled = !this.config.getDisableAllHooks(); // PreToolUse Hook if (hooksEnabled && messageBus) { diff --git a/packages/core/src/hooks/hookRegistry.test.ts b/packages/core/src/hooks/hookRegistry.test.ts index a9e79f5fa..bcb6481d9 100644 --- a/packages/core/src/hooks/hookRegistry.test.ts +++ b/packages/core/src/hooks/hookRegistry.test.ts @@ -28,7 +28,6 @@ describe('HookRegistry', () => { isTrustedFolder: vi.fn().mockReturnValue(true), getHooks: vi.fn().mockReturnValue(undefined), getProjectHooks: vi.fn().mockReturnValue(undefined), - getDisabledHooks: vi.fn().mockReturnValue([]), getExtensions: vi.fn().mockReturnValue([]), }; mockFeedbackEmitter = { @@ -123,21 +122,20 @@ describe('HookRegistry', () => { expect(postHooks[0].config.name).toBe('post-hook'); }); - it('should filter out disabled hooks', async () => { - mockConfig.getDisabledHooks = vi.fn().mockReturnValue(['disabled-hook']); + it('should register all hooks as enabled by default', async () => { const hooksConfig = { [HookEventName.PreToolUse]: [ { hooks: [ { type: HookType.Command, - command: 'echo enabled', - name: 'enabled-hook', + command: 'echo first', + name: 'first-hook', }, { type: HookType.Command, - command: 'echo disabled', - name: 'disabled-hook', + command: 'echo second', + name: 'second-hook', }, ], }, @@ -149,8 +147,9 @@ describe('HookRegistry', () => { await registry.initialize(); const hooks = registry.getHooksForEvent(HookEventName.PreToolUse); - expect(hooks).toHaveLength(1); - expect(hooks[0].config.name).toBe('enabled-hook'); + expect(hooks).toHaveLength(2); + expect(hooks[0].enabled).toBe(true); + expect(hooks[1].enabled).toBe(true); }); it('should sort hooks by source priority', async () => { @@ -181,37 +180,6 @@ describe('HookRegistry', () => { }); describe('setHookEnabled', () => { - it('should enable a disabled hook', async () => { - mockConfig.getDisabledHooks = vi.fn().mockReturnValue(['test-hook']); - const hooksConfig = { - [HookEventName.PreToolUse]: [ - { - hooks: [ - { - type: HookType.Command, - command: 'echo test', - name: 'test-hook', - }, - ], - }, - ], - }; - mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); - - const registry = new HookRegistry(mockConfig); - await registry.initialize(); - - expect(registry.getHooksForEvent(HookEventName.PreToolUse)).toHaveLength( - 0, - ); - - registry.setHookEnabled('test-hook', true); - - const hooks = registry.getHooksForEvent(HookEventName.PreToolUse); - expect(hooks).toHaveLength(1); - expect(hooks[0].enabled).toBe(true); - }); - it('should disable an enabled hook', async () => { const hooksConfig = { [HookEventName.PreToolUse]: [ @@ -237,9 +205,40 @@ describe('HookRegistry', () => { registry.setHookEnabled('test-hook', false); + const hooks = registry.getHooksForEvent(HookEventName.PreToolUse); + expect(hooks).toHaveLength(0); + }); + + it('should enable a disabled hook', async () => { + const hooksConfig = { + [HookEventName.PreToolUse]: [ + { + hooks: [ + { + type: HookType.Command, + command: 'echo test', + name: 'test-hook', + }, + ], + }, + ], + }; + mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig); + + const registry = new HookRegistry(mockConfig); + await registry.initialize(); + + // First disable the hook + registry.setHookEnabled('test-hook', false); expect(registry.getHooksForEvent(HookEventName.PreToolUse)).toHaveLength( 0, ); + + // Then enable it again + registry.setHookEnabled('test-hook', true); + expect(registry.getHooksForEvent(HookEventName.PreToolUse)).toHaveLength( + 1, + ); }); it('should update all hooks with matching name', async () => { diff --git a/packages/core/src/hooks/hookRegistry.ts b/packages/core/src/hooks/hookRegistry.ts index 54251c495..37fb76f0c 100644 --- a/packages/core/src/hooks/hookRegistry.ts +++ b/packages/core/src/hooks/hookRegistry.ts @@ -32,7 +32,6 @@ export interface HookRegistryConfig { isTrustedFolder(): boolean; getHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined; getProjectHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined; - getDisabledHooks(): string[]; getExtensions(): ExtensionWithHooks[]; } @@ -250,18 +249,13 @@ please review the project settings (.qwen/settings.json) and remove them.`; return; } - // Get disabled hooks list from settings - const disabledHooks = this.config.getDisabledHooks(); - for (const hookConfig of definition.hooks) { if ( hookConfig && typeof hookConfig === 'object' && this.validateHookConfig(hookConfig, eventName, source) ) { - // Check if this hook is in the disabled list const hookName = this.getHookName({ config: hookConfig }); - const isDisabled = disabledHooks.includes(hookName); // Check for duplicate hooks (same name+command+source+eventName+matcher+sequential) const isDuplicate = this.entries.some( @@ -288,7 +282,7 @@ please review the project settings (.qwen/settings.json) and remove them.`; eventName, matcher: definition.matcher, sequential: definition.sequential, - enabled: !isDisabled, + enabled: true, }); } else { // Invalid hooks are logged and discarded here, they won't reach HookRunner diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index c7f53048e..25586d562 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -640,23 +640,10 @@ } } }, - "hooksConfig": { - "description": "Hook configurations for intercepting and customizing agent behavior.", - "type": "object", - "properties": { - "enabled": { - "description": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.", - "type": "boolean", - "default": true - }, - "disabled": { - "description": "List of hook names (commands) that should be disabled. Hooks in this list will not execute even if configured.", - "type": "array", - "items": { - "type": "string" - } - } - } + "disableAllHooks": { + "description": "Temporarily disable all hooks without deleting configurations. Default is false (hooks enabled).", + "type": "boolean", + "default": false }, "hooks": { "description": "Hook event configurations for extending CLI behavior at various lifecycle points.",