diff --git a/packages/cli/src/ui/components/subagents/ActionSelectionStep.tsx b/packages/cli/src/ui/components/subagents/ActionSelectionStep.tsx index f3e14637..1a6ec7ae 100644 --- a/packages/cli/src/ui/components/subagents/ActionSelectionStep.tsx +++ b/packages/cli/src/ui/components/subagents/ActionSelectionStep.tsx @@ -4,16 +4,23 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { useState } from 'react'; import { Box } from 'ink'; import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; -import { ManagementStepProps } from './types.js'; +import { MANAGEMENT_STEPS } from './types.js'; + +interface ActionSelectionStepProps { + onNavigateToStep: (step: string) => void; + onNavigateBack: () => void; +} export const ActionSelectionStep = ({ - state, - dispatch, - onNext, - onPrevious, -}: ManagementStepProps) => { + onNavigateToStep, + onNavigateBack, +}: ActionSelectionStepProps) => { + const [selectedAction, setSelectedAction] = useState< + 'view' | 'edit' | 'delete' | null + >(null); const actions = [ { label: 'View Agent', value: 'view' as const }, { label: 'Edit Agent', value: 'edit' as const }, @@ -23,16 +30,24 @@ export const ActionSelectionStep = ({ const handleActionSelect = (value: 'view' | 'edit' | 'delete' | 'back') => { if (value === 'back') { - onPrevious(); + onNavigateBack(); return; } - dispatch({ type: 'SELECT_ACTION', payload: value }); - onNext(); + setSelectedAction(value); + + // Navigate to appropriate step based on action + if (value === 'view') { + onNavigateToStep(MANAGEMENT_STEPS.AGENT_VIEWER); + } else if (value === 'edit') { + onNavigateToStep(MANAGEMENT_STEPS.EDIT_OPTIONS); + } else if (value === 'delete') { + onNavigateToStep(MANAGEMENT_STEPS.DELETE_CONFIRMATION); + } }; - const selectedIndex = state.selectedAction - ? actions.findIndex((action) => action.value === state.selectedAction) + const selectedIndex = selectedAction + ? actions.findIndex((action) => action.value === selectedAction) : 0; return ( diff --git a/packages/cli/src/ui/components/subagents/AgentDeleteStep.tsx b/packages/cli/src/ui/components/subagents/AgentDeleteStep.tsx new file mode 100644 index 00000000..4e6b69e0 --- /dev/null +++ b/packages/cli/src/ui/components/subagents/AgentDeleteStep.tsx @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { SubagentConfig } from '@qwen-code/qwen-code-core'; +import { StepNavigationProps } from './types.js'; +import { theme } from '../../semantic-colors.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; + +interface AgentDeleteStepProps extends StepNavigationProps { + selectedAgent: SubagentConfig | null; + onDelete: (agent: SubagentConfig) => Promise; +} + +export function AgentDeleteStep({ + selectedAgent, + onDelete, + onNavigateBack, +}: AgentDeleteStepProps) { + useKeypress( + async (key) => { + if (!selectedAgent) return; + + if (key.name === 'y' || key.name === 'return') { + try { + await onDelete(selectedAgent); + // Navigation will be handled by the parent component after successful deletion + } catch (error) { + console.error('Failed to delete agent:', error); + } + } else if (key.name === 'n') { + onNavigateBack(); + } + }, + { isActive: true }, + ); + + if (!selectedAgent) { + return ( + + No agent selected + + ); + } + + return ( + + + Are you sure you want to delete agent “{selectedAgent.name} + ”? + + + ); +} diff --git a/packages/cli/src/ui/components/subagents/AgentEditStep.tsx b/packages/cli/src/ui/components/subagents/AgentEditStep.tsx new file mode 100644 index 00000000..2cc46d70 --- /dev/null +++ b/packages/cli/src/ui/components/subagents/AgentEditStep.tsx @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; +import { Box, Text } from 'ink'; +import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; +import { MANAGEMENT_STEPS } from './types.js'; +import { theme } from '../../semantic-colors.js'; +import { useLaunchEditor } from './useLaunchEditor.js'; +import { SubagentConfig } from '@qwen-code/qwen-code-core'; + +interface EditOption { + id: string; + label: string; +} + +const editOptions: EditOption[] = [ + { + id: 'editor', + label: 'Open in editor', + }, + { + id: 'tools', + label: 'Edit tools', + }, + { + id: 'color', + label: 'Edit color', + }, +]; + +interface EditOptionsStepProps { + selectedAgent: SubagentConfig | null; + onNavigateToStep: (step: string) => void; +} + +/** + * Edit options selection step - choose what to edit about the agent. + */ +export function EditOptionsStep({ + selectedAgent, + onNavigateToStep, +}: EditOptionsStepProps) { + const [selectedOption, setSelectedOption] = useState('editor'); + const [error, setError] = useState(null); + + const launchEditor = useLaunchEditor(); + + const handleHighlight = (selectedValue: string) => { + setSelectedOption(selectedValue); + }; + + const handleSelect = useCallback( + async (selectedValue: string) => { + if (!selectedAgent) return; + + setError(null); + + if (selectedValue === 'editor') { + // Launch editor directly + try { + await launchEditor(selectedAgent?.filePath); + } catch (err) { + setError( + `Failed to launch editor: ${err instanceof Error ? err.message : 'Unknown error'}`, + ); + } + } else if (selectedValue === 'tools') { + onNavigateToStep(MANAGEMENT_STEPS.EDIT_TOOLS); + } else if (selectedValue === 'color') { + onNavigateToStep(MANAGEMENT_STEPS.EDIT_COLOR); + } + }, + [selectedAgent, onNavigateToStep, launchEditor], + ); + + return ( + + + ({ + label: option.label, + value: option.id, + }))} + initialIndex={editOptions.findIndex( + (opt) => opt.id === selectedOption, + )} + onSelect={handleSelect} + onHighlight={handleHighlight} + isFocused={true} + /> + + + {error && ( + + + ❌ Error: + + + + {error} + + + + )} + + ); +} diff --git a/packages/cli/src/ui/components/subagents/AgentSelectionStep.tsx b/packages/cli/src/ui/components/subagents/AgentSelectionStep.tsx index 3e68a0d9..aeecafe8 100644 --- a/packages/cli/src/ui/components/subagents/AgentSelectionStep.tsx +++ b/packages/cli/src/ui/components/subagents/AgentSelectionStep.tsx @@ -4,12 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useEffect, useState } from 'react'; +import { useState, useEffect, useMemo } 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'; +import { SubagentConfig } from '@qwen-code/qwen-code-core'; interface NavigationState { currentBlock: 'project' | 'user'; @@ -17,13 +17,15 @@ interface NavigationState { userIndex: number; } +interface AgentSelectionStepProps { + availableAgents: SubagentConfig[]; + onAgentSelect: (agentIndex: number) => void; +} + export const AgentSelectionStep = ({ - state, - dispatch, - onNext, - config, -}: ManagementStepProps) => { - const [isLoading, setIsLoading] = useState(false); + availableAgents, + onAgentSelect, +}: AgentSelectionStepProps) => { const [navigation, setNavigation] = useState({ currentBlock: 'project', projectIndex: 0, @@ -31,60 +33,27 @@ export const AgentSelectionStep = ({ }); // Group agents by level - const projectAgents = state.availableAgents.filter( - (agent) => agent.level === 'project', + const projectAgents = useMemo( + () => availableAgents.filter((agent) => agent.level === 'project'), + [availableAgents], ); - const userAgents = state.availableAgents.filter( - (agent) => agent.level === 'user', + const userAgents = useMemo( + () => availableAgents.filter((agent) => agent.level === 'user'), + [availableAgents], + ); + const projectNames = useMemo( + () => new Set(projectAgents.map((agent) => agent.name)), + [projectAgents], ); - 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 + // Initialize navigation state when agents are loaded (only once) useEffect(() => { if (projectAgents.length > 0) { setNavigation((prev) => ({ ...prev, currentBlock: 'project' })); } else if (userAgents.length > 0) { setNavigation((prev) => ({ ...prev, currentBlock: 'user' })); } - }, [projectAgents.length, userAgents.length]); + }, [projectAgents, userAgents]); // Custom keyboard navigation useKeypress( @@ -148,71 +117,24 @@ export const AgentSelectionStep = ({ } }); } else if (name === 'return' || name === 'space') { - // Select current item - const currentAgent = - navigation.currentBlock === 'project' - ? projectAgents[navigation.projectIndex] - : userAgents[navigation.userIndex]; + // Calculate global index and select current item + let globalIndex: number; + if (navigation.currentBlock === 'project') { + globalIndex = navigation.projectIndex; + } else { + // User agents come after project agents in the availableAgents array + globalIndex = projectAgents.length + navigation.userIndex; + } - if (currentAgent) { - const agentIndex = state.availableAgents.indexOf(currentAgent); - handleAgentSelect(agentIndex); + if (globalIndex >= 0 && globalIndex < availableAgents.length) { + onAgentSelect(globalIndex); } } }, { 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 ( - - Loading agents... - - ); - } - - if (state.error) { - return ( - - {state.error} - - ); - } - - if (state.availableAgents.length === 0) { + if (availableAgents.length === 0) { return ( No subagents found. diff --git a/packages/cli/src/ui/components/subagents/AgentViewerStep.tsx b/packages/cli/src/ui/components/subagents/AgentViewerStep.tsx index 3aa48f8e..48fbf0fc 100644 --- a/packages/cli/src/ui/components/subagents/AgentViewerStep.tsx +++ b/packages/cli/src/ui/components/subagents/AgentViewerStep.tsx @@ -4,20 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Box, Text, useInput } from 'ink'; -import { ManagementStepProps } from './types.js'; +import { Box, Text } from 'ink'; import { theme } from '../../semantic-colors.js'; import { shouldShowColor, getColorForDisplay } from './utils.js'; +import { SubagentConfig } from '@qwen-code/qwen-code-core'; -export const AgentViewerStep = ({ state, onPrevious }: ManagementStepProps) => { - // Handle keyboard input - useInput((input, key) => { - if (key.escape || input === 'b') { - onPrevious(); - } - }); +interface AgentViewerStepProps { + selectedAgent: SubagentConfig | null; +} - if (!state.selectedAgent) { +export const AgentViewerStep = ({ selectedAgent }: AgentViewerStepProps) => { + if (!selectedAgent) { return ( No agent selected @@ -25,7 +22,7 @@ export const AgentViewerStep = ({ state, onPrevious }: ManagementStepProps) => { ); } - const agent = state.selectedAgent; + const agent = selectedAgent; const toolsDisplay = agent.tools ? agent.tools.join(', ') : '*'; diff --git a/packages/cli/src/ui/components/subagents/AgentsManagerDialog.tsx b/packages/cli/src/ui/components/subagents/AgentsManagerDialog.tsx index 699e80e7..4971ca3c 100644 --- a/packages/cli/src/ui/components/subagents/AgentsManagerDialog.tsx +++ b/packages/cli/src/ui/components/subagents/AgentsManagerDialog.tsx @@ -4,16 +4,19 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useReducer, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, useEffect } 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 { EditOptionsStep } from './AgentEditStep.js'; +import { AgentDeleteStep } from './AgentDeleteStep.js'; +import { ToolSelector } from './ToolSelector.js'; +import { ColorSelector } from './ColorSelector.js'; +import { MANAGEMENT_STEPS } from './types.js'; import { Colors } from '../../colors.js'; import { theme } from '../../semantic-colors.js'; -import { Config } from '@qwen-code/qwen-code-core'; +import { Config, SubagentConfig } from '@qwen-code/qwen-code-core'; interface AgentsManagerDialogProps { onClose: () => void; @@ -27,67 +30,132 @@ export function AgentsManagerDialog({ onClose, config, }: AgentsManagerDialogProps) { - const [state, dispatch] = useReducer( - managementReducer, - initialManagementState, + // Simple state management with useState hooks + const [availableAgents, setAvailableAgents] = useState([]); + const [selectedAgentIndex, setSelectedAgentIndex] = useState(-1); + const [navigationStack, setNavigationStack] = useState([ + MANAGEMENT_STEPS.AGENT_SELECTION, + ]); + + // Memoized selectedAgent based on index + const selectedAgent = useMemo( + () => + selectedAgentIndex >= 0 ? availableAgents[selectedAgentIndex] : null, + [availableAgents, selectedAgentIndex], ); - const handleNext = useCallback(() => { - dispatch({ type: 'GO_TO_NEXT_STEP' }); + // Function to load agents + const loadAgents = useCallback(async () => { + if (!config) return; + + 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 || [])]; + + setAvailableAgents(allAgents); + }, [config]); + + // Load agents when component mounts or config changes + useEffect(() => { + loadAgents(); + }, [loadAgents]); + + // Helper to get current step + const getCurrentStep = useCallback( + () => + navigationStack[navigationStack.length - 1] || + MANAGEMENT_STEPS.AGENT_SELECTION, + [navigationStack], + ); + + const handleSelectAgent = useCallback((agentIndex: number) => { + setSelectedAgentIndex(agentIndex); + setNavigationStack((prev) => [...prev, MANAGEMENT_STEPS.ACTION_SELECTION]); }, []); - const handlePrevious = useCallback(() => { - dispatch({ type: 'GO_TO_PREVIOUS_STEP' }); + const handleNavigateToStep = useCallback((step: string) => { + setNavigationStack((prev) => [...prev, step]); }, []); - const handleCancel = useCallback(() => { - dispatch({ type: 'RESET_DIALOG' }); - onClose(); - }, [onClose]); + const handleNavigateBack = useCallback(() => { + setNavigationStack((prev) => { + if (prev.length <= 1) { + return prev; // Can't go back from root step + } + return prev.slice(0, -1); + }); + }, []); + + const handleDeleteAgent = useCallback( + async (agent: SubagentConfig) => { + if (!config) return; + + try { + const subagentManager = config.getSubagentManager(); + await subagentManager.deleteSubagent(agent.name, agent.level); + + // Reload agents to get updated state + await loadAgents(); + + // Navigate back to agent selection after successful deletion + setNavigationStack([MANAGEMENT_STEPS.AGENT_SELECTION]); + setSelectedAgentIndex(-1); + } catch (error) { + console.error('Failed to delete agent:', error); + throw error; // Re-throw to let the component handle the error state + } + }, + [config, loadAgents], + ); // 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) { + const currentStep = getCurrentStep(); + if (currentStep === MANAGEMENT_STEPS.AGENT_SELECTION) { // On first step, ESC cancels the entire dialog - handleCancel(); + onClose(); } else { - // On other steps, ESC goes back to previous step - handlePrevious(); + // On other steps, ESC goes back to previous step in navigation stack + handleNavigateBack(); } } }); - const stepProps: ManagementStepProps = useMemo( + // Props for child components - now using direct state and callbacks + const commonProps = useMemo( () => ({ - state, - config, - dispatch, - onNext: handleNext, - onPrevious: handlePrevious, - onCancel: handleCancel, + onNavigateToStep: handleNavigateToStep, + onNavigateBack: handleNavigateBack, }), - [state, dispatch, handleNext, handlePrevious, handleCancel, config], + [handleNavigateToStep, handleNavigateBack], ); const renderStepHeader = useCallback(() => { + const currentStep = getCurrentStep(); const getStepHeaderText = () => { - switch (state.currentStep) { + switch (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'}`; + return selectedAgent?.name; + case MANAGEMENT_STEPS.EDIT_OPTIONS: + return `Edit ${selectedAgent?.name}`; + case MANAGEMENT_STEPS.EDIT_TOOLS: + return `Edit Tools: ${selectedAgent?.name}`; + case MANAGEMENT_STEPS.EDIT_COLOR: + return `Edit Color: ${selectedAgent?.name}`; case MANAGEMENT_STEPS.DELETE_CONFIRMATION: - return `Delete: ${state.selectedAgent?.name || 'Unknown'}`; + return `Delete ${selectedAgent?.name}`; default: return 'Unknown Step'; } @@ -98,22 +166,27 @@ export function AgentsManagerDialog({ {getStepHeaderText()} ); - }, [state.currentStep, state.selectedAgent?.name]); + }, [getCurrentStep, selectedAgent]); const renderStepFooter = useCallback(() => { + const currentStep = getCurrentStep(); 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) { + if (currentStep === MANAGEMENT_STEPS.AGENT_SELECTION) { + if (availableAgents.length === 0) { return 'Esc to close'; } return 'Enter to select, ↑↓ to navigate, Esc to close'; } - return 'Esc to go back'; + if (currentStep === MANAGEMENT_STEPS.AGENT_VIEWER) { + return 'Esc to go back'; + } + + if (currentStep === MANAGEMENT_STEPS.DELETE_CONFIRMATION) { + return 'Enter to confirm, Esc to cancel'; + } + + return 'Enter to select, ↑↓ to navigate, Esc to go back'; }; return ( @@ -121,42 +194,110 @@ export function AgentsManagerDialog({ {getNavigationInstructions()} ); - }, [state.currentStep, state.availableAgents.length]); + }, [getCurrentStep, availableAgents]); const renderStepContent = useCallback(() => { - switch (state.currentStep) { + const currentStep = getCurrentStep(); + switch (currentStep) { case MANAGEMENT_STEPS.AGENT_SELECTION: - return ; - case MANAGEMENT_STEPS.ACTION_SELECTION: - return ; - case MANAGEMENT_STEPS.AGENT_VIEWER: - return ; - case MANAGEMENT_STEPS.AGENT_EDITOR: return ( - - - Agent editing not yet implemented - + + ); + case MANAGEMENT_STEPS.ACTION_SELECTION: + return ; + case MANAGEMENT_STEPS.AGENT_VIEWER: + return ( + + ); + case MANAGEMENT_STEPS.EDIT_OPTIONS: + return ( + + ); + case MANAGEMENT_STEPS.EDIT_TOOLS: + return ( + + { + if (selectedAgent && config) { + try { + // Save the changes using SubagentManager + const subagentManager = config.getSubagentManager(); + await subagentManager.updateSubagent( + selectedAgent.name, + { tools }, + selectedAgent.level, + ); + // Reload agents to get updated state + await loadAgents(); + handleNavigateBack(); + } catch (error) { + console.error('Failed to save agent changes:', error); + } + } + }} + config={config} + /> + + ); + case MANAGEMENT_STEPS.EDIT_COLOR: + return ( + + { + // Save changes and reload agents + if (selectedAgent && config) { + try { + // Save the changes using SubagentManager + const subagentManager = config.getSubagentManager(); + await subagentManager.updateSubagent( + selectedAgent.name, + { backgroundColor: color }, + selectedAgent.level, + ); + // Reload agents to get updated state + await loadAgents(); + handleNavigateBack(); + } catch (error) { + console.error('Failed to save color changes:', error); + } + } + }} + /> ); case MANAGEMENT_STEPS.DELETE_CONFIRMATION: return ( - - - Agent deletion not yet implemented - - + ); default: return ( - - Invalid step: {state.currentStep} - + Invalid step: {currentStep} ); } - }, [stepProps, state.currentStep]); + }, [ + getCurrentStep, + availableAgents, + selectedAgent, + commonProps, + config, + loadAgents, + handleNavigateBack, + handleSelectAgent, + handleDeleteAgent, + ]); return ( diff --git a/packages/cli/src/ui/components/subagents/ColorSelector.tsx b/packages/cli/src/ui/components/subagents/ColorSelector.tsx index 7e318803..3ffe061a 100644 --- a/packages/cli/src/ui/components/subagents/ColorSelector.tsx +++ b/packages/cli/src/ui/components/subagents/ColorSelector.tsx @@ -4,25 +4,43 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { useState, useEffect } from 'react'; import { Box, Text } from 'ink'; import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; -import { WizardStepProps, ColorOption } from './types.js'; +import { ColorOption } from './types.js'; import { Colors } from '../../colors.js'; import { COLOR_OPTIONS } from './constants.js'; const colorOptions: ColorOption[] = COLOR_OPTIONS; +interface ColorSelectorProps { + backgroundColor?: string; + agentName?: string; + onSelect: (color: string) => void; +} + /** - * Step 5: Background color selection with preview. + * Color selection with preview. */ export function ColorSelector({ - state, - dispatch, - onNext, - onPrevious: _onPrevious, -}: WizardStepProps) { - const handleSelect = (_selectedValue: string) => { - onNext(); + backgroundColor = 'auto', + agentName = 'Agent', + onSelect, +}: ColorSelectorProps) { + const [selectedColor, setSelectedColor] = useState(backgroundColor); + + // Update selected color when backgroundColor prop changes + useEffect(() => { + setSelectedColor(backgroundColor); + }, [backgroundColor]); + + const handleSelect = (selectedValue: string) => { + const colorOption = colorOptions.find( + (option) => option.id === selectedValue, + ); + if (colorOption) { + onSelect(colorOption.name); + } }; const handleHighlight = (selectedValue: string) => { @@ -30,12 +48,12 @@ export function ColorSelector({ (option) => option.id === selectedValue, ); if (colorOption) { - dispatch({ type: 'SET_BACKGROUND_COLOR', color: colorOption.name }); + setSelectedColor(colorOption.name); } }; const currentColor = - colorOptions.find((option) => option.name === state.backgroundColor) || + colorOptions.find((option) => option.name === selectedColor) || colorOptions[0]; return ( @@ -58,7 +76,7 @@ export function ColorSelector({ Preview: - {` ${state.generatedName} `} + {` ${agentName} `} diff --git a/packages/cli/src/ui/components/subagents/CreationSummary.tsx b/packages/cli/src/ui/components/subagents/CreationSummary.tsx index 10af42f4..1b21df84 100644 --- a/packages/cli/src/ui/components/subagents/CreationSummary.tsx +++ b/packages/cli/src/ui/components/subagents/CreationSummary.tsx @@ -5,18 +5,12 @@ */ import { useCallback, useState, useEffect } from 'react'; -import { Box, Text, useInput, useStdin } from 'ink'; +import { Box, Text, useInput } from 'ink'; import { WizardStepProps } from './types.js'; -import { validateSubagentConfig } from './validation.js'; -import { - SubagentManager, - SubagentConfig, - EditorType, -} from '@qwen-code/qwen-code-core'; -import { useSettings } from '../../contexts/SettingsContext.js'; -import { spawnSync } from 'child_process'; +import { SubagentManager, SubagentConfig } from '@qwen-code/qwen-code-core'; import { theme } from '../../semantic-colors.js'; import { shouldShowColor, getColorForDisplay } from './utils.js'; +import { useLaunchEditor } from './useLaunchEditor.js'; /** * Step 6: Final confirmation and actions. @@ -31,8 +25,7 @@ export function CreationSummary({ const [saveSuccess, setSaveSuccess] = useState(false); const [warnings, setWarnings] = useState([]); - const settings = useSettings(); - const { stdin, setRawMode } = useStdin(); + const launchEditor = useLaunchEditor(); const truncateText = (text: string, maxLength: number): string => { if (text.length <= maxLength) return text; @@ -113,19 +106,6 @@ export function CreationSummary({ // Common method to save subagent configuration const saveSubagent = useCallback(async (): Promise => { - // Validate configuration before saving - const configToValidate = { - name: state.generatedName, - description: state.generatedDescription, - systemPrompt: state.generatedSystemPrompt, - tools: state.selectedTools, - }; - - const validation = validateSubagentConfig(configToValidate); - if (!validation.isValid) { - throw new Error(`Validation failed: ${validation.errors.join(', ')}`); - } - // Create SubagentManager instance if (!config) { throw new Error('Configuration not available'); @@ -190,62 +170,11 @@ export function CreationSummary({ state.location, ); - // Determine editor to use - const preferredEditor = settings.merged.preferredEditor as - | EditorType - | undefined; - - let editor: string; - if (preferredEditor) { - editor = preferredEditor; - } else { - // Platform-specific defaults with UI preference for macOS - switch (process.platform) { - case 'darwin': - editor = 'open -t'; // TextEdit in plain text mode - break; - case 'win32': - editor = 'notepad'; - break; - default: - editor = process.env['VISUAL'] || process.env['EDITOR'] || 'vi'; - break; - } - } - // Launch editor with the actual subagent file - const wasRaw = stdin?.isRaw ?? false; - try { - setRawMode?.(false); + await launchEditor(subagentFilePath); - // Handle different editor command formats - let editorCommand: string; - let editorArgs: string[]; - - if (editor === 'open -t') { - // macOS TextEdit in plain text mode - editorCommand = 'open'; - editorArgs = ['-t', subagentFilePath]; - } else { - // Standard editor command - editorCommand = editor; - editorArgs = [subagentFilePath]; - } - - const { status, error } = spawnSync(editorCommand, editorArgs, { - stdio: 'inherit', - }); - - if (error) throw error; - if (typeof status === 'number' && status !== 0) { - throw new Error(`Editor exited with status ${status}`); - } - - // Show success UI and auto-close after successful edit - showSuccessAndClose(); - } finally { - if (wasRaw) setRawMode?.(true); - } + // Show success UI and auto-close after successful edit + showSuccessAndClose(); } catch (error) { setSaveError( `Failed to save and edit subagent: ${error instanceof Error ? error.message : 'Unknown error'}`, @@ -256,9 +185,7 @@ export function CreationSummary({ showSuccessAndClose, state.generatedName, state.location, - settings.merged.preferredEditor, - stdin, - setRawMode, + launchEditor, ]); // Handle keyboard input diff --git a/packages/cli/src/ui/components/subagents/DescriptionInput.tsx b/packages/cli/src/ui/components/subagents/DescriptionInput.tsx index 1d1d7f72..04d5b7db 100644 --- a/packages/cli/src/ui/components/subagents/DescriptionInput.tsx +++ b/packages/cli/src/ui/components/subagents/DescriptionInput.tsx @@ -7,7 +7,7 @@ import { useState, useCallback, useRef } from 'react'; import { Box, Text } from 'ink'; import { WizardStepProps, WizardAction } from './types.js'; -import { sanitizeInput } from './validation.js'; +import { sanitizeInput } from './utils.js'; import { Config, subagentGenerator } from '@qwen-code/qwen-code-core'; import { useTextBuffer } from '../shared/text-buffer.js'; import { useKeypress, Key } from '../../hooks/useKeypress.js'; diff --git a/packages/cli/src/ui/components/subagents/SubagentCreationWizard.tsx b/packages/cli/src/ui/components/subagents/SubagentCreationWizard.tsx index 3d660506..86992beb 100644 --- a/packages/cli/src/ui/components/subagents/SubagentCreationWizard.tsx +++ b/packages/cli/src/ui/components/subagents/SubagentCreationWizard.tsx @@ -194,9 +194,27 @@ export function SubagentCreationWizard({ case WIZARD_STEPS.DESCRIPTION_INPUT: return ; case WIZARD_STEPS.TOOL_SELECTION: - return ; + return ( + { + dispatch({ type: 'SET_TOOLS', tools }); + handleNext(); + }} + config={config} + /> + ); case WIZARD_STEPS.COLOR_SELECTION: - return ; + return ( + { + dispatch({ type: 'SET_BACKGROUND_COLOR', color }); + handleNext(); + }} + /> + ); case WIZARD_STEPS.FINAL_CONFIRMATION: return ; default: @@ -208,7 +226,16 @@ export function SubagentCreationWizard({ ); } - }, [stepProps, state.currentStep]); + }, [ + stepProps, + state.currentStep, + state.selectedTools, + state.backgroundColor, + state.generatedName, + config, + handleNext, + dispatch, + ]); return ( diff --git a/packages/cli/src/ui/components/subagents/ToolSelector.tsx b/packages/cli/src/ui/components/subagents/ToolSelector.tsx index c6f656e2..c0df99d3 100644 --- a/packages/cli/src/ui/components/subagents/ToolSelector.tsx +++ b/packages/cli/src/ui/components/subagents/ToolSelector.tsx @@ -4,11 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { Box, Text } from 'ink'; import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; -import { WizardStepProps, ToolCategory } from './types.js'; -import { Kind } from '@qwen-code/qwen-code-core'; +import { ToolCategory } from './types.js'; +import { Kind, Config } from '@qwen-code/qwen-code-core'; import { Colors } from '../../colors.js'; interface ToolOption { @@ -17,20 +17,28 @@ interface ToolOption { category: ToolCategory; } +interface ToolSelectorProps { + tools?: string[]; + onSelect: (tools: string[]) => void; + config: Config | null; +} + /** - * Step 4: Tool selection with categories. + * Tool selection with categories. */ export function ToolSelector({ - state: _state, - dispatch, - onNext, - onPrevious: _onPrevious, + tools = [], + onSelect, config, -}: WizardStepProps) { - const [selectedCategory, setSelectedCategory] = useState('all'); - +}: ToolSelectorProps) { // Generate tool categories from actual tool registry - const { toolCategories, readTools, editTools, executeTools } = useMemo(() => { + const { + toolCategories, + readTools, + editTools, + executeTools, + initialCategory, + } = useMemo(() => { if (!config) { // Fallback categories if config not available return { @@ -44,6 +52,7 @@ export function ToolSelector({ readTools: [], editTools: [], executeTools: [], + initialCategory: 'all', }; } @@ -100,8 +109,49 @@ export function ToolSelector({ }, ].filter((category) => category.id === 'all' || category.tools.length > 0); - return { toolCategories, readTools, editTools, executeTools }; - }, [config]); + // Determine initial category based on tools prop + let initialCategory = 'all'; // default to first option + + if (tools.length === 0) { + // Empty array represents all tools + initialCategory = 'all'; + } else { + // Try to match tools array to a category + const matchingCategory = toolCategories.find((category) => { + if (category.id === 'all') return false; + + // Check if the tools array exactly matches this category's tools + const categoryToolsSet = new Set(category.tools); + const inputToolsSet = new Set(tools); + + return ( + categoryToolsSet.size === inputToolsSet.size && + [...categoryToolsSet].every((tool) => inputToolsSet.has(tool)) + ); + }); + + if (matchingCategory) { + initialCategory = matchingCategory.id; + } + // If no exact match found, keep default 'all' + } + + return { + toolCategories, + readTools, + editTools, + executeTools, + initialCategory, + }; + }, [config, tools]); + + const [selectedCategory, setSelectedCategory] = + useState(initialCategory); + + // Update selected category when initialCategory changes (when tools prop changes) + useEffect(() => { + setSelectedCategory(initialCategory); + }, [initialCategory]); const toolOptions: ToolOption[] = toolCategories.map((category) => ({ label: category.name, @@ -117,11 +167,10 @@ export function ToolSelector({ const category = toolCategories.find((cat) => cat.id === selectedValue); if (category) { if (category.id === 'all') { - dispatch({ type: 'SET_TOOLS', tools: 'all' }); + onSelect([]); // Empty array for 'all' } else { - dispatch({ type: 'SET_TOOLS', tools: category.tools }); + onSelect(category.tools); } - onNext(); } }; diff --git a/packages/cli/src/ui/components/subagents/index.ts b/packages/cli/src/ui/components/subagents/index.ts index eb4aa00d..e77e8562 100644 --- a/packages/cli/src/ui/components/subagents/index.ts +++ b/packages/cli/src/ui/components/subagents/index.ts @@ -18,6 +18,7 @@ export { AgentsManagerDialog } from './AgentsManagerDialog.js'; export { AgentSelectionStep } from './AgentSelectionStep.js'; export { ActionSelectionStep } from './ActionSelectionStep.js'; export { AgentViewerStep } from './AgentViewerStep.js'; +export { AgentDeleteStep } from './AgentDeleteStep.js'; // Creation Wizard Types and State export type { @@ -30,16 +31,3 @@ export type { } from './types.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'; diff --git a/packages/cli/src/ui/components/subagents/reducers.tsx b/packages/cli/src/ui/components/subagents/reducers.tsx index a7f57a3f..ff31fee7 100644 --- a/packages/cli/src/ui/components/subagents/reducers.tsx +++ b/packages/cli/src/ui/components/subagents/reducers.tsx @@ -4,17 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - CreationWizardState, - WizardAction, - ManagementDialogState, - ManagementAction, - MANAGEMENT_STEPS, -} from './types.js'; +import { CreationWizardState, WizardAction } from './types.js'; import { WIZARD_STEPS, TOTAL_WIZARD_STEPS } from './constants.js'; -export { MANAGEMENT_STEPS }; - /** * Initial state for the creation wizard. */ @@ -26,7 +18,7 @@ export const initialWizardState: CreationWizardState = { generatedSystemPrompt: '', generatedDescription: '', generatedName: '', - selectedTools: 'all', + selectedTools: [], backgroundColor: 'auto', isGenerating: false, validationErrors: [], @@ -171,124 +163,3 @@ 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; - } -} diff --git a/packages/cli/src/ui/components/subagents/types.ts b/packages/cli/src/ui/components/subagents/types.ts index d3393b98..54173292 100644 --- a/packages/cli/src/ui/components/subagents/types.ts +++ b/packages/cli/src/ui/components/subagents/types.ts @@ -4,12 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - SubagentLevel, - SubagentConfig, - SubagentMetadata, - Config, -} from '@qwen-code/qwen-code-core'; +import { SubagentLevel, Config } from '@qwen-code/qwen-code-core'; /** * State management for the subagent creation wizard. @@ -37,7 +32,7 @@ export interface CreationWizardState { generatedName: string; /** Selected tools for the subagent */ - selectedTools: string[] | 'all'; + selectedTools: string[]; /** Background color for runtime display */ backgroundColor: string; @@ -84,7 +79,7 @@ export type WizardAction = description: string; systemPrompt: string; } - | { type: 'SET_TOOLS'; tools: string[] | 'all' } + | { type: 'SET_TOOLS'; tools: string[] } | { type: 'SET_BACKGROUND_COLOR'; color: string } | { type: 'SET_GENERATING'; isGenerating: boolean } | { type: 'SET_VALIDATION_ERRORS'; errors: string[] } @@ -116,54 +111,20 @@ export interface WizardResult { 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; - 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, + AGENT_SELECTION: 'agent-selection', + ACTION_SELECTION: 'action-selection', + AGENT_VIEWER: 'agent-viewer', + EDIT_OPTIONS: 'edit-options', + EDIT_TOOLS: 'edit-tools', + EDIT_COLOR: 'edit-color', + DELETE_CONFIRMATION: 'delete-confirmation', } as const; + +/** + * Common props for step navigation. + */ +export interface StepNavigationProps { + onNavigateToStep: (step: string) => void; + onNavigateBack: () => void; +} diff --git a/packages/cli/src/ui/components/subagents/useLaunchEditor.ts b/packages/cli/src/ui/components/subagents/useLaunchEditor.ts new file mode 100644 index 00000000..36171fa1 --- /dev/null +++ b/packages/cli/src/ui/components/subagents/useLaunchEditor.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback } from 'react'; +import { useStdin } from 'ink'; +import { EditorType } from '@qwen-code/qwen-code-core'; +import { spawnSync } from 'child_process'; +import { useSettings } from '../../contexts/SettingsContext.js'; + +/** + * Determines the editor command to use based on user preferences and platform. + */ +function getEditorCommand(preferredEditor?: EditorType): string { + if (preferredEditor) { + return preferredEditor; + } + + // Platform-specific defaults with UI preference for macOS + switch (process.platform) { + case 'darwin': + return 'open -t'; // TextEdit in plain text mode + case 'win32': + return 'notepad'; + default: + return process.env['VISUAL'] || process.env['EDITOR'] || 'vi'; + } +} + +/** + * React hook that provides an editor launcher function. + * Uses settings context and stdin management internally. + */ +export function useLaunchEditor() { + const settings = useSettings(); + const { stdin, setRawMode } = useStdin(); + + const launchEditor = useCallback( + async (filePath: string): Promise => { + const preferredEditor = settings.merged.preferredEditor as + | EditorType + | undefined; + const editor = getEditorCommand(preferredEditor); + + // Handle different editor command formats + let editorCommand: string; + let editorArgs: string[]; + + if (editor === 'open -t') { + // macOS TextEdit in plain text mode + editorCommand = 'open'; + editorArgs = ['-t', filePath]; + } else { + // Standard editor command + editorCommand = editor; + editorArgs = [filePath]; + } + + // Temporarily disable raw mode for editor + const wasRaw = stdin?.isRaw ?? false; + try { + setRawMode?.(false); + + const { status, error } = spawnSync(editorCommand, editorArgs, { + stdio: 'inherit', + }); + + if (error) throw error; + if (typeof status === 'number' && status !== 0) { + throw new Error(`Editor exited with status ${status}`); + } + } finally { + if (wasRaw) setRawMode?.(true); + } + }, + [settings.merged.preferredEditor, setRawMode, stdin], + ); + + return launchEditor; +} diff --git a/packages/cli/src/ui/components/subagents/utils.ts b/packages/cli/src/ui/components/subagents/utils.ts index 5f777bf9..4259c261 100644 --- a/packages/cli/src/ui/components/subagents/utils.ts +++ b/packages/cli/src/ui/components/subagents/utils.ts @@ -7,3 +7,16 @@ export const getColorForDisplay = (colorName?: string): string | undefined => !colorName || colorName === 'auto' ? undefined : COLOR_OPTIONS.find((color) => color.name === colorName)?.value; + +/** + * Sanitizes user input by removing dangerous characters and normalizing whitespace. + */ +export function sanitizeInput(input: string): string { + return ( + input + .trim() + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // Remove control characters + .replace(/\s+/g, ' ') // Normalize whitespace + ); // Limit length +} diff --git a/packages/cli/src/ui/components/subagents/validation.ts b/packages/cli/src/ui/components/subagents/validation.ts deleted file mode 100644 index 9dbc6b0a..00000000 --- a/packages/cli/src/ui/components/subagents/validation.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Validation result interface. - */ -export interface ValidationResult { - isValid: boolean; - errors: string[]; -} - -/** - * Sanitizes user input by removing dangerous characters and normalizing whitespace. - */ -export function sanitizeInput(input: string): string { - return ( - input - .trim() - // eslint-disable-next-line no-control-regex - .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // Remove control characters - .replace(/\s+/g, ' ') // Normalize whitespace - ); // Limit length -} - -/** - * Validates a system prompt. - */ -export function validateSystemPrompt(prompt: string): ValidationResult { - const errors: string[] = []; - const sanitized = sanitizeInput(prompt); - - if (sanitized.length === 0) { - errors.push('System prompt cannot be empty'); - } - - if (sanitized.length > 5000) { - errors.push('System prompt is too long (maximum 5000 characters)'); - } - - return { - isValid: errors.length === 0, - errors, - }; -} - -/** - * Validates tool selection. - */ -export function validateToolSelection( - tools: string[] | 'all', -): ValidationResult { - const errors: string[] = []; - - if (Array.isArray(tools)) { - if (tools.length === 0) { - errors.push('At least one tool must be selected'); - } - - // Check for valid tool names (basic validation) - const invalidTools = tools.filter( - (tool) => - typeof tool !== 'string' || - tool.trim().length === 0 || - !/^[a-zA-Z0-9_-]+$/.test(tool), - ); - - if (invalidTools.length > 0) { - errors.push(`Invalid tool names: ${invalidTools.join(', ')}`); - } - } - - return { - isValid: errors.length === 0, - errors, - }; -} - -/** - * Comprehensive validation for the entire subagent configuration. - */ -export function validateSubagentConfig(config: { - name: string; - description: string; - systemPrompt: string; - tools: string[] | 'all'; -}): ValidationResult { - const errors: string[] = []; - - const promptValidation = validateSystemPrompt(config.systemPrompt); - if (!promptValidation.isValid) { - errors.push(...promptValidation.errors); - } - - const toolsValidation = validateToolSelection(config.tools); - if (!toolsValidation.isValid) { - errors.push(...toolsValidation.errors); - } - - return { - isValid: errors.length === 0, - errors, - }; -} diff --git a/packages/cli/src/ui/components/subagents/wizardReducer.ts b/packages/cli/src/ui/components/subagents/wizardReducer.ts deleted file mode 100644 index a7f57a3f..00000000 --- a/packages/cli/src/ui/components/subagents/wizardReducer.ts +++ /dev/null @@ -1,294 +0,0 @@ -/** - * @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; - } -} diff --git a/packages/core/src/subagents/index.ts b/packages/core/src/subagents/index.ts index 3aa869cb..16ff4bc1 100644 --- a/packages/core/src/subagents/index.ts +++ b/packages/core/src/subagents/index.ts @@ -23,7 +23,6 @@ // Core types and interfaces export type { SubagentConfig, - SubagentMetadata, SubagentLevel, SubagentRuntimeConfig, ValidationResult, diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index a2d94b96..8bb4ac3c 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -16,7 +16,6 @@ import { import { SubagentConfig, SubagentRuntimeConfig, - SubagentMetadata, SubagentLevel, ListSubagentsOptions, CreateSubagentOptions, @@ -235,8 +234,8 @@ export class SubagentManager { */ async listSubagents( options: ListSubagentsOptions = {}, - ): Promise { - const subagents: SubagentMetadata[] = []; + ): Promise { + const subagents: SubagentConfig[] = []; const seenNames = new Set(); const levelsToCheck: SubagentLevel[] = options.level @@ -275,12 +274,6 @@ export class SubagentManager { case 'name': comparison = a.name.localeCompare(b.name); break; - case 'lastModified': { - const aTime = a.lastModified?.getTime() || 0; - const bTime = b.lastModified?.getTime() || 0; - comparison = aTime - bTime; - break; - } case 'level': // Project comes before user comparison = @@ -302,18 +295,18 @@ export class SubagentManager { * Finds a subagent by name and returns its metadata. * * @param name - Name of the subagent to find - * @returns SubagentMetadata or null if not found + * @returns SubagentConfig or null if not found */ async findSubagentByName( name: string, level?: SubagentLevel, - ): Promise { + ): Promise { const config = await this.loadSubagent(name, level); if (!config) { return null; } - return this.configToMetadata(config); + return config; } /** @@ -584,7 +577,7 @@ export class SubagentManager { */ private async listSubagentsAtLevel( level: SubagentLevel, - ): Promise { + ): Promise { const baseDir = level === 'project' ? path.join(this.projectRoot, QWEN_CONFIG_DIR, AGENT_CONFIG_DIR) @@ -592,7 +585,7 @@ export class SubagentManager { try { const files = await fs.readdir(baseDir); - const subagents: SubagentMetadata[] = []; + const subagents: SubagentConfig[] = []; for (const file of files) { if (!file.endsWith('.md')) continue; @@ -601,8 +594,7 @@ export class SubagentManager { try { const config = await this.parseSubagentFile(filePath); - const metadata = this.configToMetadata(config); - subagents.push(metadata); + subagents.push(config); } catch (error) { // Skip invalid files but log the error console.warn( @@ -618,24 +610,6 @@ export class SubagentManager { } } - /** - * Converts a SubagentConfig to SubagentMetadata. - * - * @param config - Full configuration - * @returns Metadata object - */ - private configToMetadata(config: SubagentConfig): SubagentMetadata { - return { - name: config.name, - description: config.description, - tools: config.tools, - level: config.level, - filePath: config.filePath, - // Add file stats if available - lastModified: undefined, // Would need to stat the file - }; - } - /** * Validates that a subagent name is available (not already in use). * diff --git a/packages/core/src/subagents/types.ts b/packages/core/src/subagents/types.ts index 955112f6..3220b849 100644 --- a/packages/core/src/subagents/types.ts +++ b/packages/core/src/subagents/types.ts @@ -67,33 +67,6 @@ export interface SubagentConfig { backgroundColor?: string; } -/** - * Metadata extracted from a subagent configuration file. - * Used for listing and discovery without loading full configuration. - */ -export interface SubagentMetadata { - /** Unique name identifier */ - name: string; - - /** Human-readable description */ - description: string; - - /** Optional list of allowed tools */ - tools?: string[]; - - /** Storage level */ - level: SubagentLevel; - - /** File path */ - filePath: string; - - /** File modification time for sorting */ - lastModified?: Date; - - /** Additional metadata from YAML frontmatter */ - [key: string]: unknown; -} - /** * Runtime configuration that converts file-based config to existing SubAgentScope. * This interface maps SubagentConfig to the existing runtime interfaces.