feat: subagent feature - add manual creation subagent steps

This commit is contained in:
tanzhenxin
2025-09-10 15:26:47 +08:00
parent 4839cb9320
commit 22dfefc9f1
8 changed files with 504 additions and 234 deletions

View File

@@ -0,0 +1,194 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
// no hooks needed beyond keypress handled inside
import { Box, Text } from 'ink';
import chalk from 'chalk';
import stringWidth from 'string-width';
import { useTextBuffer } from './text-buffer.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { cpSlice, cpLen } from '../../utils/textUtils.js';
import { theme } from '../../semantic-colors.js';
import { Colors } from '../../colors.js';
import type { Key } from '../../hooks/useKeypress.js';
import { useCallback, useRef, useEffect } from 'react';
export interface TextInputProps {
value: string;
onChange: (text: string) => void;
onSubmit?: () => void;
placeholder?: string;
height?: number; // lines in viewport; >1 enables multiline
isActive?: boolean; // when false, ignore keypresses
validationErrors?: string[];
inputWidth?: number;
}
export function TextInput({
value,
onChange,
onSubmit,
placeholder,
height = 1,
isActive = true,
validationErrors = [],
inputWidth = 80,
}: TextInputProps) {
const allowMultiline = height > 1;
// Stabilize onChange to avoid triggering useTextBuffer's onChange effect every render
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
const stableOnChange = useCallback((text: string) => {
onChangeRef.current?.(text);
}, []);
const buffer = useTextBuffer({
initialText: value || '',
viewport: { height, width: inputWidth },
isValidPath: () => false,
onChange: stableOnChange,
});
const handleSubmit = () => {
if (!onSubmit) return;
onSubmit();
};
useKeypress(
(key: Key) => {
if (!buffer || !isActive) return;
// Submit on Enter
if (keyMatchers[Command.SUBMIT](key) || key.name === 'return') {
if (allowMultiline) {
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();
}
} else {
handleSubmit();
}
return;
}
// Multiline newline insertion (Shift+Enter etc.)
if (allowMultiline && keyMatchers[Command.NEWLINE](key)) {
buffer.newline();
return;
}
// Navigation helpers
if (keyMatchers[Command.HOME](key)) {
buffer.move('home');
return;
}
if (keyMatchers[Command.END](key)) {
buffer.move('end');
buffer.moveToOffset(cpLen(buffer.text));
return;
}
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) buffer.setText('');
return;
}
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
buffer.killLineRight();
return;
}
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
buffer.killLineLeft();
return;
}
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
buffer.openInExternalEditor();
return;
}
buffer.handleInput(key);
},
{ isActive },
);
if (!buffer) return null;
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow;
return (
<Box flexDirection="column" gap={1}>
<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>
{validationErrors.length > 0 && (
<Box flexDirection="column">
{validationErrors.map((error, index) => (
<Text key={index} color={theme.status.error}>
{error}
</Text>
))}
</Box>
)}
</Box>
);
}

View File

