mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat: subagent feature - add manual creation subagent steps
This commit is contained in:
194
packages/cli/src/ui/components/shared/TextInput.tsx
Normal file
194
packages/cli/src/ui/components/shared/TextInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,9 +15,11 @@ import { ColorSelector } from './ColorSelector.js';
|
|||||||
import { CreationSummary } from './CreationSummary.js';
|
import { CreationSummary } from './CreationSummary.js';
|
||||||
import { WizardStepProps } from '../types.js';
|
import { WizardStepProps } from '../types.js';
|
||||||
import { WIZARD_STEPS } from '../constants.js';
|
import { WIZARD_STEPS } from '../constants.js';
|
||||||
|
import { getStepKind } from '../utils.js';
|
||||||
import { Config } from '@qwen-code/qwen-code-core';
|
import { Config } from '@qwen-code/qwen-code-core';
|
||||||
import { Colors } from '../../../colors.js';
|
import { Colors } from '../../../colors.js';
|
||||||
import { theme } from '../../../semantic-colors.js';
|
import { theme } from '../../../semantic-colors.js';
|
||||||
|
import { TextEntryStep } from './TextEntryStep.js';
|
||||||
|
|
||||||
interface AgentCreationWizardProps {
|
interface AgentCreationWizardProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -49,11 +51,9 @@ export function AgentCreationWizard({
|
|||||||
// Centralized ESC key handling for the entire wizard
|
// Centralized ESC key handling for the entire wizard
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
if (key.escape) {
|
if (key.escape) {
|
||||||
// Step 3 (DescriptionInput) handles its own ESC logic when generating
|
// LLM DescriptionInput handles its own ESC logic when generating
|
||||||
if (
|
const kind = getStepKind(state.generationMethod, state.currentStep);
|
||||||
state.currentStep === WIZARD_STEPS.DESCRIPTION_INPUT &&
|
if (kind === 'LLM_DESC' && state.isGenerating) {
|
||||||
state.isGenerating
|
|
||||||
) {
|
|
||||||
return; // Let DescriptionInput handle it
|
return; // Let DescriptionInput handle it
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,19 +81,27 @@ export function AgentCreationWizard({
|
|||||||
|
|
||||||
const renderStepHeader = useCallback(() => {
|
const renderStepHeader = useCallback(() => {
|
||||||
const getStepHeaderText = () => {
|
const getStepHeaderText = () => {
|
||||||
switch (state.currentStep) {
|
const kind = getStepKind(state.generationMethod, state.currentStep);
|
||||||
case WIZARD_STEPS.LOCATION_SELECTION:
|
const n = state.currentStep;
|
||||||
return 'Step 1: Choose Location';
|
switch (kind) {
|
||||||
case WIZARD_STEPS.GENERATION_METHOD:
|
case 'LOCATION':
|
||||||
return 'Step 2: Choose Generation Method';
|
return `Step ${n}: Choose Location`;
|
||||||
case WIZARD_STEPS.DESCRIPTION_INPUT:
|
case 'GEN_METHOD':
|
||||||
return 'Step 3: Describe Your Subagent';
|
return `Step ${n}: Choose Generation Method`;
|
||||||
case WIZARD_STEPS.TOOL_SELECTION:
|
case 'LLM_DESC':
|
||||||
return 'Step 4: Select Tools';
|
return `Step ${n}: Describe Your Subagent`;
|
||||||
case WIZARD_STEPS.COLOR_SELECTION:
|
case 'MANUAL_NAME':
|
||||||
return 'Step 5: Choose Background Color';
|
return `Step ${n}: Enter Subagent Name`;
|
||||||
case WIZARD_STEPS.FINAL_CONFIRMATION:
|
case 'MANUAL_PROMPT':
|
||||||
return 'Step 6: Confirm and Save';
|
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:
|
default:
|
||||||
return 'Unknown Step';
|
return 'Unknown Step';
|
||||||
}
|
}
|
||||||
@@ -104,7 +112,7 @@ export function AgentCreationWizard({
|
|||||||
<Text bold>{getStepHeaderText()}</Text>
|
<Text bold>{getStepHeaderText()}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}, [state.currentStep]);
|
}, [state.currentStep, state.generationMethod]);
|
||||||
|
|
||||||
const renderDebugContent = useCallback(() => {
|
const renderDebugContent = useCallback(() => {
|
||||||
if (process.env['NODE_ENV'] !== 'development') {
|
if (process.env['NODE_ENV'] !== 'development') {
|
||||||
@@ -146,28 +154,22 @@ export function AgentCreationWizard({
|
|||||||
const renderStepFooter = useCallback(() => {
|
const renderStepFooter = useCallback(() => {
|
||||||
const getNavigationInstructions = () => {
|
const getNavigationInstructions = () => {
|
||||||
// Special case: During generation in description input step, only show cancel option
|
// Special case: During generation in description input step, only show cancel option
|
||||||
if (
|
const kind = getStepKind(state.generationMethod, state.currentStep);
|
||||||
state.currentStep === WIZARD_STEPS.DESCRIPTION_INPUT &&
|
if (kind === 'LLM_DESC' && state.isGenerating) {
|
||||||
state.isGenerating
|
|
||||||
) {
|
|
||||||
return 'Esc to cancel';
|
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';
|
return 'Press Enter to save, e to save and edit, Esc to go back';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Steps that have ↑↓ navigation (RadioButtonSelect components)
|
// Steps that have ↑↓ navigation (RadioButtonSelect components)
|
||||||
const stepsWithNavigation = [
|
const kindForNav = getStepKind(state.generationMethod, state.currentStep);
|
||||||
WIZARD_STEPS.LOCATION_SELECTION,
|
const hasNavigation =
|
||||||
WIZARD_STEPS.GENERATION_METHOD,
|
kindForNav === 'LOCATION' ||
|
||||||
WIZARD_STEPS.TOOL_SELECTION,
|
kindForNav === 'GEN_METHOD' ||
|
||||||
WIZARD_STEPS.COLOR_SELECTION,
|
kindForNav === 'TOOLS' ||
|
||||||
] as const;
|
kindForNav === 'COLOR';
|
||||||
|
|
||||||
const hasNavigation = (stepsWithNavigation as readonly number[]).includes(
|
|
||||||
state.currentStep,
|
|
||||||
);
|
|
||||||
const navigationPart = hasNavigation ? '↑↓ to navigate, ' : '';
|
const navigationPart = hasNavigation ? '↑↓ to navigate, ' : '';
|
||||||
|
|
||||||
const escAction =
|
const escAction =
|
||||||
@@ -183,17 +185,79 @@ export function AgentCreationWizard({
|
|||||||
<Text color={theme.text.secondary}>{getNavigationInstructions()}</Text>
|
<Text color={theme.text.secondary}>{getNavigationInstructions()}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}, [state.currentStep, state.isGenerating]);
|
}, [state.currentStep, state.isGenerating, state.generationMethod]);
|
||||||
|
|
||||||
const renderStepContent = useCallback(() => {
|
const renderStepContent = useCallback(() => {
|
||||||
switch (state.currentStep) {
|
const kind = getStepKind(state.generationMethod, state.currentStep);
|
||||||
case WIZARD_STEPS.LOCATION_SELECTION:
|
switch (kind) {
|
||||||
|
case 'LOCATION':
|
||||||
return <LocationSelector {...stepProps} />;
|
return <LocationSelector {...stepProps} />;
|
||||||
case WIZARD_STEPS.GENERATION_METHOD:
|
case 'GEN_METHOD':
|
||||||
return <GenerationMethodSelector {...stepProps} />;
|
return <GenerationMethodSelector {...stepProps} />;
|
||||||
case WIZARD_STEPS.DESCRIPTION_INPUT:
|
case 'LLM_DESC':
|
||||||
return <DescriptionInput {...stepProps} />;
|
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 (
|
return (
|
||||||
<ToolSelector
|
<ToolSelector
|
||||||
tools={state.selectedTools}
|
tools={state.selectedTools}
|
||||||
@@ -204,7 +268,7 @@ export function AgentCreationWizard({
|
|||||||
config={config}
|
config={config}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case WIZARD_STEPS.COLOR_SELECTION:
|
case 'COLOR':
|
||||||
return (
|
return (
|
||||||
<ColorSelector
|
<ColorSelector
|
||||||
color={state.color}
|
color={state.color}
|
||||||
@@ -215,7 +279,7 @@ export function AgentCreationWizard({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case WIZARD_STEPS.FINAL_CONFIRMATION:
|
case 'FINAL':
|
||||||
return <CreationSummary {...stepProps} />;
|
return <CreationSummary {...stepProps} />;
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
@@ -226,16 +290,7 @@ export function AgentCreationWizard({
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [stepProps, state, config, handleNext, dispatch]);
|
||||||
stepProps,
|
|
||||||
state.currentStep,
|
|
||||||
state.selectedTools,
|
|
||||||
state.color,
|
|
||||||
state.generatedName,
|
|
||||||
config,
|
|
||||||
handleNext,
|
|
||||||
dispatch,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
|
|||||||
@@ -4,20 +4,17 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import Spinner from 'ink-spinner';
|
import Spinner from 'ink-spinner';
|
||||||
import { WizardStepProps, WizardAction } from '../types.js';
|
import { WizardStepProps, WizardAction } from '../types.js';
|
||||||
import { sanitizeInput } from '../utils.js';
|
import { sanitizeInput } from '../utils.js';
|
||||||
import { Config, subagentGenerator } from '@qwen-code/qwen-code-core';
|
import { Config, subagentGenerator } from '@qwen-code/qwen-code-core';
|
||||||
import { useTextBuffer } from '../../shared/text-buffer.js';
|
|
||||||
import { useKeypress, Key } from '../../../hooks/useKeypress.js';
|
import { useKeypress, Key } from '../../../hooks/useKeypress.js';
|
||||||
import { keyMatchers, Command } from '../../../keyMatchers.js';
|
import { keyMatchers, Command } from '../../../keyMatchers.js';
|
||||||
import { theme } from '../../../semantic-colors.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 { Colors } from '../../../colors.js';
|
||||||
|
import { TextInput } from '../../shared/TextInput.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 3: Description input with LLM generation.
|
* Step 3: Description input with LLM generation.
|
||||||
@@ -28,7 +25,6 @@ export function DescriptionInput({
|
|||||||
onNext,
|
onNext,
|
||||||
config,
|
config,
|
||||||
}: WizardStepProps) {
|
}: WizardStepProps) {
|
||||||
const [inputWidth] = useState(80); // Fixed width for now
|
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const handleTextChange = useCallback(
|
const handleTextChange = useCallback(
|
||||||
@@ -42,12 +38,7 @@ export function DescriptionInput({
|
|||||||
[dispatch],
|
[dispatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const buffer = useTextBuffer({
|
// TextInput will manage its own buffer; we just pass value and handlers
|
||||||
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(
|
||||||
async (
|
async (
|
||||||
@@ -83,11 +74,11 @@ export function DescriptionInput({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!state.canProceed || state.isGenerating || !buffer) {
|
if (!state.canProceed || state.isGenerating) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputValue = buffer.text.trim();
|
const inputValue = state.userDescription.trim();
|
||||||
if (!inputValue) {
|
if (!inputValue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -120,7 +111,7 @@ export function DescriptionInput({
|
|||||||
}, [
|
}, [
|
||||||
state.canProceed,
|
state.canProceed,
|
||||||
state.isGenerating,
|
state.isGenerating,
|
||||||
buffer,
|
state.userDescription,
|
||||||
dispatch,
|
dispatch,
|
||||||
config,
|
config,
|
||||||
handleGenerate,
|
handleGenerate,
|
||||||
@@ -140,92 +131,11 @@ export function DescriptionInput({
|
|||||||
[dispatch],
|
[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
|
// Use separate keypress handlers for different states
|
||||||
useKeypress(handleGenerationKeypress, {
|
useKeypress(handleGenerationKeypress, {
|
||||||
isActive: state.isGenerating,
|
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 =
|
const placeholder =
|
||||||
'e.g., Expert code reviewer that reviews code based on best practices...';
|
'e.g., Expert code reviewer that reviews code based on best practices...';
|
||||||
|
|
||||||
@@ -248,71 +158,15 @@ export function DescriptionInput({
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<TextInput
|
||||||
<Box>
|
value={state.userDescription || ''}
|
||||||
<Text color={theme.text.accent}>{'> '}</Text>
|
onChange={handleTextChange}
|
||||||
<Box flexGrow={1} flexDirection="column">
|
onSubmit={handleSubmit}
|
||||||
{buffer.text.length === 0 && placeholder ? (
|
placeholder={placeholder}
|
||||||
<Text>
|
height={10}
|
||||||
{chalk.inverse(placeholder.slice(0, 1))}
|
isActive={!state.isGenerating}
|
||||||
<Text color={Colors.Gray}>{placeholder.slice(1)}</Text>
|
validationErrors={state.validationErrors}
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
export { AgentCreationWizard } from './create/AgentCreationWizard.js';
|
export { AgentCreationWizard } from './create/AgentCreationWizard.js';
|
||||||
|
|
||||||
// Management Dialog
|
// Management Dialog
|
||||||
export { AgentsManagerDialog } from './view/AgentsManagerDialog.js';
|
export { AgentsManagerDialog } from './manage/AgentsManagerDialog.js';
|
||||||
|
|
||||||
// Execution Display
|
// Execution Display
|
||||||
export { AgentExecutionDisplay } from './runtime/AgentExecutionDisplay.js';
|
export { AgentExecutionDisplay } from './runtime/AgentExecutionDisplay.js';
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { CreationWizardState, WizardAction } from './types.js';
|
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.
|
* Initial state for the creation wizard.
|
||||||
@@ -38,7 +39,7 @@ export function wizardReducer(
|
|||||||
...state,
|
...state,
|
||||||
currentStep: Math.max(
|
currentStep: Math.max(
|
||||||
WIZARD_STEPS.LOCATION_SELECTION,
|
WIZARD_STEPS.LOCATION_SELECTION,
|
||||||
Math.min(TOTAL_WIZARD_STEPS, action.step),
|
Math.min(getTotalSteps(state.generationMethod), action.step),
|
||||||
),
|
),
|
||||||
validationErrors: [],
|
validationErrors: [],
|
||||||
};
|
};
|
||||||
@@ -74,6 +75,27 @@ export function wizardReducer(
|
|||||||
canProceed: true,
|
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':
|
case 'SET_TOOLS':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -103,7 +125,10 @@ export function wizardReducer(
|
|||||||
};
|
};
|
||||||
|
|
||||||
case 'GO_TO_NEXT_STEP':
|
case 'GO_TO_NEXT_STEP':
|
||||||
if (state.canProceed && state.currentStep < TOTAL_WIZARD_STEPS) {
|
if (
|
||||||
|
state.canProceed &&
|
||||||
|
state.currentStep < getTotalSteps(state.generationMethod)
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentStep: state.currentStep + 1,
|
currentStep: state.currentStep + 1,
|
||||||
@@ -136,29 +161,29 @@ export function wizardReducer(
|
|||||||
* Validates whether a step can proceed based on current state.
|
* Validates whether a step can proceed based on current state.
|
||||||
*/
|
*/
|
||||||
function validateStep(step: number, state: CreationWizardState): boolean {
|
function validateStep(step: number, state: CreationWizardState): boolean {
|
||||||
switch (step) {
|
const kind = getStepKind(state.generationMethod, step);
|
||||||
case WIZARD_STEPS.LOCATION_SELECTION: // Location selection
|
switch (kind) {
|
||||||
return true; // Always can proceed from location selection
|
case 'LOCATION':
|
||||||
|
case 'GEN_METHOD':
|
||||||
case WIZARD_STEPS.GENERATION_METHOD: // Generation method
|
return true;
|
||||||
return true; // Always can proceed from method selection
|
case 'LLM_DESC':
|
||||||
|
|
||||||
case WIZARD_STEPS.DESCRIPTION_INPUT: // Description input
|
|
||||||
return state.userDescription.trim().length >= 0;
|
return state.userDescription.trim().length >= 0;
|
||||||
|
case 'MANUAL_NAME':
|
||||||
case WIZARD_STEPS.TOOL_SELECTION: // Tool selection
|
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 (
|
return (
|
||||||
state.generatedName.length > 0 &&
|
state.generatedName.length > 0 &&
|
||||||
state.generatedDescription.length > 0 &&
|
state.generatedDescription.length > 0 &&
|
||||||
state.generatedSystemPrompt.length > 0
|
state.generatedSystemPrompt.length > 0
|
||||||
);
|
);
|
||||||
|
case 'COLOR':
|
||||||
case WIZARD_STEPS.COLOR_SELECTION: // Color selection
|
return true;
|
||||||
return true; // Always can proceed from tool selection
|
case 'FINAL':
|
||||||
|
|
||||||
case WIZARD_STEPS.FINAL_CONFIRMATION: // Final confirmation
|
|
||||||
return state.color.length > 0;
|
return state.color.length > 0;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ export type WizardAction =
|
|||||||
| { type: 'SET_LOCATION'; location: SubagentLevel }
|
| { type: 'SET_LOCATION'; location: SubagentLevel }
|
||||||
| { type: 'SET_GENERATION_METHOD'; method: 'qwen' | 'manual' }
|
| { type: 'SET_GENERATION_METHOD'; method: 'qwen' | 'manual' }
|
||||||
| { type: 'SET_USER_DESCRIPTION'; description: string }
|
| { 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';
|
type: 'SET_GENERATED_CONTENT';
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* 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 =>
|
export const shouldShowColor = (color?: string): boolean =>
|
||||||
color !== undefined && color !== 'auto';
|
color !== undefined && color !== 'auto';
|
||||||
@@ -39,3 +39,64 @@ export function fmtDuration(ms: number): string {
|
|||||||
const m = Math.floor((ms % 3600000) / 60000);
|
const m = Math.floor((ms % 3600000) / 60000);
|
||||||
return `${h}h ${m}m`;
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user