feat: subagent list dialog - working

This commit is contained in:
tanzhenxin
2025-09-04 16:34:51 +08:00
parent 9fcc7a4cbe
commit e44e28a640
24 changed files with 1340 additions and 307 deletions

View File

@@ -25,6 +25,7 @@ import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useMessageQueue } from './hooks/useMessageQueue.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
@@ -42,7 +43,10 @@ import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
import { FolderTrustDialog } from './components/FolderTrustDialog.js';
import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
import { SubagentCreationWizard } from './components/subagents/SubagentCreationWizard.js';
import {
SubagentCreationWizard,
AgentsManagerDialog,
} from './components/subagents/index.js';
import { Colors } from './colors.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js';
import { LoadedSettings, SettingScope } from '../config/settings.js';
@@ -277,6 +281,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
closeSubagentCreateDialog,
} = useSubagentCreateDialog();
const {
isAgentsManagerDialogOpen,
openAgentsManagerDialog,
closeAgentsManagerDialog,
} = useAgentsManagerDialog();
const { isFolderTrustDialogOpen, handleFolderTrustSelect } = useFolderTrust(
settings,
setIsTrustedFolder,
@@ -574,6 +584,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
openPrivacyNotice,
openSettingsDialog,
openSubagentCreateDialog,
openAgentsManagerDialog,
toggleVimEnabled,
setIsProcessing,
setGeminiMdFileCount,
@@ -1087,6 +1098,13 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
config={config}
/>
</Box>
) : isAgentsManagerDialogOpen ? (
<Box flexDirection="column">
<AgentsManagerDialog
onClose={closeAgentsManagerDialog}
config={config}
/>
</Box>
) : isAuthenticating ? (
<>
{isQwenAuth && isQwenAuthenticating ? (

View File

@@ -4,19 +4,22 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { MessageType } from '../types.js';
import {
CommandKind,
SlashCommand,
SlashCommandActionReturn,
OpenDialogActionReturn,
} from './types.js';
import { CommandKind, SlashCommand, OpenDialogActionReturn } from './types.js';
export const agentsCommand: SlashCommand = {
name: 'agents',
description: 'Manage subagents for specialized task delegation.',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'list',
description: 'Manage existing subagents (view, edit, delete).',
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'subagent_list',
}),
},
{
name: 'create',
description: 'Create a new subagent with guided setup.',
@@ -26,94 +29,5 @@ export const agentsCommand: SlashCommand = {
dialog: 'subagent_create',
}),
},
{
name: 'list',
description: 'List all available subagents.',
kind: CommandKind.BUILT_IN,
action: async (context): Promise<SlashCommandActionReturn | void> => {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'Listing subagents... (not implemented yet)',
},
Date.now(),
);
},
},
{
name: 'show',
description: 'Show detailed information about a subagent.',
kind: CommandKind.BUILT_IN,
action: async (
context,
args,
): Promise<SlashCommandActionReturn | void> => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: 'Usage: /agents show <subagent-name>',
};
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Showing details for subagent: ${args.trim()} (not implemented yet)`,
},
Date.now(),
);
},
},
{
name: 'edit',
description: 'Edit an existing subagent configuration.',
kind: CommandKind.BUILT_IN,
action: async (
context,
args,
): Promise<SlashCommandActionReturn | void> => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: 'Usage: /agents edit <subagent-name>',
};
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Editing subagent: ${args.trim()} (not implemented yet)`,
},
Date.now(),
);
},
},
{
name: 'delete',
description: 'Delete a subagent configuration.',
kind: CommandKind.BUILT_IN,
action: async (
context,
args,
): Promise<SlashCommandActionReturn | void> => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: 'Usage: /agents delete <subagent-name>',
};
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Deleting subagent: ${args.trim()} (not implemented yet)`,
},
Date.now(),
);
},
},
],
};

View File

@@ -111,7 +111,8 @@ export interface OpenDialogActionReturn {
| 'editor'
| 'privacy'
| 'settings'
| 'subagent_create';
| 'subagent_create'
| 'subagent_list';
}
/**

View File

@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { Box } from 'ink';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { ManagementStepProps } from './types.js';
export const ActionSelectionStep = ({
state,
dispatch,
onNext,
onPrevious,
}: ManagementStepProps) => {
const actions = [
{ label: 'View Agent', value: 'view' as const },
{ label: 'Edit Agent', value: 'edit' as const },
{ label: 'Delete Agent', value: 'delete' as const },
{ label: 'Back', value: 'back' as const },
];
const handleActionSelect = (value: 'view' | 'edit' | 'delete' | 'back') => {
if (value === 'back') {
onPrevious();
return;
}
dispatch({ type: 'SELECT_ACTION', payload: value });
onNext();
};
const selectedIndex = state.selectedAction
? actions.findIndex((action) => action.value === state.selectedAction)
: 0;
return (
<Box flexDirection="column">
<RadioButtonSelect
items={actions}
initialIndex={selectedIndex}
onSelect={handleActionSelect}
showNumbers={false}
/>
</Box>
);
};

View File

@@ -0,0 +1,305 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useState } from 'react';
import { Box, Text } from 'ink';
import { ManagementStepProps } from './types.js';
import { theme } from '../../semantic-colors.js';
import { Colors } from '../../colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
interface NavigationState {
currentBlock: 'project' | 'user';
projectIndex: number;
userIndex: number;
}
export const AgentSelectionStep = ({
state,
dispatch,
onNext,
config,
}: ManagementStepProps) => {
const [isLoading, setIsLoading] = useState(false);
const [navigation, setNavigation] = useState<NavigationState>({
currentBlock: 'project',
projectIndex: 0,
userIndex: 0,
});
// Group agents by level
const projectAgents = state.availableAgents.filter(
(agent) => agent.level === 'project',
);
const userAgents = state.availableAgents.filter(
(agent) => agent.level === 'user',
);
const projectNames = new Set(projectAgents.map((agent) => agent.name));
useEffect(() => {
const loadAgents = async () => {
setIsLoading(true);
dispatch({ type: 'SET_LOADING', payload: true });
try {
if (!config) {
throw new Error('Configuration not available');
}
const manager = config.getSubagentManager();
// Load agents from both levels separately to show all agents including conflicts
const [projectAgents, userAgents] = await Promise.all([
manager.listSubagents({ level: 'project' }),
manager.listSubagents({ level: 'user' }),
]);
// Combine all agents (project and user level)
const allAgents = [...projectAgents, ...userAgents];
dispatch({ type: 'SET_AVAILABLE_AGENTS', payload: allAgents });
dispatch({ type: 'SET_ERROR', payload: null });
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
dispatch({
type: 'SET_ERROR',
payload: `Failed to load agents: ${errorMessage}`,
});
} finally {
setIsLoading(false);
dispatch({ type: 'SET_LOADING', payload: false });
}
};
loadAgents();
}, [dispatch, config]);
// Initialize navigation state when agents are loaded
useEffect(() => {
if (projectAgents.length > 0) {
setNavigation((prev) => ({ ...prev, currentBlock: 'project' }));
} else if (userAgents.length > 0) {
setNavigation((prev) => ({ ...prev, currentBlock: 'user' }));
}
}, [projectAgents.length, userAgents.length]);
// Custom keyboard navigation
useKeypress(
(key) => {
const { name } = key;
if (name === 'up' || name === 'k') {
setNavigation((prev) => {
if (prev.currentBlock === 'project') {
if (prev.projectIndex > 0) {
return { ...prev, projectIndex: prev.projectIndex - 1 };
} else if (userAgents.length > 0) {
// Move to last item in user block
return {
...prev,
currentBlock: 'user',
userIndex: userAgents.length - 1,
};
} else {
// Wrap to last item in project block
return { ...prev, projectIndex: projectAgents.length - 1 };
}
} else {
if (prev.userIndex > 0) {
return { ...prev, userIndex: prev.userIndex - 1 };
} else if (projectAgents.length > 0) {
// Move to last item in project block
return {
...prev,
currentBlock: 'project',
projectIndex: projectAgents.length - 1,
};
} else {
// Wrap to last item in user block
return { ...prev, userIndex: userAgents.length - 1 };
}
}
});
} else if (name === 'down' || name === 'j') {
setNavigation((prev) => {
if (prev.currentBlock === 'project') {
if (prev.projectIndex < projectAgents.length - 1) {
return { ...prev, projectIndex: prev.projectIndex + 1 };
} else if (userAgents.length > 0) {
// Move to first item in user block
return { ...prev, currentBlock: 'user', userIndex: 0 };
} else {
// Wrap to first item in project block
return { ...prev, projectIndex: 0 };
}
} else {
if (prev.userIndex < userAgents.length - 1) {
return { ...prev, userIndex: prev.userIndex + 1 };
} else if (projectAgents.length > 0) {
// Move to first item in project block
return { ...prev, currentBlock: 'project', projectIndex: 0 };
} else {
// Wrap to first item in user block
return { ...prev, userIndex: 0 };
}
}
});
} else if (name === 'return' || name === 'space') {
// Select current item
const currentAgent =
navigation.currentBlock === 'project'
? projectAgents[navigation.projectIndex]
: userAgents[navigation.userIndex];
if (currentAgent) {
const agentIndex = state.availableAgents.indexOf(currentAgent);
handleAgentSelect(agentIndex);
}
}
},
{ isActive: true },
);
const handleAgentSelect = async (index: number) => {
const selectedMetadata = state.availableAgents[index];
if (!selectedMetadata) return;
try {
if (!config) {
throw new Error('Configuration not available');
}
const manager = config.getSubagentManager();
const agent = await manager.loadSubagent(
selectedMetadata.name,
selectedMetadata.level,
);
if (agent) {
dispatch({ type: 'SELECT_AGENT', payload: { agent, index } });
onNext();
} else {
dispatch({
type: 'SET_ERROR',
payload: `Failed to load agent: ${selectedMetadata.name}`,
});
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
dispatch({
type: 'SET_ERROR',
payload: `Failed to load agent: ${errorMessage}`,
});
}
};
if (isLoading) {
return (
<Box>
<Text color={theme.text.secondary}>Loading agents...</Text>
</Box>
);
}
if (state.error) {
return (
<Box>
<Text color={theme.status.error}>{state.error}</Text>
</Box>
);
}
if (state.availableAgents.length === 0) {
return (
<Box flexDirection="column">
<Text color={theme.text.secondary}>No subagents found.</Text>
<Text color={theme.text.secondary}>
Use &apos;/agents create&apos; to create your first subagent.
</Text>
</Box>
);
}
// Render custom radio button items
const renderAgentItem = (
agent: { name: string; level: 'project' | 'user' },
index: number,
isSelected: boolean,
) => {
const textColor = isSelected ? theme.text.accent : theme.text.primary;
return (
<Box key={agent.name} alignItems="center">
<Box minWidth={2} flexShrink={0}>
<Text color={isSelected ? theme.text.accent : theme.text.primary}>
{isSelected ? '●' : ' '}
</Text>
</Box>
<Text color={textColor} wrap="truncate">
{agent.name}
{agent.level === 'user' && projectNames.has(agent.name) && (
<Text color={isSelected ? theme.status.warning : Colors.Gray}>
{' '}
(overridden by project level agent)
</Text>
)}
</Text>
</Box>
);
};
// Calculate enabled agents count (excluding conflicted user-level agents)
const enabledAgentsCount =
projectAgents.length +
userAgents.filter((agent) => !projectNames.has(agent.name)).length;
return (
<Box flexDirection="column">
{/* Project Level Agents */}
{projectAgents.length > 0 && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.text.primary} bold>
Project Level ({projectAgents[0].filePath.replace(/\/[^/]+$/, '')})
</Text>
<Box marginTop={1} flexDirection="column">
{projectAgents.map((agent, index) => {
const isSelected =
navigation.currentBlock === 'project' &&
navigation.projectIndex === index;
return renderAgentItem(agent, index, isSelected);
})}
</Box>
</Box>
)}
{/* User Level Agents */}
{userAgents.length > 0 && (
<Box flexDirection="column">
<Text color={theme.text.primary} bold>
User Level ({userAgents[0].filePath.replace(/\/[^/]+$/, '')})
</Text>
<Box marginTop={1} flexDirection="column">
{userAgents.map((agent, index) => {
const isSelected =
navigation.currentBlock === 'user' &&
navigation.userIndex === index;
return renderAgentItem(agent, index, isSelected);
})}
</Box>
</Box>
)}
{/* Agent count summary */}
{(projectAgents.length > 0 || userAgents.length > 0) && (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Using: {enabledAgentsCount} agents
</Text>
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,79 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text, useInput } from 'ink';
import { ManagementStepProps } from './types.js';
import { theme } from '../../semantic-colors.js';
import { shouldShowColor, getColorForDisplay } from './utils.js';
export const AgentViewerStep = ({ state, onPrevious }: ManagementStepProps) => {
// Handle keyboard input
useInput((input, key) => {
if (key.escape || input === 'b') {
onPrevious();
}
});
if (!state.selectedAgent) {
return (
<Box>
<Text color={theme.status.error}>No agent selected</Text>
</Box>
);
}
const agent = state.selectedAgent;
const toolsDisplay = agent.tools ? agent.tools.join(', ') : '*';
return (
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<Box>
<Text bold>Location: </Text>
<Text>
{agent.level === 'project'
? 'Project Level (.qwen/agents/)'
: 'User Level (~/.qwen/agents/)'}
</Text>
</Box>
<Box>
<Text bold>File Path: </Text>
<Text>{agent.filePath}</Text>
</Box>
<Box>
<Text bold>Tools: </Text>
<Text>{toolsDisplay}</Text>
</Box>
{shouldShowColor(agent.backgroundColor) && (
<Box>
<Text bold>Color: </Text>
<Box backgroundColor={getColorForDisplay(agent.backgroundColor)}>
<Text color="black">{` ${agent.name} `}</Text>
</Box>
</Box>
)}
<Box marginTop={1}>
<Text bold>Description:</Text>
</Box>
<Box padding={1} paddingBottom={0}>
<Text wrap="wrap">{agent.description}</Text>
</Box>
<Box marginTop={1}>
<Text bold>System Prompt:</Text>
</Box>
<Box padding={1} paddingBottom={0}>
<Text wrap="wrap">{agent.systemPrompt}</Text>
</Box>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,178 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useReducer, useCallback, useMemo } from 'react';
import { Box, Text, useInput } from 'ink';
import { managementReducer, initialManagementState } from './reducers.js';
import { AgentSelectionStep } from './AgentSelectionStep.js';
import { ActionSelectionStep } from './ActionSelectionStep.js';
import { AgentViewerStep } from './AgentViewerStep.js';
import { ManagementStepProps, MANAGEMENT_STEPS } from './types.js';
import { Colors } from '../../colors.js';
import { theme } from '../../semantic-colors.js';
import { Config } from '@qwen-code/qwen-code-core';
interface AgentsManagerDialogProps {
onClose: () => void;
config: Config | null;
}
/**
* Main orchestrator component for the agents management dialog.
*/
export function AgentsManagerDialog({
onClose,
config,
}: AgentsManagerDialogProps) {
const [state, dispatch] = useReducer(
managementReducer,
initialManagementState,
);
const handleNext = useCallback(() => {
dispatch({ type: 'GO_TO_NEXT_STEP' });
}, []);
const handlePrevious = useCallback(() => {
dispatch({ type: 'GO_TO_PREVIOUS_STEP' });
}, []);
const handleCancel = useCallback(() => {
dispatch({ type: 'RESET_DIALOG' });
onClose();
}, [onClose]);
// Centralized ESC key handling for the entire dialog
useInput((input, key) => {
if (key.escape) {
// Agent viewer step handles its own ESC logic
if (state.currentStep === MANAGEMENT_STEPS.AGENT_VIEWER) {
return; // Let AgentViewerStep handle it
}
if (state.currentStep === MANAGEMENT_STEPS.AGENT_SELECTION) {
// On first step, ESC cancels the entire dialog
handleCancel();
} else {
// On other steps, ESC goes back to previous step
handlePrevious();
}
}
});
const stepProps: ManagementStepProps = useMemo(
() => ({
state,
config,
dispatch,
onNext: handleNext,
onPrevious: handlePrevious,
onCancel: handleCancel,
}),
[state, dispatch, handleNext, handlePrevious, handleCancel, config],
);
const renderStepHeader = useCallback(() => {
const getStepHeaderText = () => {
switch (state.currentStep) {
case MANAGEMENT_STEPS.AGENT_SELECTION:
return 'Agents';
case MANAGEMENT_STEPS.ACTION_SELECTION:
return 'Choose Action';
case MANAGEMENT_STEPS.AGENT_VIEWER:
return state.selectedAgent?.name;
case MANAGEMENT_STEPS.AGENT_EDITOR:
return `Editing: ${state.selectedAgent?.name || 'Unknown'}`;
case MANAGEMENT_STEPS.DELETE_CONFIRMATION:
return `Delete: ${state.selectedAgent?.name || 'Unknown'}`;
default:
return 'Unknown Step';
}
};
return (
<Box>
<Text bold>{getStepHeaderText()}</Text>
</Box>
);
}, [state.currentStep, state.selectedAgent?.name]);
const renderStepFooter = useCallback(() => {
const getNavigationInstructions = () => {
if (state.currentStep === MANAGEMENT_STEPS.ACTION_SELECTION) {
return 'Enter to select, ↑↓ to navigate, Esc to go back';
}
if (state.currentStep === MANAGEMENT_STEPS.AGENT_SELECTION) {
if (state.availableAgents.length === 0) {
return 'Esc to close';
}
return 'Enter to select, ↑↓ to navigate, Esc to close';
}
return 'Esc to go back';
};
return (
<Box>
<Text color={theme.text.secondary}>{getNavigationInstructions()}</Text>
</Box>
);
}, [state.currentStep, state.availableAgents.length]);
const renderStepContent = useCallback(() => {
switch (state.currentStep) {
case MANAGEMENT_STEPS.AGENT_SELECTION:
return <AgentSelectionStep {...stepProps} />;
case MANAGEMENT_STEPS.ACTION_SELECTION:
return <ActionSelectionStep {...stepProps} />;
case MANAGEMENT_STEPS.AGENT_VIEWER:
return <AgentViewerStep {...stepProps} />;
case MANAGEMENT_STEPS.AGENT_EDITOR:
return (
<Box>
<Text color={theme.status.warning}>
Agent editing not yet implemented
</Text>
</Box>
);
case MANAGEMENT_STEPS.DELETE_CONFIRMATION:
return (
<Box>
<Text color={theme.status.warning}>
Agent deletion not yet implemented
</Text>
</Box>
);
default:
return (
<Box>
<Text color={theme.status.error}>
Invalid step: {state.currentStep}
</Text>
</Box>
);
}
}, [stepProps, state.currentStep]);
return (
<Box flexDirection="column">
{/* Main content wrapped in bounding box */}
<Box
borderStyle="single"
borderColor={Colors.Gray}
flexDirection="column"
padding={1}
width="100%"
gap={1}
>
{renderStepHeader()}
{renderStepContent()}
{renderStepFooter()}
</Box>
</Box>
);
}

View File

@@ -8,44 +8,9 @@ import { Box, Text } from 'ink';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { WizardStepProps, ColorOption } from './types.js';
import { Colors } from '../../colors.js';
import { COLOR_OPTIONS } from './constants.js';
const colorOptions: ColorOption[] = [
{
id: 'auto',
name: 'Automatic Color',
value: 'auto',
},
{
id: 'blue',
name: 'Blue',
value: '#3b82f6',
},
{
id: 'green',
name: 'Green',
value: '#10b981',
},
{
id: 'purple',
name: 'Purple',
value: '#8b5cf6',
},
{
id: 'orange',
name: 'Orange',
value: '#f59e0b',
},
{
id: 'red',
name: 'Red',
value: '#ef4444',
},
{
id: 'cyan',
name: 'Cyan',
value: '#06b6d4',
},
];
const colorOptions: ColorOption[] = COLOR_OPTIONS;
/**
* Step 5: Background color selection with preview.
@@ -65,12 +30,12 @@ export function ColorSelector({
(option) => option.id === selectedValue,
);
if (colorOption) {
dispatch({ type: 'SET_BACKGROUND_COLOR', color: colorOption.value });
dispatch({ type: 'SET_BACKGROUND_COLOR', color: colorOption.name });
}
};
const currentColor =
colorOptions.find((option) => option.value === state.backgroundColor) ||
colorOptions.find((option) => option.name === state.backgroundColor) ||
colorOptions[0];
return (
@@ -92,8 +57,8 @@ export function ColorSelector({
<Box flexDirection="row">
<Text color={Colors.Gray}>Preview:</Text>
<Box marginLeft={2}>
<Text color={currentColor.value}>{state.generatedName}</Text>
<Box marginLeft={2} backgroundColor={currentColor.value}>
<Text color="black">{` ${state.generatedName} `}</Text>
</Box>
</Box>
</Box>

View File

@@ -16,6 +16,7 @@ import {
import { useSettings } from '../../contexts/SettingsContext.js';
import { spawnSync } from 'child_process';
import { theme } from '../../semantic-colors.js';
import { shouldShowColor, getColorForDisplay } from './utils.js';
/**
* Step 6: Final confirmation and actions.
@@ -47,8 +48,7 @@ export function CreationSummary({
try {
// Get project root from config
const projectRoot = config.getProjectRoot();
const subagentManager = new SubagentManager(projectRoot);
const subagentManager = config.getSubagentManager();
// Check for name conflicts
const isAvailable = await subagentManager.isNameAvailable(
@@ -130,8 +130,7 @@ export function CreationSummary({
if (!config) {
throw new Error('Configuration not available');
}
const projectRoot = config.getProjectRoot();
const subagentManager = new SubagentManager(projectRoot);
const subagentManager = config.getSubagentManager();
// Build subagent configuration
const subagentConfig: SubagentConfig = {
@@ -143,6 +142,7 @@ export function CreationSummary({
tools: Array.isArray(state.selectedTools)
? state.selectedTools
: undefined,
backgroundColor: state.backgroundColor,
};
// Create the subagent
@@ -316,6 +316,15 @@ export function CreationSummary({
<Text>{toolsDisplay}</Text>
</Box>
{shouldShowColor(state.backgroundColor) && (
<Box>
<Text bold>Color: </Text>
<Box backgroundColor={getColorForDisplay(state.backgroundColor)}>
<Text color="black">{` ${state.generatedName} `}</Text>
</Box>
</Box>
)}
<Box marginTop={1}>
<Text bold>Description:</Text>
</Box>
@@ -337,11 +346,11 @@ export function CreationSummary({
{saveError && (
<Box flexDirection="column">
<Text bold color="red">
<Text bold color={theme.status.error}>
Error saving subagent:
</Text>
<Box flexDirection="column" padding={1} paddingBottom={0}>
<Text color="red" wrap="wrap">
<Text color={theme.status.error} wrap="wrap">
{saveError}
</Text>
</Box>

View File

@@ -302,7 +302,7 @@ export function DescriptionInput({
{state.validationErrors.length > 0 && (
<Box flexDirection="column">
{state.validationErrors.map((error, index) => (
<Text key={index} color="red">
<Text key={index} color={theme.status.error}>
{error}
</Text>
))}

View File

@@ -1,114 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import React, { Component, ReactNode } from 'react';
import { Box, Text } from 'ink';
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
errorInfo?: React.ErrorInfo;
}
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
/**
* Error boundary component for graceful error handling in the subagent wizard.
*/
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return {
hasError: true,
error,
};
}
override componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.setState({
error,
errorInfo,
});
// Call optional error handler
this.props.onError?.(error, errorInfo);
// Log error for debugging
console.error(
'SubagentWizard Error Boundary caught an error:',
error,
errorInfo,
);
}
override render() {
if (this.state.hasError) {
// Custom fallback UI or default error display
if (this.props.fallback) {
return this.props.fallback;
}
return (
<Box flexDirection="column" gap={1}>
<Box>
<Text bold color="red">
Subagent Wizard Error
</Text>
</Box>
<Box>
<Text>
An unexpected error occurred in the subagent creation wizard.
</Text>
</Box>
{this.state.error && (
<Box flexDirection="column" marginTop={1}>
<Text bold color="yellow">
Error Details:
</Text>
<Text color="gray" wrap="wrap">
{this.state.error.message}
</Text>
</Box>
)}
<Box marginTop={1}>
<Text color="gray">
Press <Text color="cyan">Esc</Text> to close the wizard and try
again.
</Text>
</Box>
{process.env['NODE_ENV'] === 'development' &&
this.state.errorInfo && (
<Box flexDirection="column" marginTop={1}>
<Text bold color="yellow">
Stack Trace (Development):
</Text>
<Text color="gray" wrap="wrap">
{this.state.errorInfo.componentStack}
</Text>
</Box>
)}
</Box>
);
}
return this.props.children;
}
}

View File

@@ -6,14 +6,13 @@
import { useReducer, useCallback, useMemo } from 'react';
import { Box, Text, useInput } from 'ink';
import { wizardReducer, initialWizardState } from './wizardReducer.js';
import { wizardReducer, initialWizardState } from './reducers.js';
import { LocationSelector } from './LocationSelector.js';
import { GenerationMethodSelector } from './GenerationMethodSelector.js';
import { DescriptionInput } from './DescriptionInput.js';
import { ToolSelector } from './ToolSelector.js';
import { ColorSelector } from './ColorSelector.js';
import { CreationSummary } from './CreationSummary.js';
import { ErrorBoundary } from './ErrorBoundary.js';
import { WizardStepProps } from './types.js';
import { WIZARD_STEPS } from './constants.js';
import { Config } from '@qwen-code/qwen-code-core';
@@ -113,9 +112,9 @@ export function SubagentCreationWizard({
}
return (
<Box borderStyle="single" borderColor="yellow" padding={1}>
<Box borderStyle="single" borderColor={theme.status.warning} padding={1}>
<Box flexDirection="column">
<Text color="yellow" bold>
<Text color={theme.status.warning} bold>
Debug Info:
</Text>
<Text color={Colors.Gray}>Step: {state.currentStep}</Text>
@@ -128,7 +127,9 @@ export function SubagentCreationWizard({
<Text color={Colors.Gray}>Location: {state.location}</Text>
<Text color={Colors.Gray}>Method: {state.generationMethod}</Text>
{state.validationErrors.length > 0 && (
<Text color="red">Errors: {state.validationErrors.join(', ')}</Text>
<Text color={theme.status.error}>
Errors: {state.validationErrors.join(', ')}
</Text>
)}
</Box>
</Box>
@@ -201,35 +202,30 @@ export function SubagentCreationWizard({
default:
return (
<Box>
<Text color="red">Invalid step: {state.currentStep}</Text>
<Text color={theme.status.error}>
Invalid step: {state.currentStep}
</Text>
</Box>
);
}
}, [stepProps, state.currentStep]);
return (
<ErrorBoundary
onError={(error, errorInfo) => {
// Additional error handling if needed
console.error('Subagent wizard error:', error, errorInfo);
}}
>
<Box flexDirection="column">
{/* Main content wrapped in bounding box */}
<Box
borderStyle="single"
borderColor={Colors.Gray}
flexDirection="column"
padding={1}
width="100%"
gap={1}
>
{renderStepHeader()}
{renderStepContent()}
{renderDebugContent()}
{renderStepFooter()}
</Box>
<Box flexDirection="column">
{/* Main content wrapped in bounding box */}
<Box
borderStyle="single"
borderColor={Colors.Gray}
flexDirection="column"
padding={1}
width="100%"
gap={1}
>
{renderStepHeader()}
{renderStepContent()}
{renderDebugContent()}
{renderStepFooter()}
</Box>
</ErrorBoundary>
</Box>
);
}

View File

@@ -30,3 +30,42 @@ export const STEP_NAMES: Record<number, string> = {
[WIZARD_STEPS.COLOR_SELECTION]: 'Color Selection',
[WIZARD_STEPS.FINAL_CONFIRMATION]: 'Final Confirmation',
};
// Color options for subagent display
export const COLOR_OPTIONS = [
{
id: 'auto',
name: 'Automatic Color',
value: 'auto',
},
{
id: 'blue',
name: 'Blue',
value: '#3b82f6',
},
{
id: 'green',
name: 'Green',
value: '#10b981',
},
{
id: 'purple',
name: 'Purple',
value: '#8b5cf6',
},
{
id: 'orange',
name: 'Orange',
value: '#f59e0b',
},
{
id: 'red',
name: 'Red',
value: '#ef4444',
},
{
id: 'cyan',
name: 'Cyan',
value: '#06b6d4',
},
];

View File

@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
// Creation Wizard Components
export { SubagentCreationWizard } from './SubagentCreationWizard.js';
export { LocationSelector } from './LocationSelector.js';
export { GenerationMethodSelector } from './GenerationMethodSelector.js';
@@ -12,6 +13,13 @@ export { ToolSelector } from './ToolSelector.js';
export { ColorSelector } from './ColorSelector.js';
export { CreationSummary } from './CreationSummary.js';
// Management Dialog Components
export { AgentsManagerDialog } from './AgentsManagerDialog.js';
export { AgentSelectionStep } from './AgentSelectionStep.js';
export { ActionSelectionStep } from './ActionSelectionStep.js';
export { AgentViewerStep } from './AgentViewerStep.js';
// Creation Wizard Types and State
export type {
CreationWizardState,
WizardAction,
@@ -21,4 +29,17 @@ export type {
ColorOption,
} from './types.js';
export { wizardReducer, initialWizardState } from './wizardReducer.js';
export { wizardReducer, initialWizardState } from './reducers.js';
// Management Dialog Types and State
export type {
ManagementDialogState,
ManagementAction,
ManagementStepProps,
} from './types.js';
export {
managementReducer,
initialManagementState,
MANAGEMENT_STEPS,
} from './reducers.js';

View File

@@ -0,0 +1,294 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import {
CreationWizardState,
WizardAction,
ManagementDialogState,
ManagementAction,
MANAGEMENT_STEPS,
} from './types.js';
import { WIZARD_STEPS, TOTAL_WIZARD_STEPS } from './constants.js';
export { MANAGEMENT_STEPS };
/**
* Initial state for the creation wizard.
*/
export const initialWizardState: CreationWizardState = {
currentStep: WIZARD_STEPS.LOCATION_SELECTION,
location: 'project',
generationMethod: 'qwen',
userDescription: '',
generatedSystemPrompt: '',
generatedDescription: '',
generatedName: '',
selectedTools: 'all',
backgroundColor: 'auto',
isGenerating: false,
validationErrors: [],
canProceed: false,
};
/**
* Reducer for managing wizard state transitions.
*/
export function wizardReducer(
state: CreationWizardState,
action: WizardAction,
): CreationWizardState {
switch (action.type) {
case 'SET_STEP':
return {
...state,
currentStep: Math.max(
WIZARD_STEPS.LOCATION_SELECTION,
Math.min(TOTAL_WIZARD_STEPS, action.step),
),
validationErrors: [],
};
case 'SET_LOCATION':
return {
...state,
location: action.location,
canProceed: true,
};
case 'SET_GENERATION_METHOD':
return {
...state,
generationMethod: action.method,
canProceed: true,
};
case 'SET_USER_DESCRIPTION':
return {
...state,
userDescription: action.description,
canProceed: action.description.trim().length >= 0,
};
case 'SET_GENERATED_CONTENT':
return {
...state,
generatedName: action.name,
generatedDescription: action.description,
generatedSystemPrompt: action.systemPrompt,
isGenerating: false,
canProceed: true,
};
case 'SET_TOOLS':
return {
...state,
selectedTools: action.tools,
canProceed: true,
};
case 'SET_BACKGROUND_COLOR':
return {
...state,
backgroundColor: action.color,
canProceed: true,
};
case 'SET_GENERATING':
return {
...state,
isGenerating: action.isGenerating,
canProceed: !action.isGenerating,
};
case 'SET_VALIDATION_ERRORS':
return {
...state,
validationErrors: action.errors,
canProceed: action.errors.length === 0,
};
case 'GO_TO_NEXT_STEP':
if (state.canProceed && state.currentStep < TOTAL_WIZARD_STEPS) {
return {
...state,
currentStep: state.currentStep + 1,
validationErrors: [],
canProceed: validateStep(state.currentStep + 1, state),
};
}
return state;
case 'GO_TO_PREVIOUS_STEP':
if (state.currentStep > WIZARD_STEPS.LOCATION_SELECTION) {
return {
...state,
currentStep: state.currentStep - 1,
validationErrors: [],
canProceed: validateStep(state.currentStep - 1, state),
};
}
return state;
case 'RESET_WIZARD':
return initialWizardState;
default:
return state;
}
}
/**
* Validates whether a step can proceed based on current state.
*/
function validateStep(step: number, state: CreationWizardState): boolean {
switch (step) {
case WIZARD_STEPS.LOCATION_SELECTION: // Location selection
return true; // Always can proceed from location selection
case WIZARD_STEPS.GENERATION_METHOD: // Generation method
return true; // Always can proceed from method selection
case WIZARD_STEPS.DESCRIPTION_INPUT: // Description input
return state.userDescription.trim().length >= 0;
case WIZARD_STEPS.TOOL_SELECTION: // Tool selection
return (
state.generatedName.length > 0 &&
state.generatedDescription.length > 0 &&
state.generatedSystemPrompt.length > 0
);
case WIZARD_STEPS.COLOR_SELECTION: // Color selection
return true; // Always can proceed from tool selection
case WIZARD_STEPS.FINAL_CONFIRMATION: // Final confirmation
return state.backgroundColor.length > 0;
default:
return false;
}
}
/**
* Initial state for the management dialog.
*/
export const initialManagementState: ManagementDialogState = {
currentStep: MANAGEMENT_STEPS.AGENT_SELECTION,
availableAgents: [],
selectedAgent: null,
selectedAgentIndex: -1,
selectedAction: null,
isLoading: false,
error: null,
canProceed: false,
};
/**
* Reducer for managing management dialog state transitions.
*/
export function managementReducer(
state: ManagementDialogState,
action: ManagementAction,
): ManagementDialogState {
switch (action.type) {
case 'SET_AVAILABLE_AGENTS':
return {
...state,
availableAgents: action.payload,
canProceed: action.payload.length > 0,
};
case 'SELECT_AGENT':
return {
...state,
selectedAgent: action.payload.agent,
selectedAgentIndex: action.payload.index,
canProceed: true,
};
case 'SELECT_ACTION':
return {
...state,
selectedAction: action.payload,
canProceed: true,
};
case 'GO_TO_NEXT_STEP': {
const nextStep = state.currentStep + 1;
return {
...state,
currentStep: nextStep,
canProceed: getCanProceedForStep(nextStep, state),
};
}
case 'GO_TO_PREVIOUS_STEP': {
const prevStep = Math.max(
MANAGEMENT_STEPS.AGENT_SELECTION,
state.currentStep - 1,
);
return {
...state,
currentStep: prevStep,
canProceed: getCanProceedForStep(prevStep, state),
};
}
case 'GO_TO_STEP':
return {
...state,
currentStep: action.payload,
canProceed: getCanProceedForStep(action.payload, state),
};
case 'SET_LOADING':
return {
...state,
isLoading: action.payload,
};
case 'SET_ERROR':
return {
...state,
error: action.payload,
};
case 'SET_CAN_PROCEED':
return {
...state,
canProceed: action.payload,
};
case 'RESET_DIALOG':
return initialManagementState;
default:
return state;
}
}
/**
* Validates whether a management step can proceed based on current state.
*/
function getCanProceedForStep(
step: number,
state: ManagementDialogState,
): boolean {
switch (step) {
case MANAGEMENT_STEPS.AGENT_SELECTION:
return state.availableAgents.length > 0 && state.selectedAgent !== null;
case MANAGEMENT_STEPS.ACTION_SELECTION:
return state.selectedAction !== null;
case MANAGEMENT_STEPS.AGENT_VIEWER:
return true; // Can always go back from viewer
case MANAGEMENT_STEPS.AGENT_EDITOR:
return true; // TODO: Add validation for editor
case MANAGEMENT_STEPS.DELETE_CONFIRMATION:
return true; // Can always proceed from confirmation
default:
return false;
}
}

View File

@@ -4,7 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { SubagentLevel, Config } from '@qwen-code/qwen-code-core';
import {
SubagentLevel,
SubagentConfig,
SubagentMetadata,
Config,
} from '@qwen-code/qwen-code-core';
/**
* State management for the subagent creation wizard.
@@ -110,3 +115,55 @@ export interface WizardResult {
tools?: string[];
backgroundColor: string;
}
/**
* State management for the subagent management dialog.
*/
export interface ManagementDialogState {
currentStep: number;
availableAgents: SubagentMetadata[];
selectedAgent: SubagentConfig | null;
selectedAgentIndex: number;
selectedAction: 'view' | 'edit' | 'delete' | null;
isLoading: boolean;
error: string | null;
canProceed: boolean;
}
/**
* Actions that can be dispatched to update management dialog state.
*/
export type ManagementAction =
| { type: 'SET_AVAILABLE_AGENTS'; payload: SubagentMetadata[] }
| { type: 'SELECT_AGENT'; payload: { agent: SubagentConfig; index: number } }
| { type: 'SELECT_ACTION'; payload: 'view' | 'edit' | 'delete' }
| { type: 'GO_TO_NEXT_STEP' }
| { type: 'GO_TO_PREVIOUS_STEP' }
| { type: 'GO_TO_STEP'; payload: number }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'SET_ERROR'; payload: string | null }
| { type: 'SET_CAN_PROCEED'; payload: boolean }
| { type: 'RESET_DIALOG' };
/**
* Props for management dialog step components.
*/
export interface ManagementStepProps {
state: ManagementDialogState;
dispatch: React.Dispatch<ManagementAction>;
onNext: () => void;
onPrevious: () => void;
onCancel: () => void;
config: Config | null;
}
/**
* Constants for management dialog steps.
*/
export const MANAGEMENT_STEPS = {
AGENT_SELECTION: 1,
ACTION_SELECTION: 2,
AGENT_VIEWER: 3,
AGENT_EDITOR: 4,
DELETE_CONFIRMATION: 5,
} as const;

View File

@@ -0,0 +1,9 @@
import { COLOR_OPTIONS } from './constants.js';
export const shouldShowColor = (backgroundColor?: string): boolean =>
backgroundColor !== undefined && backgroundColor !== 'auto';
export const getColorForDisplay = (colorName?: string): string | undefined =>
!colorName || colorName === 'auto'
? undefined
: COLOR_OPTIONS.find((color) => color.name === colorName)?.value;

View File

@@ -4,9 +4,17 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CreationWizardState, WizardAction } from './types.js';
import {
CreationWizardState,
WizardAction,
ManagementDialogState,
ManagementAction,
MANAGEMENT_STEPS,
} from './types.js';
import { WIZARD_STEPS, TOTAL_WIZARD_STEPS } from './constants.js';
export { MANAGEMENT_STEPS };
/**
* Initial state for the creation wizard.
*/
@@ -163,3 +171,124 @@ function validateStep(step: number, state: CreationWizardState): boolean {
return false;
}
}
/**
* Initial state for the management dialog.
*/
export const initialManagementState: ManagementDialogState = {
currentStep: MANAGEMENT_STEPS.AGENT_SELECTION,
availableAgents: [],
selectedAgent: null,
selectedAgentIndex: -1,
selectedAction: null,
isLoading: false,
error: null,
canProceed: false,
};
/**
* Reducer for managing management dialog state transitions.
*/
export function managementReducer(
state: ManagementDialogState,
action: ManagementAction,
): ManagementDialogState {
switch (action.type) {
case 'SET_AVAILABLE_AGENTS':
return {
...state,
availableAgents: action.payload,
canProceed: action.payload.length > 0,
};
case 'SELECT_AGENT':
return {
...state,
selectedAgent: action.payload.agent,
selectedAgentIndex: action.payload.index,
canProceed: true,
};
case 'SELECT_ACTION':
return {
...state,
selectedAction: action.payload,
canProceed: true,
};
case 'GO_TO_NEXT_STEP': {
const nextStep = state.currentStep + 1;
return {
...state,
currentStep: nextStep,
canProceed: getCanProceedForStep(nextStep, state),
};
}
case 'GO_TO_PREVIOUS_STEP': {
const prevStep = Math.max(
MANAGEMENT_STEPS.AGENT_SELECTION,
state.currentStep - 1,
);
return {
...state,
currentStep: prevStep,
canProceed: getCanProceedForStep(prevStep, state),
};
}
case 'GO_TO_STEP':
return {
...state,
currentStep: action.payload,
canProceed: getCanProceedForStep(action.payload, state),
};
case 'SET_LOADING':
return {
...state,
isLoading: action.payload,
};
case 'SET_ERROR':
return {
...state,
error: action.payload,
};
case 'SET_CAN_PROCEED':
return {
...state,
canProceed: action.payload,
};
case 'RESET_DIALOG':
return initialManagementState;
default:
return state;
}
}
/**
* Validates whether a management step can proceed based on current state.
*/
function getCanProceedForStep(
step: number,
state: ManagementDialogState,
): boolean {
switch (step) {
case MANAGEMENT_STEPS.AGENT_SELECTION:
return state.availableAgents.length > 0 && state.selectedAgent !== null;
case MANAGEMENT_STEPS.ACTION_SELECTION:
return state.selectedAction !== null;
case MANAGEMENT_STEPS.AGENT_VIEWER:
return true; // Can always go back from viewer
case MANAGEMENT_STEPS.AGENT_EDITOR:
return true; // TODO: Add validation for editor
case MANAGEMENT_STEPS.DELETE_CONFIRMATION:
return true; // Can always proceed from confirmation
default:
return false;
}
}

View File

@@ -145,6 +145,7 @@ describe('useSlashCommandProcessor', () => {
vi.fn(), // openPrivacyNotice
vi.fn(), // openSettingsDialog
vi.fn(), // openSubagentCreateDialog
vi.fn(), // openAgentsManagerDialog
vi.fn(), // toggleVimEnabled
setIsProcessing,
vi.fn(), // setGeminiMdFileCount
@@ -898,6 +899,7 @@ describe('useSlashCommandProcessor', () => {
vi.fn(), // openPrivacyNotice
vi.fn(), // openSettingsDialog
vi.fn(), // openSubagentCreateDialog
vi.fn(), // openAgentsManagerDialog
vi.fn(), // toggleVimEnabled
vi.fn(), // setIsProcessing
vi.fn(), // setGeminiMdFileCount

View File

@@ -52,6 +52,7 @@ export const useSlashCommandProcessor = (
openPrivacyNotice: () => void,
openSettingsDialog: () => void,
openSubagentCreateDialog: () => void,
openAgentsManagerDialog: () => void,
toggleVimEnabled: () => Promise<boolean>,
setIsProcessing: (isProcessing: boolean) => void,
setGeminiMdFileCount: (count: number) => void,
@@ -352,16 +353,19 @@ export const useSlashCommandProcessor = (
toolArgs: result.toolArgs,
};
case 'message':
addItem(
{
type:
result.messageType === 'error'
? MessageType.ERROR
: MessageType.INFO,
text: result.content,
},
Date.now(),
);
if (result.messageType === 'info') {
addMessage({
type: MessageType.INFO,
content: result.content,
timestamp: new Date(),
});
} else {
addMessage({
type: MessageType.ERROR,
content: result.content,
timestamp: new Date(),
});
}
return { type: 'handled' };
case 'dialog':
switch (result.dialog) {
@@ -383,6 +387,9 @@ export const useSlashCommandProcessor = (
case 'subagent_create':
openSubagentCreateDialog();
return { type: 'handled' };
case 'subagent_list':
openAgentsManagerDialog();
return { type: 'handled' };
case 'help':
return { type: 'handled' };
default: {
@@ -558,6 +565,7 @@ export const useSlashCommandProcessor = (
setQuittingMessages,
openSettingsDialog,
openSubagentCreateDialog,
openAgentsManagerDialog,
setShellConfirmationRequest,
setSessionShellAllowlist,
setIsProcessing,

View File

@@ -0,0 +1,32 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
export interface UseAgentsManagerDialogReturn {
isAgentsManagerDialogOpen: boolean;
openAgentsManagerDialog: () => void;
closeAgentsManagerDialog: () => void;
}
export const useAgentsManagerDialog = (): UseAgentsManagerDialogReturn => {
const [isAgentsManagerDialogOpen, setIsAgentsManagerDialogOpen] =
useState(false);
const openAgentsManagerDialog = useCallback(() => {
setIsAgentsManagerDialogOpen(true);
}, []);
const closeAgentsManagerDialog = useCallback(() => {
setIsAgentsManagerDialogOpen(false);
}, []);
return {
isAgentsManagerDialogOpen,
openAgentsManagerDialog,
closeAgentsManagerDialog,
};
};