@@ -15,9 +15,11 @@ import { ColorSelector } from './ColorSelector.js';
import { CreationSummary } from './CreationSummary.js';
import { WizardStepProps } from '../types.js';
import { WIZARD_STEPS } from '../constants.js';
import { getStepKind } from '../utils.js';
import { Config } from '@qwen-code/qwen-code-core';
import { Colors } from '../../../colors.js';
import { theme } from '../../../semantic-colors.js';
import { TextEntryStep } from './TextEntryStep.js';
interface AgentCreationWizardProps {
onClose: () => void;
@@ -49,11 +51,9 @@ export function AgentCreationWizard({
// 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
) {
// LLM DescriptionInput handles its own ESC logic when generating
const kind = getStepKind(state.generationMethod, state.currentStep);
if (kind === 'LLM_DESC' && state.isGenerating) {
return; // Let DescriptionInput handle it
}
@@ -81,19 +81,27 @@ export function AgentCreationWizard({
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';
const kind = getStepKind(state.generationMethod, state.currentStep);
const n = state.currentStep;
switch (kind) {
case 'LOCATION':
return `Step ${n}: Choose Location`;
case 'GEN_METHOD':
return `Step ${n}: Choose Generation Method`;
case 'LLM_DESC':
return `Step ${n}: Describe Your Subagent`;
case 'MANUAL_NAME':
return `Step ${n}: Enter Subagent Name`;
case 'MANUAL_PROMPT':
return `Step ${n}: Enter System Prompt`;
case 'MANUAL_DESC':
return `Step ${n}: Enter Description`;
case 'TOOLS':
return `Step ${n}: Select Tools`;
case 'COLOR':
return `Step ${n}: Choose Background Color`;
case 'FINAL':
return `Step ${n}: Confirm and Save`;
default:
return 'Unknown Step';
}
@@ -104,7 +112,7 @@ export function AgentCreationWizard({
<Text bold>{getStepHeaderText()}</Text>
</Box>
);
}, [state.currentStep]);
}, [state.currentStep, state.generationMethod]);
const renderDebugContent = useCallback(() => {
if (process.env['NODE_ENV'] !== 'development') {
@@ -146,28 +154,22 @@ export function AgentCreationWizard({
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
) {
const kind = getStepKind(state.generationMethod, state.currentStep);
if (kind === 'LLM_DESC' && state.isGenerating) {
return 'Esc to cancel';
}
if (state.currentStep === WIZARD_STEPS.FINAL_CONFIRMATION) {
if (getStepKind(state.generationMethod, state.currentStep) === 'FINAL') {
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 kindForNav = getStepKind(state.generationMethod, state.currentStep);
const hasNavigation =
kindForNav === 'LOCATION' ||
kindForNav === 'GEN_METHOD' ||
kindForNav === 'TOOLS' ||
kindForNav === 'COLOR';
const navigationPart = hasNavigation ? '↑↓ to navigate, ' : '';
const escAction =
@@ -183,17 +185,79 @@ export function AgentCreationWizard({
<Text color={theme.text.secondary}>{getNavigationInstructions()}</Text>
</Box>
);
}, [state.currentStep, state.isGenerating]);
}, [state.currentStep, state.isGenerating, state.generationMethod]);
const renderStepContent = useCallback(() => {
switch (state.currentStep) {
case WIZARD_STEPS.LOCATION_SELECTION:
const kind = getStepKind(state.generationMethod, state.currentStep);
switch (kind) {
case 'LOCATION':
return <LocationSelector {...stepProps} />;
case WIZARD_STEPS.GENERATION_METHOD:
case 'GEN_METHOD':
return <GenerationMethodSelector {...stepProps} />;
case WIZARD_STEPS.DESCRIPTION_INPUT:
case 'LLM_DESC':
return <DescriptionInput {...stepProps} />;
case WIZARD_STEPS.TOOL_SELECTION:
case 'MANUAL_NAME':
return (
<TextEntryStep
key="manual-name"
state={state}
dispatch={dispatch}
onNext={handleNext}
description="Enter a clear, unique name for this subagent."
placeholder="e.g., Code Reviewer"
height={1}
initialText={state.generatedName}
onChange={(t) => {
const value = t; // keep raw, trim later when validating
dispatch({ type: 'SET_GENERATED_NAME', name: value });
}}
validate={(t) =>
t.trim().length === 0 ? 'Name cannot be empty.' : null
}
/>
);
case 'MANUAL_PROMPT':
return (
<TextEntryStep
key="manual-prompt"
state={state}
dispatch={dispatch}
onNext={handleNext}
description="Write the system prompt that defines this subagent's behavior. Be comprehensive for best results."
placeholder="e.g., You are an expert code reviewer..."
height={10}
initialText={state.generatedSystemPrompt}
onChange={(t) => {
dispatch({
type: 'SET_GENERATED_SYSTEM_PROMPT',
systemPrompt: t,
});
}}
validate={(t) =>
t.trim().length === 0 ? 'System prompt cannot be empty.' : null
}
/>
);
case 'MANUAL_DESC':
return (
<TextEntryStep
key="manual-desc"
state={state}
dispatch={dispatch}
onNext={handleNext}
description="Describe when and how this subagent should be used."
placeholder="e.g., Reviews code for best practices and potential bugs."
height={6}
initialText={state.generatedDescription}
onChange={(t) => {
dispatch({ type: 'SET_GENERATED_DESCRIPTION', description: t });
}}
validate={(t) =>
t.trim().length === 0 ? 'Description cannot be empty.' : null
}
/>
);
case 'TOOLS':
return (
<ToolSelector
tools={state.selectedTools}
@@ -204,7 +268,7 @@ export function AgentCreationWizard({
config={config}
/>
);
case WIZARD_STEPS.COLOR_SELECTION:
case 'COLOR':
return (
<ColorSelector
color={state.color}
@@ -215,7 +279,7 @@ export function AgentCreationWizard({
}}
/>
);
case WIZARD_STEPS.FINAL_CONFIRMATION:
case 'FINAL':
return <CreationSummary {...stepProps} />;
default:
return (
@@ -226,16 +290,7 @@ export function AgentCreationWizard({
</Box>
);
}
}, [
stepProps,
state.currentStep,
state.selectedTools,
state.color,
state.generatedName,
config,
handleNext,
dispatch,
]);
}, [stepProps, state, config, handleNext, dispatch]);
return (
<Box flexDirection="column">

View File

@@ -4,20 +4,17 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useRef } from 'react';
import { useCallback, useRef } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { WizardStepProps, WizardAction } from '../types.js';
import { sanitizeInput } from '../utils.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';
import { TextInput } from '../../shared/TextInput.js';
/**
* Step 3: Description input with LLM generation.
@@ -28,7 +25,6 @@ export function DescriptionInput({
onNext,
config,
}: WizardStepProps) {
const [inputWidth] = useState(80); // Fixed width for now
const abortControllerRef = useRef<AbortController | null>(null);
const handleTextChange = useCallback(
@@ -42,12 +38,7 @@ export function DescriptionInput({
[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,
});
// TextInput will manage its own buffer; we just pass value and handlers
const handleGenerate = useCallback(
async (
@@ -83,11 +74,11 @@ export function DescriptionInput({
);
const handleSubmit = useCallback(async () => {
if (!state.canProceed || state.isGenerating || !buffer) {
if (!state.canProceed || state.isGenerating) {
return;
}
const inputValue = buffer.text.trim();
const inputValue = state.userDescription.trim();
if (!inputValue) {
return;
}
@@ -120,7 +111,7 @@ export function DescriptionInput({
}, [
state.canProceed,
state.isGenerating,
buffer,
state.userDescription,
dispatch,
config,
handleGenerate,
@@ -140,92 +131,11 @@ export function DescriptionInput({
[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...';
@@ -248,71 +158,15 @@ export function DescriptionInput({
</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={theme.status.error}>
{error}
</Text>
))}
</Box>
)}
</>
<TextInput
value={state.userDescription || ''}
onChange={handleTextChange}
onSubmit={handleSubmit}
placeholder={placeholder}
height={10}
isActive={!state.isGenerating}
validationErrors={state.validationErrors}
/>
)}
</Box>
);

View File

@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback } from 'react';
import { Box, Text } from 'ink';
import { WizardStepProps } from '../types.js';
import { Colors } from '../../../colors.js';
import { TextInput } from '../../shared/TextInput.js';
interface TextEntryStepProps
extends Pick<WizardStepProps, 'dispatch' | 'onNext' | 'state'> {
description: string;
placeholder?: string;
/**
* Visual height of the input viewport in rows. Name entry can be 1, others can be larger.
*/
height?: number;
/** Initial text value when the step loads */
initialText?: string;
/**
* Called on every text change to update state.
*/
onChange: (text: string) => void;
/**
* Optional validation. Return error message when invalid.
*/
validate?: (text: string) => string | null;
}
export function TextEntryStep({
state,
dispatch,
onNext,
description,
placeholder,
height = 1,
initialText = '',
onChange,
validate,
}: TextEntryStepProps) {
const submit = useCallback(() => {
const value = initialText ? initialText.trim() : '';
const error = validate
? validate(value)
: value.length === 0
? 'Please enter a value.'
: null;
if (error) {
dispatch({ type: 'SET_VALIDATION_ERRORS', errors: [error] });
return;
}
dispatch({ type: 'SET_VALIDATION_ERRORS', errors: [] });
onNext();
}, [dispatch, onNext, validate, initialText]);
return (
<Box flexDirection="column" gap={1}>
{description && (
<Box>
<Text color={Colors.Gray}>{description}</Text>
</Box>
)}
<TextInput
value={initialText}
onChange={onChange}
onSubmit={submit}
placeholder={placeholder}
height={height}
isActive={!state.isGenerating}
validationErrors={state.validationErrors}
/>
</Box>
);
}

View File

@@ -8,7 +8,7 @@
export { AgentCreationWizard } from './create/AgentCreationWizard.js';
// Management Dialog
export { AgentsManagerDialog } from './view/AgentsManagerDialog.js';
export { AgentsManagerDialog } from './manage/AgentsManagerDialog.js';
// Execution Display
export { AgentExecutionDisplay } from './runtime/AgentExecutionDisplay.js';

View File

@@ -5,7 +5,8 @@
*/
import { CreationWizardState, WizardAction } from './types.js';
import { WIZARD_STEPS, TOTAL_WIZARD_STEPS } from './constants.js';
import { WIZARD_STEPS } from './constants.js';
import { getStepKind, getTotalSteps } from './utils.js';
/**
* Initial state for the creation wizard.
@@ -38,7 +39,7 @@ export function wizardReducer(
...state,
currentStep: Math.max(
WIZARD_STEPS.LOCATION_SELECTION,
Math.min(TOTAL_WIZARD_STEPS, action.step),
Math.min(getTotalSteps(state.generationMethod), action.step),
),
validationErrors: [],
};
@@ -74,6 +75,27 @@ export function wizardReducer(
canProceed: true,
};
case 'SET_GENERATED_NAME':
return {
...state,
generatedName: action.name,
canProceed: action.name.trim().length > 0,
};
case 'SET_GENERATED_SYSTEM_PROMPT':
return {
...state,
generatedSystemPrompt: action.systemPrompt,
canProceed: action.systemPrompt.trim().length > 0,
};
case 'SET_GENERATED_DESCRIPTION':
return {
...state,
generatedDescription: action.description,
canProceed: action.description.trim().length > 0,
};
case 'SET_TOOLS':
return {
...state,
@@ -103,7 +125,10 @@ export function wizardReducer(
};
case 'GO_TO_NEXT_STEP':
if (state.canProceed && state.currentStep < TOTAL_WIZARD_STEPS) {
if (
state.canProceed &&
state.currentStep < getTotalSteps(state.generationMethod)
) {
return {
...state,
currentStep: state.currentStep + 1,
@@ -136,29 +161,29 @@ export function wizardReducer(
* 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
const kind = getStepKind(state.generationMethod, step);
switch (kind) {
case 'LOCATION':
case 'GEN_METHOD':
return true;
case 'LLM_DESC':
return state.userDescription.trim().length >= 0;
case WIZARD_STEPS.TOOL_SELECTION: // Tool selection
case 'MANUAL_NAME':
return state.generatedName.trim().length > 0;
case 'MANUAL_PROMPT':
return state.generatedSystemPrompt.trim().length > 0;
case 'MANUAL_DESC':
return state.generatedDescription.trim().length > 0;
case 'TOOLS':
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
case 'COLOR':
return true;
case 'FINAL':
return state.color.length > 0;
default:
return false;
}

View File

@@ -73,6 +73,9 @@ export type WizardAction =
| { type: 'SET_LOCATION'; location: SubagentLevel }
| { type: 'SET_GENERATION_METHOD'; method: 'qwen' | 'manual' }
| { type: 'SET_USER_DESCRIPTION'; description: string }
| { type: 'SET_GENERATED_NAME'; name: string }
| { type: 'SET_GENERATED_SYSTEM_PROMPT'; systemPrompt: string }
| { type: 'SET_GENERATED_DESCRIPTION'; description: string }
| {
type: 'SET_GENERATED_CONTENT';
name: string;

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { COLOR_OPTIONS } from './constants.js';
import { COLOR_OPTIONS, TOTAL_WIZARD_STEPS } from './constants.js';
export const shouldShowColor = (color?: string): boolean =>
color !== undefined && color !== 'auto';
@@ -39,3 +39,64 @@ export function fmtDuration(ms: number): string {
const m = Math.floor((ms % 3600000) / 60000);
return `${h}h ${m}m`;
}
// Dynamic step flow helpers (support manual and guided flows)
export type StepKind =
| 'LOCATION'
| 'GEN_METHOD'
| 'LLM_DESC'
| 'MANUAL_NAME'
| 'MANUAL_PROMPT'
| 'MANUAL_DESC'
| 'TOOLS'
| 'COLOR'
| 'FINAL';
export function getTotalSteps(method: 'qwen' | 'manual'): number {
return method === 'manual' ? 8 : TOTAL_WIZARD_STEPS;
}
export function getStepKind(
method: 'qwen' | 'manual',
stepNumber: number,
): StepKind {
if (method === 'manual') {
switch (stepNumber) {
case 1:
return 'LOCATION';
case 2:
return 'GEN_METHOD';
case 3:
return 'MANUAL_NAME';
case 4:
return 'MANUAL_PROMPT';
case 5:
return 'MANUAL_DESC';
case 6:
return 'TOOLS';
case 7:
return 'COLOR';
case 8:
return 'FINAL';
default:
return 'FINAL';
}
}
switch (stepNumber) {
case 1:
return 'LOCATION';
case 2:
return 'GEN_METHOD';
case 3:
return 'LLM_DESC';
case 4:
return 'TOOLS';
case 5:
return 'COLOR';
case 6:
return 'FINAL';
default:
return 'FINAL';
}
}