mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 20:20:57 +00:00
Merge branch 'main' into fix/acp-permission-flow
This commit is contained in:
commit
0fd6f3a78b
51 changed files with 6164 additions and 441 deletions
|
|
@ -41,6 +41,7 @@ import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
|||
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
||||
import { ExtensionsManagerDialog } from './extensions/ExtensionsManagerDialog.js';
|
||||
import { MCPManagementDialog } from './mcp/MCPManagementDialog.js';
|
||||
import { HooksManagementDialog } from './hooks/HooksManagementDialog.js';
|
||||
import { SessionPicker } from './SessionPicker.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
|
|
@ -351,6 +352,9 @@ export const DialogManager = ({
|
|||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isHooksDialogOpen) {
|
||||
return <HooksManagementDialog onClose={uiActions.closeHooksDialog} />;
|
||||
}
|
||||
if (uiState.isMcpDialogOpen) {
|
||||
return <MCPManagementDialog onClose={uiActions.closeMcpDialog} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,280 @@
|
|||
/**
|
||||
* @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 {
|
||||
HookEventName,
|
||||
HooksConfigSource,
|
||||
HookType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { HookConfigDetailStep } from './HookConfigDetailStep.js';
|
||||
import type { HookEventDisplayInfo, HookConfigDisplayInfo } from './types.js';
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('../../../i18n/index.js', () => ({
|
||||
t: vi.fn((key: string) => key),
|
||||
}));
|
||||
|
||||
// Mock useTerminalSize
|
||||
vi.mock('../../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(() => ({ columns: 100, rows: 24 })),
|
||||
}));
|
||||
|
||||
// Mock semantic-colors
|
||||
vi.mock('../../semantic-colors.js', () => ({
|
||||
theme: {
|
||||
text: {
|
||||
primary: 'white',
|
||||
secondary: 'gray',
|
||||
accent: 'cyan',
|
||||
},
|
||||
border: {
|
||||
default: 'gray',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('HookConfigDetailStep', () => {
|
||||
const createMockHookEvent = (): HookEventDisplayInfo => ({
|
||||
event: HookEventName.Stop,
|
||||
shortDescription: 'Right before Qwen Code concludes its response',
|
||||
description: '',
|
||||
exitCodes: [
|
||||
{ code: 0, description: 'stdout/stderr not shown' },
|
||||
{
|
||||
code: 2,
|
||||
description: 'show stderr to model and continue conversation',
|
||||
},
|
||||
{ code: 'Other', description: 'show stderr to user only' },
|
||||
],
|
||||
configs: [],
|
||||
});
|
||||
|
||||
const createMockHookConfig = (
|
||||
source: HooksConfigSource = HooksConfigSource.User,
|
||||
sourceDisplay = 'User Settings',
|
||||
sourcePath?: string,
|
||||
): HookConfigDisplayInfo => ({
|
||||
config: {
|
||||
type: HookType.Command,
|
||||
command: '/path/to/hook.sh',
|
||||
},
|
||||
source,
|
||||
sourceDisplay,
|
||||
sourcePath,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render hook details title', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Hook details');
|
||||
});
|
||||
|
||||
it('should render event name', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Event:');
|
||||
expect(lastFrame()).toContain(HookEventName.Stop);
|
||||
});
|
||||
|
||||
it('should render hook type', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Type:');
|
||||
expect(lastFrame()).toContain('command');
|
||||
});
|
||||
|
||||
it('should render source for User Settings', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig(HooksConfigSource.User);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Source:');
|
||||
expect(lastFrame()).toContain('User Settings');
|
||||
});
|
||||
|
||||
it('should render source for Local Settings', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig(HooksConfigSource.Project);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Local Settings');
|
||||
});
|
||||
|
||||
it('should render source for Extensions with path', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig(
|
||||
HooksConfigSource.Extensions,
|
||||
'ralph-wiggum',
|
||||
'/Users/test/.qwen/extensions/ralph-wiggum',
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Extensions');
|
||||
expect(lastFrame()).toContain('/Users/test/.qwen/extensions/ralph-wiggum');
|
||||
});
|
||||
|
||||
it('should render Extension field for extensions', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig(
|
||||
HooksConfigSource.Extensions,
|
||||
'ralph-wiggum',
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Extension:');
|
||||
expect(lastFrame()).toContain('ralph-wiggum');
|
||||
});
|
||||
|
||||
it('should not render Extension field for non-extensions', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig(HooksConfigSource.User);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
// Should not have Extension label for User Settings
|
||||
const output = lastFrame();
|
||||
const extensionMatch = output?.match(/Extension:/g);
|
||||
expect(extensionMatch).toBeNull();
|
||||
});
|
||||
|
||||
it('should render command', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Command:');
|
||||
expect(lastFrame()).toContain('/path/to/hook.sh');
|
||||
});
|
||||
|
||||
it('should render hook name if present', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig: HookConfigDisplayInfo = {
|
||||
config: {
|
||||
type: HookType.Command,
|
||||
command: '/path/to/hook.sh',
|
||||
name: 'My Hook',
|
||||
},
|
||||
source: HooksConfigSource.User,
|
||||
sourceDisplay: 'User Settings',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Name:');
|
||||
expect(lastFrame()).toContain('My Hook');
|
||||
});
|
||||
|
||||
it('should render hook description if present', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig: HookConfigDisplayInfo = {
|
||||
config: {
|
||||
type: HookType.Command,
|
||||
command: '/path/to/hook.sh',
|
||||
description: 'A test hook',
|
||||
},
|
||||
source: HooksConfigSource.User,
|
||||
sourceDisplay: 'User Settings',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Desc:');
|
||||
expect(lastFrame()).toContain('A test hook');
|
||||
});
|
||||
|
||||
it('should render help text', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('To modify or remove this hook');
|
||||
});
|
||||
|
||||
it('should render Esc hint', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Esc to go back');
|
||||
});
|
||||
|
||||
it('should handle different event types', () => {
|
||||
const events = [
|
||||
HookEventName.PreToolUse,
|
||||
HookEventName.PostToolUse,
|
||||
HookEventName.UserPromptSubmit,
|
||||
HookEventName.SessionStart,
|
||||
];
|
||||
|
||||
for (const event of events) {
|
||||
const hookEvent: HookEventDisplayInfo = {
|
||||
event,
|
||||
shortDescription: 'Test',
|
||||
description: '',
|
||||
exitCodes: [],
|
||||
configs: [],
|
||||
};
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain(event);
|
||||
}
|
||||
});
|
||||
});
|
||||
167
packages/cli/src/ui/components/hooks/HookConfigDetailStep.tsx
Normal file
167
packages/cli/src/ui/components/hooks/HookConfigDetailStep.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import type { HookConfigDisplayInfo, HookEventDisplayInfo } from './types.js';
|
||||
import { HooksConfigSource } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
interface HookConfigDetailStepProps {
|
||||
hookEvent: HookEventDisplayInfo;
|
||||
hookConfig: HookConfigDisplayInfo;
|
||||
}
|
||||
|
||||
export function HookConfigDetailStep({
|
||||
hookEvent,
|
||||
hookConfig,
|
||||
}: HookConfigDetailStepProps): React.JSX.Element {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
|
||||
// Get source display
|
||||
const getSourceDisplay = (): string => {
|
||||
switch (hookConfig.source) {
|
||||
case HooksConfigSource.Project:
|
||||
return t('Local Settings');
|
||||
case HooksConfigSource.User:
|
||||
return t('User Settings');
|
||||
case HooksConfigSource.System:
|
||||
return t('System Settings');
|
||||
case HooksConfigSource.Extensions:
|
||||
return t('Extensions');
|
||||
default:
|
||||
return hookConfig.source;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if this is from an extension
|
||||
const isFromExtension = hookConfig.source === HooksConfigSource.Extensions;
|
||||
|
||||
// Get hook type display
|
||||
const getHookTypeDisplay = (): string => {
|
||||
switch (hookConfig.config.type) {
|
||||
case 'command':
|
||||
return 'command';
|
||||
default:
|
||||
return hookConfig.config.type;
|
||||
}
|
||||
};
|
||||
|
||||
// Get command to display
|
||||
const getCommand = (): string => {
|
||||
if (hookConfig.config.type === 'command') {
|
||||
return hookConfig.config.command;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Calculate box width for command display
|
||||
const commandBoxWidth = Math.min(terminalWidth - 6, 80);
|
||||
|
||||
// Label width for alignment (Extension: is the longest label)
|
||||
const labelWidth = 12;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{/* Title */}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Hook details')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Event */}
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Event:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>{hookEvent.event}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Type */}
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Type:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>{getHookTypeDisplay()}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Source */}
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Source:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>{getSourceDisplay()}</Text>
|
||||
{hookConfig.sourcePath && (
|
||||
<Text color={theme.text.secondary}> ({hookConfig.sourcePath})</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Extension name (only for extensions) */}
|
||||
{isFromExtension && hookConfig.sourceDisplay && (
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Extension:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>{hookConfig.sourceDisplay}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Name (if exists) */}
|
||||
{hookConfig.config.name && (
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Name:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>{hookConfig.config.name}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Description (if exists) */}
|
||||
{hookConfig.config.description && (
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Desc:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>
|
||||
{hookConfig.config.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Command */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Command:')}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Command box */}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={1}
|
||||
width={commandBoxWidth}
|
||||
>
|
||||
<Text color={theme.text.primary}>{getCommand()}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Help text */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t(
|
||||
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Footer hint */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Esc to go back')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
228
packages/cli/src/ui/components/hooks/HookDetailStep.test.tsx
Normal file
228
packages/cli/src/ui/components/hooks/HookDetailStep.test.tsx
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* @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 {
|
||||
HookEventName,
|
||||
HooksConfigSource,
|
||||
HookType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { HookDetailStep } from './HookDetailStep.js';
|
||||
import type { HookEventDisplayInfo } from './types.js';
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('../../../i18n/index.js', () => ({
|
||||
t: vi.fn((key: string) => key),
|
||||
}));
|
||||
|
||||
// Mock useTerminalSize
|
||||
vi.mock('../../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(() => ({ columns: 100, rows: 24 })),
|
||||
}));
|
||||
|
||||
// Mock semantic-colors
|
||||
vi.mock('../../semantic-colors.js', () => ({
|
||||
theme: {
|
||||
text: {
|
||||
primary: 'white',
|
||||
secondary: 'gray',
|
||||
accent: 'cyan',
|
||||
},
|
||||
status: {
|
||||
success: 'green',
|
||||
error: 'red',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('HookDetailStep', () => {
|
||||
const createMockHookInfo = (
|
||||
event: HookEventName,
|
||||
configCount = 0,
|
||||
hasDescription = true,
|
||||
): HookEventDisplayInfo => ({
|
||||
event,
|
||||
shortDescription: `Short description for ${event}`,
|
||||
description: hasDescription ? `Detailed description for ${event}` : '',
|
||||
exitCodes: [
|
||||
{ code: 0, description: 'Success' },
|
||||
{ code: 2, description: 'Block' },
|
||||
],
|
||||
configs: Array(configCount)
|
||||
.fill(null)
|
||||
.map((_, i) => ({
|
||||
config: { command: `hook-command-${i}`, type: HookType.Command },
|
||||
source:
|
||||
i % 2 === 0 ? HooksConfigSource.User : HooksConfigSource.Project,
|
||||
sourceDisplay: i % 2 === 0 ? 'User Settings' : 'Local Settings',
|
||||
enabled: true,
|
||||
})),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render hook event name as title', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PreToolUse);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain(HookEventName.PreToolUse);
|
||||
});
|
||||
|
||||
it('should render description when present', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PreToolUse, 0, true);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Detailed description for PreToolUse');
|
||||
});
|
||||
|
||||
it('should not render description section when empty', () => {
|
||||
const hook = createMockHookInfo(HookEventName.Stop, 0, false);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
// Stop event has empty description
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(HookEventName.Stop);
|
||||
});
|
||||
|
||||
it('should render exit codes', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PreToolUse);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Exit codes');
|
||||
expect(output).toContain('0');
|
||||
expect(output).toContain('Success');
|
||||
expect(output).toContain('2');
|
||||
expect(output).toContain('Block');
|
||||
});
|
||||
|
||||
it('should show empty state when no configs', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PreToolUse, 0);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('No hooks configured for this event');
|
||||
expect(output).toContain('To add hooks, edit settings.json');
|
||||
});
|
||||
|
||||
it('should show configured hooks list when configs exist', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PreToolUse, 2);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Configured hooks');
|
||||
expect(output).toContain('[command]');
|
||||
expect(output).toContain('hook-command-0');
|
||||
expect(output).toContain('hook-command-1');
|
||||
});
|
||||
|
||||
it('should show source display for each config', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PreToolUse, 2);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('User Settings');
|
||||
expect(output).toContain('Local Settings');
|
||||
});
|
||||
|
||||
it('should show selection indicator for first config', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PreToolUse, 3);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('❯');
|
||||
});
|
||||
|
||||
it('should show keyboard hint for going back', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PreToolUse);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Esc to go back');
|
||||
});
|
||||
|
||||
it('should render with multiple configs', () => {
|
||||
const hook = createMockHookInfo(HookEventName.PostToolUse, 5);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('1.');
|
||||
expect(output).toContain('2.');
|
||||
expect(output).toContain('3.');
|
||||
expect(output).toContain('4.');
|
||||
expect(output).toContain('5.');
|
||||
});
|
||||
|
||||
it('should handle hook with no exit codes', () => {
|
||||
const hook: HookEventDisplayInfo = {
|
||||
event: HookEventName.PreToolUse,
|
||||
shortDescription: 'Test',
|
||||
description: 'Test description',
|
||||
exitCodes: [],
|
||||
configs: [],
|
||||
};
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Exit codes');
|
||||
});
|
||||
|
||||
it('should handle different hook event types', () => {
|
||||
const events = [
|
||||
HookEventName.Stop,
|
||||
HookEventName.PreToolUse,
|
||||
HookEventName.PostToolUse,
|
||||
HookEventName.UserPromptSubmit,
|
||||
HookEventName.SessionStart,
|
||||
HookEventName.SessionEnd,
|
||||
];
|
||||
|
||||
for (const event of events) {
|
||||
const hook = createMockHookInfo(event, 1);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookDetailStep hook={hook} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain(event);
|
||||
}
|
||||
});
|
||||
});
|
||||
150
packages/cli/src/ui/components/hooks/HookDetailStep.tsx
Normal file
150
packages/cli/src/ui/components/hooks/HookDetailStep.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import type { HookEventDisplayInfo } from './types.js';
|
||||
import { HooksConfigSource } from '@qwen-code/qwen-code-core';
|
||||
import { getTranslatedSourceDisplayMap } from './constants.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
interface HookDetailStepProps {
|
||||
hook: HookEventDisplayInfo;
|
||||
selectedIndex: number;
|
||||
}
|
||||
|
||||
export function HookDetailStep({
|
||||
hook,
|
||||
selectedIndex,
|
||||
}: HookDetailStepProps): React.JSX.Element {
|
||||
const hasConfigs = hook.configs.length > 0;
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
|
||||
// Get translated source display map
|
||||
const sourceDisplayMap = getTranslatedSourceDisplayMap();
|
||||
|
||||
// Calculate column widths (command: 70%, source: 30%)
|
||||
const commandWidth = Math.floor(terminalWidth * 0.65);
|
||||
const sourceWidth = Math.floor(terminalWidth * 0.3);
|
||||
|
||||
// Get source display for config list
|
||||
const getConfigSourceDisplay = (config: {
|
||||
source: HooksConfigSource;
|
||||
sourceDisplay: string;
|
||||
}): string => {
|
||||
if (config.source === HooksConfigSource.Extensions) {
|
||||
// For extensions, sourceDisplay is the extension name
|
||||
return `${sourceDisplayMap[HooksConfigSource.Extensions]} (${config.sourceDisplay})`;
|
||||
}
|
||||
return sourceDisplayMap[config.source] || config.source;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{/* Title */}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{hook.event}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
{hook.description && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.secondary}>{hook.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Exit codes */}
|
||||
{hook.exitCodes.length > 0 && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Exit codes:')}
|
||||
</Text>
|
||||
{hook.exitCodes.map((ec, index) => (
|
||||
<Box key={index}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{` ${ec.code}: ${ec.description}`}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1} />
|
||||
|
||||
{/* Configs or empty state */}
|
||||
{hasConfigs ? (
|
||||
<>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Configured hooks:')}
|
||||
</Text>
|
||||
{hook.configs.map((config, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const sourceDisplay = getConfigSourceDisplay(config);
|
||||
const command =
|
||||
config.config.type === 'command' ? config.config.command : '';
|
||||
const hookType = config.config.type;
|
||||
|
||||
return (
|
||||
<Box key={index}>
|
||||
{/* Left column: selector + command */}
|
||||
<Box width={commandWidth}>
|
||||
<Box minWidth={2}>
|
||||
<Text
|
||||
color={
|
||||
isSelected ? theme.text.accent : theme.text.primary
|
||||
}
|
||||
>
|
||||
{isSelected ? '❯' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
bold={isSelected}
|
||||
wrap="wrap"
|
||||
>
|
||||
{`${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">
|
||||
{sourceDisplay}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enter to select · Esc to go back')}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('No hooks configured for this event.')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('To add hooks, edit settings.json directly or ask Qwen.')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Esc to go back')}</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
197
packages/cli/src/ui/components/hooks/HooksListStep.test.tsx
Normal file
197
packages/cli/src/ui/components/hooks/HooksListStep.test.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
/**
|
||||
* @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 {
|
||||
HookEventName,
|
||||
HookType,
|
||||
HooksConfigSource,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { HooksListStep } from './HooksListStep.js';
|
||||
import type { HookEventDisplayInfo } from './types.js';
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('../../../i18n/index.js', () => ({
|
||||
t: vi.fn((key: string, options?: { count?: string }) => {
|
||||
// Handle pluralization
|
||||
if (key === '{{count}} hook configured' && options?.count) {
|
||||
return `${options.count} hook configured`;
|
||||
}
|
||||
if (key === '{{count}} hooks configured' && options?.count) {
|
||||
return `${options.count} hooks configured`;
|
||||
}
|
||||
return key;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useTerminalSize
|
||||
vi.mock('../../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(() => ({ columns: 120, rows: 24 })),
|
||||
}));
|
||||
|
||||
// Mock semantic-colors
|
||||
vi.mock('../../semantic-colors.js', () => ({
|
||||
theme: {
|
||||
text: {
|
||||
primary: 'white',
|
||||
secondary: 'gray',
|
||||
accent: 'cyan',
|
||||
},
|
||||
status: {
|
||||
success: 'green',
|
||||
error: 'red',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('HooksListStep', () => {
|
||||
const createMockHookInfo = (
|
||||
event: HookEventName,
|
||||
configCount = 0,
|
||||
): HookEventDisplayInfo => ({
|
||||
event,
|
||||
shortDescription: `Description for ${event}`,
|
||||
description: `Detailed description for ${event}`,
|
||||
exitCodes: [
|
||||
{ code: 0, description: 'Success' },
|
||||
{ code: 2, description: 'Block' },
|
||||
],
|
||||
configs: Array(configCount)
|
||||
.fill(null)
|
||||
.map((_, i) => ({
|
||||
config: { command: `hook-${i}`, type: HookType.Command },
|
||||
source: HooksConfigSource.User,
|
||||
sourceDisplay: 'User Settings',
|
||||
enabled: true,
|
||||
})),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render empty state when no hooks', () => {
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={[]} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('No hook events found');
|
||||
});
|
||||
|
||||
it('should render list of hooks', () => {
|
||||
const hooks: HookEventDisplayInfo[] = [
|
||||
createMockHookInfo(HookEventName.PreToolUse),
|
||||
createMockHookInfo(HookEventName.PostToolUse),
|
||||
];
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={hooks} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Hooks');
|
||||
expect(output).toContain(HookEventName.PreToolUse);
|
||||
expect(output).toContain(HookEventName.PostToolUse);
|
||||
});
|
||||
|
||||
it('should show config count for hooks with configs', () => {
|
||||
const hooks: HookEventDisplayInfo[] = [
|
||||
createMockHookInfo(HookEventName.PreToolUse, 3),
|
||||
createMockHookInfo(HookEventName.PostToolUse, 0),
|
||||
];
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={hooks} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('(3)');
|
||||
expect(output).not.toContain('(0)');
|
||||
});
|
||||
|
||||
it('should show singular form for single hook', () => {
|
||||
const hooks: HookEventDisplayInfo[] = [
|
||||
createMockHookInfo(HookEventName.PreToolUse, 1),
|
||||
];
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={hooks} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('1 hook configured');
|
||||
});
|
||||
|
||||
it('should show read-only message', () => {
|
||||
const hooks: HookEventDisplayInfo[] = [
|
||||
createMockHookInfo(HookEventName.PreToolUse),
|
||||
];
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={hooks} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('read-only');
|
||||
expect(output).toContain('settings.json');
|
||||
});
|
||||
|
||||
it('should show keyboard hints', () => {
|
||||
const hooks: HookEventDisplayInfo[] = [
|
||||
createMockHookInfo(HookEventName.PreToolUse),
|
||||
];
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={hooks} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Enter to select');
|
||||
expect(output).toContain('Esc to cancel');
|
||||
});
|
||||
|
||||
it('should show selection indicator for first item', () => {
|
||||
const hooks: HookEventDisplayInfo[] = [
|
||||
createMockHookInfo(HookEventName.PreToolUse),
|
||||
createMockHookInfo(HookEventName.PostToolUse),
|
||||
];
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={hooks} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('❯');
|
||||
});
|
||||
|
||||
it('should display hook short descriptions', () => {
|
||||
const hooks: HookEventDisplayInfo[] = [
|
||||
createMockHookInfo(HookEventName.PreToolUse),
|
||||
];
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={hooks} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Description for PreToolUse');
|
||||
});
|
||||
|
||||
it('should pad index numbers based on total count', () => {
|
||||
const hooks: HookEventDisplayInfo[] = Array(10)
|
||||
.fill(null)
|
||||
.map((_, i) => createMockHookInfo(`${i}` as HookEventName));
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HooksListStep hooks={hooks} selectedIndex={0} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(' 1.');
|
||||
expect(output).toContain('10.');
|
||||
});
|
||||
});
|
||||
103
packages/cli/src/ui/components/hooks/HooksListStep.tsx
Normal file
103
packages/cli/src/ui/components/hooks/HooksListStep.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import type { HookEventDisplayInfo } from './types.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
interface HooksListStepProps {
|
||||
hooks: HookEventDisplayInfo[];
|
||||
selectedIndex: number;
|
||||
}
|
||||
|
||||
export function HooksListStep({
|
||||
hooks,
|
||||
selectedIndex,
|
||||
}: HooksListStepProps): React.JSX.Element {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
|
||||
// Calculate responsive width for hook name column (min 20, max 35)
|
||||
const hookNameWidth = Math.min(
|
||||
35,
|
||||
Math.max(20, Math.floor(terminalWidth * 0.25)),
|
||||
);
|
||||
|
||||
if (hooks.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.text.secondary}>{t('No hook events found.')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate total configured hooks
|
||||
const totalConfigured = hooks.reduce(
|
||||
(sum, hook) => sum + hook.configs.length,
|
||||
0,
|
||||
);
|
||||
|
||||
// Get the correct plural/singular form
|
||||
const hooksConfiguredText =
|
||||
totalConfigured === 1
|
||||
? t('{{count}} hook configured', { count: String(totalConfigured) })
|
||||
: t('{{count}} hooks configured', { count: String(totalConfigured) });
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Hooks')}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>{` · ${hooksConfiguredText}`}</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t(
|
||||
'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{hooks.map((hook, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const configCount = hook.configs.length;
|
||||
const maxDigits = String(hooks.length).length;
|
||||
const paddedIndex = String(index + 1).padStart(maxDigits);
|
||||
|
||||
return (
|
||||
<Box key={hook.event}>
|
||||
<Box minWidth={2}>
|
||||
<Text color={isSelected ? theme.text.accent : theme.text.primary}>
|
||||
{isSelected ? '❯' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={hookNameWidth}>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
bold={isSelected}
|
||||
>
|
||||
{paddedIndex}. {hook.event}
|
||||
{configCount > 0 && (
|
||||
<Text color={theme.status.success}> ({configCount})</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.secondary}>{hook.shortDescription}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enter to select · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HooksManagementDialog } from './HooksManagementDialog.js';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('../../../i18n/index.js', () => ({
|
||||
t: vi.fn((key: string, options?: { count?: string }) => {
|
||||
// Handle pluralization
|
||||
if (key === '{{count}} hook configured' && options?.count) {
|
||||
return `${options.count} hook configured`;
|
||||
}
|
||||
if (key === '{{count}} hooks configured' && options?.count) {
|
||||
return `${options.count} hooks configured`;
|
||||
}
|
||||
return key;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useTerminalSize
|
||||
vi.mock('../../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(() => ({ columns: 120, rows: 24 })),
|
||||
}));
|
||||
|
||||
// Mock useConfig
|
||||
vi.mock('../../contexts/ConfigContext.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../contexts/ConfigContext.js')>();
|
||||
return {
|
||||
...actual,
|
||||
useConfig: vi.fn(() => ({
|
||||
getExtensions: vi.fn(() => []),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock loadSettings
|
||||
vi.mock('../../../config/settings.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../../config/settings.js')>();
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn(() => ({
|
||||
forScope: vi.fn(() => ({ settings: {} })),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock semantic-colors
|
||||
vi.mock('../../semantic-colors.js', () => ({
|
||||
theme: {
|
||||
text: {
|
||||
primary: 'white',
|
||||
secondary: 'gray',
|
||||
accent: 'cyan',
|
||||
},
|
||||
status: {
|
||||
success: 'green',
|
||||
error: 'red',
|
||||
},
|
||||
border: {
|
||||
default: 'gray',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock createDebugLogger
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
createDebugLogger: vi.fn(() => ({
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('HooksManagementDialog', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render loading state initially', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HooksManagementDialog onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Loading hooks');
|
||||
});
|
||||
|
||||
it('should render with border', async () => {
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<HooksManagementDialog onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// The dialog should have a border (rendered as box-drawing characters)
|
||||
const output = lastFrame();
|
||||
expect(output).toBeTruthy();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle empty hooks list gracefully', async () => {
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<HooksManagementDialog onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const output = lastFrame();
|
||||
// Should show 0 hooks configured when no hooks are configured
|
||||
expect(output).toContain('0 hooks configured');
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
421
packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx
Normal file
421
packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
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,
|
||||
HOOKS_CONFIG_FIELDS,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
HooksManagementDialogProps,
|
||||
HookEventDisplayInfo,
|
||||
} from './types.js';
|
||||
import { HOOKS_MANAGEMENT_STEPS } from './types.js';
|
||||
import { HooksListStep } from './HooksListStep.js';
|
||||
import { HookDetailStep } from './HookDetailStep.js';
|
||||
import { HookConfigDetailStep } from './HookConfigDetailStep.js';
|
||||
import {
|
||||
DISPLAY_HOOK_EVENTS,
|
||||
getTranslatedSourceDisplayMap,
|
||||
createEmptyHookEventInfo,
|
||||
} from './constants.js';
|
||||
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 [key, value] of Object.entries(record)) {
|
||||
// Skip non-event configuration fields
|
||||
if (HOOKS_CONFIG_FIELDS.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
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 {
|
||||
const config = useConfig();
|
||||
const { columns: width } = useTerminalSize();
|
||||
const boxWidth = width - 4;
|
||||
|
||||
const [navigationStack, setNavigationStack] = useState<string[]>([
|
||||
HOOKS_MANAGEMENT_STEPS.HOOKS_LIST,
|
||||
]);
|
||||
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 [];
|
||||
|
||||
const settings = loadSettings();
|
||||
const userSettings = settings.forScope(SettingScope.User).settings;
|
||||
const workspaceSettings = settings.forScope(
|
||||
SettingScope.Workspace,
|
||||
).settings;
|
||||
|
||||
// Get translated source display map
|
||||
const sourceDisplayMap = getTranslatedSourceDisplayMap();
|
||||
|
||||
const result: HookEventDisplayInfo[] = [];
|
||||
|
||||
for (const eventName of DISPLAY_HOOK_EVENTS) {
|
||||
const hookInfo = createEmptyHookEventInfo(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,
|
||||
source: HooksConfigSource.User,
|
||||
sourceDisplay: sourceDisplayMap[HooksConfigSource.User],
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
source: HooksConfigSource.Project,
|
||||
sourceDisplay: sourceDisplayMap[HooksConfigSource.Project],
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get hooks from extensions (with type validation)
|
||||
const extensions = config.getExtensions() || [];
|
||||
for (const extension of extensions) {
|
||||
if (extension.isActive && extension.hooks?.[eventName]) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push(hookInfo);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [config]);
|
||||
|
||||
// Load hooks data on initial render
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const hooksData = fetchHooksData();
|
||||
if (!cancelled) {
|
||||
setHooks(hooksData);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
debugLogger.error('Error loading hooks:', error);
|
||||
setLoadError(
|
||||
error instanceof Error ? error.message : 'Failed to load hooks',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchHooksData]);
|
||||
|
||||
// Navigation handler for going back
|
||||
const handleNavigateBack = useCallback(() => {
|
||||
setNavigationStack((prev) => {
|
||||
if (prev.length <= 1) {
|
||||
onClose();
|
||||
return prev;
|
||||
}
|
||||
return prev.slice(0, -1);
|
||||
});
|
||||
}, [onClose]);
|
||||
|
||||
// Selected hook config
|
||||
const selectedConfig = useMemo(() => {
|
||||
if (
|
||||
selectedHook &&
|
||||
selectedConfigIndex >= 0 &&
|
||||
selectedConfigIndex < selectedHook.configs.length
|
||||
) {
|
||||
return selectedHook.configs[selectedConfigIndex];
|
||||
}
|
||||
return null;
|
||||
}, [selectedHook, selectedConfigIndex]);
|
||||
|
||||
// Render based on current step
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.text.secondary}>{t('Loading hooks...')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.status.error}>{t('Error loading hooks:')}</Text>
|
||||
<Text color={theme.text.secondary}>{loadError}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Press Escape to close')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
switch (currentStep) {
|
||||
case HOOKS_MANAGEMENT_STEPS.HOOKS_LIST:
|
||||
return (
|
||||
<HooksListStep hooks={hooks} selectedIndex={listSelectedIndex} />
|
||||
);
|
||||
|
||||
case HOOKS_MANAGEMENT_STEPS.HOOK_DETAIL:
|
||||
if (selectedHook) {
|
||||
return (
|
||||
<HookDetailStep
|
||||
hook={selectedHook}
|
||||
selectedIndex={detailSelectedIndex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.text.secondary}>{t('No hook selected')}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case HOOKS_MANAGEMENT_STEPS.HOOK_CONFIG_DETAIL:
|
||||
if (selectedHook && selectedConfig) {
|
||||
return (
|
||||
<HookConfigDetailStep
|
||||
hookEvent={selectedHook}
|
||||
hookConfig={selectedConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('No hook config selected')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
width={boxWidth}
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
>
|
||||
{renderContent()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
219
packages/cli/src/ui/components/hooks/constants.test.ts
Normal file
219
packages/cli/src/ui/components/hooks/constants.test.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HookEventName, HooksConfigSource } from '@qwen-code/qwen-code-core';
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('../../../i18n/index.js', () => ({
|
||||
t: vi.fn((key: string) => key),
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
import {
|
||||
getHookExitCodes,
|
||||
getHookShortDescription,
|
||||
getHookDescription,
|
||||
getTranslatedSourceDisplayMap,
|
||||
createEmptyHookEventInfo,
|
||||
DISPLAY_HOOK_EVENTS,
|
||||
} from './constants.js';
|
||||
|
||||
describe('hooks constants', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getHookExitCodes', () => {
|
||||
it('should return exit codes for Stop event', () => {
|
||||
const exitCodes = getHookExitCodes(HookEventName.Stop);
|
||||
expect(exitCodes).toHaveLength(3);
|
||||
expect(exitCodes[0]).toEqual({
|
||||
code: 0,
|
||||
description: expect.any(String),
|
||||
});
|
||||
expect(exitCodes[1]).toEqual({
|
||||
code: 2,
|
||||
description: expect.any(String),
|
||||
});
|
||||
expect(exitCodes[2]).toEqual({
|
||||
code: 'Other',
|
||||
description: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return exit codes for PreToolUse event', () => {
|
||||
const exitCodes = getHookExitCodes(HookEventName.PreToolUse);
|
||||
expect(exitCodes).toHaveLength(3);
|
||||
expect(exitCodes[0].code).toBe(0);
|
||||
expect(exitCodes[1].code).toBe(2);
|
||||
expect(exitCodes[2].code).toBe('Other');
|
||||
});
|
||||
|
||||
it('should return exit codes for PostToolUse event', () => {
|
||||
const exitCodes = getHookExitCodes(HookEventName.PostToolUse);
|
||||
expect(exitCodes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should return exit codes for UserPromptSubmit event', () => {
|
||||
const exitCodes = getHookExitCodes(HookEventName.UserPromptSubmit);
|
||||
expect(exitCodes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should return exit codes for Notification event', () => {
|
||||
const exitCodes = getHookExitCodes(HookEventName.Notification);
|
||||
expect(exitCodes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return exit codes for SessionStart event', () => {
|
||||
const exitCodes = getHookExitCodes(HookEventName.SessionStart);
|
||||
expect(exitCodes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return exit codes for SessionEnd event', () => {
|
||||
const exitCodes = getHookExitCodes(HookEventName.SessionEnd);
|
||||
expect(exitCodes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return exit codes for PreCompact event', () => {
|
||||
const exitCodes = getHookExitCodes(HookEventName.PreCompact);
|
||||
expect(exitCodes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should return empty array for unknown event', () => {
|
||||
const exitCodes = getHookExitCodes('unknown_event' as HookEventName);
|
||||
expect(exitCodes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHookShortDescription', () => {
|
||||
it('should return description for PreToolUse', () => {
|
||||
const desc = getHookShortDescription(HookEventName.PreToolUse);
|
||||
expect(desc).toBe('Before tool execution');
|
||||
});
|
||||
|
||||
it('should return description for PostToolUse', () => {
|
||||
const desc = getHookShortDescription(HookEventName.PostToolUse);
|
||||
expect(desc).toBe('After tool execution');
|
||||
});
|
||||
|
||||
it('should return description for UserPromptSubmit', () => {
|
||||
const desc = getHookShortDescription(HookEventName.UserPromptSubmit);
|
||||
expect(desc).toBe('When the user submits a prompt');
|
||||
});
|
||||
|
||||
it('should return description for SessionStart', () => {
|
||||
const desc = getHookShortDescription(HookEventName.SessionStart);
|
||||
expect(desc).toBe('When a new session is started');
|
||||
});
|
||||
|
||||
it('should return empty string for unknown event', () => {
|
||||
const desc = getHookShortDescription('unknown_event' as HookEventName);
|
||||
expect(desc).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHookDescription', () => {
|
||||
it('should return description for PreToolUse', () => {
|
||||
const desc = getHookDescription(HookEventName.PreToolUse);
|
||||
expect(desc).toBe('Input to command is JSON of tool call arguments.');
|
||||
});
|
||||
|
||||
it('should return description for PostToolUse', () => {
|
||||
const desc = getHookDescription(HookEventName.PostToolUse);
|
||||
expect(desc).toContain('inputs');
|
||||
expect(desc).toContain('response');
|
||||
});
|
||||
|
||||
it('should return empty string for Stop event', () => {
|
||||
const desc = getHookDescription(HookEventName.Stop);
|
||||
expect(desc).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for unknown event', () => {
|
||||
const desc = getHookDescription('unknown_event' as HookEventName);
|
||||
expect(desc).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTranslatedSourceDisplayMap', () => {
|
||||
it('should return mapping for all sources', () => {
|
||||
const map = getTranslatedSourceDisplayMap();
|
||||
|
||||
expect(map[HooksConfigSource.Project]).toBe('Local Settings');
|
||||
expect(map[HooksConfigSource.User]).toBe('User Settings');
|
||||
expect(map[HooksConfigSource.System]).toBe('System Settings');
|
||||
expect(map[HooksConfigSource.Extensions]).toBe('Extensions');
|
||||
});
|
||||
|
||||
it('should return translated strings', () => {
|
||||
const map = getTranslatedSourceDisplayMap();
|
||||
|
||||
// All values should be strings (translated)
|
||||
Object.values(map).forEach((value) => {
|
||||
expect(typeof value).toBe('string');
|
||||
expect(value.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DISPLAY_HOOK_EVENTS', () => {
|
||||
it('should contain all expected hook events', () => {
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.Stop);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PreToolUse);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PostToolUse);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PostToolUseFailure);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.Notification);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.UserPromptSubmit);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.SessionStart);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.SessionEnd);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.SubagentStart);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.SubagentStop);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PreCompact);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PermissionRequest);
|
||||
});
|
||||
|
||||
it('should have 12 events', () => {
|
||||
expect(DISPLAY_HOOK_EVENTS).toHaveLength(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEmptyHookEventInfo', () => {
|
||||
it('should create empty info for PreToolUse', () => {
|
||||
const info = createEmptyHookEventInfo(HookEventName.PreToolUse);
|
||||
|
||||
expect(info.event).toBe(HookEventName.PreToolUse);
|
||||
expect(info.shortDescription).toBe('Before tool execution');
|
||||
expect(info.description).toBe(
|
||||
'Input to command is JSON of tool call arguments.',
|
||||
);
|
||||
expect(info.exitCodes).toHaveLength(3);
|
||||
expect(info.configs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should create empty info for Stop', () => {
|
||||
const info = createEmptyHookEventInfo(HookEventName.Stop);
|
||||
|
||||
expect(info.event).toBe(HookEventName.Stop);
|
||||
expect(info.shortDescription).toBe(
|
||||
'Right before Qwen Code concludes its response',
|
||||
);
|
||||
expect(info.description).toBe('');
|
||||
expect(info.exitCodes).toHaveLength(3);
|
||||
expect(info.configs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should create empty info for unknown event', () => {
|
||||
const info = createEmptyHookEventInfo('unknown_event' as HookEventName);
|
||||
|
||||
expect(info.event).toBe('unknown_event');
|
||||
expect(info.shortDescription).toBe('');
|
||||
expect(info.description).toBe('');
|
||||
expect(info.exitCodes).toEqual([]);
|
||||
expect(info.configs).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
217
packages/cli/src/ui/components/hooks/constants.ts
Normal file
217
packages/cli/src/ui/components/hooks/constants.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { HooksConfigSource, HookEventName } from '@qwen-code/qwen-code-core';
|
||||
import type { HookExitCode, HookEventDisplayInfo } from './types.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Exit code descriptions for different hook types
|
||||
*/
|
||||
export function getHookExitCodes(eventName: string): HookExitCode[] {
|
||||
const exitCodesMap: Record<string, HookExitCode[]> = {
|
||||
[HookEventName.Stop]: [
|
||||
{ code: 0, description: t('stdout/stderr not shown') },
|
||||
{
|
||||
code: 2,
|
||||
description: t('show stderr to model and continue conversation'),
|
||||
},
|
||||
{ code: 'Other', description: t('show stderr to user only') },
|
||||
],
|
||||
[HookEventName.PreToolUse]: [
|
||||
{ code: 0, description: t('stdout/stderr not shown') },
|
||||
{ code: 2, description: t('show stderr to model and block tool call') },
|
||||
{
|
||||
code: 'Other',
|
||||
description: t('show stderr to user only but continue with tool call'),
|
||||
},
|
||||
],
|
||||
[HookEventName.PostToolUse]: [
|
||||
{ code: 0, description: t('stdout shown in transcript mode (ctrl+o)') },
|
||||
{ code: 2, description: t('show stderr to model immediately') },
|
||||
{ code: 'Other', description: t('show stderr to user only') },
|
||||
],
|
||||
[HookEventName.PostToolUseFailure]: [
|
||||
{ code: 0, description: t('stdout shown in transcript mode (ctrl+o)') },
|
||||
{ code: 2, description: t('show stderr to model immediately') },
|
||||
{ code: 'Other', description: t('show stderr to user only') },
|
||||
],
|
||||
[HookEventName.Notification]: [
|
||||
{ code: 0, description: t('stdout/stderr not shown') },
|
||||
{ code: 'Other', description: t('show stderr to user only') },
|
||||
],
|
||||
[HookEventName.UserPromptSubmit]: [
|
||||
{ code: 0, description: t('stdout shown to Qwen') },
|
||||
{
|
||||
code: 2,
|
||||
description: t(
|
||||
'block processing, erase original prompt, and show stderr to user only',
|
||||
),
|
||||
},
|
||||
{ code: 'Other', description: t('show stderr to user only') },
|
||||
],
|
||||
[HookEventName.SessionStart]: [
|
||||
{ code: 0, description: t('stdout shown to Qwen') },
|
||||
{
|
||||
code: 'Other',
|
||||
description: t('show stderr to user only (blocking errors ignored)'),
|
||||
},
|
||||
],
|
||||
[HookEventName.SessionEnd]: [
|
||||
{ code: 0, description: t('command completes successfully') },
|
||||
{ code: 'Other', description: t('show stderr to user only') },
|
||||
],
|
||||
[HookEventName.SubagentStart]: [
|
||||
{ code: 0, description: t('stdout shown to subagent') },
|
||||
{
|
||||
code: 'Other',
|
||||
description: t('show stderr to user only (blocking errors ignored)'),
|
||||
},
|
||||
],
|
||||
[HookEventName.SubagentStop]: [
|
||||
{ code: 0, description: t('stdout/stderr not shown') },
|
||||
{
|
||||
code: 2,
|
||||
description: t('show stderr to subagent and continue having it run'),
|
||||
},
|
||||
{ code: 'Other', description: t('show stderr to user only') },
|
||||
],
|
||||
[HookEventName.PreCompact]: [
|
||||
{
|
||||
code: 0,
|
||||
description: t('stdout appended as custom compact instructions'),
|
||||
},
|
||||
{ code: 2, description: t('block compaction') },
|
||||
{
|
||||
code: 'Other',
|
||||
description: t('show stderr to user only but continue with compaction'),
|
||||
},
|
||||
],
|
||||
[HookEventName.PermissionRequest]: [
|
||||
{ code: 0, description: t('use hook decision if provided') },
|
||||
{ code: 'Other', description: t('show stderr to user only') },
|
||||
],
|
||||
};
|
||||
return exitCodesMap[eventName] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Short one-line description for hooks list view
|
||||
*/
|
||||
export function getHookShortDescription(eventName: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
[HookEventName.PreToolUse]: t('Before tool execution'),
|
||||
[HookEventName.PostToolUse]: t('After tool execution'),
|
||||
[HookEventName.PostToolUseFailure]: t('After tool execution fails'),
|
||||
[HookEventName.Notification]: t('When notifications are sent'),
|
||||
[HookEventName.UserPromptSubmit]: t('When the user submits a prompt'),
|
||||
[HookEventName.SessionStart]: t('When a new session is started'),
|
||||
[HookEventName.Stop]: t('Right before Qwen Code concludes its response'),
|
||||
[HookEventName.SubagentStart]: t(
|
||||
'When a subagent (Agent tool call) is started',
|
||||
),
|
||||
[HookEventName.SubagentStop]: t(
|
||||
'Right before a subagent concludes its response',
|
||||
),
|
||||
[HookEventName.PreCompact]: t('Before conversation compaction'),
|
||||
[HookEventName.SessionEnd]: t('When a session is ending'),
|
||||
[HookEventName.PermissionRequest]: t(
|
||||
'When a permission dialog is displayed',
|
||||
),
|
||||
};
|
||||
return descriptions[eventName] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed description for each hook event type (shown in detail view)
|
||||
*/
|
||||
export function getHookDescription(eventName: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
[HookEventName.Stop]: '',
|
||||
[HookEventName.PreToolUse]: t(
|
||||
'Input to command is JSON of tool call arguments.',
|
||||
),
|
||||
[HookEventName.PostToolUse]: t(
|
||||
'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).',
|
||||
),
|
||||
[HookEventName.PostToolUseFailure]: t(
|
||||
'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.',
|
||||
),
|
||||
[HookEventName.Notification]: t(
|
||||
'Input to command is JSON with notification message and type.',
|
||||
),
|
||||
[HookEventName.UserPromptSubmit]: t(
|
||||
'Input to command is JSON with original user prompt text.',
|
||||
),
|
||||
[HookEventName.SessionStart]: t(
|
||||
'Input to command is JSON with session start source.',
|
||||
),
|
||||
[HookEventName.SessionEnd]: t(
|
||||
'Input to command is JSON with session end reason.',
|
||||
),
|
||||
[HookEventName.SubagentStart]: t(
|
||||
'Input to command is JSON with agent_id and agent_type.',
|
||||
),
|
||||
[HookEventName.SubagentStop]: t(
|
||||
'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.',
|
||||
),
|
||||
[HookEventName.PreCompact]: t(
|
||||
'Input to command is JSON with compaction details.',
|
||||
),
|
||||
[HookEventName.PermissionRequest]: t(
|
||||
'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.',
|
||||
),
|
||||
};
|
||||
return descriptions[eventName] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Source display mapping (translated)
|
||||
*/
|
||||
export function getTranslatedSourceDisplayMap(): Record<
|
||||
HooksConfigSource,
|
||||
string
|
||||
> {
|
||||
return {
|
||||
[HooksConfigSource.Project]: t('Local Settings'),
|
||||
[HooksConfigSource.User]: t('User Settings'),
|
||||
[HooksConfigSource.System]: t('System Settings'),
|
||||
[HooksConfigSource.Extensions]: t('Extensions'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List of hook events to display in the UI
|
||||
*/
|
||||
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,
|
||||
];
|
||||
|
||||
/**
|
||||
* Create empty hook event display info
|
||||
*/
|
||||
export function createEmptyHookEventInfo(
|
||||
eventName: HookEventName,
|
||||
): HookEventDisplayInfo {
|
||||
return {
|
||||
event: eventName,
|
||||
shortDescription: getHookShortDescription(eventName),
|
||||
description: getHookDescription(eventName),
|
||||
exitCodes: getHookExitCodes(eventName),
|
||||
configs: [],
|
||||
};
|
||||
}
|
||||
11
packages/cli/src/ui/components/hooks/index.ts
Normal file
11
packages/cli/src/ui/components/hooks/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export { HooksManagementDialog } from './HooksManagementDialog.js';
|
||||
export { HooksListStep } from './HooksListStep.js';
|
||||
export { HookDetailStep } from './HookDetailStep.js';
|
||||
export * from './types.js';
|
||||
export * from './constants.js';
|
||||
60
packages/cli/src/ui/components/hooks/types.ts
Normal file
60
packages/cli/src/ui/components/hooks/types.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
HookConfig,
|
||||
HooksConfigSource,
|
||||
HookEventName,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Exit code description for hooks
|
||||
*/
|
||||
export interface HookExitCode {
|
||||
code: number | string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI display information for a hook event
|
||||
*/
|
||||
export interface HookEventDisplayInfo {
|
||||
event: HookEventName;
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
exitCodes: HookExitCode[];
|
||||
configs: HookConfigDisplayInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* UI display information for a hook configuration
|
||||
*/
|
||||
export interface HookConfigDisplayInfo {
|
||||
config: HookConfig;
|
||||
source: HooksConfigSource;
|
||||
sourceDisplay: string;
|
||||
sourcePath?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook management dialog step names
|
||||
*/
|
||||
export const HOOKS_MANAGEMENT_STEPS = {
|
||||
HOOKS_LIST: 'hooks_list',
|
||||
HOOK_DETAIL: 'hook_detail',
|
||||
HOOK_CONFIG_DETAIL: 'hook_config_detail',
|
||||
} as const;
|
||||
|
||||
export type HooksManagementStep =
|
||||
(typeof HOOKS_MANAGEMENT_STEPS)[keyof typeof HOOKS_MANAGEMENT_STEPS];
|
||||
|
||||
/**
|
||||
* Props for HooksManagementDialog
|
||||
*/
|
||||
export interface HooksManagementDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue