mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 23:42:03 +00:00
refactor ui for qwen code hooks
This commit is contained in:
parent
38caa0b218
commit
b08154dbee
21 changed files with 972 additions and 357 deletions
|
|
@ -1,25 +1,25 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { enableCommand } from './hooks/enable.js';
|
||||
import { disableCommand } from './hooks/disable.js';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const debugLogger = createDebugLogger('HOOKS_UI');
|
||||
|
||||
export const hooksCommand: CommandModule = {
|
||||
command: 'hooks <command>',
|
||||
command: 'hooks',
|
||||
aliases: ['hook'],
|
||||
describe: 'Manage Qwen Code hooks.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(enableCommand)
|
||||
.command(disableCommand)
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
describe: 'Manage Qwen Code hooks (use /hooks in interactive mode).',
|
||||
builder: (yargs) => yargs.version(false).help(false),
|
||||
handler: () => {
|
||||
// This handler is not called when a subcommand is provided.
|
||||
// Yargs will show the help menu.
|
||||
// In CLI mode, this command is not interactive.
|
||||
// Users should use /hooks in interactive mode for the full UI experience.
|
||||
debugLogger.debug(
|
||||
'Use /hooks in interactive mode to manage hooks with the UI.',
|
||||
);
|
||||
process.exit(0);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { createDebugLogger, getErrorMessage } from '@qwen-code/qwen-code-core';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
|
||||
const debugLogger = createDebugLogger('HOOKS_DISABLE');
|
||||
|
||||
interface DisableArgs {
|
||||
hookName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a hook by adding it to the disabled list
|
||||
*/
|
||||
export async function handleDisableHook(hookName: string): Promise<void> {
|
||||
const workingDir = process.cwd();
|
||||
const settings = loadSettings(workingDir);
|
||||
|
||||
try {
|
||||
// Get current hooks settings
|
||||
const mergedSettings = settings.merged as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const hooksSettings = (mergedSettings?.['hooks'] || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const disabledHooks = (hooksSettings['disabled'] || []) as string[];
|
||||
|
||||
// Check if hook is already disabled
|
||||
if (disabledHooks.includes(hookName)) {
|
||||
debugLogger.info(`Hook "${hookName}" is already disabled.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add hook to disabled list
|
||||
const newDisabledHooks = [...disabledHooks, hookName];
|
||||
const newHooksSettings = {
|
||||
...hooksSettings,
|
||||
disabled: newDisabledHooks,
|
||||
};
|
||||
|
||||
// Save updated settings
|
||||
settings.setValue(
|
||||
SettingScope.Workspace,
|
||||
'hooks' as keyof typeof settings.merged,
|
||||
newHooksSettings as never,
|
||||
);
|
||||
|
||||
debugLogger.info(`✓ Hook "${hookName}" has been disabled.`);
|
||||
} catch (error) {
|
||||
debugLogger.error(`Error disabling hook: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const disableCommand: CommandModule = {
|
||||
command: 'disable <hook-name>',
|
||||
describe: 'Disable an active hook',
|
||||
builder: (yargs) =>
|
||||
yargs.positional('hook-name', {
|
||||
describe: 'Name of the hook to disable',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const args = argv as unknown as DisableArgs;
|
||||
await handleDisableHook(args.hookName);
|
||||
process.exit(0);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { createDebugLogger, getErrorMessage } from '@qwen-code/qwen-code-core';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
|
||||
const debugLogger = createDebugLogger('HOOKS_ENABLE');
|
||||
|
||||
interface EnableArgs {
|
||||
hookName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a hook by removing it from the disabled list
|
||||
*/
|
||||
export async function handleEnableHook(hookName: string): Promise<void> {
|
||||
const workingDir = process.cwd();
|
||||
const settings = loadSettings(workingDir);
|
||||
|
||||
try {
|
||||
// Get current hooks settings
|
||||
const mergedSettings = settings.merged as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const hooksSettings = (mergedSettings?.['hooks'] || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const disabledHooks = (hooksSettings['disabled'] || []) as string[];
|
||||
|
||||
// Check if hook is in disabled list
|
||||
if (!disabledHooks.includes(hookName)) {
|
||||
debugLogger.info(`Hook "${hookName}" is not disabled.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove hook from disabled list
|
||||
const newDisabledHooks = disabledHooks.filter((h) => h !== hookName);
|
||||
const newHooksSettings = {
|
||||
...hooksSettings,
|
||||
disabled: newDisabledHooks,
|
||||
};
|
||||
|
||||
// Save updated settings
|
||||
settings.setValue(
|
||||
SettingScope.Workspace,
|
||||
'hooks' as keyof typeof settings.merged,
|
||||
newHooksSettings as never,
|
||||
);
|
||||
|
||||
debugLogger.info(`✓ Hook "${hookName}" has been enabled.`);
|
||||
} catch (error) {
|
||||
debugLogger.error(`Error enabling hook: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const enableCommand: CommandModule = {
|
||||
command: 'enable <hook-name>',
|
||||
describe: 'Enable a disabled hook',
|
||||
builder: (yargs) =>
|
||||
yargs.positional('hook-name', {
|
||||
describe: 'Name of the hook to enable',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const args = argv as unknown as EnableArgs;
|
||||
await handleEnableHook(args.hookName);
|
||||
process.exit(0);
|
||||
},
|
||||
};
|
||||
|
|
@ -108,6 +108,7 @@ import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
|
|||
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
|
||||
import { useExtensionsManagerDialog } from './hooks/useExtensionsManagerDialog.js';
|
||||
import { useMcpDialog } from './hooks/useMcpDialog.js';
|
||||
import { useHooksDialog } from './hooks/useHooksDialog.js';
|
||||
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
|
||||
import {
|
||||
requestConsentInteractive,
|
||||
|
|
@ -546,6 +547,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
closeExtensionsManagerDialog,
|
||||
} = useExtensionsManagerDialog();
|
||||
const { isMcpDialogOpen, openMcpDialog, closeMcpDialog } = useMcpDialog();
|
||||
const { isHooksDialogOpen, openHooksDialog, closeHooksDialog } =
|
||||
useHooksDialog();
|
||||
|
||||
const slashCommandActions = useMemo(
|
||||
() => ({
|
||||
|
|
@ -572,6 +575,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
openAgentsManagerDialog,
|
||||
openExtensionsManagerDialog,
|
||||
openMcpDialog,
|
||||
openHooksDialog,
|
||||
openResumeDialog,
|
||||
}),
|
||||
[
|
||||
|
|
@ -591,6 +595,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
openAgentsManagerDialog,
|
||||
openExtensionsManagerDialog,
|
||||
openMcpDialog,
|
||||
openHooksDialog,
|
||||
openResumeDialog,
|
||||
],
|
||||
);
|
||||
|
|
@ -1399,6 +1404,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
isSubagentCreateDialogOpen ||
|
||||
isAgentsManagerDialogOpen ||
|
||||
isMcpDialogOpen ||
|
||||
isHooksDialogOpen ||
|
||||
isApprovalModeDialogOpen ||
|
||||
isResumeDialogOpen ||
|
||||
isExtensionsManagerDialogOpen;
|
||||
|
|
@ -1517,6 +1523,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
isExtensionsManagerDialogOpen,
|
||||
// MCP dialog
|
||||
isMcpDialogOpen,
|
||||
// Hooks dialog
|
||||
isHooksDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
// Per-task token tracking
|
||||
|
|
@ -1615,6 +1623,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
isExtensionsManagerDialogOpen,
|
||||
// MCP dialog
|
||||
isMcpDialogOpen,
|
||||
// Hooks dialog
|
||||
isHooksDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
// Per-task token tracking
|
||||
|
|
@ -1666,6 +1676,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
closeExtensionsManagerDialog,
|
||||
// MCP dialog
|
||||
closeMcpDialog,
|
||||
// Hooks dialog
|
||||
openHooksDialog,
|
||||
// Hooks dialog
|
||||
closeHooksDialog,
|
||||
// Resume session dialog
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
|
|
@ -1717,6 +1731,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
closeExtensionsManagerDialog,
|
||||
// MCP dialog
|
||||
closeMcpDialog,
|
||||
// Hooks dialog
|
||||
openHooksDialog,
|
||||
// Hooks dialog
|
||||
closeHooksDialog,
|
||||
// Resume session dialog
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
|
|
|
|||
88
packages/cli/src/ui/commands/hooksCommand.test.ts
Normal file
88
packages/cli/src/ui/commands/hooksCommand.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { hooksCommand } from './hooksCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('hooksCommand', () => {
|
||||
let mockContext: ReturnType<typeof createMockCommandContext>;
|
||||
let mockConfig: {
|
||||
getHookSystem: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create mock config with hook system
|
||||
mockConfig = {
|
||||
getHookSystem: vi.fn().mockReturnValue({
|
||||
getRegistry: vi.fn().mockReturnValue({
|
||||
getAllHooks: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: mockConfig,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('should open hooks management dialog in interactive mode', async () => {
|
||||
const result = await hooksCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'hooks',
|
||||
});
|
||||
});
|
||||
|
||||
it('should open hooks management dialog even if config is not available', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: {
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await hooksCommand.action!(contextWithoutConfig, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'hooks',
|
||||
});
|
||||
});
|
||||
|
||||
it('should open hooks management dialog even if hook system is not available', async () => {
|
||||
mockConfig.getHookSystem = vi.fn().mockReturnValue(null);
|
||||
|
||||
const result = await hooksCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'hooks',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-interactive mode', () => {
|
||||
it('should list hooks in non-interactive mode', async () => {
|
||||
const nonInteractiveContext = createMockCommandContext({
|
||||
services: {
|
||||
config: mockConfig,
|
||||
},
|
||||
executionMode: 'non_interactive',
|
||||
});
|
||||
|
||||
const result = await hooksCommand.action!(nonInteractiveContext, '');
|
||||
|
||||
// In non-interactive mode, it should return a message
|
||||
expect(result).toHaveProperty('type', 'message');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
|
@ -114,209 +114,27 @@ const listCommand: SlashCommand = {
|
|||
},
|
||||
};
|
||||
|
||||
const enableCommand: SlashCommand = {
|
||||
name: 'enable',
|
||||
get description() {
|
||||
return t('Enable a disabled hook');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
const hookName = args.trim();
|
||||
if (!hookName) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'Please specify a hook name. Usage: /hooks enable <hook-name>',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
if (!hookSystem) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Hooks are not enabled.'),
|
||||
};
|
||||
}
|
||||
|
||||
const registry = hookSystem.getRegistry();
|
||||
registry.setHookEnabled(hookName, true);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t('Hook "{{name}}" has been enabled for this session.', {
|
||||
name: hookName,
|
||||
}),
|
||||
};
|
||||
},
|
||||
completion: async (context: CommandContext, partialArg: string) => {
|
||||
const { config } = context.services;
|
||||
if (!config) return [];
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
if (!hookSystem) return [];
|
||||
|
||||
const registry = hookSystem.getRegistry();
|
||||
const allHooks = registry.getAllHooks();
|
||||
|
||||
// Return disabled hooks for enable command (deduplicated by name)
|
||||
const disabledHookNames = allHooks
|
||||
.filter((hook) => !hook.enabled)
|
||||
.map((hook) => hook.config.name || hook.config.command || '')
|
||||
.filter((name) => name && name.startsWith(partialArg));
|
||||
return [...new Set(disabledHookNames)];
|
||||
},
|
||||
};
|
||||
|
||||
const disableCommand: SlashCommand = {
|
||||
name: 'disable',
|
||||
get description() {
|
||||
return t('Disable an active hook');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
const hookName = args.trim();
|
||||
if (!hookName) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'Please specify a hook name. Usage: /hooks disable <hook-name>',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
if (!hookSystem) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Hooks are not enabled.'),
|
||||
};
|
||||
}
|
||||
|
||||
const registry = hookSystem.getRegistry();
|
||||
registry.setHookEnabled(hookName, false);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t('Hook "{{name}}" has been disabled for this session.', {
|
||||
name: hookName,
|
||||
}),
|
||||
};
|
||||
},
|
||||
completion: async (context: CommandContext, partialArg: string) => {
|
||||
const { config } = context.services;
|
||||
if (!config) return [];
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
if (!hookSystem) return [];
|
||||
|
||||
const registry = hookSystem.getRegistry();
|
||||
const allHooks = registry.getAllHooks();
|
||||
|
||||
// Return enabled hooks for disable command (deduplicated by name)
|
||||
const enabledHookNames = allHooks
|
||||
.filter((hook) => hook.enabled)
|
||||
.map((hook) => hook.config.name || hook.config.command || '')
|
||||
.filter((name) => name && name.startsWith(partialArg));
|
||||
return [...new Set(enabledHookNames)];
|
||||
},
|
||||
};
|
||||
|
||||
export const hooksCommand: SlashCommand = {
|
||||
name: 'hooks',
|
||||
get description() {
|
||||
return t('Manage Qwen Code hooks');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [listCommand, enableCommand, disableCommand],
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
// If no subcommand provided, show list
|
||||
if (!args.trim()) {
|
||||
const result = await listCommand.action?.(context, '');
|
||||
return result ?? { type: 'message', messageType: 'info', content: '' };
|
||||
// In interactive mode, open the hooks dialog
|
||||
const executionMode = context.executionMode ?? 'interactive';
|
||||
if (executionMode === 'interactive') {
|
||||
return {
|
||||
type: 'dialog',
|
||||
dialog: 'hooks',
|
||||
};
|
||||
}
|
||||
|
||||
const [subcommand, ...rest] = args.trim().split(/\s+/);
|
||||
const subArgs = rest.join(' ');
|
||||
|
||||
let result: SlashCommandActionReturn | void;
|
||||
switch (subcommand.toLowerCase()) {
|
||||
case 'list':
|
||||
result = await listCommand.action?.(context, subArgs);
|
||||
break;
|
||||
case 'enable':
|
||||
result = await enableCommand.action?.(context, subArgs);
|
||||
break;
|
||||
case 'disable':
|
||||
result = await disableCommand.action?.(context, subArgs);
|
||||
break;
|
||||
default:
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'Unknown subcommand: {{cmd}}. Available: list, enable, disable',
|
||||
{
|
||||
cmd: subcommand,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
// In non-interactive mode, list hooks
|
||||
const result = await listCommand.action?.(context, args);
|
||||
return result ?? { type: 'message', messageType: 'info', content: '' };
|
||||
},
|
||||
completion: async (context: CommandContext, partialArg: string) => {
|
||||
const subcommands = ['list', 'enable', 'disable'];
|
||||
const parts = partialArg.split(/\s+/);
|
||||
|
||||
if (parts.length <= 1) {
|
||||
// Complete subcommand
|
||||
return subcommands.filter((cmd) => cmd.startsWith(partialArg));
|
||||
}
|
||||
|
||||
// Complete subcommand arguments
|
||||
const [subcommand, ...rest] = parts;
|
||||
const subArgs = rest.join(' ');
|
||||
|
||||
switch (subcommand.toLowerCase()) {
|
||||
case 'enable':
|
||||
return enableCommand.completion?.(context, subArgs) ?? [];
|
||||
case 'disable':
|
||||
return disableCommand.completion?.(context, subArgs) ?? [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ export interface OpenDialogActionReturn {
|
|||
| 'approval-mode'
|
||||
| 'resume'
|
||||
| 'extensions_manage'
|
||||
| 'hooks'
|
||||
| 'mcp';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
132
packages/cli/src/ui/components/hooks/HookDetailStep.tsx
Normal file
132
packages/cli/src/ui/components/hooks/HookDetailStep.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import type { HookEventDisplayInfo } from './types.js';
|
||||
import { SOURCE_DISPLAY_MAP } from './constants.js';
|
||||
|
||||
interface HookDetailStepProps {
|
||||
hook: HookEventDisplayInfo;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function HookDetailStep({
|
||||
hook,
|
||||
onBack,
|
||||
}: HookDetailStepProps): React.JSX.Element {
|
||||
const hasConfigs = hook.configs.length > 0;
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// Handle keyboard navigation
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onBack();
|
||||
} else if (hasConfigs) {
|
||||
if (key.name === 'up') {
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.name === 'down') {
|
||||
setSelectedIndex((prev) =>
|
||||
Math.min(hook.configs.length - 1, prev + 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
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}>
|
||||
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}>
|
||||
Configured hooks:
|
||||
</Text>
|
||||
{hook.configs.map((config, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const sourceDisplay =
|
||||
SOURCE_DISPLAY_MAP[config.source] || config.source;
|
||||
|
||||
return (
|
||||
<Box key={index}>
|
||||
<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}
|
||||
>
|
||||
{`${index + 1}. ${config.config.command}`}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> · </Text>
|
||||
<Text color={theme.text.secondary}>{sourceDisplay}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>Esc to go back</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
No hooks configured for this event.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
To add hooks, edit settings.json directly or ask Qwen.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>Esc to go back</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
109
packages/cli/src/ui/components/hooks/HooksListStep.tsx
Normal file
109
packages/cli/src/ui/components/hooks/HooksListStep.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import type { HookEventDisplayInfo } from './types.js';
|
||||
|
||||
interface HooksListStepProps {
|
||||
hooks: HookEventDisplayInfo[];
|
||||
onSelect: (index: number) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function HooksListStep({
|
||||
hooks,
|
||||
onSelect,
|
||||
onCancel,
|
||||
}: HooksListStepProps): React.JSX.Element {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'up') {
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.name === 'down') {
|
||||
setSelectedIndex((prev) => Math.min(hooks.length - 1, prev + 1));
|
||||
} else if (key.name === 'return') {
|
||||
onSelect(selectedIndex);
|
||||
} else if (key.name === 'escape') {
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
if (hooks.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.text.secondary}>No hook events found.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate total configured hooks
|
||||
const totalConfigured = hooks.reduce(
|
||||
(sum, hook) => sum + hook.configs.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Hooks
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{` · ${totalConfigured} hook${totalConfigured !== 1 ? 's' : ''} configured`}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
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={30}>
|
||||
<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}>
|
||||
Enter to select · Esc to cancel
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
227
packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx
Normal file
227
packages/cli/src/ui/components/hooks/HooksManagementDialog.tsx
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
/**
|
||||
* @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 { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { loadSettings, SettingScope } from '../../../config/settings.js';
|
||||
import {
|
||||
HooksConfigSource,
|
||||
type HookDefinition,
|
||||
createDebugLogger,
|
||||
} 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 {
|
||||
DISPLAY_HOOK_EVENTS,
|
||||
SOURCE_DISPLAY_MAP,
|
||||
createEmptyHookEventInfo,
|
||||
} from './constants.js';
|
||||
|
||||
const debugLogger = createDebugLogger('HOOKS_DIALOG');
|
||||
|
||||
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 [hooks, setHooks] = useState<HookEventDisplayInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(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;
|
||||
|
||||
const result: HookEventDisplayInfo[] = [];
|
||||
|
||||
for (const eventName of DISPLAY_HOOK_EVENTS) {
|
||||
const hookInfo = createEmptyHookEventInfo(eventName);
|
||||
|
||||
// Get hooks from user settings
|
||||
const userHooks = (userSettings as Record<string, unknown>)?.['hooks'] as
|
||||
| Record<string, HookDefinition[]>
|
||||
| undefined;
|
||||
if (userHooks?.[eventName]) {
|
||||
for (const def of userHooks[eventName]) {
|
||||
for (const hookConfig of def.hooks) {
|
||||
hookInfo.configs.push({
|
||||
config: hookConfig,
|
||||
source: HooksConfigSource.User,
|
||||
sourceDisplay: SOURCE_DISPLAY_MAP[HooksConfigSource.User],
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get hooks from workspace settings
|
||||
const workspaceHooks = (workspaceSettings as Record<string, unknown>)?.[
|
||||
'hooks'
|
||||
] as Record<string, HookDefinition[]> | undefined;
|
||||
if (workspaceHooks?.[eventName]) {
|
||||
for (const def of workspaceHooks[eventName]) {
|
||||
for (const hookConfig of def.hooks) {
|
||||
hookInfo.configs.push({
|
||||
config: hookConfig,
|
||||
source: HooksConfigSource.Project,
|
||||
sourceDisplay: SOURCE_DISPLAY_MAP[HooksConfigSource.Project],
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get hooks from extensions
|
||||
const extensions = config.getExtensions() || [];
|
||||
for (const extension of extensions) {
|
||||
if (extension.isActive && extension.hooks?.[eventName]) {
|
||||
for (const def of extension.hooks[eventName]!) {
|
||||
for (const hookConfig of def.hooks) {
|
||||
hookInfo.configs.push({
|
||||
config: hookConfig,
|
||||
source: HooksConfigSource.Extensions,
|
||||
sourceDisplay: SOURCE_DISPLAY_MAP[HooksConfigSource.Extensions],
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push(hookInfo);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [config]);
|
||||
|
||||
// Load hooks data on initial render
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const hooksData = fetchHooksData();
|
||||
setHooks(hooksData);
|
||||
} catch (error) {
|
||||
debugLogger.error('Error loading hooks:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [fetchHooksData]);
|
||||
|
||||
// Current step
|
||||
const getCurrentStep = useCallback(
|
||||
() =>
|
||||
navigationStack[navigationStack.length - 1] ||
|
||||
HOOKS_MANAGEMENT_STEPS.HOOKS_LIST,
|
||||
[navigationStack],
|
||||
);
|
||||
|
||||
// Navigation handlers
|
||||
const handleNavigateBack = useCallback(() => {
|
||||
setNavigationStack((prev) => {
|
||||
if (prev.length <= 1) {
|
||||
onClose();
|
||||
return prev;
|
||||
}
|
||||
return prev.slice(0, -1);
|
||||
});
|
||||
}, [onClose]);
|
||||
|
||||
// Handle escape key globally
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
handleNavigateBack();
|
||||
}
|
||||
},
|
||||
{ isActive: getCurrentStep() === HOOKS_MANAGEMENT_STEPS.HOOKS_LIST },
|
||||
);
|
||||
|
||||
// Select hook
|
||||
const handleSelectHook = useCallback((index: number) => {
|
||||
setSelectedHookIndex(index);
|
||||
setNavigationStack((prev) => [...prev, HOOKS_MANAGEMENT_STEPS.HOOK_DETAIL]);
|
||||
}, []);
|
||||
|
||||
// Selected hook
|
||||
const selectedHook = useMemo(() => {
|
||||
if (selectedHookIndex >= 0 && selectedHookIndex < hooks.length) {
|
||||
return hooks[selectedHookIndex];
|
||||
}
|
||||
return null;
|
||||
}, [hooks, selectedHookIndex]);
|
||||
|
||||
// Render based on current step
|
||||
const renderContent = () => {
|
||||
const currentStep = getCurrentStep();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.text.secondary}>Loading hooks...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
switch (currentStep) {
|
||||
case HOOKS_MANAGEMENT_STEPS.HOOKS_LIST:
|
||||
return (
|
||||
<HooksListStep
|
||||
hooks={hooks}
|
||||
onSelect={handleSelectHook}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
case HOOKS_MANAGEMENT_STEPS.HOOK_DETAIL:
|
||||
if (selectedHook) {
|
||||
return (
|
||||
<HookDetailStep hook={selectedHook} onBack={handleNavigateBack} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.text.secondary}>No hook selected</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
width={boxWidth}
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
>
|
||||
{renderContent()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
179
packages/cli/src/ui/components/hooks/constants.ts
Normal file
179
packages/cli/src/ui/components/hooks/constants.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* @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';
|
||||
|
||||
/**
|
||||
* Exit code descriptions for different hook types
|
||||
*/
|
||||
export const HOOK_EXIT_CODES: Record<string, HookExitCode[]> = {
|
||||
[HookEventName.Stop]: [
|
||||
{ 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' },
|
||||
],
|
||||
[HookEventName.PreToolUse]: [
|
||||
{ code: 0, description: 'stdout/stderr not shown' },
|
||||
{ code: 2, description: 'show stderr to model and block tool call' },
|
||||
{
|
||||
code: 'Other',
|
||||
description: 'show stderr to user only but continue with tool call',
|
||||
},
|
||||
],
|
||||
[HookEventName.PostToolUse]: [
|
||||
{ code: 0, description: 'stdout shown in transcript mode (ctrl+o)' },
|
||||
{ code: 2, description: 'show stderr to model immediately' },
|
||||
{ code: 'Other', description: 'show stderr to user only' },
|
||||
],
|
||||
[HookEventName.PostToolUseFailure]: [
|
||||
{ code: 0, description: 'stdout shown in transcript mode (ctrl+o)' },
|
||||
{ code: 2, description: 'show stderr to model immediately' },
|
||||
{ code: 'Other', description: 'show stderr to user only' },
|
||||
],
|
||||
[HookEventName.Notification]: [
|
||||
{ code: 0, description: 'stdout/stderr not shown' },
|
||||
{ code: 'Other', description: 'show stderr to user only' },
|
||||
],
|
||||
[HookEventName.UserPromptSubmit]: [
|
||||
{ code: 0, description: 'stdout shown to model' },
|
||||
{
|
||||
code: 2,
|
||||
description:
|
||||
'block processing, erase original prompt, and show stderr to user only',
|
||||
},
|
||||
{ code: 'Other', description: 'show stderr to user only' },
|
||||
],
|
||||
[HookEventName.SessionStart]: [
|
||||
{ code: 0, description: 'stdout shown to model' },
|
||||
{
|
||||
code: 'Other',
|
||||
description: 'show stderr to user only (blocking errors ignored)',
|
||||
},
|
||||
],
|
||||
[HookEventName.SessionEnd]: [
|
||||
{ code: 0, description: 'command completes successfully' },
|
||||
{ code: 'Other', description: 'show stderr to user only' },
|
||||
],
|
||||
[HookEventName.SubagentStart]: [
|
||||
{ code: 0, description: 'stdout shown to subagent' },
|
||||
{
|
||||
code: 'Other',
|
||||
description: 'show stderr to user only (blocking errors ignored)',
|
||||
},
|
||||
],
|
||||
[HookEventName.SubagentStop]: [
|
||||
{ code: 0, description: 'stdout/stderr not shown' },
|
||||
{
|
||||
code: 2,
|
||||
description: 'show stderr to subagent and continue having it run',
|
||||
},
|
||||
{ code: 'Other', description: 'show stderr to user only' },
|
||||
],
|
||||
[HookEventName.PreCompact]: [
|
||||
{ code: 0, description: 'stdout appended as custom compact instructions' },
|
||||
{ code: 2, description: 'block compaction' },
|
||||
{
|
||||
code: 'Other',
|
||||
description: 'show stderr to user only but continue with compaction',
|
||||
},
|
||||
],
|
||||
[HookEventName.PermissionRequest]: [
|
||||
{ code: 0, description: 'use hook decision if provided' },
|
||||
{ code: 'Other', description: 'show stderr to user only' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Short one-line description for hooks list view
|
||||
*/
|
||||
export const HOOK_SHORT_DESCRIPTIONS: Record<string, string> = {
|
||||
[HookEventName.PreToolUse]: 'Before tool execution',
|
||||
[HookEventName.PostToolUse]: 'After tool execution',
|
||||
[HookEventName.PostToolUseFailure]: 'After tool execution fails',
|
||||
[HookEventName.Notification]: 'When notifications are sent',
|
||||
[HookEventName.UserPromptSubmit]: 'When the user submits a prompt',
|
||||
[HookEventName.SessionStart]: 'When a new session is started',
|
||||
[HookEventName.Stop]: 'Right before Qwen Code concludes its response',
|
||||
[HookEventName.SubagentStart]: 'When a subagent (Agent tool call) is started',
|
||||
[HookEventName.SubagentStop]:
|
||||
'Right before a subagent concludes its response',
|
||||
[HookEventName.PreCompact]: 'Before conversation compaction',
|
||||
[HookEventName.SessionEnd]: 'When a session is ending',
|
||||
[HookEventName.PermissionRequest]: 'When a permission dialog is displayed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Detailed description for each hook event type (shown in detail view)
|
||||
*/
|
||||
export const HOOK_DESCRIPTIONS: Record<string, string> = {
|
||||
[HookEventName.Stop]: '',
|
||||
[HookEventName.PreToolUse]:
|
||||
'Input to command is JSON of tool call arguments.',
|
||||
[HookEventName.PostToolUse]:
|
||||
'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).',
|
||||
[HookEventName.PostToolUseFailure]:
|
||||
'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.',
|
||||
[HookEventName.Notification]:
|
||||
'Input to command is JSON with notification message and type.',
|
||||
[HookEventName.UserPromptSubmit]:
|
||||
'Input to command is JSON with original user prompt text.',
|
||||
[HookEventName.SessionStart]:
|
||||
'Input to command is JSON with session start source.',
|
||||
[HookEventName.SessionEnd]:
|
||||
'Input to command is JSON with session end reason.',
|
||||
[HookEventName.SubagentStart]:
|
||||
'Input to command is JSON with agent_id and agent_type.',
|
||||
[HookEventName.SubagentStop]:
|
||||
'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.',
|
||||
[HookEventName.PreCompact]:
|
||||
'Input to command is JSON with compaction details.',
|
||||
[HookEventName.PermissionRequest]:
|
||||
'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.',
|
||||
};
|
||||
|
||||
/**
|
||||
* Source display mapping
|
||||
*/
|
||||
export const SOURCE_DISPLAY_MAP: Record<HooksConfigSource, string> = {
|
||||
[HooksConfigSource.Project]: 'Local Settings',
|
||||
[HooksConfigSource.User]: 'User Settings',
|
||||
[HooksConfigSource.System]: 'System Settings',
|
||||
[HooksConfigSource.Extensions]: '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: HOOK_SHORT_DESCRIPTIONS[eventName] || '',
|
||||
description: HOOK_DESCRIPTIONS[eventName] || '',
|
||||
exitCodes: HOOK_EXIT_CODES[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';
|
||||
58
packages/cli/src/ui/components/hooks/types.ts
Normal file
58
packages/cli/src/ui/components/hooks/types.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* @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;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook management dialog step names
|
||||
*/
|
||||
export const HOOKS_MANAGEMENT_STEPS = {
|
||||
HOOKS_LIST: 'hooks_list',
|
||||
HOOK_DETAIL: 'hook_detail',
|
||||
} as const;
|
||||
|
||||
export type HooksManagementStep =
|
||||
(typeof HOOKS_MANAGEMENT_STEPS)[keyof typeof HOOKS_MANAGEMENT_STEPS];
|
||||
|
||||
/**
|
||||
* Props for HooksManagementDialog
|
||||
*/
|
||||
export interface HooksManagementDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
|
@ -83,6 +83,10 @@ export interface UIActions {
|
|||
closeExtensionsManagerDialog: () => void;
|
||||
// MCP dialog
|
||||
closeMcpDialog: () => void;
|
||||
// Hooks dialog
|
||||
openHooksDialog: () => void;
|
||||
// Hooks dialog
|
||||
closeHooksDialog: () => void;
|
||||
// Resume session dialog
|
||||
openResumeDialog: () => void;
|
||||
closeResumeDialog: () => void;
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@ export interface UIState {
|
|||
isExtensionsManagerDialogOpen: boolean;
|
||||
// MCP dialog
|
||||
isMcpDialogOpen: boolean;
|
||||
// Hooks dialog
|
||||
isHooksDialogOpen: boolean;
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen: boolean;
|
||||
// Per-task token tracking
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ interface SlashCommandProcessorActions {
|
|||
openAgentsManagerDialog: () => void;
|
||||
openExtensionsManagerDialog: () => void;
|
||||
openMcpDialog: () => void;
|
||||
openHooksDialog: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -501,6 +502,9 @@ export const useSlashCommandProcessor = (
|
|||
case 'mcp':
|
||||
actions.openMcpDialog();
|
||||
return { type: 'handled' };
|
||||
case 'hooks':
|
||||
actions.openHooksDialog();
|
||||
return { type: 'handled' };
|
||||
case 'approval-mode':
|
||||
actions.openApprovalModeDialog();
|
||||
return { type: 'handled' };
|
||||
|
|
|
|||
31
packages/cli/src/ui/hooks/useHooksDialog.ts
Normal file
31
packages/cli/src/ui/hooks/useHooksDialog.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface UseHooksDialogReturn {
|
||||
isHooksDialogOpen: boolean;
|
||||
openHooksDialog: () => void;
|
||||
closeHooksDialog: () => void;
|
||||
}
|
||||
|
||||
export const useHooksDialog = (): UseHooksDialogReturn => {
|
||||
const [isHooksDialogOpen, setIsHooksDialogOpen] = useState(false);
|
||||
|
||||
const openHooksDialog = useCallback(() => {
|
||||
setIsHooksDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeHooksDialog = useCallback(() => {
|
||||
setIsHooksDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isHooksDialogOpen,
|
||||
openHooksDialog,
|
||||
closeHooksDialog,
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue