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 * SPDX-License-Identifier: Apache-2.0
*/ */
import { useCallback, useState } from 'react'; import { useCallback, useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text, useInput, useStdin } from 'ink';
import { WizardStepProps } from './types.js'; import { WizardStepProps } from './types.js';
import { UI } from './constants.js';
import { validateSubagentConfig } from './validation.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. * Step 6: Final confirmation and actions.
@@ -18,27 +24,95 @@ export function CreationSummary({
state, state,
onPrevious: _onPrevious, onPrevious: _onPrevious,
onCancel, onCancel,
config,
}: WizardStepProps) { }: WizardStepProps) {
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false);
const [warnings, setWarnings] = useState<string[]>([]);
const settings = useSettings();
const { stdin, setRawMode } = useStdin();
const truncateText = (text: string, maxLength: number): string => { const truncateText = (text: string, maxLength: number): string => {
if (text.length <= maxLength) return text; if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...'; return text.substring(0, maxLength - 3) + '...';
}; };
const toolsDisplay = Array.isArray(state.selectedTools) // Check for warnings
? state.selectedTools.join(', ') useEffect(() => {
: 'All available tools'; const checkWarnings = async () => {
if (!config || !state.generatedName) return;
const handleSave = useCallback(async () => { const allWarnings: string[] = [];
if (isSaving) return;
setIsSaving(true);
setSaveError(null);
try { 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(', ')
: '*';
// Common method to save subagent configuration
const saveSubagent = useCallback(async (): Promise<SubagentManager> => {
// Validate configuration before saving // Validate configuration before saving
const configToValidate = { const configToValidate = {
name: state.generatedName, name: state.generatedName,
@@ -53,12 +127,14 @@ export function CreationSummary({
} }
// Create SubagentManager instance // Create SubagentManager instance
// TODO: Get project root from config or context if (!config) {
const projectRoot = process.cwd(); throw new Error('Configuration not available');
}
const projectRoot = config.getProjectRoot();
const subagentManager = new SubagentManager(projectRoot); const subagentManager = new SubagentManager(projectRoot);
// Build subagent configuration // Build subagent configuration
const config: SubagentConfig = { const subagentConfig: SubagentConfig = {
name: state.generatedName, name: state.generatedName,
description: state.generatedDescription, description: state.generatedDescription,
systemPrompt: state.generatedSystemPrompt, systemPrompt: state.generatedSystemPrompt,
@@ -67,38 +143,127 @@ export function CreationSummary({
tools: Array.isArray(state.selectedTools) tools: Array.isArray(state.selectedTools)
? state.selectedTools ? state.selectedTools
: undefined, : undefined,
// TODO: Add modelConfig and runConfig if needed
}; };
// Create the subagent // Create the subagent
await subagentManager.createSubagent(config, { await subagentManager.createSubagent(subagentConfig, {
level: state.location, level: state.location,
overwrite: false, overwrite: true,
}); });
setSaveSuccess(true); return subagentManager;
}, [state, config]);
// Common method to show success and auto-close
const showSuccessAndClose = useCallback(() => {
setSaveSuccess(true);
// Auto-close after successful save // Auto-close after successful save
setTimeout(() => { setTimeout(() => {
onCancel(); onCancel();
}, UI.AUTO_CLOSE_DELAY_MS); }, 2000);
}, [onCancel]);
const handleSave = useCallback(async () => {
setSaveError(null);
try {
await saveSubagent();
showSuccessAndClose();
} catch (error) { } catch (error) {
setSaveError( setSaveError(
error instanceof Error ? error.message : 'Unknown error occurred', error instanceof Error ? error.message : 'Unknown error occurred',
); );
} finally {
setIsSaving(false);
} }
}, [state, isSaving, onCancel]); }, [saveSubagent, showSuccessAndClose]);
const handleEdit = useCallback(() => { const handleEdit = useCallback(async () => {
// TODO: Implement system editor integration // Clear any previous error messages
setSaveError('Edit functionality not yet implemented'); 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 // Handle keyboard input
useInput((input, key) => { useInput((input, key) => {
if (isSaving || saveSuccess) return; if (saveSuccess) return;
if (key.return || input === 's') { if (key.return || input === 's') {
handleSave(); handleSave();
@@ -115,7 +280,7 @@ export function CreationSummary({
return ( return (
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
<Box> <Box>
<Text bold color="green"> <Text bold color={theme.status.success}>
Subagent Created Successfully! Subagent Created Successfully!
</Text> </Text>
</Box> </Box>
@@ -125,45 +290,14 @@ export function CreationSummary({
{state.location} level. {state.location} level.
</Text> </Text>
</Box> </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> </Box>
); );
} }
return ( return (
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
{saveError && ( <Box flexDirection="column">
<Box flexDirection="column" marginTop={1}> <Box>
<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 bold>Name: </Text>
<Text>{state.generatedName}</Text> <Text>{state.generatedName}</Text>
</Box> </Box>
@@ -185,30 +319,49 @@ export function CreationSummary({
<Box marginTop={1}> <Box marginTop={1}>
<Text bold>Description:</Text> <Text bold>Description:</Text>
</Box> </Box>
<Box> <Box padding={1} paddingBottom={0}>
<Text wrap="wrap"> <Text wrap="wrap">
{truncateText(state.generatedDescription, 200)} {truncateText(state.generatedDescription, 250)}
</Text> </Text>
</Box> </Box>
<Box marginTop={1}> <Box marginTop={1}>
<Text bold>System Prompt:</Text> <Text bold>System Prompt:</Text>
</Box> </Box>
<Box> <Box padding={1} paddingBottom={0}>
<Text wrap="wrap"> <Text wrap="wrap">
{truncateText(state.generatedSystemPrompt, 200)} {truncateText(state.generatedSystemPrompt, 250)}
</Text> </Text>
</Box> </Box>
</Box>
<Box marginTop={1}> {saveError && (
<Text bold>Background Color: </Text> <Box flexDirection="column">
<Text> <Text bold color="red">
{state.backgroundColor === 'auto' Error saving subagent:
? 'Automatic' </Text>
: state.backgroundColor} <Box flexDirection="column" padding={1} paddingBottom={0}>
<Text color="red" wrap="wrap">
{saveError}
</Text> </Text>
</Box> </Box>
</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> </Box>
); );
} }

View File

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

View File

@@ -30,10 +30,3 @@ export const STEP_NAMES: Record<number, string> = {
[WIZARD_STEPS.COLOR_SELECTION]: 'Color Selection', [WIZARD_STEPS.COLOR_SELECTION]: 'Color Selection',
[WIZARD_STEPS.FINAL_CONFIRMATION]: 'Final Confirmation', [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 * @param level - Storage level
* @returns Absolute file path * @returns Absolute file path
*/ */
private getSubagentPath(name: string, level: SubagentLevel): string { getSubagentPath(name: string, level: SubagentLevel): string {
const baseDir = const baseDir =
level === 'project' level === 'project'
? path.join(this.projectRoot, QWEN_CONFIG_DIR, AGENT_CONFIG_DIR) ? path.join(this.projectRoot, QWEN_CONFIG_DIR, AGENT_CONFIG_DIR)

View File

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

View File

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