refactor ui for qwen code hooks

This commit is contained in:
DennisYu07 2026-03-23 11:24:59 +08:00
parent 38caa0b218
commit b08154dbee
21 changed files with 972 additions and 357 deletions

View file

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

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

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

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

View 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: [],
};
}

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

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