feat: add CLI Command Node and Prompt Node components for orchestrator

- Implemented CliCommandNode component for executing CLI tools with AI models.
- Implemented PromptNode component for constructing AI prompts with context.
- Added styling for mode and tool badges in both components.
- Enhanced user experience with command and argument previews, execution status, and error handling.

test: add comprehensive tests for ask_question tool

- Created direct test for ask_question tool execution.
- Developed end-to-end tests to validate ask_question tool integration with WebSocket and A2UI surfaces.
- Implemented simple and integrated WebSocket tests to ensure proper message handling and surface reception.
- Added tool registration test to verify ask_question tool is correctly registered.

chore: add WebSocket listener and simulation tests

- Added WebSocket listener for A2UI surfaces to facilitate testing.
- Implemented frontend simulation test to validate complete flow from backend to frontend.
- Created various test scripts to ensure robust testing of ask_question tool functionality.
This commit is contained in:
catlog22
2026-02-03 23:10:36 +08:00
parent a806d70d9b
commit c6093ef741
134 changed files with 6392 additions and 634 deletions

View File

@@ -1,8 +1,8 @@
// ========================================
// MCP Manager Page
// ========================================
// Manage MCP servers (Model Context Protocol) with project/global scope switching
// Supports both Claude and Codex CLI modes
// Manage MCP servers (Model Context Protocol) with tabbed interface
// Supports Templates, Servers, and Cross-CLI tabs
import { useState } from 'react';
import { useIntl } from 'react-intl';
@@ -27,15 +27,24 @@ import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { McpServerDialog } from '@/components/mcp/McpServerDialog';
import { CliModeToggle, type CliMode } from '@/components/mcp/CliModeToggle';
import { CodexMcpCard } from '@/components/mcp/CodexMcpCard';
import { CodexMcpEditableCard } from '@/components/mcp/CodexMcpEditableCard';
import { CcwToolsMcpCard } from '@/components/mcp/CcwToolsMcpCard';
import { McpTemplatesSection } from '@/components/mcp/McpTemplatesSection';
import { RecommendedMcpSection } from '@/components/mcp/RecommendedMcpSection';
import { ConfigTypeToggle } from '@/components/mcp/ConfigTypeToggle';
import { WindowsCompatibilityWarning } from '@/components/mcp/WindowsCompatibilityWarning';
import { CrossCliCopyButton } from '@/components/mcp/CrossCliCopyButton';
import { AllProjectsTable } from '@/components/mcp/AllProjectsTable';
import { OtherProjectsSection } from '@/components/mcp/OtherProjectsSection';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
import { useMcpServers, useMcpServerMutations } from '@/hooks';
import {
fetchCodexMcpServers,
fetchCcwMcpConfig,
updateCcwConfig,
codexRemoveServer,
codexToggleServer,
type McpServer,
type CodexMcpServer,
type CcwMcpConfig,
} from '@/lib/api';
import { cn } from '@/lib/utils';
@@ -190,6 +199,7 @@ function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, o
export function McpManagerPage() {
const { formatMessage } = useIntl();
const [activeTab, setActiveTab] = useState<'templates' | 'servers' | 'cross-cli'>('servers');
const [searchQuery, setSearchQuery] = useState('');
const [scopeFilter, setScopeFilter] = useState<'all' | 'project' | 'global'>('all');
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
@@ -197,6 +207,7 @@ export function McpManagerPage() {
const [editingServer, setEditingServer] = useState<McpServer | undefined>(undefined);
const [cliMode, setCliMode] = useState<CliMode>('claude');
const [codexExpandedServers, setCodexExpandedServers] = useState<Set<string>>(new Set());
const [configType, setConfigType] = useState<'mcp-json' | 'claude-json'>('mcp-json');
const {
servers,
@@ -317,6 +328,44 @@ export function McpManagerPage() {
ccwMcpQuery.refetch();
};
// Template handlers
const handleInstallTemplate = (template: any) => {
setEditingServer({
name: template.name,
command: template.serverConfig.command,
args: template.serverConfig.args || [],
env: template.serverConfig.env,
scope: 'project',
enabled: true,
});
setDialogOpen(true);
};
const handleSaveAsTemplate = (serverName: string, config: { command: string; args: string[] }) => {
// This would open a dialog to save current server as template
// For now, just log it
console.log('Save as template:', serverName, config);
};
// Codex MCP handlers
const handleCodexRemove = async (serverName: string) => {
try {
await codexRemoveServer(serverName);
codexQuery.refetch();
} catch (error) {
console.error('Failed to remove Codex MCP server:', error);
}
};
const handleCodexToggle = async (serverName: string, enabled: boolean) => {
try {
await codexToggleServer(serverName, enabled);
codexQuery.refetch();
} catch (error) {
console.error('Failed to toggle Codex MCP server:', error);
}
};
// Filter servers by search query
const filteredServers = servers.filter((s) =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
@@ -372,7 +421,48 @@ export function McpManagerPage() {
codexConfigPath={codexConfigPath}
/>
{/* Stats Cards - Claude mode only */}
{/* Tabbed Interface */}
<TabsNavigation
value={activeTab}
onValueChange={(value) => setActiveTab(value as 'templates' | 'servers' | 'cross-cli')}
tabs={[
{ value: 'templates', label: formatMessage({ id: 'mcp.tabs.templates' }) },
{ value: 'servers', label: formatMessage({ id: 'mcp.tabs.servers' }) },
{ value: 'cross-cli', label: formatMessage({ id: 'mcp.tabs.crossCli' }) },
]}
/>
{/* Tab Content: Templates */}
{activeTab === 'templates' && (
<div className="mt-4">
<McpTemplatesSection
onInstallTemplate={handleInstallTemplate}
onSaveAsTemplate={handleSaveAsTemplate}
/>
</div>
)}
{/* Tab Content: Servers */}
{activeTab === 'servers' && (
<div className="mt-4 space-y-4">
{/* Windows Compatibility Warning */}
<WindowsCompatibilityWarning />
{/* Recommended MCP Servers */}
{cliMode === 'claude' && (
<RecommendedMcpSection onInstallComplete={() => refetch()} />
)}
{/* Config Type Toggle */}
{cliMode === 'claude' && (
<ConfigTypeToggle
currentType={configType}
onTypeChange={setConfigType}
existingServersCount={totalCount}
/>
)}
{/* Stats Cards - Claude mode only */}
{cliMode === 'claude' && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4">
@@ -492,12 +582,15 @@ export function McpManagerPage() {
<div className="space-y-3">
{currentServers.map((server) => (
cliMode === 'codex' ? (
<CodexMcpCard
<CodexMcpEditableCard
key={server.name}
server={server as CodexMcpServer}
server={server as McpServer}
enabled={server.enabled}
isExpanded={currentExpanded.has(server.name)}
onToggleExpand={() => currentToggleExpand(server.name)}
isEditable={true}
onRemove={handleCodexRemove}
onToggle={handleCodexToggle}
/>
) : (
<McpServerCard
@@ -513,8 +606,46 @@ export function McpManagerPage() {
))}
</div>
)}
</div>
)}
{/* Add/Edit Dialog - Claude mode only */}
{/* Tab Content: Cross-CLI */}
{activeTab === 'cross-cli' && (
<div className="mt-4 space-y-4">
{/* Cross-CLI Copy Button */}
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div className="flex-1">
<h3 className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.crossCli.title' })}
</h3>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'mcp.crossCli.selectServersHint' })}
</p>
</div>
<CrossCliCopyButton
currentMode={cliMode}
onSuccess={() => refetch()}
/>
</div>
{/* All Projects Table */}
<AllProjectsTable
maxProjects={10}
onProjectClick={(path) => console.log('Open project:', path)}
onOpenNewWindow={(path) => window.open(`/?project=${encodeURIComponent(path)}`, '_blank')}
/>
{/* Other Projects Section */}
<OtherProjectsSection
onImportSuccess={(serverName, sourceProject) => {
console.log('Imported server:', serverName, 'from:', sourceProject);
refetch();
}}
/>
</div>
)}
{/* Add/Edit Dialog - Claude mode only (shared across tabs) */}
{cliMode === 'claude' && (
<McpServerDialog
mode={editingServer ? 'edit' : 'add'}

View File

@@ -171,6 +171,10 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
return '#f59e0b'; // amber-500
case 'parallel':
return '#a855f7'; // purple-500
case 'cli-command':
return '#f59e0b'; // amber-500
case 'prompt':
return '#a855f7'; // purple-500
default:
return '#6b7280'; // gray-500
}

View File

@@ -18,6 +18,8 @@ const nodeIcons: Record<FlowNodeType, React.FC<{ className?: string }>> = {
'file-operation': FileText,
conditional: GitBranch,
parallel: GitMerge,
'cli-command': Terminal,
prompt: FileText,
};
// Color mapping for node types
@@ -26,6 +28,8 @@ const nodeColors: Record<FlowNodeType, string> = {
'file-operation': 'bg-green-500 hover:bg-green-600',
conditional: 'bg-amber-500 hover:bg-amber-600',
parallel: 'bg-purple-500 hover:bg-purple-600',
'cli-command': 'bg-amber-500 hover:bg-amber-600',
prompt: 'bg-purple-500 hover:bg-purple-600',
};
const nodeBorderColors: Record<FlowNodeType, string> = {
@@ -33,6 +37,8 @@ const nodeBorderColors: Record<FlowNodeType, string> = {
'file-operation': 'border-green-500',
conditional: 'border-amber-500',
parallel: 'border-purple-500',
'cli-command': 'border-amber-500',
prompt: 'border-purple-500',
};
interface NodePaletteProps {

View File

@@ -9,6 +9,8 @@ import { Settings, X, Terminal, FileText, GitBranch, GitMerge, Trash2 } from 'lu
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { MultiNodeSelector, type NodeOption } from '@/components/ui/MultiNodeSelector';
import { ContextAssembler } from '@/components/ui/ContextAssembler';
import { useFlowStore } from '@/stores';
import type {
FlowNodeType,
@@ -16,9 +18,55 @@ import type {
FileOperationNodeData,
ConditionalNodeData,
ParallelNodeData,
CliCommandNodeData,
PromptNodeData,
NodeData,
} from '@/types/flow';
// ========== Common Form Field Components ==========
interface LabelInputProps {
value: string;
onChange: (value: string) => void;
}
function LabelInput({ value, onChange }: LabelInputProps) {
const { formatMessage } = useIntl();
return (
<div>
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}
</label>
<Input
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
/>
</div>
);
}
interface OutputVariableInputProps {
value?: string;
onChange: (value?: string) => void;
}
function OutputVariableInput({ value, onChange }: OutputVariableInputProps) {
const { formatMessage } = useIntl();
return (
<div>
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputVariable' })}
</label>
<Input
value={value || ''}
onChange={(e) => onChange(e.target.value || undefined)}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.variableName' })}
/>
</div>
);
}
interface PropertyPanelProps {
className?: string;
}
@@ -29,6 +77,8 @@ const nodeIcons: Record<FlowNodeType, React.FC<{ className?: string }>> = {
'file-operation': FileText,
conditional: GitBranch,
parallel: GitMerge,
'cli-command': Terminal,
prompt: FileText,
};
// Slash Command Property Editor
@@ -43,14 +93,7 @@ function SlashCommandProperties({
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}</label>
<Input
value={data.label || ''}
onChange={(e) => onChange({ label: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
/>
</div>
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.command' })}</label>
@@ -118,14 +161,7 @@ function SlashCommandProperties({
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputVariable' })}</label>
<Input
value={data.outputVariable || ''}
onChange={(e) => onChange({ outputVariable: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.variableName' })}
/>
</div>
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
</div>
);
}
@@ -142,14 +178,7 @@ function FileOperationProperties({
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}</label>
<Input
value={data.label || ''}
onChange={(e) => onChange({ label: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
/>
</div>
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.operation' })}</label>
@@ -205,14 +234,7 @@ function FileOperationProperties({
</div>
)}
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputVariable' })}</label>
<Input
value={data.outputVariable || ''}
onChange={(e) => onChange({ outputVariable: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.variableName' })}
/>
</div>
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
<div className="flex items-center gap-2">
<input
@@ -242,14 +264,7 @@ function ConditionalProperties({
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}</label>
<Input
value={data.label || ''}
onChange={(e) => onChange({ label: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
/>
</div>
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.condition' })}</label>
@@ -280,14 +295,7 @@ function ConditionalProperties({
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputVariable' })}</label>
<Input
value={data.outputVariable || ''}
onChange={(e) => onChange({ outputVariable: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.variableName' })}
/>
</div>
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
</div>
);
}
@@ -304,14 +312,7 @@ function ParallelProperties({
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}</label>
<Input
value={data.label || ''}
onChange={(e) => onChange({ label: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
/>
</div>
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.joinMode' })}</label>
@@ -353,14 +354,174 @@ function ParallelProperties({
</label>
</div>
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
</div>
);
}
// CLI Command Property Editor
function CliCommandProperties({
data,
onChange,
}: {
data: CliCommandNodeData;
onChange: (updates: Partial<CliCommandNodeData>) => void;
}) {
const { formatMessage } = useIntl();
return (
<div className="space-y-4">
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputVariable' })}</label>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.command' })}</label>
<Input
value={data.outputVariable || ''}
onChange={(e) => onChange({ outputVariable: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.variableName' })}
value={data.command || ''}
onChange={(e) => onChange({ command: e.target.value })}
placeholder="PURPOSE: ... TASK: ..."
className="font-mono"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.arguments' })}</label>
<Input
value={data.args || ''}
onChange={(e) => onChange({ args: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.commandArgs' })}
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.tool' })}</label>
<select
value={data.tool || 'gemini'}
onChange={(e) => onChange({ tool: e.target.value as 'gemini' | 'qwen' | 'codex' })}
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
>
<option value="gemini">{formatMessage({ id: 'orchestrator.propertyPanel.options.toolGemini' })}</option>
<option value="qwen">{formatMessage({ id: 'orchestrator.propertyPanel.options.toolQwen' })}</option>
<option value="codex">{formatMessage({ id: 'orchestrator.propertyPanel.options.toolCodex' })}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.mode' })}</label>
<select
value={data.mode || 'analysis'}
onChange={(e) => onChange({ mode: e.target.value as 'analysis' | 'write' | 'review' })}
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
>
<option value="analysis">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeAnalysis' })}</option>
<option value="write">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeWrite' })}</option>
<option value="review">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeReview' })}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.timeout' })}</label>
<Input
type="number"
value={data.execution?.timeout || ''}
onChange={(e) =>
onChange({
execution: {
...data.execution,
timeout: e.target.value ? parseInt(e.target.value) : undefined,
},
})
}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.timeout' })}
/>
</div>
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
</div>
);
}
// Prompt Property Editor
function PromptProperties({
data,
onChange,
}: {
data: PromptNodeData;
onChange: (updates: Partial<PromptNodeData>) => void;
}) {
const { formatMessage } = useIntl();
const nodes = useFlowStore((state) => state.nodes);
// Build available nodes list for MultiNodeSelector and ContextAssembler
const availableNodes: NodeOption[] = nodes
.filter((n) => n.id !== useFlowStore.getState().selectedNodeId) // Exclude current node
.map((n) => ({
id: n.id,
label: n.data?.label || n.id,
type: n.type,
}));
// Build available variables list from nodes with outputVariable
const availableVariables = nodes
.filter((n) => n.data?.outputVariable)
.map((n) => n.data?.outputVariable as string)
.filter(Boolean);
return (
<div className="space-y-4">
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.promptType' })}</label>
<select
value={data.promptType || 'custom'}
onChange={(e) => onChange({ promptType: e.target.value as PromptNodeData['promptType'] })}
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
>
<option value="organize">{formatMessage({ id: 'orchestrator.propertyPanel.options.promptTypeOrganize' })}</option>
<option value="refine">{formatMessage({ id: 'orchestrator.propertyPanel.options.promptTypeRefine' })}</option>
<option value="summarize">{formatMessage({ id: 'orchestrator.propertyPanel.options.promptTypeSummarize' })}</option>
<option value="transform">{formatMessage({ id: 'orchestrator.propertyPanel.options.promptTypeTransform' })}</option>
<option value="custom">{formatMessage({ id: 'orchestrator.propertyPanel.options.promptTypeCustom' })}</option>
</select>
</div>
{/* MultiNodeSelector for source nodes */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.sourceNodes' })}</label>
<MultiNodeSelector
availableNodes={availableNodes}
selectedNodes={data.sourceNodes || []}
onChange={(selectedIds) => onChange({ sourceNodes: selectedIds })}
placeholder={formatMessage({ id: 'orchestrator.multiNodeSelector.empty' })}
/>
</div>
{/* ContextAssembler for context template management */}
<div>
<ContextAssembler
value={data.contextTemplate || ''}
onChange={(value) => onChange({ contextTemplate: value })}
availableNodes={nodes.map((n) => ({
id: n.id,
label: n.data?.label || n.id,
type: n.type,
outputVariable: n.data?.outputVariable,
}))}
availableVariables={availableVariables}
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.promptText' })}</label>
<textarea
value={data.promptText || ''}
onChange={(e) => onChange({ promptText: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.promptText' })}
className="w-full h-32 px-3 py-2 rounded-md border border-border bg-background text-foreground text-sm resize-none font-mono"
/>
</div>
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
</div>
);
}
@@ -489,6 +650,18 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
onChange={handleChange}
/>
)}
{nodeType === 'cli-command' && (
<CliCommandProperties
data={selectedNode.data as CliCommandNodeData}
onChange={handleChange}
/>
)}
{nodeType === 'prompt' && (
<PromptProperties
data={selectedNode.data as PromptNodeData}
onChange={handleChange}
/>
)}
</div>
{/* Delete Button */}

View File

@@ -0,0 +1,112 @@
// ========================================
// CLI Command Node Component
// ========================================
// Custom node for executing CLI tools with AI models
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { Terminal } from 'lucide-react';
import type { CliCommandNodeData } from '@/types/flow';
import { NodeWrapper } from './NodeWrapper';
import { cn } from '@/lib/utils';
interface CliCommandNodeProps {
data: CliCommandNodeData;
selected?: boolean;
}
// Mode badge styling
const MODE_STYLES = {
analysis: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
write: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
review: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
};
// Tool badge styling
const TOOL_STYLES = {
gemini: 'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400 border border-blue-200 dark:border-blue-800',
qwen: 'bg-green-50 text-green-600 dark:bg-green-900/20 dark:text-green-400 border border-green-200 dark:border-green-800',
codex: 'bg-purple-50 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400 border border-purple-200 dark:border-purple-800',
};
export const CliCommandNode = memo(({ data, selected }: CliCommandNodeProps) => {
const mode = data.mode || 'analysis';
const tool = data.tool || 'gemini';
return (
<NodeWrapper
status={data.executionStatus}
selected={selected}
accentColor="amber"
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Top}
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-background"
/>
{/* Node Header */}
<div className="flex items-center gap-2 px-3 py-2 bg-amber-500 text-white rounded-t-md">
<Terminal className="w-4 h-4 shrink-0" />
<span className="text-sm font-medium truncate flex-1">
{data.label || 'CLI Command'}
</span>
{/* Tool badge */}
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded bg-white/20', TOOL_STYLES[tool])}>
{tool}
</span>
</div>
{/* Node Content */}
<div className="px-3 py-2 space-y-1.5">
{/* Command name */}
{data.command && (
<div className="flex items-center gap-1">
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded text-foreground">
ccw cli {data.command}
</span>
</div>
)}
{/* Arguments (truncated) */}
{data.args && (
<div className="text-xs text-muted-foreground truncate max-w-[160px]">
<span className="text-foreground/70 font-mono">{data.args}</span>
</div>
)}
{/* Mode badge */}
<div className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground">Mode:</span>
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded', MODE_STYLES[mode])}>
{mode}
</span>
</div>
{/* Output variable indicator */}
{data.outputVariable && (
<div className="text-[10px] text-muted-foreground">
{'->'} {data.outputVariable}
</div>
)}
{/* Execution error message */}
{data.executionStatus === 'failed' && data.executionError && (
<div className="text-[10px] text-destructive truncate max-w-[160px]" title={data.executionError}>
{data.executionError}
</div>
)}
</div>
{/* Output Handle */}
<Handle
type="source"
position={Position.Bottom}
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-background"
/>
</NodeWrapper>
);
});
CliCommandNode.displayName = 'CliCommandNode';

View File

@@ -0,0 +1,120 @@
// ========================================
// Prompt Node Component
// ========================================
// Custom node for constructing AI prompts with context
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { FileText } from 'lucide-react';
import type { PromptNodeData } from '@/types/flow';
import { NodeWrapper } from './NodeWrapper';
import { cn } from '@/lib/utils';
interface PromptNodeProps {
data: PromptNodeData;
selected?: boolean;
}
// Prompt type badge styling
const PROMPT_TYPE_STYLES = {
organize: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
refine: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
summarize: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
transform: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
custom: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
};
// Prompt type labels for display
const PROMPT_TYPE_LABELS: Record<PromptNodeData['promptType'], string> = {
organize: 'Organize',
refine: 'Refine',
summarize: 'Summarize',
transform: 'Transform',
custom: 'Custom',
};
export const PromptNode = memo(({ data, selected }: PromptNodeProps) => {
const promptType = data.promptType || 'custom';
// Truncate prompt text for display
const displayPrompt = data.promptText
? data.promptText.length > 40
? data.promptText.slice(0, 37) + '...'
: data.promptText
: 'No prompt';
return (
<NodeWrapper
status={data.executionStatus}
selected={selected}
accentColor="purple"
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Top}
className="!w-3 !h-3 !bg-purple-500 !border-2 !border-background"
/>
{/* Node Header */}
<div className="flex items-center gap-2 px-3 py-2 bg-purple-500 text-white rounded-t-md">
<FileText className="w-4 h-4 shrink-0" />
<span className="text-sm font-medium truncate flex-1">
{data.label || 'Prompt'}
</span>
{/* Prompt type badge */}
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded', PROMPT_TYPE_STYLES[promptType])}>
{PROMPT_TYPE_LABELS[promptType]}
</span>
</div>
{/* Node Content */}
<div className="px-3 py-2 space-y-1.5">
{/* Prompt text preview */}
<div
className="font-mono text-xs bg-muted px-2 py-1 rounded text-foreground/90 truncate"
title={data.promptText}
>
{displayPrompt}
</div>
{/* Source nodes count */}
{data.sourceNodes && data.sourceNodes.length > 0 && (
<div className="text-[10px] text-muted-foreground">
Sources: {data.sourceNodes.length} node{data.sourceNodes.length !== 1 ? 's' : ''}
</div>
)}
{/* Context template indicator */}
{data.contextTemplate && (
<div className="text-[10px] text-muted-foreground truncate max-w-[160px]" title={data.contextTemplate}>
Template: {data.contextTemplate.slice(0, 20)}...
</div>
)}
{/* Output variable indicator */}
{data.outputVariable && (
<div className="text-[10px] text-muted-foreground">
{'->'} {data.outputVariable}
</div>
)}
{/* Execution error message */}
{data.executionStatus === 'failed' && data.executionError && (
<div className="text-[10px] text-destructive truncate max-w-[160px]" title={data.executionError}>
{data.executionError}
</div>
)}
</div>
{/* Output Handle */}
<Handle
type="source"
position={Position.Bottom}
className="!w-3 !h-3 !bg-purple-500 !border-2 !border-background"
/>
</NodeWrapper>
);
});
PromptNode.displayName = 'PromptNode';

View File

@@ -10,12 +10,16 @@ export { SlashCommandNode } from './SlashCommandNode';
export { FileOperationNode } from './FileOperationNode';
export { ConditionalNode } from './ConditionalNode';
export { ParallelNode } from './ParallelNode';
export { CliCommandNode } from './CliCommandNode';
export { PromptNode } from './PromptNode';
// Node types map for React Flow registration
import { SlashCommandNode } from './SlashCommandNode';
import { FileOperationNode } from './FileOperationNode';
import { ConditionalNode } from './ConditionalNode';
import { ParallelNode } from './ParallelNode';
import { CliCommandNode } from './CliCommandNode';
import { PromptNode } from './PromptNode';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const nodeTypes: Record<string, any> = {
@@ -23,4 +27,6 @@ export const nodeTypes: Record<string, any> = {
'file-operation': FileOperationNode,
conditional: ConditionalNode,
parallel: ParallelNode,
'cli-command': CliCommandNode,
prompt: PromptNode,
};