feat: subagent creation dialog - continued

This commit is contained in:
tanzhenxin
2025-09-04 11:07:42 +08:00
parent 5d8874205d
commit 9fcc7a4cbe
6 changed files with 273 additions and 122 deletions

View File

@@ -4,12 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useCallback, useState, useEffect } from 'react';
import { Box, Text, useInput, useStdin } 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';
import {
SubagentManager,
SubagentConfig,
EditorType,
} from '@qwen-code/qwen-code-core';
import { useSettings } from '../../contexts/SettingsContext.js';
import { spawnSync } from 'child_process';
import { theme } from '../../semantic-colors.js';
/**
* Step 6: Final confirmation and actions.
@@ -18,87 +24,246 @@ export function CreationSummary({
state,
onPrevious: _onPrevious,
onCancel,
config,
}: WizardStepProps) {
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const [warnings, setWarnings] = useState<string[]>([]);
const settings = useSettings();
const { stdin, setRawMode } = useStdin();
const truncateText = (text: string, maxLength: number): string => {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
};
// Check for warnings
useEffect(() => {
const checkWarnings = async () => {
if (!config || !state.generatedName) return;
const allWarnings: string[] = [];
try {
// Get project root from config
const projectRoot = config.getProjectRoot();
const subagentManager = new SubagentManager(projectRoot);
// Check for name conflicts
const isAvailable = await subagentManager.isNameAvailable(
state.generatedName,
);
if (!isAvailable) {
const existing = await subagentManager.loadSubagent(
state.generatedName,
);
if (existing) {
const conflictLevel =
existing.level === 'project' ? 'project' : 'user';
const targetLevel = state.location;
if (conflictLevel === targetLevel) {
allWarnings.push(
`Name "${state.generatedName}" already exists at ${conflictLevel} level - will overwrite existing subagent`,
);
} else if (targetLevel === 'project') {
allWarnings.push(
`Name "${state.generatedName}" exists at user level - project level will take precedence`,
);
} else {
allWarnings.push(
`Name "${state.generatedName}" exists at project level - existing subagent will take precedence`,
);
}
}
}
} catch (error) {
// Silently handle errors in warning checks
console.warn('Error checking subagent name availability:', error);
}
// Check length warnings
if (state.generatedDescription.length > 300) {
allWarnings.push(
`Description is over ${state.generatedDescription.length} characters`,
);
}
if (state.generatedSystemPrompt.length > 10000) {
allWarnings.push(
`System prompt is over ${state.generatedSystemPrompt.length} characters`,
);
}
setWarnings(allWarnings);
};
checkWarnings();
}, [
config,
state.generatedName,
state.generatedDescription,
state.generatedSystemPrompt,
state.location,
]);
const toolsDisplay = Array.isArray(state.selectedTools)
? state.selectedTools.join(', ')
: 'All available tools';
: '*';
// Common method to save subagent configuration
const saveSubagent = useCallback(async (): Promise<SubagentManager> => {
// Validate configuration before saving
const configToValidate = {
name: state.generatedName,
description: state.generatedDescription,
systemPrompt: state.generatedSystemPrompt,
tools: state.selectedTools,
};
const validation = validateSubagentConfig(configToValidate);
if (!validation.isValid) {
throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
}
// Create SubagentManager instance
if (!config) {
throw new Error('Configuration not available');
}
const projectRoot = config.getProjectRoot();
const subagentManager = new SubagentManager(projectRoot);
// Build subagent configuration
const subagentConfig: 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,
};
// Create the subagent
await subagentManager.createSubagent(subagentConfig, {
level: state.location,
overwrite: true,
});
return subagentManager;
}, [state, config]);
// Common method to show success and auto-close
const showSuccessAndClose = useCallback(() => {
setSaveSuccess(true);
// Auto-close after successful save
setTimeout(() => {
onCancel();
}, 2000);
}, [onCancel]);
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);
await saveSubagent();
showSuccessAndClose();
} catch (error) {
setSaveError(
error instanceof Error ? error.message : 'Unknown error occurred',
);
} finally {
setIsSaving(false);
}
}, [state, isSaving, onCancel]);
}, [saveSubagent, showSuccessAndClose]);
const handleEdit = useCallback(() => {
// TODO: Implement system editor integration
setSaveError('Edit functionality not yet implemented');
}, []);
const handleEdit = useCallback(async () => {
// Clear any previous error messages
setSaveError(null);
try {
// Save the subagent to file first using shared logic
const subagentManager = await saveSubagent();
// Get the file path of the created subagent
const subagentFilePath = subagentManager.getSubagentPath(
state.generatedName,
state.location,
);
// Determine editor to use
const preferredEditor = settings.merged.preferredEditor as
| EditorType
| undefined;
let editor: string;
if (preferredEditor) {
editor = preferredEditor;
} else {
// Platform-specific defaults with UI preference for macOS
switch (process.platform) {
case 'darwin':
editor = 'open -t'; // TextEdit in plain text mode
break;
case 'win32':
editor = 'notepad';
break;
default:
editor = process.env['VISUAL'] || process.env['EDITOR'] || 'vi';
break;
}
}
// Launch editor with the actual subagent file
const wasRaw = stdin?.isRaw ?? false;
try {
setRawMode?.(false);
// Handle different editor command formats
let editorCommand: string;
let editorArgs: string[];
if (editor === 'open -t') {
// macOS TextEdit in plain text mode
editorCommand = 'open';
editorArgs = ['-t', subagentFilePath];
} else {
// Standard editor command
editorCommand = editor;
editorArgs = [subagentFilePath];
}
const { status, error } = spawnSync(editorCommand, editorArgs, {
stdio: 'inherit',
});
if (error) throw error;
if (typeof status === 'number' && status !== 0) {
throw new Error(`Editor exited with status ${status}`);
}
// Show success UI and auto-close after successful edit
showSuccessAndClose();
} finally {
if (wasRaw) setRawMode?.(true);
}
} catch (error) {
setSaveError(
`Failed to save and edit subagent: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}, [
saveSubagent,
showSuccessAndClose,
state.generatedName,
state.location,
settings.merged.preferredEditor,
stdin,
setRawMode,
]);
// Handle keyboard input
useInput((input, key) => {
if (isSaving || saveSuccess) return;
if (saveSuccess) return;
if (key.return || input === 's') {
handleSave();
@@ -115,7 +280,7 @@ export function CreationSummary({
return (
<Box flexDirection="column" gap={1}>
<Box>
<Text bold color="green">
<Text bold color={theme.status.success}>
Subagent Created Successfully!
</Text>
</Box>
@@ -125,45 +290,14 @@ export function CreationSummary({
{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 >
<Box flexDirection="column">
<Box>
<Text bold>Name: </Text>
<Text>{state.generatedName}</Text>
</Box>
@@ -185,30 +319,49 @@ export function CreationSummary({
<Box marginTop={1}>
<Text bold>Description:</Text>
</Box>
<Box>
<Box padding={1} paddingBottom={0}>
<Text wrap="wrap">
{truncateText(state.generatedDescription, 200)}
{truncateText(state.generatedDescription, 250)}
</Text>
</Box>
<Box marginTop={1}>
<Text bold>System Prompt:</Text>
</Box>
<Box>
<Box padding={1} paddingBottom={0}>
<Text wrap="wrap">
{truncateText(state.generatedSystemPrompt, 200)}
</Text>
</Box>
<Box marginTop={1}>
<Text bold>Background Color: </Text>
<Text>
{state.backgroundColor === 'auto'
? 'Automatic'
: state.backgroundColor}
{truncateText(state.generatedSystemPrompt, 250)}
</Text>
</Box>
</Box>
{saveError && (
<Box flexDirection="column">
<Text bold color="red">
Error saving subagent:
</Text>
<Box flexDirection="column" padding={1} paddingBottom={0}>
<Text color="red" wrap="wrap">
{saveError}
</Text>
</Box>
</Box>
)}
{warnings.length > 0 && (
<Box flexDirection="column">
<Text bold color={theme.status.warning}>
Warnings:
</Text>
<Box flexDirection="column" padding={1} paddingBottom={0}>
{warnings.map((warning, index) => (
<Text key={index} color={theme.status.warning} wrap="wrap">
{warning}
</Text>
))}
</Box>
</Box>
)}
</Box>
);
}

View File

@@ -30,17 +30,22 @@ export function DescriptionInput({
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 handleTextChange = useCallback(
(text: string) => {
const sanitized = sanitizeInput(text);
dispatch({
type: 'SET_USER_DESCRIPTION',
description: sanitized,
});
},
[dispatch],
);
const buffer = useTextBuffer({
initialText: state.userDescription || '',
viewport: { height: 10, width: inputWidth },
isValidPath: () => false, // For subagent description, we don't need file path validation
onChange: handleTextChange,
});
const handleGenerate = useCallback(

View File

@@ -30,10 +30,3 @@ export const STEP_NAMES: Record<number, string> = {
[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

@@ -539,7 +539,7 @@ export class SubagentManager {
* @param level - Storage level
* @returns Absolute file path
*/
private getSubagentPath(name: string, level: SubagentLevel): string {
getSubagentPath(name: string, level: SubagentLevel): string {
const baseDir =
level === 'project'
? path.join(this.projectRoot, QWEN_CONFIG_DIR, AGENT_CONFIG_DIR)

View File

@@ -193,7 +193,7 @@ describe('MemoryTool', () => {
it('should have correct name, displayName, description, and schema', () => {
expect(memoryTool.name).toBe('save_memory');
expect(memoryTool.displayName).toBe('Save Memory');
expect(memoryTool.displayName).toBe('SaveMemory');
expect(memoryTool.description).toContain(
'Saves a specific piece of information',
);

View File

@@ -242,7 +242,7 @@ describe('TodoWriteTool', () => {
});
it('should have correct display name', () => {
expect(tool.displayName).toBe('Todo Write');
expect(tool.displayName).toBe('TodoWrite');
});
it('should have correct kind', () => {