feat: subagent phase 2 implementation

This commit is contained in:
tanzhenxin
2025-09-03 19:17:29 +08:00
parent c49e4f6e8a
commit 5d8874205d
33 changed files with 2435 additions and 21 deletions

View File

@@ -24,6 +24,7 @@ import { useQwenAuth } from './hooks/useQwenAuth.js';
import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useMessageQueue } from './hooks/useMessageQueue.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
@@ -41,6 +42,7 @@ import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
import { FolderTrustDialog } from './components/FolderTrustDialog.js';
import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
import { SubagentCreationWizard } from './components/subagents/SubagentCreationWizard.js';
import { Colors } from './colors.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js';
import { LoadedSettings, SettingScope } from '../config/settings.js';
@@ -269,6 +271,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
useSettingsCommand();
const {
isSubagentCreateDialogOpen,
openSubagentCreateDialog,
closeSubagentCreateDialog,
} = useSubagentCreateDialog();
const { isFolderTrustDialogOpen, handleFolderTrustSelect } = useFolderTrust(
settings,
setIsTrustedFolder,
@@ -565,6 +573,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
setQuittingMessages,
openPrivacyNotice,
openSettingsDialog,
openSubagentCreateDialog,
toggleVimEnabled,
setIsProcessing,
setGeminiMdFileCount,
@@ -894,6 +903,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
!isAuthDialogOpen &&
!isThemeDialogOpen &&
!isEditorDialogOpen &&
!isSubagentCreateDialogOpen &&
!showPrivacyNotice &&
geminiClient?.isInitialized?.()
) {
@@ -907,6 +917,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
isAuthDialogOpen,
isThemeDialogOpen,
isEditorDialogOpen,
isSubagentCreateDialogOpen,
showPrivacyNotice,
geminiClient,
]);
@@ -1069,6 +1080,13 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
onRestartRequest={() => process.exit(0)}
/>
</Box>
) : isSubagentCreateDialogOpen ? (
<Box flexDirection="column">
<SubagentCreationWizard
onClose={closeSubagentCreateDialog}
config={config}
/>
</Box>
) : isAuthenticating ? (
<>
{isQwenAuth && isQwenAuthenticating ? (

View File

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

View File

@@ -104,7 +104,14 @@ export interface MessageActionReturn {
export interface OpenDialogActionReturn {
type: 'dialog';
dialog: 'help' | 'auth' | 'theme' | 'editor' | 'privacy' | 'settings';
dialog:
| 'help'
| 'auth'
| 'theme'
| 'editor'
| 'privacy'
| 'settings'
| 'subagent_create';
}
/**

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

View 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 &quot;{state.generatedName}&quot; 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 &quot;{state.generatedName}&quot;...</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>
);
}

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

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

@@ -144,8 +144,10 @@ describe('useSlashCommandProcessor', () => {
mockSetQuittingMessages,
vi.fn(), // openPrivacyNotice
vi.fn(), // openSettingsDialog
vi.fn(), // openSubagentCreateDialog
vi.fn(), // toggleVimEnabled
setIsProcessing,
vi.fn(), // setGeminiMdFileCount
),
);
@@ -894,11 +896,11 @@ describe('useSlashCommandProcessor', () => {
vi.fn(), // toggleCorgiMode
mockSetQuittingMessages,
vi.fn(), // openPrivacyNotice
vi.fn(), // openSettingsDialog
vi.fn(), // openSubagentCreateDialog
vi.fn(), // toggleVimEnabled
vi.fn().mockResolvedValue(false), // toggleVimEnabled
vi.fn(), // setIsProcessing
vi.fn(), // setGeminiMdFileCount
),
);

View File

@@ -51,6 +51,7 @@ export const useSlashCommandProcessor = (
setQuittingMessages: (message: HistoryItem[]) => void,
openPrivacyNotice: () => void,
openSettingsDialog: () => void,
openSubagentCreateDialog: () => void,
toggleVimEnabled: () => Promise<boolean>,
setIsProcessing: (isProcessing: boolean) => void,
setGeminiMdFileCount: (count: number) => void,
@@ -379,6 +380,9 @@ export const useSlashCommandProcessor = (
case 'settings':
openSettingsDialog();
return { type: 'handled' };
case 'subagent_create':
openSubagentCreateDialog();
return { type: 'handled' };
case 'help':
return { type: 'handled' };
default: {
@@ -553,6 +557,7 @@ export const useSlashCommandProcessor = (
openEditorDialog,
setQuittingMessages,
openSettingsDialog,
openSubagentCreateDialog,
setShellConfirmationRequest,
setSessionShellAllowlist,
setIsProcessing,

View File

@@ -0,0 +1,26 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
export function useSubagentCreateDialog() {
const [isSubagentCreateDialogOpen, setIsSubagentCreateDialogOpen] =
useState(false);
const openSubagentCreateDialog = useCallback(() => {
setIsSubagentCreateDialogOpen(true);
}, []);
const closeSubagentCreateDialog = useCallback(() => {
setIsSubagentCreateDialogOpen(false);
}, []);
return {
isSubagentCreateDialogOpen,
openSubagentCreateDialog,
closeSubagentCreateDialog,
};
}