feat: subagent list dialog - done

This commit is contained in:
tanzhenxin
2025-09-04 23:29:47 +08:00
parent e44e28a640
commit 17b2c357a0
21 changed files with 701 additions and 976 deletions

View File

@@ -4,16 +4,23 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { useState } from 'react';
import { Box } from 'ink'; import { Box } from 'ink';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; 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 = ({ export const ActionSelectionStep = ({
state, onNavigateToStep,
dispatch, onNavigateBack,
onNext, }: ActionSelectionStepProps) => {
onPrevious, const [selectedAction, setSelectedAction] = useState<
}: ManagementStepProps) => { 'view' | 'edit' | 'delete' | null
>(null);
const actions = [ const actions = [
{ label: 'View Agent', value: 'view' as const }, { label: 'View Agent', value: 'view' as const },
{ label: 'Edit Agent', value: 'edit' as const }, { label: 'Edit Agent', value: 'edit' as const },
@@ -23,16 +30,24 @@ export const ActionSelectionStep = ({
const handleActionSelect = (value: 'view' | 'edit' | 'delete' | 'back') => { const handleActionSelect = (value: 'view' | 'edit' | 'delete' | 'back') => {
if (value === 'back') { if (value === 'back') {
onPrevious(); onNavigateBack();
return; return;
} }
dispatch({ type: 'SELECT_ACTION', payload: value }); setSelectedAction(value);
onNext();
// 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 const selectedIndex = selectedAction
? actions.findIndex((action) => action.value === state.selectedAction) ? actions.findIndex((action) => action.value === selectedAction)
: 0; : 0;
return ( return (

View File

@@ -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<void>;
}
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 (
<Box>
<Text color={theme.status.error}>No agent selected</Text>
</Box>
);
}
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.status.error}>
Are you sure you want to delete agent &ldquo;{selectedAgent.name}
&rdquo;?
</Text>
</Box>
);
}

View File

@@ -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<string>('editor');
const [error, setError] = useState<string | null>(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 (
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<RadioButtonSelect
items={editOptions.map((option) => ({
label: option.label,
value: option.id,
}))}
initialIndex={editOptions.findIndex(
(opt) => opt.id === selectedOption,
)}
onSelect={handleSelect}
onHighlight={handleHighlight}
isFocused={true}
/>
</Box>
{error && (
<Box flexDirection="column">
<Text bold color={theme.status.error}>
Error:
</Text>
<Box flexDirection="column" padding={1} paddingBottom={0}>
<Text color={theme.status.error} wrap="wrap">
{error}
</Text>
</Box>
</Box>
)}
</Box>
);
}

View File

@@ -4,12 +4,12 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { useEffect, useState } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { ManagementStepProps } from './types.js';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../semantic-colors.js';
import { Colors } from '../../colors.js'; import { Colors } from '../../colors.js';
import { useKeypress } from '../../hooks/useKeypress.js'; import { useKeypress } from '../../hooks/useKeypress.js';
import { SubagentConfig } from '@qwen-code/qwen-code-core';
interface NavigationState { interface NavigationState {
currentBlock: 'project' | 'user'; currentBlock: 'project' | 'user';
@@ -17,13 +17,15 @@ interface NavigationState {
userIndex: number; userIndex: number;
} }
interface AgentSelectionStepProps {
availableAgents: SubagentConfig[];
onAgentSelect: (agentIndex: number) => void;
}
export const AgentSelectionStep = ({ export const AgentSelectionStep = ({
state, availableAgents,
dispatch, onAgentSelect,
onNext, }: AgentSelectionStepProps) => {
config,
}: ManagementStepProps) => {
const [isLoading, setIsLoading] = useState(false);
const [navigation, setNavigation] = useState<NavigationState>({ const [navigation, setNavigation] = useState<NavigationState>({
currentBlock: 'project', currentBlock: 'project',
projectIndex: 0, projectIndex: 0,
@@ -31,60 +33,27 @@ export const AgentSelectionStep = ({
}); });
// Group agents by level // Group agents by level
const projectAgents = state.availableAgents.filter( const projectAgents = useMemo(
(agent) => agent.level === 'project', () => availableAgents.filter((agent) => agent.level === 'project'),
[availableAgents],
); );
const userAgents = state.availableAgents.filter( const userAgents = useMemo(
(agent) => agent.level === 'user', () => 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(() => { // Initialize navigation state when agents are loaded (only once)
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(() => { useEffect(() => {
if (projectAgents.length > 0) { if (projectAgents.length > 0) {
setNavigation((prev) => ({ ...prev, currentBlock: 'project' })); setNavigation((prev) => ({ ...prev, currentBlock: 'project' }));
} else if (userAgents.length > 0) { } else if (userAgents.length > 0) {
setNavigation((prev) => ({ ...prev, currentBlock: 'user' })); setNavigation((prev) => ({ ...prev, currentBlock: 'user' }));
} }
}, [projectAgents.length, userAgents.length]); }, [projectAgents, userAgents]);
// Custom keyboard navigation // Custom keyboard navigation
useKeypress( useKeypress(
@@ -148,71 +117,24 @@ export const AgentSelectionStep = ({
} }
}); });
} else if (name === 'return' || name === 'space') { } else if (name === 'return' || name === 'space') {
// Select current item // Calculate global index and select current item
const currentAgent = let globalIndex: number;
navigation.currentBlock === 'project' if (navigation.currentBlock === 'project') {
? projectAgents[navigation.projectIndex] globalIndex = navigation.projectIndex;
: userAgents[navigation.userIndex]; } else {
// User agents come after project agents in the availableAgents array
globalIndex = projectAgents.length + navigation.userIndex;
}
if (currentAgent) { if (globalIndex >= 0 && globalIndex < availableAgents.length) {
const agentIndex = state.availableAgents.indexOf(currentAgent); onAgentSelect(globalIndex);
handleAgentSelect(agentIndex);
} }
} }
}, },
{ isActive: true }, { isActive: true },
); );
const handleAgentSelect = async (index: number) => { if (availableAgents.length === 0) {
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 ( return (
<Box flexDirection="column"> <Box flexDirection="column">
<Text color={theme.text.secondary}>No subagents found.</Text> <Text color={theme.text.secondary}>No subagents found.</Text>

View File

@@ -4,20 +4,17 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { Box, Text, useInput } from 'ink'; import { Box, Text } from 'ink';
import { ManagementStepProps } from './types.js';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../semantic-colors.js';
import { shouldShowColor, getColorForDisplay } from './utils.js'; import { shouldShowColor, getColorForDisplay } from './utils.js';
import { SubagentConfig } from '@qwen-code/qwen-code-core';
export const AgentViewerStep = ({ state, onPrevious }: ManagementStepProps) => { interface AgentViewerStepProps {
// Handle keyboard input selectedAgent: SubagentConfig | null;
useInput((input, key) => { }
if (key.escape || input === 'b') {
onPrevious();
}
});
if (!state.selectedAgent) { export const AgentViewerStep = ({ selectedAgent }: AgentViewerStepProps) => {
if (!selectedAgent) {
return ( return (
<Box> <Box>
<Text color={theme.status.error}>No agent selected</Text> <Text color={theme.status.error}>No agent selected</Text>
@@ -25,7 +22,7 @@ export const AgentViewerStep = ({ state, onPrevious }: ManagementStepProps) => {
); );
} }
const agent = state.selectedAgent; const agent = selectedAgent;
const toolsDisplay = agent.tools ? agent.tools.join(', ') : '*'; const toolsDisplay = agent.tools ? agent.tools.join(', ') : '*';

View File

@@ -4,16 +4,19 @@
* SPDX-License-Identifier: Apache-2.0 * 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 { Box, Text, useInput } from 'ink';
import { managementReducer, initialManagementState } from './reducers.js';
import { AgentSelectionStep } from './AgentSelectionStep.js'; import { AgentSelectionStep } from './AgentSelectionStep.js';
import { ActionSelectionStep } from './ActionSelectionStep.js'; import { ActionSelectionStep } from './ActionSelectionStep.js';
import { AgentViewerStep } from './AgentViewerStep.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 { Colors } from '../../colors.js';
import { theme } from '../../semantic-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 { interface AgentsManagerDialogProps {
onClose: () => void; onClose: () => void;
@@ -27,67 +30,132 @@ export function AgentsManagerDialog({
onClose, onClose,
config, config,
}: AgentsManagerDialogProps) { }: AgentsManagerDialogProps) {
const [state, dispatch] = useReducer( // Simple state management with useState hooks
managementReducer, const [availableAgents, setAvailableAgents] = useState<SubagentConfig[]>([]);
initialManagementState, const [selectedAgentIndex, setSelectedAgentIndex] = useState<number>(-1);
const [navigationStack, setNavigationStack] = useState<string[]>([
MANAGEMENT_STEPS.AGENT_SELECTION,
]);
// Memoized selectedAgent based on index
const selectedAgent = useMemo(
() =>
selectedAgentIndex >= 0 ? availableAgents[selectedAgentIndex] : null,
[availableAgents, selectedAgentIndex],
); );
const handleNext = useCallback(() => { // Function to load agents
dispatch({ type: 'GO_TO_NEXT_STEP' }); 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(() => { const handleNavigateToStep = useCallback((step: string) => {
dispatch({ type: 'GO_TO_PREVIOUS_STEP' }); setNavigationStack((prev) => [...prev, step]);
}, []); }, []);
const handleCancel = useCallback(() => { const handleNavigateBack = useCallback(() => {
dispatch({ type: 'RESET_DIALOG' }); setNavigationStack((prev) => {
onClose(); if (prev.length <= 1) {
}, [onClose]); 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 // Centralized ESC key handling for the entire dialog
useInput((input, key) => { useInput((input, key) => {
if (key.escape) { if (key.escape) {
// Agent viewer step handles its own ESC logic const currentStep = getCurrentStep();
if (state.currentStep === MANAGEMENT_STEPS.AGENT_VIEWER) { if (currentStep === MANAGEMENT_STEPS.AGENT_SELECTION) {
return; // Let AgentViewerStep handle it
}
if (state.currentStep === MANAGEMENT_STEPS.AGENT_SELECTION) {
// On first step, ESC cancels the entire dialog // On first step, ESC cancels the entire dialog
handleCancel(); onClose();
} else { } else {
// On other steps, ESC goes back to previous step // On other steps, ESC goes back to previous step in navigation stack
handlePrevious(); handleNavigateBack();
} }
} }
}); });
const stepProps: ManagementStepProps = useMemo( // Props for child components - now using direct state and callbacks
const commonProps = useMemo(
() => ({ () => ({
state, onNavigateToStep: handleNavigateToStep,
config, onNavigateBack: handleNavigateBack,
dispatch,
onNext: handleNext,
onPrevious: handlePrevious,
onCancel: handleCancel,
}), }),
[state, dispatch, handleNext, handlePrevious, handleCancel, config], [handleNavigateToStep, handleNavigateBack],
); );
const renderStepHeader = useCallback(() => { const renderStepHeader = useCallback(() => {
const currentStep = getCurrentStep();
const getStepHeaderText = () => { const getStepHeaderText = () => {
switch (state.currentStep) { switch (currentStep) {
case MANAGEMENT_STEPS.AGENT_SELECTION: case MANAGEMENT_STEPS.AGENT_SELECTION:
return 'Agents'; return 'Agents';
case MANAGEMENT_STEPS.ACTION_SELECTION: case MANAGEMENT_STEPS.ACTION_SELECTION:
return 'Choose Action'; return 'Choose Action';
case MANAGEMENT_STEPS.AGENT_VIEWER: case MANAGEMENT_STEPS.AGENT_VIEWER:
return state.selectedAgent?.name; return selectedAgent?.name;
case MANAGEMENT_STEPS.AGENT_EDITOR: case MANAGEMENT_STEPS.EDIT_OPTIONS:
return `Editing: ${state.selectedAgent?.name || 'Unknown'}`; 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: case MANAGEMENT_STEPS.DELETE_CONFIRMATION:
return `Delete: ${state.selectedAgent?.name || 'Unknown'}`; return `Delete ${selectedAgent?.name}`;
default: default:
return 'Unknown Step'; return 'Unknown Step';
} }
@@ -98,22 +166,27 @@ export function AgentsManagerDialog({
<Text bold>{getStepHeaderText()}</Text> <Text bold>{getStepHeaderText()}</Text>
</Box> </Box>
); );
}, [state.currentStep, state.selectedAgent?.name]); }, [getCurrentStep, selectedAgent]);
const renderStepFooter = useCallback(() => { const renderStepFooter = useCallback(() => {
const currentStep = getCurrentStep();
const getNavigationInstructions = () => { const getNavigationInstructions = () => {
if (state.currentStep === MANAGEMENT_STEPS.ACTION_SELECTION) { if (currentStep === MANAGEMENT_STEPS.AGENT_SELECTION) {
return 'Enter to select, ↑↓ to navigate, Esc to go back'; if (availableAgents.length === 0) {
}
if (state.currentStep === MANAGEMENT_STEPS.AGENT_SELECTION) {
if (state.availableAgents.length === 0) {
return 'Esc to close'; return 'Esc to close';
} }
return 'Enter to select, ↑↓ to navigate, 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 ( return (
@@ -121,42 +194,110 @@ export function AgentsManagerDialog({
<Text color={theme.text.secondary}>{getNavigationInstructions()}</Text> <Text color={theme.text.secondary}>{getNavigationInstructions()}</Text>
</Box> </Box>
); );
}, [state.currentStep, state.availableAgents.length]); }, [getCurrentStep, availableAgents]);
const renderStepContent = useCallback(() => { const renderStepContent = useCallback(() => {
switch (state.currentStep) { const currentStep = getCurrentStep();
switch (currentStep) {
case MANAGEMENT_STEPS.AGENT_SELECTION: 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 ( return (
<Box> <AgentSelectionStep
<Text color={theme.status.warning}> availableAgents={availableAgents}
Agent editing not yet implemented onAgentSelect={handleSelectAgent}
</Text> {...commonProps}
/>
);
case MANAGEMENT_STEPS.ACTION_SELECTION:
return <ActionSelectionStep {...commonProps} />;
case MANAGEMENT_STEPS.AGENT_VIEWER:
return (
<AgentViewerStep selectedAgent={selectedAgent} {...commonProps} />
);
case MANAGEMENT_STEPS.EDIT_OPTIONS:
return (
<EditOptionsStep selectedAgent={selectedAgent} {...commonProps} />
);
case MANAGEMENT_STEPS.EDIT_TOOLS:
return (
<Box flexDirection="column" gap={1}>
<ToolSelector
tools={selectedAgent?.tools || []}
onSelect={async (tools) => {
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}
/>
</Box>
);
case MANAGEMENT_STEPS.EDIT_COLOR:
return (
<Box flexDirection="column" gap={1}>
<ColorSelector
backgroundColor={selectedAgent?.backgroundColor || 'auto'}
agentName={selectedAgent?.name || 'Agent'}
onSelect={async (color) => {
// 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);
}
}
}}
/>
</Box> </Box>
); );
case MANAGEMENT_STEPS.DELETE_CONFIRMATION: case MANAGEMENT_STEPS.DELETE_CONFIRMATION:
return ( return (
<Box> <AgentDeleteStep
<Text color={theme.status.warning}> selectedAgent={selectedAgent}
Agent deletion not yet implemented onDelete={handleDeleteAgent}
</Text> {...commonProps}
</Box> />
); );
default: default:
return ( return (
<Box> <Box>
<Text color={theme.status.error}> <Text color={theme.status.error}>Invalid step: {currentStep}</Text>
Invalid step: {state.currentStep}
</Text>
</Box> </Box>
); );
} }
}, [stepProps, state.currentStep]); }, [
getCurrentStep,
availableAgents,
selectedAgent,
commonProps,
config,
loadAgents,
handleNavigateBack,
handleSelectAgent,
handleDeleteAgent,
]);
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">

View File

@@ -4,25 +4,43 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { WizardStepProps, ColorOption } from './types.js'; import { ColorOption } from './types.js';
import { Colors } from '../../colors.js'; import { Colors } from '../../colors.js';
import { COLOR_OPTIONS } from './constants.js'; import { COLOR_OPTIONS } from './constants.js';
const colorOptions: ColorOption[] = COLOR_OPTIONS; 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({ export function ColorSelector({
state, backgroundColor = 'auto',
dispatch, agentName = 'Agent',
onNext, onSelect,
onPrevious: _onPrevious, }: ColorSelectorProps) {
}: WizardStepProps) { const [selectedColor, setSelectedColor] = useState<string>(backgroundColor);
const handleSelect = (_selectedValue: string) => {
onNext(); // 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) => { const handleHighlight = (selectedValue: string) => {
@@ -30,12 +48,12 @@ export function ColorSelector({
(option) => option.id === selectedValue, (option) => option.id === selectedValue,
); );
if (colorOption) { if (colorOption) {
dispatch({ type: 'SET_BACKGROUND_COLOR', color: colorOption.name }); setSelectedColor(colorOption.name);
} }
}; };
const currentColor = const currentColor =
colorOptions.find((option) => option.name === state.backgroundColor) || colorOptions.find((option) => option.name === selectedColor) ||
colorOptions[0]; colorOptions[0];
return ( return (
@@ -58,7 +76,7 @@ export function ColorSelector({
<Box flexDirection="row"> <Box flexDirection="row">
<Text color={Colors.Gray}>Preview:</Text> <Text color={Colors.Gray}>Preview:</Text>
<Box marginLeft={2} backgroundColor={currentColor.value}> <Box marginLeft={2} backgroundColor={currentColor.value}>
<Text color="black">{` ${state.generatedName} `}</Text> <Text color="black">{` ${agentName} `}</Text>
</Box> </Box>
</Box> </Box>
</Box> </Box>

View File

@@ -5,18 +5,12 @@
*/ */
import { useCallback, useState, useEffect } from 'react'; 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 { WizardStepProps } from './types.js';
import { validateSubagentConfig } from './validation.js'; import { SubagentManager, SubagentConfig } from '@qwen-code/qwen-code-core';
import {
SubagentManager,
SubagentConfig,
EditorType,
} from '@qwen-code/qwen-code-core';
import { useSettings } from '../../contexts/SettingsContext.js';
import { spawnSync } from 'child_process';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../semantic-colors.js';
import { shouldShowColor, getColorForDisplay } from './utils.js'; import { shouldShowColor, getColorForDisplay } from './utils.js';
import { useLaunchEditor } from './useLaunchEditor.js';
/** /**
* Step 6: Final confirmation and actions. * Step 6: Final confirmation and actions.
@@ -31,8 +25,7 @@ export function CreationSummary({
const [saveSuccess, setSaveSuccess] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false);
const [warnings, setWarnings] = useState<string[]>([]); const [warnings, setWarnings] = useState<string[]>([]);
const settings = useSettings(); const launchEditor = useLaunchEditor();
const { stdin, setRawMode } = useStdin();
const truncateText = (text: string, maxLength: number): string => { const truncateText = (text: string, maxLength: number): string => {
if (text.length <= maxLength) return text; if (text.length <= maxLength) return text;
@@ -113,19 +106,6 @@ export function CreationSummary({
// Common method to save subagent configuration // Common method to save subagent configuration
const saveSubagent = useCallback(async (): Promise<SubagentManager> => { const saveSubagent = useCallback(async (): Promise<SubagentManager> => {
// 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 // Create SubagentManager instance
if (!config) { if (!config) {
throw new Error('Configuration not available'); throw new Error('Configuration not available');
@@ -190,62 +170,11 @@ export function CreationSummary({
state.location, 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 // Launch editor with the actual subagent file
const wasRaw = stdin?.isRaw ?? false; await launchEditor(subagentFilePath);
try {
setRawMode?.(false);
// Handle different editor command formats // Show success UI and auto-close after successful edit
let editorCommand: string; showSuccessAndClose();
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);
}
} catch (error) { } catch (error) {
setSaveError( setSaveError(
`Failed to save and edit subagent: ${error instanceof Error ? error.message : 'Unknown error'}`, `Failed to save and edit subagent: ${error instanceof Error ? error.message : 'Unknown error'}`,
@@ -256,9 +185,7 @@ export function CreationSummary({
showSuccessAndClose, showSuccessAndClose,
state.generatedName, state.generatedName,
state.location, state.location,
settings.merged.preferredEditor, launchEditor,
stdin,
setRawMode,
]); ]);
// Handle keyboard input // Handle keyboard input

View File

@@ -7,7 +7,7 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { WizardStepProps, WizardAction } from './types.js'; 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 { Config, subagentGenerator } from '@qwen-code/qwen-code-core';
import { useTextBuffer } from '../shared/text-buffer.js'; import { useTextBuffer } from '../shared/text-buffer.js';
import { useKeypress, Key } from '../../hooks/useKeypress.js'; import { useKeypress, Key } from '../../hooks/useKeypress.js';

View File

@@ -194,9 +194,27 @@ export function SubagentCreationWizard({
case WIZARD_STEPS.DESCRIPTION_INPUT: case WIZARD_STEPS.DESCRIPTION_INPUT:
return <DescriptionInput {...stepProps} />; return <DescriptionInput {...stepProps} />;
case WIZARD_STEPS.TOOL_SELECTION: case WIZARD_STEPS.TOOL_SELECTION:
return <ToolSelector {...stepProps} />; return (
<ToolSelector
tools={state.selectedTools}
onSelect={(tools) => {
dispatch({ type: 'SET_TOOLS', tools });
handleNext();
}}
config={config}
/>
);
case WIZARD_STEPS.COLOR_SELECTION: case WIZARD_STEPS.COLOR_SELECTION:
return <ColorSelector {...stepProps} />; return (
<ColorSelector
backgroundColor={state.backgroundColor}
agentName={state.generatedName}
onSelect={(color) => {
dispatch({ type: 'SET_BACKGROUND_COLOR', color });
handleNext();
}}
/>
);
case WIZARD_STEPS.FINAL_CONFIRMATION: case WIZARD_STEPS.FINAL_CONFIRMATION:
return <CreationSummary {...stepProps} />; return <CreationSummary {...stepProps} />;
default: default:
@@ -208,7 +226,16 @@ export function SubagentCreationWizard({
</Box> </Box>
); );
} }
}, [stepProps, state.currentStep]); }, [
stepProps,
state.currentStep,
state.selectedTools,
state.backgroundColor,
state.generatedName,
config,
handleNext,
dispatch,
]);
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">

View File

@@ -4,11 +4,11 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { useState, useMemo } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { WizardStepProps, ToolCategory } from './types.js'; import { ToolCategory } from './types.js';
import { Kind } from '@qwen-code/qwen-code-core'; import { Kind, Config } from '@qwen-code/qwen-code-core';
import { Colors } from '../../colors.js'; import { Colors } from '../../colors.js';
interface ToolOption { interface ToolOption {
@@ -17,20 +17,28 @@ interface ToolOption {
category: ToolCategory; 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({ export function ToolSelector({
state: _state, tools = [],
dispatch, onSelect,
onNext,
onPrevious: _onPrevious,
config, config,
}: WizardStepProps) { }: ToolSelectorProps) {
const [selectedCategory, setSelectedCategory] = useState<string>('all');
// Generate tool categories from actual tool registry // Generate tool categories from actual tool registry
const { toolCategories, readTools, editTools, executeTools } = useMemo(() => { const {
toolCategories,
readTools,
editTools,
executeTools,
initialCategory,
} = useMemo(() => {
if (!config) { if (!config) {
// Fallback categories if config not available // Fallback categories if config not available
return { return {
@@ -44,6 +52,7 @@ export function ToolSelector({
readTools: [], readTools: [],
editTools: [], editTools: [],
executeTools: [], executeTools: [],
initialCategory: 'all',
}; };
} }
@@ -100,8 +109,49 @@ export function ToolSelector({
}, },
].filter((category) => category.id === 'all' || category.tools.length > 0); ].filter((category) => category.id === 'all' || category.tools.length > 0);
return { toolCategories, readTools, editTools, executeTools }; // Determine initial category based on tools prop
}, [config]); 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<string>(initialCategory);
// Update selected category when initialCategory changes (when tools prop changes)
useEffect(() => {
setSelectedCategory(initialCategory);
}, [initialCategory]);
const toolOptions: ToolOption[] = toolCategories.map((category) => ({ const toolOptions: ToolOption[] = toolCategories.map((category) => ({
label: category.name, label: category.name,
@@ -117,11 +167,10 @@ export function ToolSelector({
const category = toolCategories.find((cat) => cat.id === selectedValue); const category = toolCategories.find((cat) => cat.id === selectedValue);
if (category) { if (category) {
if (category.id === 'all') { if (category.id === 'all') {
dispatch({ type: 'SET_TOOLS', tools: 'all' }); onSelect([]); // Empty array for 'all'
} else { } else {
dispatch({ type: 'SET_TOOLS', tools: category.tools }); onSelect(category.tools);
} }
onNext();
} }
}; };

View File

@@ -18,6 +18,7 @@ export { AgentsManagerDialog } from './AgentsManagerDialog.js';
export { AgentSelectionStep } from './AgentSelectionStep.js'; export { AgentSelectionStep } from './AgentSelectionStep.js';
export { ActionSelectionStep } from './ActionSelectionStep.js'; export { ActionSelectionStep } from './ActionSelectionStep.js';
export { AgentViewerStep } from './AgentViewerStep.js'; export { AgentViewerStep } from './AgentViewerStep.js';
export { AgentDeleteStep } from './AgentDeleteStep.js';
// Creation Wizard Types and State // Creation Wizard Types and State
export type { export type {
@@ -30,16 +31,3 @@ export type {
} from './types.js'; } from './types.js';
export { wizardReducer, initialWizardState } from './reducers.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

@@ -4,17 +4,9 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { import { CreationWizardState, WizardAction } from './types.js';
CreationWizardState,
WizardAction,
ManagementDialogState,
ManagementAction,
MANAGEMENT_STEPS,
} from './types.js';
import { WIZARD_STEPS, TOTAL_WIZARD_STEPS } from './constants.js'; import { WIZARD_STEPS, TOTAL_WIZARD_STEPS } from './constants.js';
export { MANAGEMENT_STEPS };
/** /**
* Initial state for the creation wizard. * Initial state for the creation wizard.
*/ */
@@ -26,7 +18,7 @@ export const initialWizardState: CreationWizardState = {
generatedSystemPrompt: '', generatedSystemPrompt: '',
generatedDescription: '', generatedDescription: '',
generatedName: '', generatedName: '',
selectedTools: 'all', selectedTools: [],
backgroundColor: 'auto', backgroundColor: 'auto',
isGenerating: false, isGenerating: false,
validationErrors: [], validationErrors: [],
@@ -171,124 +163,3 @@ function validateStep(step: number, state: CreationWizardState): boolean {
return false; 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,12 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { import { SubagentLevel, Config } from '@qwen-code/qwen-code-core';
SubagentLevel,
SubagentConfig,
SubagentMetadata,
Config,
} from '@qwen-code/qwen-code-core';
/** /**
* State management for the subagent creation wizard. * State management for the subagent creation wizard.
@@ -37,7 +32,7 @@ export interface CreationWizardState {
generatedName: string; generatedName: string;
/** Selected tools for the subagent */ /** Selected tools for the subagent */
selectedTools: string[] | 'all'; selectedTools: string[];
/** Background color for runtime display */ /** Background color for runtime display */
backgroundColor: string; backgroundColor: string;
@@ -84,7 +79,7 @@ export type WizardAction =
description: string; description: string;
systemPrompt: string; systemPrompt: string;
} }
| { type: 'SET_TOOLS'; tools: string[] | 'all' } | { type: 'SET_TOOLS'; tools: string[] }
| { type: 'SET_BACKGROUND_COLOR'; color: string } | { type: 'SET_BACKGROUND_COLOR'; color: string }
| { type: 'SET_GENERATING'; isGenerating: boolean } | { type: 'SET_GENERATING'; isGenerating: boolean }
| { type: 'SET_VALIDATION_ERRORS'; errors: string[] } | { type: 'SET_VALIDATION_ERRORS'; errors: string[] }
@@ -116,54 +111,20 @@ export interface WizardResult {
backgroundColor: 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 = { export const MANAGEMENT_STEPS = {
AGENT_SELECTION: 1, AGENT_SELECTION: 'agent-selection',
ACTION_SELECTION: 2, ACTION_SELECTION: 'action-selection',
AGENT_VIEWER: 3, AGENT_VIEWER: 'agent-viewer',
AGENT_EDITOR: 4, EDIT_OPTIONS: 'edit-options',
DELETE_CONFIRMATION: 5, EDIT_TOOLS: 'edit-tools',
EDIT_COLOR: 'edit-color',
DELETE_CONFIRMATION: 'delete-confirmation',
} as const; } as const;
/**
* Common props for step navigation.
*/
export interface StepNavigationProps {
onNavigateToStep: (step: string) => void;
onNavigateBack: () => void;
}

View File

@@ -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<void> => {
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;
}

View File

@@ -7,3 +7,16 @@ export const getColorForDisplay = (colorName?: string): string | undefined =>
!colorName || colorName === 'auto' !colorName || colorName === 'auto'
? undefined ? undefined
: COLOR_OPTIONS.find((color) => color.name === colorName)?.value; : 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
}

View File

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

View File

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

View File

@@ -23,7 +23,6 @@
// Core types and interfaces // Core types and interfaces
export type { export type {
SubagentConfig, SubagentConfig,
SubagentMetadata,
SubagentLevel, SubagentLevel,
SubagentRuntimeConfig, SubagentRuntimeConfig,
ValidationResult, ValidationResult,

View File

@@ -16,7 +16,6 @@ import {
import { import {
SubagentConfig, SubagentConfig,
SubagentRuntimeConfig, SubagentRuntimeConfig,
SubagentMetadata,
SubagentLevel, SubagentLevel,
ListSubagentsOptions, ListSubagentsOptions,
CreateSubagentOptions, CreateSubagentOptions,
@@ -235,8 +234,8 @@ export class SubagentManager {
*/ */
async listSubagents( async listSubagents(
options: ListSubagentsOptions = {}, options: ListSubagentsOptions = {},
): Promise<SubagentMetadata[]> { ): Promise<SubagentConfig[]> {
const subagents: SubagentMetadata[] = []; const subagents: SubagentConfig[] = [];
const seenNames = new Set<string>(); const seenNames = new Set<string>();
const levelsToCheck: SubagentLevel[] = options.level const levelsToCheck: SubagentLevel[] = options.level
@@ -275,12 +274,6 @@ export class SubagentManager {
case 'name': case 'name':
comparison = a.name.localeCompare(b.name); comparison = a.name.localeCompare(b.name);
break; break;
case 'lastModified': {
const aTime = a.lastModified?.getTime() || 0;
const bTime = b.lastModified?.getTime() || 0;
comparison = aTime - bTime;
break;
}
case 'level': case 'level':
// Project comes before user // Project comes before user
comparison = comparison =
@@ -302,18 +295,18 @@ export class SubagentManager {
* Finds a subagent by name and returns its metadata. * Finds a subagent by name and returns its metadata.
* *
* @param name - Name of the subagent to find * @param name - Name of the subagent to find
* @returns SubagentMetadata or null if not found * @returns SubagentConfig or null if not found
*/ */
async findSubagentByName( async findSubagentByName(
name: string, name: string,
level?: SubagentLevel, level?: SubagentLevel,
): Promise<SubagentMetadata | null> { ): Promise<SubagentConfig | null> {
const config = await this.loadSubagent(name, level); const config = await this.loadSubagent(name, level);
if (!config) { if (!config) {
return null; return null;
} }
return this.configToMetadata(config); return config;
} }
/** /**
@@ -584,7 +577,7 @@ export class SubagentManager {
*/ */
private async listSubagentsAtLevel( private async listSubagentsAtLevel(
level: SubagentLevel, level: SubagentLevel,
): Promise<SubagentMetadata[]> { ): Promise<SubagentConfig[]> {
const baseDir = const baseDir =
level === 'project' level === 'project'
? path.join(this.projectRoot, QWEN_CONFIG_DIR, AGENT_CONFIG_DIR) ? path.join(this.projectRoot, QWEN_CONFIG_DIR, AGENT_CONFIG_DIR)
@@ -592,7 +585,7 @@ export class SubagentManager {
try { try {
const files = await fs.readdir(baseDir); const files = await fs.readdir(baseDir);
const subagents: SubagentMetadata[] = []; const subagents: SubagentConfig[] = [];
for (const file of files) { for (const file of files) {
if (!file.endsWith('.md')) continue; if (!file.endsWith('.md')) continue;
@@ -601,8 +594,7 @@ export class SubagentManager {
try { try {
const config = await this.parseSubagentFile(filePath); const config = await this.parseSubagentFile(filePath);
const metadata = this.configToMetadata(config); subagents.push(config);
subagents.push(metadata);
} catch (error) { } catch (error) {
// Skip invalid files but log the error // Skip invalid files but log the error
console.warn( 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). * Validates that a subagent name is available (not already in use).
* *

View File

@@ -67,33 +67,6 @@ export interface SubagentConfig {
backgroundColor?: string; 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. * Runtime configuration that converts file-based config to existing SubAgentScope.
* This interface maps SubagentConfig to the existing runtime interfaces. * This interface maps SubagentConfig to the existing runtime interfaces.