fix comment

This commit is contained in:
DennisYu07 2026-03-25 11:08:23 +08:00
parent a0b3cc3268
commit 50ade83e4d
8 changed files with 232 additions and 288 deletions

View file

@ -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(
<HookConfigDetailStep
hookEvent={hookEvent}
hookConfig={hookConfig}
onBack={mockOnBack}
/>,
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Hook details');
@ -100,11 +89,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
<HookConfigDetailStep
hookEvent={hookEvent}
hookConfig={hookConfig}
onBack={mockOnBack}
/>,
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Event:');
@ -116,11 +101,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
<HookConfigDetailStep
hookEvent={hookEvent}
hookConfig={hookConfig}
onBack={mockOnBack}
/>,
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Type:');
@ -132,11 +113,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig(HooksConfigSource.User);
const { lastFrame } = render(
<HookConfigDetailStep
hookEvent={hookEvent}
hookConfig={hookConfig}
onBack={mockOnBack}
/>,
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Source:');
@ -148,11 +125,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig(HooksConfigSource.Project);
const { lastFrame } = render(
<HookConfigDetailStep
hookEvent={hookEvent}
hookConfig={hookConfig}
onBack={mockOnBack}
/>,
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Local Settings');
@ -167,11 +140,7 @@ describe('HookConfigDetailStep', () => {
);
const { lastFrame } = render(
<HookConfigDetailStep
hookEvent={hookEvent}
hookConfig={hookConfig}
onBack={mockOnBack}
/>,
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Extensions');
@ -186,11 +155,7 @@ describe('HookConfigDetailStep', () => {
);
const { lastFrame } = render(
<HookConfigDetailStep
hookEvent={hookEvent}
hookConfig={hookConfig}
onBack={mockOnBack}
/>,
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Extension:');
@ -202,11 +167,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig(HooksConfigSource.User);
const { lastFrame } = render(
<HookConfigDetailStep
hookEvent={hookEvent}
hookConfig={hookConfig}
onBack={mockOnBack}
/>,
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
// Should not have Extension label for User Settings
@ -220,11 +181,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
<HookConfigDetailStep
hookEvent={hookEvent}
hookConfig={hookConfig}
onBack={mockOnBack}
/>,
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Command:');
@ -245,11 +202,7 @@ describe('HookConfigDetailStep', () => {
};
const { lastFrame } = render(
<HookConfigDetailStep
hookEvent={hookEvent}
hookConfig={hookConfig}
onBack={mockOnBack}
/>,
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Name:');
@ -270,11 +223,7 @@ describe('HookConfigDetailStep', () => {
};
const { lastFrame } = render(
<HookConfigDetailStep
hookEvent={hookEvent}
hookConfig={hookConfig}
onBack={mockOnBack}
/>,
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Desc:');
@ -286,11 +235,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
<HookConfigDetailStep
hookEvent={hookEvent}
hookConfig={hookConfig}
onBack={mockOnBack}
/>,
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('To modify or remove this hook');
@ -301,11 +246,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
<HookConfigDetailStep
hookEvent={hookEvent}
hookConfig={hookConfig}
onBack={mockOnBack}
/>,
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Esc to go back');
@ -330,11 +271,7 @@ describe('HookConfigDetailStep', () => {
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
<HookConfigDetailStep
hookEvent={hookEvent}
hookConfig={hookConfig}
onBack={mockOnBack}
/>,
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain(event);

View file

@ -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) {

View file

@ -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(
<HookDetailStep hook={hook} onBack={mockOnBack} />,
<HookDetailStep hook={hook} selectedIndex={0} />,
);
expect(lastFrame()).toContain(HookEventName.PreToolUse);
@ -88,7 +81,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse, 0, true);
const { lastFrame } = render(
<HookDetailStep hook={hook} onBack={mockOnBack} />,
<HookDetailStep hook={hook} selectedIndex={0} />,
);
expect(lastFrame()).toContain('Detailed description for PreToolUse');
@ -98,7 +91,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.Stop, 0, false);
const { lastFrame } = render(
<HookDetailStep hook={hook} onBack={mockOnBack} />,
<HookDetailStep hook={hook} selectedIndex={0} />,
);
// Stop event has empty description
@ -110,7 +103,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse);
const { lastFrame } = render(
<HookDetailStep hook={hook} onBack={mockOnBack} />,
<HookDetailStep hook={hook} selectedIndex={0} />,
);
const output = lastFrame();
@ -125,7 +118,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse, 0);
const { lastFrame } = render(
<HookDetailStep hook={hook} onBack={mockOnBack} />,
<HookDetailStep hook={hook} selectedIndex={0} />,
);
const output = lastFrame();
@ -137,7 +130,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse, 2);
const { lastFrame } = render(
<HookDetailStep hook={hook} onBack={mockOnBack} />,
<HookDetailStep hook={hook} selectedIndex={0} />,
);
const output = lastFrame();
@ -151,7 +144,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse, 2);
const { lastFrame } = render(
<HookDetailStep hook={hook} onBack={mockOnBack} />,
<HookDetailStep hook={hook} selectedIndex={0} />,
);
const output = lastFrame();
@ -163,7 +156,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse, 3);
const { lastFrame } = render(
<HookDetailStep hook={hook} onBack={mockOnBack} />,
<HookDetailStep hook={hook} selectedIndex={0} />,
);
const output = lastFrame();
@ -174,7 +167,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse);
const { lastFrame } = render(
<HookDetailStep hook={hook} onBack={mockOnBack} />,
<HookDetailStep hook={hook} selectedIndex={0} />,
);
expect(lastFrame()).toContain('Esc to go back');
@ -184,7 +177,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(HookEventName.PostToolUse, 5);
const { lastFrame } = render(
<HookDetailStep hook={hook} onBack={mockOnBack} />,
<HookDetailStep hook={hook} selectedIndex={0} />,
);
const output = lastFrame();
@ -205,7 +198,7 @@ describe('HookDetailStep', () => {
};
const { lastFrame } = render(
<HookDetailStep hook={hook} onBack={mockOnBack} />,
<HookDetailStep hook={hook} selectedIndex={0} />,
);
const output = lastFrame();
@ -226,7 +219,7 @@ describe('HookDetailStep', () => {
const hook = createMockHookInfo(event, 1);
const { lastFrame } = render(
<HookDetailStep hook={hook} onBack={mockOnBack} />,
<HookDetailStep hook={hook} selectedIndex={0} />,
);
expect(lastFrame()).toContain(event);

View file

@ -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}`}
</Text>
</Box>
{/* Spacer between columns */}
<Box width={2} />
{/* Right column: source */}
<Box width={sourceWidth}>
<Text color={theme.text.secondary} wrap="wrap">
@ -146,13 +123,9 @@ export function HookDetailStep({
);
})}
<Box marginTop={1}>
{onSelectConfig ? (
<Text color={theme.text.secondary}>
{t('Enter to select · Esc to go back')}
</Text>
) : (
<Text color={theme.text.secondary}>{t('Esc to go back')}</Text>
)}
<Text color={theme.text.secondary}>
{t('Enter to select · Esc to go back')}
</Text>
</Box>
</>
) : (

View file

@ -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(
<HooksListStep
hooks={[]}
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
<HooksListStep hooks={[]} selectedIndex={0} />,
);
expect(lastFrame()).toContain('No hook events found');
@ -101,11 +89,7 @@ describe('HooksListStep', () => {
];
const { lastFrame } = render(
<HooksListStep
hooks={hooks}
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
<HooksListStep hooks={hooks} selectedIndex={0} />,
);
const output = lastFrame();
@ -121,11 +105,7 @@ describe('HooksListStep', () => {
];
const { lastFrame } = render(
<HooksListStep
hooks={hooks}
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
<HooksListStep hooks={hooks} selectedIndex={0} />,
);
const output = lastFrame();
@ -140,11 +120,7 @@ describe('HooksListStep', () => {
];
const { lastFrame } = render(
<HooksListStep
hooks={hooks}
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
<HooksListStep hooks={hooks} selectedIndex={0} />,
);
const output = lastFrame();
@ -157,11 +133,7 @@ describe('HooksListStep', () => {
];
const { lastFrame } = render(
<HooksListStep
hooks={hooks}
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
<HooksListStep hooks={hooks} selectedIndex={0} />,
);
const output = lastFrame();
@ -174,11 +146,7 @@ describe('HooksListStep', () => {
];
const { lastFrame } = render(
<HooksListStep
hooks={hooks}
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
<HooksListStep hooks={hooks} selectedIndex={0} />,
);
const output = lastFrame();
@ -192,11 +160,7 @@ describe('HooksListStep', () => {
];
const { lastFrame } = render(
<HooksListStep
hooks={hooks}
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
<HooksListStep hooks={hooks} selectedIndex={0} />,
);
const output = lastFrame();
@ -211,11 +175,7 @@ describe('HooksListStep', () => {
];
const { lastFrame } = render(
<HooksListStep
hooks={hooks}
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
<HooksListStep hooks={hooks} selectedIndex={0} />,
);
const output = lastFrame();
@ -228,11 +188,7 @@ describe('HooksListStep', () => {
];
const { lastFrame } = render(
<HooksListStep
hooks={hooks}
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
<HooksListStep hooks={hooks} selectedIndex={0} />,
);
const output = lastFrame();
@ -245,11 +201,7 @@ describe('HooksListStep', () => {
.map((_, i) => createMockHookInfo(`${i}` as HookEventName));
const { lastFrame } = render(
<HooksListStep
hooks={hooks}
onSelect={mockOnSelect}
onCancel={mockOnCancel}
/>,
<HooksListStep hooks={hooks} selectedIndex={0} />,
);
const output = lastFrame();

View file

@ -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 (
<Box flexDirection="column" paddingX={1}>

View file

@ -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<string, unknown>;
// 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<string, HookDefinition[]> {
if (typeof hooks !== 'object' || hooks === null) {
return false;
}
const record = hooks as Record<string, unknown>;
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<number>(-1);
const [selectedConfigIndex, setSelectedConfigIndex] = useState<number>(-1);
// Track selected index within each step for keyboard navigation
const [listSelectedIndex, setListSelectedIndex] = useState<number>(0);
const [detailSelectedIndex, setDetailSelectedIndex] = useState<number>(0);
const [hooks, setHooks] = useState<HookEventDisplayInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(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<string, unknown>)?.['hooks'] as
| Record<string, HookDefinition[]>
| undefined;
if (userHooks?.[eventName]) {
for (const def of userHooks[eventName]) {
// Get hooks from user settings (with type validation)
const userSettingsRecord = userSettings as Record<string, unknown>;
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<string, unknown>)?.[
'hooks'
] as Record<string, HookDefinition[]> | 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 (
<Box flexDirection="column" paddingX={1}>
@ -235,11 +361,7 @@ export function HooksManagementDialog({
switch (currentStep) {
case HOOKS_MANAGEMENT_STEPS.HOOKS_LIST:
return (
<HooksListStep
hooks={hooks}
onSelect={handleSelectHook}
onCancel={onClose}
/>
<HooksListStep hooks={hooks} selectedIndex={listSelectedIndex} />
);
case HOOKS_MANAGEMENT_STEPS.HOOK_DETAIL:
@ -247,8 +369,7 @@ export function HooksManagementDialog({
return (
<HookDetailStep
hook={selectedHook}
onBack={handleNavigateBack}
onSelectConfig={handleSelectConfig}
selectedIndex={detailSelectedIndex}
/>
);
}
@ -264,7 +385,6 @@ export function HooksManagementDialog({
<HookConfigDetailStep
hookEvent={selectedHook}
hookConfig={selectedConfig}
onBack={handleNavigateBack}
/>
);
}