diff --git a/packages/cli/src/ui/components/hooks/HookConfigDetailStep.test.tsx b/packages/cli/src/ui/components/hooks/HookConfigDetailStep.test.tsx
index 2c7385215..1f2728965 100644
--- a/packages/cli/src/ui/components/hooks/HookConfigDetailStep.test.tsx
+++ b/packages/cli/src/ui/components/hooks/HookConfigDetailStep.test.tsx
@@ -19,11 +19,6 @@ vi.mock('../../../i18n/index.js', () => ({
t: vi.fn((key: string) => key),
}));
-// Mock useKeypress
-vi.mock('../../hooks/useKeypress.js', () => ({
- useKeypress: vi.fn(),
-}));
-
// Mock useTerminalSize
vi.mock('../../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(() => ({ columns: 100, rows: 24 })),
@@ -44,8 +39,6 @@ vi.mock('../../semantic-colors.js', () => ({
}));
describe('HookConfigDetailStep', () => {
- const mockOnBack = vi.fn();
-
const createMockHookEvent = (): HookEventDisplayInfo => ({
event: HookEventName.Stop,
shortDescription: 'Right before Qwen Code concludes its response',
@@ -85,11 +78,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain('Hook details');
@@ -100,11 +89,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain('Event:');
@@ -116,11 +101,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain('Type:');
@@ -132,11 +113,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig(HooksConfigSource.User);
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain('Source:');
@@ -148,11 +125,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig(HooksConfigSource.Project);
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain('Local Settings');
@@ -167,11 +140,7 @@ describe('HookConfigDetailStep', () => {
);
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain('Extensions');
@@ -186,11 +155,7 @@ describe('HookConfigDetailStep', () => {
);
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain('Extension:');
@@ -202,11 +167,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig(HooksConfigSource.User);
const { lastFrame } = render(
- ,
+ ,
);
// Should not have Extension label for User Settings
@@ -220,11 +181,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain('Command:');
@@ -245,11 +202,7 @@ describe('HookConfigDetailStep', () => {
};
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain('Name:');
@@ -270,11 +223,7 @@ describe('HookConfigDetailStep', () => {
};
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain('Desc:');
@@ -286,11 +235,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain('To modify or remove this hook');
@@ -301,11 +246,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain('Esc to go back');
@@ -330,11 +271,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain(event);
diff --git a/packages/cli/src/ui/components/hooks/HookConfigDetailStep.tsx b/packages/cli/src/ui/components/hooks/HookConfigDetailStep.tsx
index e83345b43..27f3016a1 100644
--- a/packages/cli/src/ui/components/hooks/HookConfigDetailStep.tsx
+++ b/packages/cli/src/ui/components/hooks/HookConfigDetailStep.tsx
@@ -6,7 +6,6 @@
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
-import { useKeypress } from '../../hooks/useKeypress.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import type { HookConfigDisplayInfo, HookEventDisplayInfo } from './types.js';
import { HooksConfigSource } from '@qwen-code/qwen-code-core';
@@ -15,25 +14,14 @@ import { t } from '../../../i18n/index.js';
interface HookConfigDetailStepProps {
hookEvent: HookEventDisplayInfo;
hookConfig: HookConfigDisplayInfo;
- onBack: () => void;
}
export function HookConfigDetailStep({
hookEvent,
hookConfig,
- onBack,
}: HookConfigDetailStepProps): React.JSX.Element {
const { columns: terminalWidth } = useTerminalSize();
- useKeypress(
- (key) => {
- if (key.name === 'escape') {
- onBack();
- }
- },
- { isActive: true },
- );
-
// Get source display
const getSourceDisplay = (): string => {
switch (hookConfig.source) {
diff --git a/packages/cli/src/ui/components/hooks/HookDetailStep.test.tsx b/packages/cli/src/ui/components/hooks/HookDetailStep.test.tsx
index 4e53d0988..0b5f1c6b7 100644
--- a/packages/cli/src/ui/components/hooks/HookDetailStep.test.tsx
+++ b/packages/cli/src/ui/components/hooks/HookDetailStep.test.tsx
@@ -19,11 +19,6 @@ vi.mock('../../../i18n/index.js', () => ({
t: vi.fn((key: string) => key),
}));
-// Mock useKeypress
-vi.mock('../../hooks/useKeypress.js', () => ({
- useKeypress: vi.fn(),
-}));
-
// Mock useTerminalSize
vi.mock('../../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(() => ({ columns: 100, rows: 24 })),
@@ -45,8 +40,6 @@ vi.mock('../../semantic-colors.js', () => ({
}));
describe('HookDetailStep', () => {
- const mockOnBack = vi.fn();
-
const createMockHookInfo = (
event: HookEventName,
configCount = 0,
@@ -78,7 +71,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse);
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain(HookEventName.PreToolUse);
@@ -88,7 +81,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse, 0, true);
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain('Detailed description for PreToolUse');
@@ -98,7 +91,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.Stop, 0, false);
const { lastFrame } = render(
- ,
+ ,
);
// Stop event has empty description
@@ -110,7 +103,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse);
const { lastFrame } = render(
- ,
+ ,
);
const output = lastFrame();
@@ -125,7 +118,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse, 0);
const { lastFrame } = render(
- ,
+ ,
);
const output = lastFrame();
@@ -137,7 +130,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse, 2);
const { lastFrame } = render(
- ,
+ ,
);
const output = lastFrame();
@@ -151,7 +144,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse, 2);
const { lastFrame } = render(
- ,
+ ,
);
const output = lastFrame();
@@ -163,7 +156,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse, 3);
const { lastFrame } = render(
- ,
+ ,
);
const output = lastFrame();
@@ -174,7 +167,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse);
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain('Esc to go back');
@@ -184,7 +177,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.PostToolUse, 5);
const { lastFrame } = render(
- ,
+ ,
);
const output = lastFrame();
@@ -205,7 +198,7 @@ describe('HookDetailStep', () => {
};
const { lastFrame } = render(
- ,
+ ,
);
const output = lastFrame();
@@ -226,7 +219,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(event, 1);
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain(event);
diff --git a/packages/cli/src/ui/components/hooks/HookDetailStep.tsx b/packages/cli/src/ui/components/hooks/HookDetailStep.tsx
index 0a99a5cb7..69c5d24e3 100644
--- a/packages/cli/src/ui/components/hooks/HookDetailStep.tsx
+++ b/packages/cli/src/ui/components/hooks/HookDetailStep.tsx
@@ -4,10 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
-import { useKeypress } from '../../hooks/useKeypress.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import type { HookEventDisplayInfo } from './types.js';
import { HooksConfigSource } from '@qwen-code/qwen-code-core';
@@ -16,17 +14,14 @@ import { t } from '../../../i18n/index.js';
interface HookDetailStepProps {
hook: HookEventDisplayInfo;
- onBack: () => void;
- onSelectConfig?: (index: number) => void;
+ selectedIndex: number;
}
export function HookDetailStep({
hook,
- onBack,
- onSelectConfig,
+ selectedIndex,
}: HookDetailStepProps): React.JSX.Element {
const hasConfigs = hook.configs.length > 0;
- const [selectedIndex, setSelectedIndex] = useState(0);
const { columns: terminalWidth } = useTerminalSize();
// Get translated source display map
@@ -36,26 +31,6 @@ export function HookDetailStep({
const commandWidth = Math.floor(terminalWidth * 0.65);
const sourceWidth = Math.floor(terminalWidth * 0.3);
- // Handle keyboard navigation
- useKeypress(
- (key) => {
- if (key.name === 'escape') {
- onBack();
- } else if (hasConfigs) {
- if (key.name === 'up') {
- setSelectedIndex((prev) => Math.max(0, prev - 1));
- } else if (key.name === 'down') {
- setSelectedIndex((prev) =>
- Math.min(hook.configs.length - 1, prev + 1),
- );
- } else if (key.name === 'return' && onSelectConfig) {
- onSelectConfig(selectedIndex);
- }
- }
- },
- { isActive: true },
- );
-
// Get source display for config list
const getConfigSourceDisplay = (config: {
source: HooksConfigSource;
@@ -136,6 +111,8 @@ export function HookDetailStep({
{`${index + 1}. [${hookType}] ${command}`}
+ {/* Spacer between columns */}
+
{/* Right column: source */}
@@ -146,13 +123,9 @@ export function HookDetailStep({
);
})}
- {onSelectConfig ? (
-
- {t('Enter to select · Esc to go back')}
-
- ) : (
- {t('Esc to go back')}
- )}
+
+ {t('Enter to select · Esc to go back')}
+
>
) : (
diff --git a/packages/cli/src/ui/components/hooks/HooksListStep.test.tsx b/packages/cli/src/ui/components/hooks/HooksListStep.test.tsx
index 8d4b5f79f..5f60763bd 100644
--- a/packages/cli/src/ui/components/hooks/HooksListStep.test.tsx
+++ b/packages/cli/src/ui/components/hooks/HooksListStep.test.tsx
@@ -33,11 +33,6 @@ vi.mock('../../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(() => ({ columns: 120, rows: 24 })),
}));
-// Mock useKeypress
-vi.mock('../../hooks/useKeypress.js', () => ({
- useKeypress: vi.fn(),
-}));
-
// Mock semantic-colors
vi.mock('../../semantic-colors.js', () => ({
theme: {
@@ -54,9 +49,6 @@ vi.mock('../../semantic-colors.js', () => ({
}));
describe('HooksListStep', () => {
- const mockOnSelect = vi.fn();
- const mockOnCancel = vi.fn();
-
const createMockHookInfo = (
event: HookEventName,
configCount = 0,
@@ -84,11 +76,7 @@ describe('HooksListStep', () => {
it('should render empty state when no hooks', () => {
const { lastFrame } = render(
- ,
+ ,
);
expect(lastFrame()).toContain('No hook events found');
@@ -101,11 +89,7 @@ describe('HooksListStep', () => {
];
const { lastFrame } = render(
- ,
+ ,
);
const output = lastFrame();
@@ -121,11 +105,7 @@ describe('HooksListStep', () => {
];
const { lastFrame } = render(
- ,
+ ,
);
const output = lastFrame();
@@ -140,11 +120,7 @@ describe('HooksListStep', () => {
];
const { lastFrame } = render(
- ,
+ ,
);
const output = lastFrame();
@@ -157,11 +133,7 @@ describe('HooksListStep', () => {
];
const { lastFrame } = render(
- ,
+ ,
);
const output = lastFrame();
@@ -174,11 +146,7 @@ describe('HooksListStep', () => {
];
const { lastFrame } = render(
- ,
+ ,
);
const output = lastFrame();
@@ -192,11 +160,7 @@ describe('HooksListStep', () => {
];
const { lastFrame } = render(
- ,
+ ,
);
const output = lastFrame();
@@ -211,11 +175,7 @@ describe('HooksListStep', () => {
];
const { lastFrame } = render(
- ,
+ ,
);
const output = lastFrame();
@@ -228,11 +188,7 @@ describe('HooksListStep', () => {
];
const { lastFrame } = render(
- ,
+ ,
);
const output = lastFrame();
@@ -245,11 +201,7 @@ describe('HooksListStep', () => {
.map((_, i) => createMockHookInfo(`${i}` as HookEventName));
const { lastFrame } = render(
- ,
+ ,
);
const output = lastFrame();
diff --git a/packages/cli/src/ui/components/hooks/HooksListStep.tsx b/packages/cli/src/ui/components/hooks/HooksListStep.tsx
index 17b4e1b09..5b3da41f5 100644
--- a/packages/cli/src/ui/components/hooks/HooksListStep.tsx
+++ b/packages/cli/src/ui/components/hooks/HooksListStep.tsx
@@ -4,26 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
-import { useKeypress } from '../../hooks/useKeypress.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import type { HookEventDisplayInfo } from './types.js';
import { t } from '../../../i18n/index.js';
interface HooksListStepProps {
hooks: HookEventDisplayInfo[];
- onSelect: (index: number) => void;
- onCancel: () => void;
+ selectedIndex: number;
}
export function HooksListStep({
hooks,
- onSelect,
- onCancel,
+ selectedIndex,
}: HooksListStepProps): React.JSX.Element {
- const [selectedIndex, setSelectedIndex] = useState(0);
const { columns: terminalWidth } = useTerminalSize();
// Calculate responsive width for hook name column (min 20, max 35)
@@ -32,21 +27,6 @@ export function HooksListStep({
Math.max(20, Math.floor(terminalWidth * 0.25)),
);
- useKeypress(
- (key) => {
- if (key.name === 'up') {
- setSelectedIndex((prev) => Math.max(0, prev - 1));
- } else if (key.name === 'down') {
- setSelectedIndex((prev) => Math.min(hooks.length - 1, prev + 1));
- } else if (key.name === 'return') {
- onSelect(selectedIndex);
- } else if (key.name === 'escape') {
- onCancel();
- }
- },
- { isActive: true },
- );
-
if (hooks.length === 0) {
return (
diff --git a/packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx b/packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx
index 7d49e8e6a..8db0633d5 100644
--- a/packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx
+++ b/packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx
@@ -8,11 +8,13 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
+import { useKeypress } from '../../hooks/useKeypress.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { loadSettings, SettingScope } from '../../../config/settings.js';
import {
HooksConfigSource,
type HookDefinition,
+ type HookConfig,
createDebugLogger,
} from '@qwen-code/qwen-code-core';
import type {
@@ -32,6 +34,71 @@ import { t } from '../../../i18n/index.js';
const debugLogger = createDebugLogger('HOOKS_DIALOG');
+/**
+ * Type guard to check if a value is a valid HookConfig
+ */
+function isValidHookConfig(config: unknown): config is HookConfig {
+ return (
+ typeof config === 'object' &&
+ config !== null &&
+ 'type' in config &&
+ 'command' in config &&
+ typeof (config as HookConfig).command === 'string'
+ );
+}
+
+/**
+ * Type guard to check if a value is a valid HookDefinition
+ */
+function isValidHookDefinition(def: unknown): def is HookDefinition {
+ if (typeof def !== 'object' || def === null) {
+ return false;
+ }
+ const obj = def as Record;
+ // hooks array is required
+ if (!('hooks' in obj) || !Array.isArray(obj['hooks'])) {
+ return false;
+ }
+ // Validate each hook config in the array
+ for (const hook of obj['hooks']) {
+ if (!isValidHookConfig(hook)) {
+ return false;
+ }
+ }
+ // matcher is optional but must be a string if present
+ if ('matcher' in obj && typeof obj['matcher'] !== 'string') {
+ return false;
+ }
+ // sequential is optional but must be a boolean if present
+ if ('sequential' in obj && typeof obj['sequential'] !== 'boolean') {
+ return false;
+ }
+ return true;
+}
+
+/**
+ * Type guard to check if a value is a valid hooks record
+ */
+function isValidHooksRecord(
+ hooks: unknown,
+): hooks is Record {
+ if (typeof hooks !== 'object' || hooks === null) {
+ return false;
+ }
+ const record = hooks as Record;
+ for (const value of Object.values(record)) {
+ if (!Array.isArray(value)) {
+ return false;
+ }
+ for (const def of value) {
+ if (!isValidHookDefinition(def)) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
export function HooksManagementDialog({
onClose,
}: HooksManagementDialogProps): React.JSX.Element {
@@ -44,10 +111,94 @@ export function HooksManagementDialog({
]);
const [selectedHookIndex, setSelectedHookIndex] = useState(-1);
const [selectedConfigIndex, setSelectedConfigIndex] = useState(-1);
+ // Track selected index within each step for keyboard navigation
+ const [listSelectedIndex, setListSelectedIndex] = useState(0);
+ const [detailSelectedIndex, setDetailSelectedIndex] = useState(0);
const [hooks, setHooks] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
+ // Current step
+ const currentStep =
+ navigationStack[navigationStack.length - 1] ||
+ HOOKS_MANAGEMENT_STEPS.HOOKS_LIST;
+
+ // Selected hook event
+ const selectedHook = useMemo(() => {
+ if (selectedHookIndex >= 0 && selectedHookIndex < hooks.length) {
+ return hooks[selectedHookIndex];
+ }
+ return null;
+ }, [hooks, selectedHookIndex]);
+
+ // Centralized keyboard handler
+ useKeypress(
+ (key) => {
+ if (isLoading || loadError) {
+ // Allow Escape to close even during loading/error states
+ if (key.name === 'escape') {
+ onClose();
+ }
+ return;
+ }
+
+ switch (currentStep) {
+ case HOOKS_MANAGEMENT_STEPS.HOOKS_LIST:
+ if (key.name === 'up') {
+ setListSelectedIndex((prev) => Math.max(0, prev - 1));
+ } else if (key.name === 'down') {
+ setListSelectedIndex((prev) =>
+ Math.min(hooks.length - 1, prev + 1),
+ );
+ } else if (key.name === 'return') {
+ if (hooks.length > 0 && listSelectedIndex >= 0) {
+ setSelectedHookIndex(listSelectedIndex);
+ setSelectedConfigIndex(-1);
+ setDetailSelectedIndex(0);
+ setNavigationStack((prev) => [
+ ...prev,
+ HOOKS_MANAGEMENT_STEPS.HOOK_DETAIL,
+ ]);
+ }
+ } else if (key.name === 'escape') {
+ onClose();
+ }
+ break;
+
+ case HOOKS_MANAGEMENT_STEPS.HOOK_DETAIL:
+ if (key.name === 'escape') {
+ handleNavigateBack();
+ } else if (selectedHook && selectedHook.configs.length > 0) {
+ if (key.name === 'up') {
+ setDetailSelectedIndex((prev) => Math.max(0, prev - 1));
+ } else if (key.name === 'down') {
+ setDetailSelectedIndex((prev) =>
+ Math.min(selectedHook.configs.length - 1, prev + 1),
+ );
+ } else if (key.name === 'return') {
+ setSelectedConfigIndex(detailSelectedIndex);
+ setNavigationStack((prev) => [
+ ...prev,
+ HOOKS_MANAGEMENT_STEPS.HOOK_CONFIG_DETAIL,
+ ]);
+ }
+ }
+ break;
+
+ case HOOKS_MANAGEMENT_STEPS.HOOK_CONFIG_DETAIL:
+ if (key.name === 'escape') {
+ handleNavigateBack();
+ }
+ break;
+
+ default:
+ // No action for unknown steps
+ break;
+ }
+ },
+ { isActive: true },
+ );
+
// Load hooks data
const fetchHooksData = useCallback((): HookEventDisplayInfo[] => {
if (!config) return [];
@@ -66,12 +217,11 @@ export function HooksManagementDialog({
for (const eventName of DISPLAY_HOOK_EVENTS) {
const hookInfo = createEmptyHookEventInfo(eventName);
- // Get hooks from user settings
- const userHooks = (userSettings as Record)?.['hooks'] as
- | Record
- | undefined;
- if (userHooks?.[eventName]) {
- for (const def of userHooks[eventName]) {
+ // Get hooks from user settings (with type validation)
+ const userSettingsRecord = userSettings as Record;
+ const userHooksRaw = userSettingsRecord?.['hooks'];
+ if (isValidHooksRecord(userHooksRaw) && userHooksRaw[eventName]) {
+ for (const def of userHooksRaw[eventName]) {
for (const hookConfig of def.hooks) {
hookInfo.configs.push({
config: hookConfig,
@@ -83,12 +233,17 @@ export function HooksManagementDialog({
}
}
- // Get hooks from workspace settings
- const workspaceHooks = (workspaceSettings as Record)?.[
- 'hooks'
- ] as Record | undefined;
- if (workspaceHooks?.[eventName]) {
- for (const def of workspaceHooks[eventName]) {
+ // Get hooks from workspace settings (with type validation)
+ const workspaceSettingsRecord = workspaceSettings as Record<
+ string,
+ unknown
+ >;
+ const workspaceHooksRaw = workspaceSettingsRecord?.['hooks'];
+ if (
+ isValidHooksRecord(workspaceHooksRaw) &&
+ workspaceHooksRaw[eventName]
+ ) {
+ for (const def of workspaceHooksRaw[eventName]) {
for (const hookConfig of def.hooks) {
hookInfo.configs.push({
config: hookConfig,
@@ -100,19 +255,24 @@ export function HooksManagementDialog({
}
}
- // Get hooks from extensions
+ // Get hooks from extensions (with type validation)
const extensions = config.getExtensions() || [];
for (const extension of extensions) {
if (extension.isActive && extension.hooks?.[eventName]) {
- for (const def of extension.hooks[eventName]!) {
- for (const hookConfig of def.hooks) {
- hookInfo.configs.push({
- config: hookConfig,
- source: HooksConfigSource.Extensions,
- sourceDisplay: extension.name,
- sourcePath: extension.path,
- enabled: true,
- });
+ const extensionHooks = extension.hooks[eventName];
+ if (Array.isArray(extensionHooks)) {
+ for (const def of extensionHooks) {
+ if (isValidHookDefinition(def)) {
+ for (const hookConfig of def.hooks) {
+ hookInfo.configs.push({
+ config: hookConfig,
+ source: HooksConfigSource.Extensions,
+ sourceDisplay: extension.name,
+ sourcePath: extension.path,
+ enabled: true,
+ });
+ }
+ }
}
}
}
@@ -151,15 +311,7 @@ export function HooksManagementDialog({
};
}, [fetchHooksData]);
- // Current step
- const getCurrentStep = useCallback(
- () =>
- navigationStack[navigationStack.length - 1] ||
- HOOKS_MANAGEMENT_STEPS.HOOKS_LIST,
- [navigationStack],
- );
-
- // Navigation handlers
+ // Navigation handler for going back
const handleNavigateBack = useCallback(() => {
setNavigationStack((prev) => {
if (prev.length <= 1) {
@@ -170,30 +322,6 @@ export function HooksManagementDialog({
});
}, [onClose]);
- // Select hook event
- const handleSelectHook = useCallback((index: number) => {
- setSelectedHookIndex(index);
- setSelectedConfigIndex(-1);
- setNavigationStack((prev) => [...prev, HOOKS_MANAGEMENT_STEPS.HOOK_DETAIL]);
- }, []);
-
- // Select hook config
- const handleSelectConfig = useCallback((index: number) => {
- setSelectedConfigIndex(index);
- setNavigationStack((prev) => [
- ...prev,
- HOOKS_MANAGEMENT_STEPS.HOOK_CONFIG_DETAIL,
- ]);
- }, []);
-
- // Selected hook event
- const selectedHook = useMemo(() => {
- if (selectedHookIndex >= 0 && selectedHookIndex < hooks.length) {
- return hooks[selectedHookIndex];
- }
- return null;
- }, [hooks, selectedHookIndex]);
-
// Selected hook config
const selectedConfig = useMemo(() => {
if (
@@ -208,8 +336,6 @@ export function HooksManagementDialog({
// Render based on current step
const renderContent = () => {
- const currentStep = getCurrentStep();
-
if (isLoading) {
return (
@@ -235,11 +361,7 @@ export function HooksManagementDialog({
switch (currentStep) {
case HOOKS_MANAGEMENT_STEPS.HOOKS_LIST:
return (
-
+
);
case HOOKS_MANAGEMENT_STEPS.HOOK_DETAIL:
@@ -247,8 +369,7 @@ export function HooksManagementDialog({
return (
);
}
@@ -264,7 +385,6 @@ export function HooksManagementDialog({
);
}
diff --git a/packages/core/src/extension/variables.ts b/packages/core/src/extension/variables.ts
index 31c1a28e3..d9c623e78 100644
--- a/packages/core/src/extension/variables.ts
+++ b/packages/core/src/extension/variables.ts
@@ -7,7 +7,8 @@
import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js';
import path from 'node:path';
import { QWEN_DIR } from '../config/storage.js';
-import type { HookDefinition, HookEventName } from '../hooks/types.js';
+import type { HookDefinition } from '../hooks/types.js';
+import type { HookEventName } from '../hooks/types.js';
import * as fs from 'node:fs';
import { glob } from 'glob';
import { createDebugLogger } from '../utils/debugLogger.js';