mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 12:11:09 +00:00
remove hooks experimental and refactor hook Config
This commit is contained in:
parent
1b1a029fd7
commit
5221002831
33 changed files with 722 additions and 322 deletions
124
packages/cli/src/ui/components/hooks/HooksDisabledStep.test.tsx
Normal file
124
packages/cli/src/ui/components/hooks/HooksDisabledStep.test.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { HooksDisabledStep } from './HooksDisabledStep.js';
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('../../../i18n/index.js', () => ({
|
||||
t: vi.fn((key: string, options?: { count?: string }) => {
|
||||
// Handle pluralization
|
||||
if (key === '{{count}} configured hook' && options?.count) {
|
||||
return `${options.count} configured hook`;
|
||||
}
|
||||
if (key === '{{count}} configured hooks' && options?.count) {
|
||||
return `${options.count} configured hooks`;
|
||||
}
|
||||
// Handle interpolation for main message
|
||||
if (
|
||||
key ===
|
||||
'All hooks are currently disabled. You have {{count}} that are not running.' &&
|
||||
options?.count
|
||||
) {
|
||||
return `All hooks are currently disabled. You have ${options.count} that are not running.`;
|
||||
}
|
||||
return key;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock semantic-colors
|
||||
vi.mock('../../semantic-colors.js', () => ({
|
||||
theme: {
|
||||
text: {
|
||||
primary: 'white',
|
||||
secondary: 'gray',
|
||||
},
|
||||
status: {
|
||||
warning: 'yellow',
|
||||
error: 'red',
|
||||
success: 'green',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('HooksDisabledStep', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render disabled title', () => {
|
||||
const { lastFrame } = render(
|
||||
<HooksDisabledStep configuredHooksCount={2} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Hook Configuration - Disabled');
|
||||
});
|
||||
|
||||
it('should show configured hooks count', () => {
|
||||
const { lastFrame } = render(
|
||||
<HooksDisabledStep configuredHooksCount={2} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('2 configured hooks');
|
||||
});
|
||||
|
||||
it('should show singular form for single hook', () => {
|
||||
const { lastFrame } = render(
|
||||
<HooksDisabledStep configuredHooksCount={1} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('1 configured hook');
|
||||
});
|
||||
|
||||
it('should show zero hooks message', () => {
|
||||
const { lastFrame } = render(
|
||||
<HooksDisabledStep configuredHooksCount={0} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('0 configured hooks');
|
||||
});
|
||||
|
||||
it('should show explanation items', () => {
|
||||
const { lastFrame } = render(
|
||||
<HooksDisabledStep configuredHooksCount={2} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('When hooks are disabled:');
|
||||
expect(output).toContain('No hook commands will execute');
|
||||
expect(output).toContain('StatusLine will not be displayed');
|
||||
expect(output).toContain(
|
||||
'Tool operations will proceed without hook validation',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show re-enable instructions', () => {
|
||||
const { lastFrame } = render(
|
||||
<HooksDisabledStep configuredHooksCount={2} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('To re-enable hooks');
|
||||
expect(lastFrame()).toContain('disableAllHooks');
|
||||
expect(lastFrame()).toContain('settings.json');
|
||||
});
|
||||
|
||||
it('should show Esc hint', () => {
|
||||
const { lastFrame } = render(
|
||||
<HooksDisabledStep configuredHooksCount={2} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Esc to close');
|
||||
});
|
||||
|
||||
it('should handle large hook counts', () => {
|
||||
const { lastFrame } = render(
|
||||
<HooksDisabledStep configuredHooksCount={100} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('100 configured hooks');
|
||||
});
|
||||
});
|
||||
84
packages/cli/src/ui/components/hooks/HooksDisabledStep.tsx
Normal file
84
packages/cli/src/ui/components/hooks/HooksDisabledStep.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
interface HooksDisabledStepProps {
|
||||
configuredHooksCount: number;
|
||||
}
|
||||
|
||||
export function HooksDisabledStep({
|
||||
configuredHooksCount,
|
||||
}: HooksDisabledStepProps): React.JSX.Element {
|
||||
// Get the correct plural/singular form
|
||||
const hooksText =
|
||||
configuredHooksCount === 1
|
||||
? t('{{count}} configured hook', { count: String(configuredHooksCount) })
|
||||
: t('{{count}} configured hooks', {
|
||||
count: String(configuredHooksCount),
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{/* Title */}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.status.warning}>
|
||||
{t('Hook Configuration - Disabled')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Main message */}
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.primary}>
|
||||
{t(
|
||||
'All hooks are currently disabled. You have {{count}} that are not running.',
|
||||
{
|
||||
count: hooksText,
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Explanation */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('When hooks are disabled:')}
|
||||
</Text>
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
{` · ${t('No hook commands will execute')}`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
{` · ${t('StatusLine will not be displayed')}`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
{` · ${t('Tool operations will proceed without hook validation')}`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* How to re-enable */}
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t(
|
||||
'To re-enable hooks, remove "disableAllHooks" from settings.json or ask Qwen Code.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Footer hint */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Esc to close')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,9 +4,18 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HooksManagementDialog } from './HooksManagementDialog.js';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import type { Key } from '../../contexts/KeypressContext.js';
|
||||
|
||||
// Mock useKeypress
|
||||
vi.mock('../../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseKeypress = vi.mocked(useKeypress);
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('../../../i18n/index.js', () => ({
|
||||
|
|
@ -18,6 +27,20 @@ vi.mock('../../../i18n/index.js', () => ({
|
|||
if (key === '{{count}} hooks configured' && options?.count) {
|
||||
return `${options.count} hooks configured`;
|
||||
}
|
||||
if (key === '{{count}} configured hook' && options?.count) {
|
||||
return `${options.count} configured hook`;
|
||||
}
|
||||
if (key === '{{count}} configured hooks' && options?.count) {
|
||||
return `${options.count} configured hooks`;
|
||||
}
|
||||
// Handle interpolation for disabled message
|
||||
if (
|
||||
key ===
|
||||
'All hooks are currently disabled. You have {{count}} that are not running.' &&
|
||||
options?.count
|
||||
) {
|
||||
return `All hooks are currently disabled. You have ${options.count} that are not running.`;
|
||||
}
|
||||
return key;
|
||||
}),
|
||||
}));
|
||||
|
|
@ -35,6 +58,7 @@ vi.mock('../../contexts/ConfigContext.js', async (importOriginal) => {
|
|||
...actual,
|
||||
useConfig: vi.fn(() => ({
|
||||
getExtensions: vi.fn(() => []),
|
||||
getDisableAllHooks: vi.fn(() => false),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
|
@ -62,6 +86,7 @@ vi.mock('../../semantic-colors.js', () => ({
|
|||
status: {
|
||||
success: 'green',
|
||||
error: 'red',
|
||||
warning: 'yellow',
|
||||
},
|
||||
border: {
|
||||
default: 'gray',
|
||||
|
|
@ -82,46 +107,183 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
// Helper to create a key object
|
||||
function createKey(name: string, sequence = ''): Key {
|
||||
return {
|
||||
name,
|
||||
sequence,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('HooksManagementDialog', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
let keypressHandler: ((key: Key) => void) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
keypressHandler = null;
|
||||
|
||||
// Mock useKeypress to capture the handler
|
||||
mockedUseKeypress.mockImplementation((handler) => {
|
||||
keypressHandler = handler;
|
||||
});
|
||||
});
|
||||
|
||||
it('should render loading state initially', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HooksManagementDialog onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Loading hooks');
|
||||
afterEach(() => {
|
||||
keypressHandler = null;
|
||||
});
|
||||
|
||||
it('should render with border', async () => {
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<HooksManagementDialog onClose={mockOnClose} />,
|
||||
);
|
||||
describe('Initial rendering', () => {
|
||||
it('should render loading state initially', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HooksManagementDialog onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(lastFrame()).toContain('Loading hooks');
|
||||
});
|
||||
|
||||
// The dialog should have a border (rendered as box-drawing characters)
|
||||
const output = lastFrame();
|
||||
expect(output).toBeTruthy();
|
||||
it('should render with border', async () => {
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<HooksManagementDialog onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
unmount();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// The dialog should have a border (rendered as box-drawing characters)
|
||||
const output = lastFrame();
|
||||
expect(output).toBeTruthy();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle empty hooks list gracefully', async () => {
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<HooksManagementDialog onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const output = lastFrame();
|
||||
// Should show 0 hooks configured when no hooks are configured
|
||||
expect(output).toContain('0 hooks configured');
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty hooks list gracefully', async () => {
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<HooksManagementDialog onClose={mockOnClose} />,
|
||||
);
|
||||
describe('Keyboard navigation - HOOKS_LIST step', () => {
|
||||
it('should register keypress handler with isActive: true', async () => {
|
||||
renderWithProviders(<HooksManagementDialog onClose={mockOnClose} />);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const output = lastFrame();
|
||||
// Should show 0 hooks configured when no hooks are configured
|
||||
expect(output).toContain('0 hooks configured');
|
||||
expect(mockedUseKeypress).toHaveBeenCalled();
|
||||
const options = mockedUseKeypress.mock.calls[0][1];
|
||||
expect(options).toEqual({ isActive: true });
|
||||
});
|
||||
|
||||
unmount();
|
||||
it('should close dialog on Escape key', async () => {
|
||||
renderWithProviders(<HooksManagementDialog onClose={mockOnClose} />);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(keypressHandler).not.toBeNull();
|
||||
keypressHandler!(createKey('escape', '\x1b'));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should navigate up and down with arrow keys', async () => {
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<HooksManagementDialog onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Initial state - first item selected
|
||||
let output = lastFrame();
|
||||
expect(output).toContain('❯');
|
||||
|
||||
// Press down - should move selection
|
||||
keypressHandler!(createKey('down'));
|
||||
output = lastFrame();
|
||||
|
||||
// Press up - should move back
|
||||
keypressHandler!(createKey('up'));
|
||||
output = lastFrame();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not go above first item when pressing up', async () => {
|
||||
const { unmount } = renderWithProviders(
|
||||
<HooksManagementDialog onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Press up multiple times from first item
|
||||
keypressHandler!(createKey('up'));
|
||||
keypressHandler!(createKey('up'));
|
||||
keypressHandler!(createKey('up'));
|
||||
|
||||
// Should still be at first item (no crash)
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard navigation - HOOKS_DISABLED step', () => {
|
||||
it('should show disabled state when disableAllHooks is true', async () => {
|
||||
// Override the mock for this test
|
||||
const configContext = await import('../../contexts/ConfigContext.js');
|
||||
vi.mocked(configContext.useConfig).mockReturnValue({
|
||||
getExtensions: vi.fn(() => []),
|
||||
getDisableAllHooks: vi.fn(() => true),
|
||||
} as unknown as ReturnType<typeof configContext.useConfig>);
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<HooksManagementDialog onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Hook Configuration - Disabled');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should close dialog on Escape key when hooks are disabled', async () => {
|
||||
const configContext = await import('../../contexts/ConfigContext.js');
|
||||
vi.mocked(configContext.useConfig).mockReturnValue({
|
||||
getExtensions: vi.fn(() => []),
|
||||
getDisableAllHooks: vi.fn(() => true),
|
||||
} as unknown as ReturnType<typeof configContext.useConfig>);
|
||||
|
||||
renderWithProviders(<HooksManagementDialog onClose={mockOnClose} />);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(keypressHandler).not.toBeNull();
|
||||
keypressHandler!(createKey('escape', '\x1b'));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading and error states', () => {
|
||||
it('should allow Escape to close during loading state', () => {
|
||||
renderWithProviders(<HooksManagementDialog onClose={mockOnClose} />);
|
||||
|
||||
// Don't wait for loading to complete
|
||||
expect(keypressHandler).not.toBeNull();
|
||||
keypressHandler!(createKey('escape', '\x1b'));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { HOOKS_MANAGEMENT_STEPS } from './types.js';
|
|||
import { HooksListStep } from './HooksListStep.js';
|
||||
import { HookDetailStep } from './HookDetailStep.js';
|
||||
import { HookConfigDetailStep } from './HookConfigDetailStep.js';
|
||||
import { HooksDisabledStep } from './HooksDisabledStep.js';
|
||||
import {
|
||||
DISPLAY_HOOK_EVENTS,
|
||||
getTranslatedSourceDisplayMap,
|
||||
|
|
@ -111,8 +112,13 @@ export function HooksManagementDialog({
|
|||
const { columns: width } = useTerminalSize();
|
||||
const boxWidth = width - 4;
|
||||
|
||||
// Check if hooks are disabled
|
||||
const disableAllHooks = config?.getDisableAllHooks() ?? false;
|
||||
|
||||
const [navigationStack, setNavigationStack] = useState<string[]>([
|
||||
HOOKS_MANAGEMENT_STEPS.HOOKS_LIST,
|
||||
disableAllHooks
|
||||
? HOOKS_MANAGEMENT_STEPS.HOOKS_DISABLED
|
||||
: HOOKS_MANAGEMENT_STEPS.HOOKS_LIST,
|
||||
]);
|
||||
const [selectedHookIndex, setSelectedHookIndex] = useState<number>(-1);
|
||||
const [selectedConfigIndex, setSelectedConfigIndex] = useState<number>(-1);
|
||||
|
|
@ -148,6 +154,12 @@ export function HooksManagementDialog({
|
|||
}
|
||||
|
||||
switch (currentStep) {
|
||||
case HOOKS_MANAGEMENT_STEPS.HOOKS_DISABLED:
|
||||
if (key.name === 'escape') {
|
||||
onClose();
|
||||
}
|
||||
break;
|
||||
|
||||
case HOOKS_MANAGEMENT_STEPS.HOOKS_LIST:
|
||||
if (key.name === 'up') {
|
||||
setListSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||
|
|
@ -339,8 +351,19 @@ export function HooksManagementDialog({
|
|||
return null;
|
||||
}, [selectedHook, selectedConfigIndex]);
|
||||
|
||||
// Calculate total configured hooks count
|
||||
const configuredHooksCount = useMemo(
|
||||
() => hooks.reduce((sum, hook) => sum + hook.configs.length, 0),
|
||||
[hooks],
|
||||
);
|
||||
|
||||
// Render based on current step
|
||||
const renderContent = () => {
|
||||
// Show disabled state first (before loading check)
|
||||
if (currentStep === HOOKS_MANAGEMENT_STEPS.HOOKS_DISABLED) {
|
||||
return <HooksDisabledStep configuredHooksCount={configuredHooksCount} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
|
|
|
|||
|
|
@ -185,21 +185,10 @@ export function getTranslatedSourceDisplayMap(): Record<
|
|||
|
||||
/**
|
||||
* List of hook events to display in the UI
|
||||
* Automatically synced with HookEventName enum from core
|
||||
*/
|
||||
export const DISPLAY_HOOK_EVENTS: HookEventName[] = [
|
||||
HookEventName.Stop,
|
||||
HookEventName.PreToolUse,
|
||||
HookEventName.PostToolUse,
|
||||
HookEventName.PostToolUseFailure,
|
||||
HookEventName.Notification,
|
||||
HookEventName.UserPromptSubmit,
|
||||
HookEventName.SessionStart,
|
||||
HookEventName.SessionEnd,
|
||||
HookEventName.SubagentStart,
|
||||
HookEventName.SubagentStop,
|
||||
HookEventName.PreCompact,
|
||||
HookEventName.PermissionRequest,
|
||||
];
|
||||
export const DISPLAY_HOOK_EVENTS: HookEventName[] =
|
||||
Object.values(HookEventName);
|
||||
|
||||
/**
|
||||
* Create empty hook event display info
|
||||
|
|
|
|||
|
|
@ -7,5 +7,7 @@
|
|||
export { HooksManagementDialog } from './HooksManagementDialog.js';
|
||||
export { HooksListStep } from './HooksListStep.js';
|
||||
export { HookDetailStep } from './HookDetailStep.js';
|
||||
export { HookConfigDetailStep } from './HookConfigDetailStep.js';
|
||||
export { HooksDisabledStep } from './HooksDisabledStep.js';
|
||||
export * from './types.js';
|
||||
export * from './constants.js';
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export interface HookConfigDisplayInfo {
|
|||
* Hook management dialog step names
|
||||
*/
|
||||
export const HOOKS_MANAGEMENT_STEPS = {
|
||||
HOOKS_DISABLED: 'hooks_disabled',
|
||||
HOOKS_LIST: 'hooks_list',
|
||||
HOOK_DETAIL: 'hook_detail',
|
||||
HOOK_CONFIG_DETAIL: 'hook_config_detail',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue