remove hooks experimental and refactor hook Config

This commit is contained in:
DennisYu07 2026-04-01 11:50:23 +08:00
parent 1b1a029fd7
commit 5221002831
33 changed files with 722 additions and 322 deletions

View 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');
});
});

View 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>
);
}

View file

@ -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);
});
});
});

View file

@ -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}>

View file

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

View file

@ -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';

View file

@ -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',