mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
feat: subagent phase 2 implementation
This commit is contained in:
101
packages/cli/src/ui/components/subagents/ColorSelector.tsx
Normal file
101
packages/cli/src/ui/components/subagents/ColorSelector.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
|
||||
import { WizardStepProps, ColorOption } from './types.js';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
const colorOptions: ColorOption[] = [
|
||||
{
|
||||
id: 'auto',
|
||||
name: 'Automatic Color',
|
||||
value: 'auto',
|
||||
},
|
||||
{
|
||||
id: 'blue',
|
||||
name: 'Blue',
|
||||
value: '#3b82f6',
|
||||
},
|
||||
{
|
||||
id: 'green',
|
||||
name: 'Green',
|
||||
value: '#10b981',
|
||||
},
|
||||
{
|
||||
id: 'purple',
|
||||
name: 'Purple',
|
||||
value: '#8b5cf6',
|
||||
},
|
||||
{
|
||||
id: 'orange',
|
||||
name: 'Orange',
|
||||
value: '#f59e0b',
|
||||
},
|
||||
{
|
||||
id: 'red',
|
||||
name: 'Red',
|
||||
value: '#ef4444',
|
||||
},
|
||||
{
|
||||
id: 'cyan',
|
||||
name: 'Cyan',
|
||||
value: '#06b6d4',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Step 5: Background color selection with preview.
|
||||
*/
|
||||
export function ColorSelector({
|
||||
state,
|
||||
dispatch,
|
||||
onNext,
|
||||
onPrevious: _onPrevious,
|
||||
}: WizardStepProps) {
|
||||
const handleSelect = (_selectedValue: string) => {
|
||||
onNext();
|
||||
};
|
||||
|
||||
const handleHighlight = (selectedValue: string) => {
|
||||
const colorOption = colorOptions.find(
|
||||
(option) => option.id === selectedValue,
|
||||
);
|
||||
if (colorOption) {
|
||||
dispatch({ type: 'SET_BACKGROUND_COLOR', color: colorOption.value });
|
||||
}
|
||||
};
|
||||
|
||||
const currentColor =
|
||||
colorOptions.find((option) => option.value === state.backgroundColor) ||
|
||||
colorOptions[0];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<RadioButtonSelect
|
||||
items={colorOptions.map((option) => ({
|
||||
label: option.name,
|
||||
value: option.id,
|
||||
}))}
|
||||
initialIndex={colorOptions.findIndex(
|
||||
(opt) => opt.id === currentColor.id,
|
||||
)}
|
||||
onSelect={handleSelect}
|
||||
onHighlight={handleHighlight}
|
||||
isFocused={true}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row">
|
||||
<Text color={Colors.Gray}>Preview:</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text color={currentColor.value}>{state.generatedName}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
214
packages/cli/src/ui/components/subagents/CreationSummary.tsx
Normal file
214
packages/cli/src/ui/components/subagents/CreationSummary.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { WizardStepProps } from './types.js';
|
||||
import { UI } from './constants.js';
|
||||
import { validateSubagentConfig } from './validation.js';
|
||||
import { SubagentManager, SubagentConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Step 6: Final confirmation and actions.
|
||||
*/
|
||||
export function CreationSummary({
|
||||
state,
|
||||
onPrevious: _onPrevious,
|
||||
onCancel,
|
||||
}: WizardStepProps) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
const truncateText = (text: string, maxLength: number): string => {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - 3) + '...';
|
||||
};
|
||||
|
||||
const toolsDisplay = Array.isArray(state.selectedTools)
|
||||
? state.selectedTools.join(', ')
|
||||
: 'All available tools';
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
// 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
|
||||
// TODO: Get project root from config or context
|
||||
const projectRoot = process.cwd();
|
||||
const subagentManager = new SubagentManager(projectRoot);
|
||||
|
||||
// Build subagent configuration
|
||||
const config: SubagentConfig = {
|
||||
name: state.generatedName,
|
||||
description: state.generatedDescription,
|
||||
systemPrompt: state.generatedSystemPrompt,
|
||||
level: state.location,
|
||||
filePath: '', // Will be set by manager
|
||||
tools: Array.isArray(state.selectedTools)
|
||||
? state.selectedTools
|
||||
: undefined,
|
||||
// TODO: Add modelConfig and runConfig if needed
|
||||
};
|
||||
|
||||
// Create the subagent
|
||||
await subagentManager.createSubagent(config, {
|
||||
level: state.location,
|
||||
overwrite: false,
|
||||
});
|
||||
|
||||
setSaveSuccess(true);
|
||||
|
||||
// Auto-close after successful save
|
||||
setTimeout(() => {
|
||||
onCancel();
|
||||
}, UI.AUTO_CLOSE_DELAY_MS);
|
||||
} catch (error) {
|
||||
setSaveError(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [state, isSaving, onCancel]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
// TODO: Implement system editor integration
|
||||
setSaveError('Edit functionality not yet implemented');
|
||||
}, []);
|
||||
|
||||
// Handle keyboard input
|
||||
useInput((input, key) => {
|
||||
if (isSaving || saveSuccess) return;
|
||||
|
||||
if (key.return || input === 's') {
|
||||
handleSave();
|
||||
return;
|
||||
}
|
||||
|
||||
if (input === 'e') {
|
||||
handleEdit();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
if (saveSuccess) {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text bold color="green">
|
||||
✅ Subagent Created Successfully!
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>
|
||||
Subagent "{state.generatedName}" has been saved to{' '}
|
||||
{state.location} level.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="gray">Closing wizard...</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSaving) {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
💾 Saving Subagent...
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Creating subagent "{state.generatedName}"...</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{saveError && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color="red">
|
||||
❌ Error saving subagent:
|
||||
</Text>
|
||||
<Text color="red" wrap="wrap">
|
||||
{saveError}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
flexDirection="column"
|
||||
>
|
||||
<Box >
|
||||
<Text bold>Name: </Text>
|
||||
<Text>{state.generatedName}</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text bold>Location: </Text>
|
||||
<Text>
|
||||
{state.location === 'project'
|
||||
? 'Project Level (.qwen/agents/)'
|
||||
: 'User Level (~/.qwen/agents/)'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text bold>Tools: </Text>
|
||||
<Text>{toolsDisplay}</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text bold>Description:</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text wrap="wrap">
|
||||
{truncateText(state.generatedDescription, 200)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text bold>System Prompt:</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text wrap="wrap">
|
||||
{truncateText(state.generatedSystemPrompt, 200)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text bold>Background Color: </Text>
|
||||
<Text>
|
||||
{state.backgroundColor === 'auto'
|
||||
? 'Automatic'
|
||||
: state.backgroundColor}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
310
packages/cli/src/ui/components/subagents/DescriptionInput.tsx
Normal file
310
packages/cli/src/ui/components/subagents/DescriptionInput.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { WizardStepProps, WizardAction } from './types.js';
|
||||
import { sanitizeInput } from './validation.js';
|
||||
import { Config, subagentGenerator } from '@qwen-code/qwen-code-core';
|
||||
import { useTextBuffer } from '../shared/text-buffer.js';
|
||||
import { useKeypress, Key } from '../../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { cpSlice, cpLen } from '../../utils/textUtils.js';
|
||||
import chalk from 'chalk';
|
||||
import stringWidth from 'string-width';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
/**
|
||||
* Step 3: Description input with LLM generation.
|
||||
*/
|
||||
export function DescriptionInput({
|
||||
state,
|
||||
dispatch,
|
||||
onNext,
|
||||
config,
|
||||
}: WizardStepProps) {
|
||||
const [inputWidth] = useState(80); // Fixed width for now
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const buffer = useTextBuffer({
|
||||
initialText: state.userDescription || '',
|
||||
viewport: { height: 10, width: inputWidth },
|
||||
isValidPath: () => false, // For subagent description, we don't need file path validation
|
||||
onChange: (text) => {
|
||||
const sanitized = sanitizeInput(text);
|
||||
dispatch({
|
||||
type: 'SET_USER_DESCRIPTION',
|
||||
description: sanitized,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleGenerate = useCallback(
|
||||
async (
|
||||
userDescription: string,
|
||||
dispatch: (action: WizardAction) => void,
|
||||
config: Config,
|
||||
): Promise<void> => {
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
const generated = await subagentGenerator(
|
||||
userDescription,
|
||||
config.getGeminiClient(),
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
// Only dispatch if not aborted
|
||||
if (!abortController.signal.aborted) {
|
||||
dispatch({
|
||||
type: 'SET_GENERATED_CONTENT',
|
||||
name: generated.name,
|
||||
description: generated.description,
|
||||
systemPrompt: generated.systemPrompt,
|
||||
});
|
||||
onNext();
|
||||
}
|
||||
} finally {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[onNext],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!state.canProceed || state.isGenerating || !buffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputValue = buffer.text.trim();
|
||||
if (!inputValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start LLM generation
|
||||
dispatch({ type: 'SET_GENERATING', isGenerating: true });
|
||||
|
||||
try {
|
||||
if (!config) {
|
||||
throw new Error('Configuration not available');
|
||||
}
|
||||
|
||||
// Use real LLM integration
|
||||
await handleGenerate(inputValue, dispatch, config);
|
||||
} catch (error) {
|
||||
dispatch({ type: 'SET_GENERATING', isGenerating: false });
|
||||
|
||||
// Don't show error if it was cancelled by user
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'SET_VALIDATION_ERRORS',
|
||||
errors: [
|
||||
`Failed to generate subagent: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
],
|
||||
});
|
||||
}
|
||||
}, [
|
||||
state.canProceed,
|
||||
state.isGenerating,
|
||||
buffer,
|
||||
dispatch,
|
||||
config,
|
||||
handleGenerate,
|
||||
]);
|
||||
|
||||
// Handle keyboard input during generation
|
||||
const handleGenerationKeypress = useCallback(
|
||||
(key: Key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
if (abortControllerRef.current) {
|
||||
// Cancel the ongoing generation
|
||||
abortControllerRef.current.abort();
|
||||
dispatch({ type: 'SET_GENERATING', isGenerating: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
// Handle keyboard input for text editing
|
||||
const handleInput = useCallback(
|
||||
(key: Key) => {
|
||||
if (!buffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.SUBMIT](key)) {
|
||||
if (buffer.text.trim()) {
|
||||
const [row, col] = buffer.cursor;
|
||||
const line = buffer.lines[row];
|
||||
const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
|
||||
if (charBefore === '\\') {
|
||||
buffer.backspace();
|
||||
buffer.newline();
|
||||
} else {
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Newline insertion
|
||||
if (keyMatchers[Command.NEWLINE](key)) {
|
||||
buffer.newline();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+A (Home) / Ctrl+E (End)
|
||||
if (keyMatchers[Command.HOME](key)) {
|
||||
buffer.move('home');
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.END](key)) {
|
||||
buffer.move('end');
|
||||
buffer.moveToOffset(cpLen(buffer.text));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+C (Clear input)
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Kill line commands
|
||||
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
|
||||
buffer.killLineRight();
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
|
||||
buffer.killLineLeft();
|
||||
return;
|
||||
}
|
||||
|
||||
// External editor
|
||||
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
|
||||
buffer.openInExternalEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to the text buffer's default input handling for all other keys
|
||||
buffer.handleInput(key);
|
||||
},
|
||||
[buffer, handleSubmit],
|
||||
);
|
||||
|
||||
// Use separate keypress handlers for different states
|
||||
useKeypress(handleGenerationKeypress, {
|
||||
isActive: state.isGenerating,
|
||||
});
|
||||
|
||||
useKeypress(handleInput, {
|
||||
isActive: !state.isGenerating,
|
||||
});
|
||||
|
||||
if (!buffer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const linesToRender = buffer.viewportVisualLines;
|
||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
||||
buffer.visualCursor;
|
||||
const scrollVisualRow = buffer.visualScrollRow;
|
||||
const placeholder =
|
||||
'e.g., Expert code reviewer that reviews code based on best practices...';
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text color={Colors.Gray}>
|
||||
Describe what this subagent should do and when it should be used. (Be
|
||||
comprehensive for best results)
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{state.isGenerating ? (
|
||||
<Box>
|
||||
<Text color={theme.text.accent}>
|
||||
⏳ Generating subagent configuration...
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box>
|
||||
<Text color={theme.text.accent}>{'> '}</Text>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
{buffer.text.length === 0 && placeholder ? (
|
||||
<Text>
|
||||
{chalk.inverse(placeholder.slice(0, 1))}
|
||||
<Text color={Colors.Gray}>{placeholder.slice(1)}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
linesToRender.map((lineText, visualIdxInRenderedSet) => {
|
||||
const cursorVisualRow =
|
||||
cursorVisualRowAbsolute - scrollVisualRow;
|
||||
let display = cpSlice(lineText, 0, inputWidth);
|
||||
const currentVisualWidth = stringWidth(display);
|
||||
if (currentVisualWidth < inputWidth) {
|
||||
display =
|
||||
display + ' '.repeat(inputWidth - currentVisualWidth);
|
||||
}
|
||||
|
||||
if (visualIdxInRenderedSet === cursorVisualRow) {
|
||||
const relativeVisualColForHighlight =
|
||||
cursorVisualColAbsolute;
|
||||
|
||||
if (relativeVisualColForHighlight >= 0) {
|
||||
if (relativeVisualColForHighlight < cpLen(display)) {
|
||||
const charToHighlight =
|
||||
cpSlice(
|
||||
display,
|
||||
relativeVisualColForHighlight,
|
||||
relativeVisualColForHighlight + 1,
|
||||
) || ' ';
|
||||
const highlighted = chalk.inverse(charToHighlight);
|
||||
display =
|
||||
cpSlice(display, 0, relativeVisualColForHighlight) +
|
||||
highlighted +
|
||||
cpSlice(display, relativeVisualColForHighlight + 1);
|
||||
} else if (
|
||||
relativeVisualColForHighlight === cpLen(display) &&
|
||||
cpLen(display) === inputWidth
|
||||
) {
|
||||
display = display + chalk.inverse(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Text key={`line-${visualIdxInRenderedSet}`}>
|
||||
{display}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{state.validationErrors.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
{state.validationErrors.map((error, index) => (
|
||||
<Text key={index} color="red">
|
||||
⚠ {error}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
114
packages/cli/src/ui/components/subagents/ErrorBoundary.tsx
Normal file
114
packages/cli/src/ui/components/subagents/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
errorInfo?: React.ErrorInfo;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary component for graceful error handling in the subagent wizard.
|
||||
*/
|
||||
export class ErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
});
|
||||
|
||||
// Call optional error handler
|
||||
this.props.onError?.(error, errorInfo);
|
||||
|
||||
// Log error for debugging
|
||||
console.error(
|
||||
'SubagentWizard Error Boundary caught an error:',
|
||||
error,
|
||||
errorInfo,
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
// Custom fallback UI or default error display
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text bold color="red">
|
||||
❌ Subagent Wizard Error
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text>
|
||||
An unexpected error occurred in the subagent creation wizard.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{this.state.error && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color="yellow">
|
||||
Error Details:
|
||||
</Text>
|
||||
<Text color="gray" wrap="wrap">
|
||||
{this.state.error.message}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">
|
||||
Press <Text color="cyan">Esc</Text> to close the wizard and try
|
||||
again.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{process.env['NODE_ENV'] === 'development' &&
|
||||
this.state.errorInfo && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color="yellow">
|
||||
Stack Trace (Development):
|
||||
</Text>
|
||||
<Text color="gray" wrap="wrap">
|
||||
{this.state.errorInfo.componentStack}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box } from 'ink';
|
||||
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
|
||||
import { WizardStepProps } from './types.js';
|
||||
|
||||
interface GenerationOption {
|
||||
label: string;
|
||||
value: 'qwen' | 'manual';
|
||||
}
|
||||
|
||||
const generationOptions: GenerationOption[] = [
|
||||
{
|
||||
label: 'Generate with Qwen Code (Recommended)',
|
||||
value: 'qwen',
|
||||
},
|
||||
{
|
||||
label: 'Manual Creation',
|
||||
value: 'manual',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Step 2: Generation method selection.
|
||||
*/
|
||||
export function GenerationMethodSelector({
|
||||
state,
|
||||
dispatch,
|
||||
onNext,
|
||||
onPrevious: _onPrevious,
|
||||
}: WizardStepProps) {
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
const method = selectedValue as 'qwen' | 'manual';
|
||||
dispatch({ type: 'SET_GENERATION_METHOD', method });
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<RadioButtonSelect
|
||||
items={generationOptions.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
}))}
|
||||
initialIndex={generationOptions.findIndex(
|
||||
(opt) => opt.value === state.generationMethod,
|
||||
)}
|
||||
onSelect={handleSelect}
|
||||
isFocused={true}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box } from 'ink';
|
||||
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
|
||||
import { WizardStepProps } from './types.js';
|
||||
|
||||
interface LocationOption {
|
||||
label: string;
|
||||
value: 'project' | 'user';
|
||||
}
|
||||
|
||||
const locationOptions: LocationOption[] = [
|
||||
{
|
||||
label: 'Project Level (.qwen/agents/)',
|
||||
value: 'project',
|
||||
},
|
||||
{
|
||||
label: 'User Level (~/.qwen/agents/)',
|
||||
value: 'user',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Step 1: Location selection for subagent storage.
|
||||
*/
|
||||
export function LocationSelector({ state, dispatch, onNext }: WizardStepProps) {
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
const location = selectedValue as 'project' | 'user';
|
||||
dispatch({ type: 'SET_LOCATION', location });
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<RadioButtonSelect
|
||||
items={locationOptions.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
}))}
|
||||
initialIndex={locationOptions.findIndex(
|
||||
(opt) => opt.value === state.location,
|
||||
)}
|
||||
onSelect={handleSelect}
|
||||
isFocused={true}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useReducer, useCallback, useMemo } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { wizardReducer, initialWizardState } from './wizardReducer.js';
|
||||
import { LocationSelector } from './LocationSelector.js';
|
||||
import { GenerationMethodSelector } from './GenerationMethodSelector.js';
|
||||
import { DescriptionInput } from './DescriptionInput.js';
|
||||
import { ToolSelector } from './ToolSelector.js';
|
||||
import { ColorSelector } from './ColorSelector.js';
|
||||
import { CreationSummary } from './CreationSummary.js';
|
||||
import { ErrorBoundary } from './ErrorBoundary.js';
|
||||
import { WizardStepProps } from './types.js';
|
||||
import { WIZARD_STEPS } from './constants.js';
|
||||
import { Config } from '@qwen-code/qwen-code-core';
|
||||
import { Colors } from '../../colors.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
interface SubagentCreationWizardProps {
|
||||
onClose: () => void;
|
||||
config: Config | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main orchestrator component for the subagent creation wizard.
|
||||
*/
|
||||
export function SubagentCreationWizard({
|
||||
onClose,
|
||||
config,
|
||||
}: SubagentCreationWizardProps) {
|
||||
const [state, dispatch] = useReducer(wizardReducer, initialWizardState);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
dispatch({ type: 'GO_TO_NEXT_STEP' });
|
||||
}, []);
|
||||
|
||||
const handlePrevious = useCallback(() => {
|
||||
dispatch({ type: 'GO_TO_PREVIOUS_STEP' });
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
dispatch({ type: 'RESET_WIZARD' });
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// Centralized ESC key handling for the entire wizard
|
||||
useInput((input, key) => {
|
||||
if (key.escape) {
|
||||
// Step 3 (DescriptionInput) handles its own ESC logic when generating
|
||||
if (
|
||||
state.currentStep === WIZARD_STEPS.DESCRIPTION_INPUT &&
|
||||
state.isGenerating
|
||||
) {
|
||||
return; // Let DescriptionInput handle it
|
||||
}
|
||||
|
||||
if (state.currentStep === WIZARD_STEPS.LOCATION_SELECTION) {
|
||||
// On first step, ESC cancels the entire wizard
|
||||
handleCancel();
|
||||
} else {
|
||||
// On other steps, ESC goes back to previous step
|
||||
handlePrevious();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const stepProps: WizardStepProps = useMemo(
|
||||
() => ({
|
||||
state,
|
||||
dispatch,
|
||||
onNext: handleNext,
|
||||
onPrevious: handlePrevious,
|
||||
onCancel: handleCancel,
|
||||
config,
|
||||
}),
|
||||
[state, dispatch, handleNext, handlePrevious, handleCancel, config],
|
||||
);
|
||||
|
||||
const renderStepHeader = useCallback(() => {
|
||||
const getStepHeaderText = () => {
|
||||
switch (state.currentStep) {
|
||||
case WIZARD_STEPS.LOCATION_SELECTION:
|
||||
return 'Step 1: Choose Location';
|
||||
case WIZARD_STEPS.GENERATION_METHOD:
|
||||
return 'Step 2: Choose Generation Method';
|
||||
case WIZARD_STEPS.DESCRIPTION_INPUT:
|
||||
return 'Step 3: Describe Your Subagent';
|
||||
case WIZARD_STEPS.TOOL_SELECTION:
|
||||
return 'Step 4: Select Tools';
|
||||
case WIZARD_STEPS.COLOR_SELECTION:
|
||||
return 'Step 5: Choose Background Color';
|
||||
case WIZARD_STEPS.FINAL_CONFIRMATION:
|
||||
return 'Step 6: Confirm and Save';
|
||||
default:
|
||||
return 'Unknown Step';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text bold>{getStepHeaderText()}</Text>
|
||||
</Box>
|
||||
);
|
||||
}, [state.currentStep]);
|
||||
|
||||
const renderDebugContent = useCallback(() => {
|
||||
if (process.env['NODE_ENV'] !== 'development') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderStyle="single" borderColor="yellow" padding={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text color="yellow" bold>
|
||||
Debug Info:
|
||||
</Text>
|
||||
<Text color={Colors.Gray}>Step: {state.currentStep}</Text>
|
||||
<Text color={Colors.Gray}>
|
||||
Can Proceed: {state.canProceed ? 'Yes' : 'No'}
|
||||
</Text>
|
||||
<Text color={Colors.Gray}>
|
||||
Generating: {state.isGenerating ? 'Yes' : 'No'}
|
||||
</Text>
|
||||
<Text color={Colors.Gray}>Location: {state.location}</Text>
|
||||
<Text color={Colors.Gray}>Method: {state.generationMethod}</Text>
|
||||
{state.validationErrors.length > 0 && (
|
||||
<Text color="red">Errors: {state.validationErrors.join(', ')}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}, [
|
||||
state.currentStep,
|
||||
state.canProceed,
|
||||
state.isGenerating,
|
||||
state.location,
|
||||
state.generationMethod,
|
||||
state.validationErrors,
|
||||
]);
|
||||
|
||||
const renderStepFooter = useCallback(() => {
|
||||
const getNavigationInstructions = () => {
|
||||
// Special case: During generation in description input step, only show cancel option
|
||||
if (
|
||||
state.currentStep === WIZARD_STEPS.DESCRIPTION_INPUT &&
|
||||
state.isGenerating
|
||||
) {
|
||||
return 'Esc to cancel';
|
||||
}
|
||||
|
||||
if (state.currentStep === WIZARD_STEPS.FINAL_CONFIRMATION) {
|
||||
return 'Press Enter to save, e to save and edit, Esc to go back';
|
||||
}
|
||||
|
||||
// Steps that have ↑↓ navigation (RadioButtonSelect components)
|
||||
const stepsWithNavigation = [
|
||||
WIZARD_STEPS.LOCATION_SELECTION,
|
||||
WIZARD_STEPS.GENERATION_METHOD,
|
||||
WIZARD_STEPS.TOOL_SELECTION,
|
||||
WIZARD_STEPS.COLOR_SELECTION,
|
||||
] as const;
|
||||
|
||||
const hasNavigation = (stepsWithNavigation as readonly number[]).includes(
|
||||
state.currentStep,
|
||||
);
|
||||
const navigationPart = hasNavigation ? '↑↓ to navigate, ' : '';
|
||||
|
||||
const escAction =
|
||||
state.currentStep === WIZARD_STEPS.LOCATION_SELECTION
|
||||
? 'cancel'
|
||||
: 'go back';
|
||||
|
||||
return `Press Enter to continue, ${navigationPart}Esc to ${escAction}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>{getNavigationInstructions()}</Text>
|
||||
</Box>
|
||||
);
|
||||
}, [state.currentStep, state.isGenerating]);
|
||||
|
||||
const renderStepContent = useCallback(() => {
|
||||
switch (state.currentStep) {
|
||||
case WIZARD_STEPS.LOCATION_SELECTION:
|
||||
return <LocationSelector {...stepProps} />;
|
||||
case WIZARD_STEPS.GENERATION_METHOD:
|
||||
return <GenerationMethodSelector {...stepProps} />;
|
||||
case WIZARD_STEPS.DESCRIPTION_INPUT:
|
||||
return <DescriptionInput {...stepProps} />;
|
||||
case WIZARD_STEPS.TOOL_SELECTION:
|
||||
return <ToolSelector {...stepProps} />;
|
||||
case WIZARD_STEPS.COLOR_SELECTION:
|
||||
return <ColorSelector {...stepProps} />;
|
||||
case WIZARD_STEPS.FINAL_CONFIRMATION:
|
||||
return <CreationSummary {...stepProps} />;
|
||||
default:
|
||||
return (
|
||||
<Box>
|
||||
<Text color="red">Invalid step: {state.currentStep}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}, [stepProps, state.currentStep]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
onError={(error, errorInfo) => {
|
||||
// Additional error handling if needed
|
||||
console.error('Subagent wizard error:', error, errorInfo);
|
||||
}}
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
{/* Main content wrapped in bounding box */}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={Colors.Gray}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
gap={1}
|
||||
>
|
||||
{renderStepHeader()}
|
||||
{renderStepContent()}
|
||||
{renderDebugContent()}
|
||||
{renderStepFooter()}
|
||||
</Box>
|
||||
</Box>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
200
packages/cli/src/ui/components/subagents/ToolSelector.tsx
Normal file
200
packages/cli/src/ui/components/subagents/ToolSelector.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useMemo } 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 { Colors } from '../../colors.js';
|
||||
|
||||
interface ToolOption {
|
||||
label: string;
|
||||
value: string;
|
||||
category: ToolCategory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 4: Tool selection with categories.
|
||||
*/
|
||||
export function ToolSelector({
|
||||
state: _state,
|
||||
dispatch,
|
||||
onNext,
|
||||
onPrevious: _onPrevious,
|
||||
config,
|
||||
}: WizardStepProps) {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
|
||||
// Generate tool categories from actual tool registry
|
||||
const { toolCategories, readTools, editTools, executeTools } = useMemo(() => {
|
||||
if (!config) {
|
||||
// Fallback categories if config not available
|
||||
return {
|
||||
toolCategories: [
|
||||
{
|
||||
id: 'all',
|
||||
name: 'All Tools (Default)',
|
||||
tools: [],
|
||||
},
|
||||
],
|
||||
readTools: [],
|
||||
editTools: [],
|
||||
executeTools: [],
|
||||
};
|
||||
}
|
||||
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
const allTools = toolRegistry.getAllTools();
|
||||
|
||||
// Categorize tools by Kind
|
||||
const readTools = allTools
|
||||
.filter(
|
||||
(tool) =>
|
||||
tool.kind === Kind.Read ||
|
||||
tool.kind === Kind.Search ||
|
||||
tool.kind === Kind.Fetch,
|
||||
)
|
||||
.map((tool) => tool.displayName)
|
||||
.sort();
|
||||
|
||||
const editTools = allTools
|
||||
.filter(
|
||||
(tool) =>
|
||||
tool.kind === Kind.Edit ||
|
||||
tool.kind === Kind.Delete ||
|
||||
tool.kind === Kind.Move ||
|
||||
tool.kind === Kind.Think,
|
||||
)
|
||||
.map((tool) => tool.displayName)
|
||||
.sort();
|
||||
|
||||
const executeTools = allTools
|
||||
.filter((tool) => tool.kind === Kind.Execute)
|
||||
.map((tool) => tool.displayName)
|
||||
.sort();
|
||||
|
||||
const toolCategories = [
|
||||
{
|
||||
id: 'all',
|
||||
name: 'All Tools',
|
||||
tools: [],
|
||||
},
|
||||
{
|
||||
id: 'read',
|
||||
name: 'Read-only Tools',
|
||||
tools: readTools,
|
||||
},
|
||||
{
|
||||
id: 'edit',
|
||||
name: 'Read & Edit Tools',
|
||||
tools: [...readTools, ...editTools],
|
||||
},
|
||||
{
|
||||
id: 'execute',
|
||||
name: 'Read & Edit & Execution Tools',
|
||||
tools: [...readTools, ...editTools, ...executeTools],
|
||||
},
|
||||
].filter((category) => category.id === 'all' || category.tools.length > 0);
|
||||
|
||||
return { toolCategories, readTools, editTools, executeTools };
|
||||
}, [config]);
|
||||
|
||||
const toolOptions: ToolOption[] = toolCategories.map((category) => ({
|
||||
label: category.name,
|
||||
value: category.id,
|
||||
category,
|
||||
}));
|
||||
|
||||
const handleHighlight = (selectedValue: string) => {
|
||||
setSelectedCategory(selectedValue);
|
||||
};
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
const category = toolCategories.find((cat) => cat.id === selectedValue);
|
||||
if (category) {
|
||||
if (category.id === 'all') {
|
||||
dispatch({ type: 'SET_TOOLS', tools: 'all' });
|
||||
} else {
|
||||
dispatch({ type: 'SET_TOOLS', tools: category.tools });
|
||||
}
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
|
||||
// Get the currently selected category for displaying tools
|
||||
const currentCategory = toolCategories.find(
|
||||
(cat) => cat.id === selectedCategory,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<RadioButtonSelect
|
||||
items={toolOptions.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
}))}
|
||||
initialIndex={toolOptions.findIndex(
|
||||
(opt) => opt.value === selectedCategory,
|
||||
)}
|
||||
onSelect={handleSelect}
|
||||
onHighlight={handleHighlight}
|
||||
isFocused={true}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Show help information or tools for selected category */}
|
||||
{currentCategory && (
|
||||
<Box flexDirection="column">
|
||||
{currentCategory.id === 'all' ? (
|
||||
<Text color={Colors.Gray}>
|
||||
All tools selected, including MCP tools
|
||||
</Text>
|
||||
) : currentCategory.tools.length > 0 ? (
|
||||
<>
|
||||
<Text color={Colors.Gray}>Selected tools:</Text>
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
{(() => {
|
||||
// Filter the already categorized tools to show only those in current category
|
||||
const categoryReadTools = currentCategory.tools.filter(
|
||||
(tool) => readTools.includes(tool),
|
||||
);
|
||||
const categoryEditTools = currentCategory.tools.filter(
|
||||
(tool) => editTools.includes(tool),
|
||||
);
|
||||
const categoryExecuteTools = currentCategory.tools.filter(
|
||||
(tool) => executeTools.includes(tool),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{categoryReadTools.length > 0 && (
|
||||
<Text color={Colors.Gray}>
|
||||
• Read-only tools: {categoryReadTools.join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
{categoryEditTools.length > 0 && (
|
||||
<Text color={Colors.Gray}>
|
||||
• Edit tools: {categoryEditTools.join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
{categoryExecuteTools.length > 0 && (
|
||||
<Text color={Colors.Gray}>
|
||||
• Execution tools: {categoryExecuteTools.join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
39
packages/cli/src/ui/components/subagents/constants.ts
Normal file
39
packages/cli/src/ui/components/subagents/constants.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Constants for the subagent creation wizard.
|
||||
*/
|
||||
|
||||
// Wizard step numbers
|
||||
export const WIZARD_STEPS = {
|
||||
LOCATION_SELECTION: 1,
|
||||
GENERATION_METHOD: 2,
|
||||
DESCRIPTION_INPUT: 3,
|
||||
TOOL_SELECTION: 4,
|
||||
COLOR_SELECTION: 5,
|
||||
FINAL_CONFIRMATION: 6,
|
||||
} as const;
|
||||
|
||||
// Total number of wizard steps
|
||||
export const TOTAL_WIZARD_STEPS = 6;
|
||||
|
||||
// Step names for display
|
||||
export const STEP_NAMES: Record<number, string> = {
|
||||
[WIZARD_STEPS.LOCATION_SELECTION]: 'Location Selection',
|
||||
[WIZARD_STEPS.GENERATION_METHOD]: 'Generation Method',
|
||||
[WIZARD_STEPS.DESCRIPTION_INPUT]: 'Description Input',
|
||||
[WIZARD_STEPS.TOOL_SELECTION]: 'Tool Selection',
|
||||
[WIZARD_STEPS.COLOR_SELECTION]: 'Color Selection',
|
||||
[WIZARD_STEPS.FINAL_CONFIRMATION]: 'Final Confirmation',
|
||||
};
|
||||
|
||||
// UI constants
|
||||
export const UI = {
|
||||
AUTO_CLOSE_DELAY_MS: 2000,
|
||||
PROGRESS_BAR_FILLED: '█',
|
||||
PROGRESS_BAR_EMPTY: '░',
|
||||
} as const;
|
||||
24
packages/cli/src/ui/components/subagents/index.ts
Normal file
24
packages/cli/src/ui/components/subagents/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export { SubagentCreationWizard } from './SubagentCreationWizard.js';
|
||||
export { LocationSelector } from './LocationSelector.js';
|
||||
export { GenerationMethodSelector } from './GenerationMethodSelector.js';
|
||||
export { DescriptionInput } from './DescriptionInput.js';
|
||||
export { ToolSelector } from './ToolSelector.js';
|
||||
export { ColorSelector } from './ColorSelector.js';
|
||||
export { CreationSummary } from './CreationSummary.js';
|
||||
|
||||
export type {
|
||||
CreationWizardState,
|
||||
WizardAction,
|
||||
WizardStepProps,
|
||||
WizardResult,
|
||||
ToolCategory,
|
||||
ColorOption,
|
||||
} from './types.js';
|
||||
|
||||
export { wizardReducer, initialWizardState } from './wizardReducer.js';
|
||||
112
packages/cli/src/ui/components/subagents/types.ts
Normal file
112
packages/cli/src/ui/components/subagents/types.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { SubagentLevel, Config } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* State management for the subagent creation wizard.
|
||||
*/
|
||||
export interface CreationWizardState {
|
||||
/** Current step in the wizard (1-6) */
|
||||
currentStep: number;
|
||||
|
||||
/** Storage location for the subagent */
|
||||
location: SubagentLevel;
|
||||
|
||||
/** Generation method selection */
|
||||
generationMethod: 'qwen' | 'manual';
|
||||
|
||||
/** User's description input for the subagent */
|
||||
userDescription: string;
|
||||
|
||||
/** LLM-generated system prompt */
|
||||
generatedSystemPrompt: string;
|
||||
|
||||
/** LLM-generated refined description */
|
||||
generatedDescription: string;
|
||||
|
||||
/** Generated subagent name */
|
||||
generatedName: string;
|
||||
|
||||
/** Selected tools for the subagent */
|
||||
selectedTools: string[] | 'all';
|
||||
|
||||
/** Background color for runtime display */
|
||||
backgroundColor: string;
|
||||
|
||||
/** Whether LLM generation is in progress */
|
||||
isGenerating: boolean;
|
||||
|
||||
/** Validation errors for current step */
|
||||
validationErrors: string[];
|
||||
|
||||
/** Whether the wizard can proceed to next step */
|
||||
canProceed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool categories for organized selection.
|
||||
*/
|
||||
export interface ToolCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
tools: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined color options for subagent display.
|
||||
*/
|
||||
export interface ColorOption {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions that can be dispatched to update wizard state.
|
||||
*/
|
||||
export type WizardAction =
|
||||
| { type: 'SET_STEP'; step: number }
|
||||
| { type: 'SET_LOCATION'; location: SubagentLevel }
|
||||
| { type: 'SET_GENERATION_METHOD'; method: 'qwen' | 'manual' }
|
||||
| { type: 'SET_USER_DESCRIPTION'; description: string }
|
||||
| {
|
||||
type: 'SET_GENERATED_CONTENT';
|
||||
name: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
}
|
||||
| { type: 'SET_TOOLS'; tools: string[] | 'all' }
|
||||
| { type: 'SET_BACKGROUND_COLOR'; color: string }
|
||||
| { type: 'SET_GENERATING'; isGenerating: boolean }
|
||||
| { type: 'SET_VALIDATION_ERRORS'; errors: string[] }
|
||||
| { type: 'RESET_WIZARD' }
|
||||
| { type: 'GO_TO_PREVIOUS_STEP' }
|
||||
| { type: 'GO_TO_NEXT_STEP' };
|
||||
|
||||
/**
|
||||
* Props for wizard step components.
|
||||
*/
|
||||
export interface WizardStepProps {
|
||||
state: CreationWizardState;
|
||||
dispatch: (action: WizardAction) => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
onCancel: () => void;
|
||||
config: Config | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the wizard completion.
|
||||
*/
|
||||
export interface WizardResult {
|
||||
name: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
location: SubagentLevel;
|
||||
tools?: string[];
|
||||
backgroundColor: string;
|
||||
}
|
||||
106
packages/cli/src/ui/components/subagents/validation.ts
Normal file
106
packages/cli/src/ui/components/subagents/validation.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @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,
|
||||
};
|
||||
}
|
||||
165
packages/cli/src/ui/components/subagents/wizardReducer.ts
Normal file
165
packages/cli/src/ui/components/subagents/wizardReducer.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CreationWizardState, WizardAction } from './types.js';
|
||||
import { WIZARD_STEPS, TOTAL_WIZARD_STEPS } from './constants.js';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user