@marktoflow/gui 2.0.0-alpha.4 → 2.0.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.turbo/turbo-build.log +24 -8
  2. package/README.md +11 -1
  3. package/dist/client/assets/index-CM44OayM.js +704 -0
  4. package/dist/client/assets/index-CM44OayM.js.map +1 -0
  5. package/dist/client/assets/index-Dru63gi6.css +1 -0
  6. package/dist/client/index.html +2 -2
  7. package/dist/server/{server/index.js → index.js} +22 -1
  8. package/dist/server/index.js.map +1 -0
  9. package/dist/server/routes/executions.js +125 -0
  10. package/dist/server/routes/executions.js.map +1 -0
  11. package/dist/server/{server/routes → routes}/workflows.js +37 -1
  12. package/dist/server/routes/workflows.js.map +1 -0
  13. package/dist/server/{server/services → services}/WorkflowService.js +158 -15
  14. package/dist/server/services/WorkflowService.js.map +1 -0
  15. package/dist/server/{server/websocket → websocket}/index.js +12 -0
  16. package/dist/server/{server/websocket → websocket}/index.js.map +1 -1
  17. package/marktoflow-gui-2.0.0-alpha.5.tgz +0 -0
  18. package/package.json +19 -5
  19. package/scripts/flatten-dist.js +69 -0
  20. package/src/client/components/Canvas/Canvas.tsx +3 -1
  21. package/src/client/components/Canvas/ExecutionOverlay.tsx +120 -32
  22. package/src/client/components/Canvas/ForEachNode.tsx +27 -3
  23. package/src/client/components/Canvas/IfElseNode.tsx +22 -7
  24. package/src/client/components/Canvas/NodeContextMenu.tsx +8 -4
  25. package/src/client/components/Canvas/ParallelNode.tsx +25 -8
  26. package/src/client/components/Canvas/SwitchNode.tsx +41 -20
  27. package/src/client/components/Canvas/Toolbar.tsx +59 -21
  28. package/src/client/components/Canvas/TransformNode.tsx +9 -0
  29. package/src/client/components/Canvas/WhileNode.tsx +35 -3
  30. package/src/client/components/Debug/VariableInspector.tsx +148 -0
  31. package/src/client/components/Prompt/PromptInput.tsx +3 -1
  32. package/src/client/components/Settings/ProviderSwitcher.tsx +228 -0
  33. package/src/client/components/Sidebar/ImportDialog.tsx +257 -0
  34. package/src/client/components/Sidebar/Sidebar.tsx +21 -2
  35. package/src/client/components/common/KeyboardShortcuts.tsx +8 -2
  36. package/src/client/stores/agentStore.ts +109 -0
  37. package/src/client/stores/executionStore.ts +64 -2
  38. package/src/client/stores/workflowStore.ts +10 -2
  39. package/src/client/styles/globals.css +106 -0
  40. package/src/client/utils/platform.ts +46 -0
  41. package/src/client/utils/workflowToGraph.ts +245 -21
  42. package/src/server/index.ts +24 -1
  43. package/src/server/routes/executions.ts +136 -0
  44. package/src/server/routes/workflows.ts +42 -1
  45. package/src/server/services/WorkflowService.ts +176 -16
  46. package/src/server/websocket/index.ts +13 -0
  47. package/tests/unit/ForEachNode.test.tsx +96 -6
  48. package/tests/unit/IfElseNode.test.tsx +47 -0
  49. package/tests/unit/ParallelNode.test.tsx +80 -0
  50. package/tests/unit/SwitchNode.test.tsx +75 -0
  51. package/tests/unit/WhileNode.test.tsx +12 -8
  52. package/tests/unit/agentStore.test.ts +218 -0
  53. package/tests/unit/executionStore.test.ts +40 -0
  54. package/tests/unit/platform.test.ts +118 -0
  55. package/tests/unit/workflowToGraph.test.ts +22 -0
  56. package/dist/client/assets/index-C90Y_aBX.js +0 -678
  57. package/dist/client/assets/index-C90Y_aBX.js.map +0 -1
  58. package/dist/client/assets/index-CRWeQ3NN.css +0 -1
  59. package/dist/server/server/index.js.map +0 -1
  60. package/dist/server/server/routes/workflows.js.map +0 -1
  61. package/dist/server/server/services/WorkflowService.js.map +0 -1
  62. /package/dist/server/{server/routes → routes}/ai.js +0 -0
  63. /package/dist/server/{server/routes → routes}/ai.js.map +0 -0
  64. /package/dist/server/{server/routes → routes}/execute.js +0 -0
  65. /package/dist/server/{server/routes → routes}/execute.js.map +0 -0
  66. /package/dist/server/{server/routes → routes}/tools.js +0 -0
  67. /package/dist/server/{server/routes → routes}/tools.js.map +0 -0
  68. /package/dist/server/{server/services → services}/AIService.js +0 -0
  69. /package/dist/server/{server/services → services}/AIService.js.map +0 -0
  70. /package/dist/server/{server/services → services}/FileWatcher.js +0 -0
  71. /package/dist/server/{server/services → services}/FileWatcher.js.map +0 -0
  72. /package/dist/server/{server/services → services}/agents/claude-code-provider.js +0 -0
  73. /package/dist/server/{server/services → services}/agents/claude-code-provider.js.map +0 -0
  74. /package/dist/server/{server/services → services}/agents/claude-provider.js +0 -0
  75. /package/dist/server/{server/services → services}/agents/claude-provider.js.map +0 -0
  76. /package/dist/server/{server/services → services}/agents/codex-provider.js +0 -0
  77. /package/dist/server/{server/services → services}/agents/codex-provider.js.map +0 -0
  78. /package/dist/server/{server/services → services}/agents/copilot-provider.js +0 -0
  79. /package/dist/server/{server/services → services}/agents/copilot-provider.js.map +0 -0
  80. /package/dist/server/{server/services → services}/agents/demo-provider.js +0 -0
  81. /package/dist/server/{server/services → services}/agents/demo-provider.js.map +0 -0
  82. /package/dist/server/{server/services → services}/agents/index.js +0 -0
  83. /package/dist/server/{server/services → services}/agents/index.js.map +0 -0
  84. /package/dist/server/{server/services → services}/agents/ollama-provider.js +0 -0
  85. /package/dist/server/{server/services → services}/agents/ollama-provider.js.map +0 -0
  86. /package/dist/server/{server/services → services}/agents/prompts.js +0 -0
  87. /package/dist/server/{server/services → services}/agents/prompts.js.map +0 -0
  88. /package/dist/server/{server/services → services}/agents/registry.js +0 -0
  89. /package/dist/server/{server/services → services}/agents/registry.js.map +0 -0
  90. /package/dist/server/{server/services → services}/agents/types.js +0 -0
  91. /package/dist/server/{server/services → services}/agents/types.js.map +0 -0
  92. /package/dist/{server/shared → shared}/constants.js +0 -0
  93. /package/dist/{server/shared → shared}/constants.js.map +0 -0
  94. /package/dist/{server/shared → shared}/types.js +0 -0
  95. /package/dist/{server/shared → shared}/types.js.map +0 -0
@@ -1,6 +1,6 @@
1
1
  import { memo } from 'react';
2
2
  import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
3
- import { Layers, CheckCircle, XCircle, Clock } from 'lucide-react';
3
+ import { Layers, CheckCircle, XCircle, Clock, AlertTriangle } from 'lucide-react';
4
4
 
5
5
  export interface ParallelNodeData extends Record<string, unknown> {
6
6
  id: string;
@@ -11,6 +11,8 @@ export interface ParallelNodeData extends Record<string, unknown> {
11
11
  status?: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
12
12
  activeBranches?: string[];
13
13
  completedBranches?: string[];
14
+ failedBranches?: string[];
15
+ maxConcurrentExceeded?: boolean;
14
16
  }
15
17
 
16
18
  export type ParallelNodeType = Node<ParallelNodeData, 'parallel'>;
@@ -46,7 +48,7 @@ function ParallelNodeComponent({ data, selected }: NodeProps<ParallelNodeType>)
46
48
 
47
49
  return (
48
50
  <div
49
- className={`control-flow-node parallel-node p-0 ${selected ? 'selected' : ''} ${status === 'running' ? 'running' : ''}`}
51
+ className={`control-flow-node parallel-node p-0 ${selected ? 'selected' : ''} ${status === 'running' ? 'running' : ''} ${status === 'completed' ? 'completed' : ''} ${status === 'failed' ? 'failed' : ''}`}
50
52
  style={{
51
53
  background: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
52
54
  }}
@@ -80,6 +82,16 @@ function ParallelNodeComponent({ data, selected }: NodeProps<ParallelNodeType>)
80
82
 
81
83
  {/* Node body */}
82
84
  <div className="p-3 bg-white/10">
85
+ {/* Max concurrent warning */}
86
+ {data.maxConcurrentExceeded && (
87
+ <div className="mb-3 p-2 bg-yellow-500/20 border border-yellow-500/30 rounded flex items-center gap-2">
88
+ <AlertTriangle className="w-4 h-4 text-yellow-200" />
89
+ <span className="text-xs text-yellow-200 font-medium">
90
+ Rate limiting active
91
+ </span>
92
+ </div>
93
+ )}
94
+
83
95
  <div className="text-xs text-white/90 mb-3">
84
96
  <span className="text-white/60">Branches:</span>{' '}
85
97
  <span className="font-medium">{data.branches?.length || 0}</span>
@@ -87,7 +99,9 @@ function ParallelNodeComponent({ data, selected }: NodeProps<ParallelNodeType>)
87
99
  <>
88
100
  {' '}
89
101
  <span className="text-white/60">• Max Concurrent:</span>{' '}
90
- <span className="font-medium">{data.maxConcurrent}</span>
102
+ <span className={`font-medium ${data.maxConcurrentExceeded ? 'text-yellow-300' : ''}`}>
103
+ {data.maxConcurrent}
104
+ </span>
91
105
  </>
92
106
  )}
93
107
  </div>
@@ -97,15 +111,18 @@ function ParallelNodeComponent({ data, selected }: NodeProps<ParallelNodeType>)
97
111
  {data.branches?.slice(0, 6).map((branch) => {
98
112
  const isActive = data.activeBranches?.includes(branch.id);
99
113
  const isCompleted = data.completedBranches?.includes(branch.id);
114
+ const isFailed = data.failedBranches?.includes(branch.id);
100
115
  return (
101
116
  <div
102
117
  key={branch.id}
103
118
  className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
104
- isCompleted
105
- ? 'bg-green-500/30 text-green-200'
106
- : isActive
107
- ? 'bg-blue-500/30 text-blue-200 animate-pulse'
108
- : 'bg-white/10 text-white/60'
119
+ isFailed
120
+ ? 'bg-red-500/30 text-red-200'
121
+ : isCompleted
122
+ ? 'bg-green-500/30 text-green-200'
123
+ : isActive
124
+ ? 'bg-blue-500/30 text-blue-200 animate-pulse'
125
+ : 'bg-white/10 text-white/60'
109
126
  }`}
110
127
  title={branch.name || branch.id}
111
128
  >
@@ -10,6 +10,7 @@ export interface SwitchNodeData extends Record<string, unknown> {
10
10
  hasDefault?: boolean;
11
11
  status?: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
12
12
  activeCase?: string | null;
13
+ skippedBranches?: string[];
13
14
  }
14
15
 
15
16
  export type SwitchNodeType = Node<SwitchNodeData, 'switch'>;
@@ -47,9 +48,16 @@ function SwitchNodeComponent({ data, selected }: NodeProps<SwitchNodeType>) {
47
48
  const displayCases = caseKeys.slice(0, 4); // Show up to 4 cases
48
49
  const hasMore = caseKeys.length > 4;
49
50
 
51
+ // Calculate handle positions to avoid overlap
52
+ const totalHandles = Math.min(caseKeys.length, 4) + (data.hasDefault ? 1 : 0);
53
+ const getHandlePosition = (index: number) => {
54
+ if (totalHandles === 1) return 50;
55
+ return ((index + 1) / (totalHandles + 1)) * 100;
56
+ };
57
+
50
58
  return (
51
59
  <div
52
- className={`control-flow-node switch-node p-0 ${selected ? 'selected' : ''} ${status === 'running' ? 'running' : ''}`}
60
+ className={`control-flow-node switch-node p-0 ${selected ? 'selected' : ''} ${status === 'running' ? 'running' : ''} ${status === 'completed' ? 'completed' : ''} ${status === 'failed' ? 'failed' : ''}`}
53
61
  style={{
54
62
  background: 'linear-gradient(135deg, #a855f7 0%, #ec4899 100%)',
55
63
  }}
@@ -93,27 +101,27 @@ function SwitchNodeComponent({ data, selected }: NodeProps<SwitchNodeType>) {
93
101
  <div className="text-xs text-white/70 font-medium mb-1">Cases:</div>
94
102
  {displayCases.map((caseKey, index) => {
95
103
  const isActive = data.activeCase === caseKey;
96
- const handlePosition = ((index + 1) / (displayCases.length + (data.hasDefault ? 2 : 1))) * 100;
104
+ const isSkipped = data.skippedBranches?.includes(caseKey);
105
+ const handlePosition = getHandlePosition(index);
97
106
 
98
107
  return (
99
108
  <div key={caseKey} className="relative">
100
109
  <div
101
- className={`text-xs px-2 py-1.5 rounded font-medium transition-colors ${
110
+ className={`text-xs px-2 py-1.5 rounded font-medium transition-colors relative ${
102
111
  isActive
103
112
  ? 'bg-purple-500/30 text-purple-200 ring-1 ring-purple-400/50'
104
- : 'bg-white/5 text-white/70'
113
+ : isSkipped
114
+ ? 'bg-gray-500/20 text-gray-400 line-through'
115
+ : 'bg-white/5 text-white/70'
105
116
  }`}
106
117
  >
107
118
  {caseKey}
119
+ {isSkipped && (
120
+ <span className="ml-2 text-[8px] px-1 py-0.5 rounded bg-gray-500/30">
121
+ SKIPPED
122
+ </span>
123
+ )}
108
124
  </div>
109
- {/* Output handle for this case */}
110
- <Handle
111
- type="source"
112
- position={Position.Bottom}
113
- id={`case-${caseKey}`}
114
- style={{ left: `${handlePosition}%` }}
115
- className="!w-2.5 !h-2.5 !bg-purple-400 !border-2 !border-node-bg"
116
- />
117
125
  </div>
118
126
  );
119
127
  })}
@@ -136,14 +144,6 @@ function SwitchNodeComponent({ data, selected }: NodeProps<SwitchNodeType>) {
136
144
  >
137
145
  default
138
146
  </div>
139
- {/* Output handle for default */}
140
- <Handle
141
- type="source"
142
- position={Position.Bottom}
143
- id="case-default"
144
- style={{ left: '95%' }}
145
- className="!w-2.5 !h-2.5 !bg-gray-400 !border-2 !border-node-bg"
146
- />
147
147
  </div>
148
148
  )}
149
149
  </div>
@@ -157,6 +157,27 @@ function SwitchNodeComponent({ data, selected }: NodeProps<SwitchNodeType>) {
157
157
  </span>
158
158
  </div>
159
159
  </div>
160
+
161
+ {/* Output handles - positioned independently to avoid overlap */}
162
+ {displayCases.map((caseKey, index) => (
163
+ <Handle
164
+ key={`handle-${caseKey}`}
165
+ type="source"
166
+ position={Position.Bottom}
167
+ id={`case-${caseKey}`}
168
+ style={{ left: `${getHandlePosition(index)}%` }}
169
+ className="!w-2.5 !h-2.5 !bg-purple-400 !border-2 !border-node-bg"
170
+ />
171
+ ))}
172
+ {data.hasDefault && (
173
+ <Handle
174
+ type="source"
175
+ position={Position.Bottom}
176
+ id="case-default"
177
+ style={{ left: `${getHandlePosition(displayCases.length)}%` }}
178
+ className="!w-2.5 !h-2.5 !bg-gray-400 !border-2 !border-node-bg"
179
+ />
180
+ )}
160
181
  </div>
161
182
  );
162
183
  }
@@ -1,3 +1,4 @@
1
+ import { useState, useEffect } from 'react';
1
2
  import {
2
3
  Plus,
3
4
  Play,
@@ -11,10 +12,15 @@ import {
11
12
  Redo,
12
13
  Copy,
13
14
  Trash2,
15
+ Bot,
16
+ ChevronDown,
14
17
  } from 'lucide-react';
15
18
  import { useCanvas } from '../../hooks/useCanvas';
16
19
  import { useEditorStore } from '../../stores/editorStore';
17
20
  import { useReactFlow } from '@xyflow/react';
21
+ import { getModKey } from '../../utils/platform';
22
+ import { useAgentStore } from '../../stores/agentStore';
23
+ import { ProviderSwitcher } from '../Settings/ProviderSwitcher';
18
24
 
19
25
  interface ToolbarProps {
20
26
  onAddStep: () => void;
@@ -33,11 +39,21 @@ export function Toolbar({
33
39
  useCanvas();
34
40
  const { undo, redo, undoStack, redoStack } = useEditorStore();
35
41
  const { zoomIn, zoomOut } = useReactFlow();
42
+ const modKey = getModKey();
43
+ const { providers, activeProviderId, loadProviders } = useAgentStore();
44
+ const [showProviderSwitcher, setShowProviderSwitcher] = useState(false);
36
45
 
37
46
  const canUndo = undoStack.length > 0;
38
47
  const canRedo = redoStack.length > 0;
39
48
  const hasSelection = selectedNodes.length > 0;
40
49
 
50
+ // Load providers on mount
51
+ useEffect(() => {
52
+ loadProviders();
53
+ }, [loadProviders]);
54
+
55
+ const activeProvider = providers.find((p) => p.id === activeProviderId);
56
+
41
57
  return (
42
58
  <div className="absolute top-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1 px-2 py-1.5 bg-panel-bg/95 backdrop-blur border border-node-border rounded-lg shadow-lg">
43
59
  {/* Add Step */}
@@ -56,14 +72,14 @@ export function Toolbar({
56
72
  label="Undo"
57
73
  onClick={() => undo()}
58
74
  disabled={!canUndo}
59
- shortcut="⌘Z"
75
+ shortcut={`${modKey}Z`}
60
76
  />
61
77
  <ToolbarButton
62
78
  icon={<Redo className="w-4 h-4" />}
63
79
  label="Redo"
64
80
  onClick={() => redo()}
65
81
  disabled={!canRedo}
66
- shortcut="⌘⇧Z"
82
+ shortcut={`${modKey}⇧Z`}
67
83
  />
68
84
 
69
85
  <ToolbarDivider />
@@ -74,7 +90,7 @@ export function Toolbar({
74
90
  label="Duplicate"
75
91
  onClick={duplicateSelected}
76
92
  disabled={!hasSelection}
77
- shortcut="⌘D"
93
+ shortcut={`${modKey}D`}
78
94
  />
79
95
  <ToolbarButton
80
96
  icon={<Trash2 className="w-4 h-4" />}
@@ -91,44 +107,60 @@ export function Toolbar({
91
107
  icon={<Layout className="w-4 h-4" />}
92
108
  label="Auto Layout"
93
109
  onClick={autoLayout}
94
- shortcut="⌘L"
110
+ shortcut={`${modKey}L`}
95
111
  />
96
112
  <ToolbarButton
97
113
  icon={<ZoomIn className="w-4 h-4" />}
98
114
  label="Zoom In"
99
115
  onClick={() => zoomIn()}
100
- shortcut="⌘+"
116
+ shortcut={`${modKey}+`}
101
117
  />
102
118
  <ToolbarButton
103
119
  icon={<ZoomOut className="w-4 h-4" />}
104
120
  label="Zoom Out"
105
121
  onClick={() => zoomOut()}
106
- shortcut="⌘-"
122
+ shortcut={`${modKey}-`}
107
123
  />
108
124
  <ToolbarButton
109
125
  icon={<Maximize className="w-4 h-4" />}
110
126
  label="Fit View"
111
127
  onClick={fitView}
112
- shortcut="⌘0"
128
+ shortcut={`${modKey}0`}
113
129
  />
114
130
 
115
131
  <ToolbarDivider />
116
132
 
133
+ {/* AI Provider */}
134
+ <button
135
+ onClick={() => setShowProviderSwitcher(true)}
136
+ className="flex items-center gap-1.5 px-2 py-1.5 rounded text-sm text-gray-300 hover:text-white hover:bg-white/10 transition-colors"
137
+ title="Select AI Provider"
138
+ >
139
+ <Bot className="w-4 h-4" />
140
+ <span className="hidden sm:inline text-xs">
141
+ {activeProvider?.name || 'No Provider'}
142
+ </span>
143
+ <ChevronDown className="w-3 h-3" />
144
+ </button>
145
+
117
146
  {/* Execute */}
118
147
  {onExecute && (
119
- <ToolbarButton
120
- icon={
121
- isExecuting ? (
122
- <Pause className="w-4 h-4" />
123
- ) : (
124
- <Play className="w-4 h-4" />
125
- )
126
- }
127
- label={isExecuting ? 'Stop' : 'Execute'}
128
- onClick={onExecute}
129
- variant={isExecuting ? 'destructive' : 'primary'}
130
- shortcut="⌘⏎"
131
- />
148
+ <>
149
+ <ToolbarDivider />
150
+ <ToolbarButton
151
+ icon={
152
+ isExecuting ? (
153
+ <Pause className="w-4 h-4" />
154
+ ) : (
155
+ <Play className="w-4 h-4" />
156
+ )
157
+ }
158
+ label={isExecuting ? 'Stop' : 'Execute'}
159
+ onClick={onExecute}
160
+ variant={isExecuting ? 'destructive' : 'primary'}
161
+ shortcut={`${modKey}⏎`}
162
+ />
163
+ </>
132
164
  )}
133
165
 
134
166
  {/* Save */}
@@ -137,9 +169,15 @@ export function Toolbar({
137
169
  icon={<Save className="w-4 h-4" />}
138
170
  label="Save"
139
171
  onClick={onSave}
140
- shortcut="⌘S"
172
+ shortcut={`${modKey}S`}
141
173
  />
142
174
  )}
175
+
176
+ {/* Provider Switcher Modal */}
177
+ <ProviderSwitcher
178
+ open={showProviderSwitcher}
179
+ onOpenChange={setShowProviderSwitcher}
180
+ />
143
181
  </div>
144
182
  );
145
183
  }
@@ -178,6 +178,15 @@ function TransformNodeComponent({ data, selected }: NodeProps<TransformNodeType>
178
178
  position={Position.Bottom}
179
179
  className="!w-3 !h-3 !bg-primary !border-2 !border-node-bg"
180
180
  />
181
+
182
+ {/* Loop-back handle for iteration visualization */}
183
+ <Handle
184
+ id="loop-back"
185
+ type="source"
186
+ position={Position.Left}
187
+ className="!w-3 !h-3 !bg-teal-500 !border-2 !border-node-bg"
188
+ style={{ top: '50%', left: '-6px' }}
189
+ />
181
190
  </div>
182
191
  );
183
192
  }
@@ -1,6 +1,6 @@
1
1
  import { memo } from 'react';
2
2
  import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
3
- import { RotateCw, CheckCircle, XCircle, Clock } from 'lucide-react';
3
+ import { RotateCw, CheckCircle, XCircle, Clock, LogOut, AlertTriangle } from 'lucide-react';
4
4
 
5
5
  export interface WhileNodeData extends Record<string, unknown> {
6
6
  id: string;
@@ -9,6 +9,8 @@ export interface WhileNodeData extends Record<string, unknown> {
9
9
  maxIterations?: number;
10
10
  status?: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
11
11
  currentIteration?: number;
12
+ earlyExit?: boolean;
13
+ exitReason?: 'break' | 'max_iterations' | 'error';
12
14
  }
13
15
 
14
16
  export type WhileNodeType = Node<WhileNodeData, 'while'>;
@@ -44,7 +46,7 @@ function WhileNodeComponent({ data, selected }: NodeProps<WhileNodeType>) {
44
46
 
45
47
  return (
46
48
  <div
47
- className={`control-flow-node while-node p-0 ${selected ? 'selected' : ''} ${status === 'running' ? 'running' : ''}`}
49
+ className={`control-flow-node while-node p-0 ${selected ? 'selected' : ''} ${status === 'running' ? 'running' : ''} ${status === 'completed' ? 'completed' : ''} ${status === 'failed' ? 'failed' : ''}`}
48
50
  style={{
49
51
  background: 'linear-gradient(135deg, #fb923c 0%, #f97316 100%)',
50
52
  }}
@@ -89,6 +91,24 @@ function WhileNodeComponent({ data, selected }: NodeProps<WhileNodeType>) {
89
91
  <span className="font-medium">{data.maxIterations || 100}</span>
90
92
  </div>
91
93
 
94
+ {/* Early exit indicator */}
95
+ {data.earlyExit && (
96
+ <div className="mb-3 p-2 bg-orange-500/20 border border-orange-500/30 rounded flex items-center gap-2">
97
+ {data.exitReason === 'max_iterations' ? (
98
+ <AlertTriangle className="w-4 h-4 text-orange-200" />
99
+ ) : (
100
+ <LogOut className="w-4 h-4 text-orange-200" />
101
+ )}
102
+ <span className="text-xs text-orange-200 font-medium">
103
+ {data.exitReason === 'break'
104
+ ? 'Loop exited early (break)'
105
+ : data.exitReason === 'max_iterations'
106
+ ? 'Max iterations reached'
107
+ : 'Loop stopped on error'}
108
+ </span>
109
+ </div>
110
+ )}
111
+
92
112
  {/* Current iteration counter */}
93
113
  {data.currentIteration !== undefined && (
94
114
  <div className="mt-2 p-2 bg-white/5 rounded">
@@ -96,11 +116,14 @@ function WhileNodeComponent({ data, selected }: NodeProps<WhileNodeType>) {
96
116
  <span className="text-xs text-white/70">Iterations</span>
97
117
  <span className="text-xs text-white font-medium">
98
118
  {data.currentIteration} / {data.maxIterations || 100}
119
+ {data.earlyExit && (
120
+ <span className="ml-1 text-orange-300 text-[10px]">(stopped)</span>
121
+ )}
99
122
  </span>
100
123
  </div>
101
124
  <div className="w-full bg-white/10 rounded-full h-1.5">
102
125
  <div
103
- className="bg-orange-400 h-1.5 rounded-full transition-all"
126
+ className={`h-1.5 rounded-full transition-all ${data.earlyExit ? 'bg-orange-400' : 'bg-orange-500'}`}
104
127
  style={{
105
128
  width: `${(data.currentIteration / (data.maxIterations || 100)) * 100}%`,
106
129
  }}
@@ -122,6 +145,15 @@ function WhileNodeComponent({ data, selected }: NodeProps<WhileNodeType>) {
122
145
  position={Position.Bottom}
123
146
  className="!w-3 !h-3 !bg-primary !border-2 !border-node-bg"
124
147
  />
148
+
149
+ {/* Loop-back handle (left side for iteration feedback) */}
150
+ <Handle
151
+ id="loop-back"
152
+ type="source"
153
+ position={Position.Left}
154
+ className="!w-3 !h-3 !bg-orange-500 !border-2 !border-node-bg"
155
+ style={{ top: '50%', left: '-6px' }}
156
+ />
125
157
  </div>
126
158
  );
127
159
  }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Variable Inspector Component
3
+ * Displays JSON data with syntax highlighting and expand/collapse functionality
4
+ */
5
+
6
+ import { useState } from 'react';
7
+ import { ChevronRight, ChevronDown, Copy, Check } from 'lucide-react';
8
+
9
+ interface VariableInspectorProps {
10
+ data: unknown;
11
+ name?: string;
12
+ expanded?: boolean;
13
+ }
14
+
15
+ export function VariableInspector({ data, name, expanded = true }: VariableInspectorProps) {
16
+ return (
17
+ <div className="font-mono text-sm">
18
+ <JsonNode value={data} name={name} depth={0} initiallyExpanded={expanded} />
19
+ </div>
20
+ );
21
+ }
22
+
23
+ interface JsonNodeProps {
24
+ value: unknown;
25
+ name?: string;
26
+ depth: number;
27
+ initiallyExpanded?: boolean;
28
+ }
29
+
30
+ function JsonNode({ value, name, depth, initiallyExpanded = true }: JsonNodeProps) {
31
+ const [isExpanded, setIsExpanded] = useState(initiallyExpanded);
32
+ const [copied, setCopied] = useState(false);
33
+
34
+ const indent = depth * 16;
35
+ const isObject = value !== null && typeof value === 'object' && !Array.isArray(value);
36
+ const isArray = Array.isArray(value);
37
+ const isExpandable = isObject || isArray;
38
+
39
+ const handleCopy = () => {
40
+ navigator.clipboard.writeText(JSON.stringify(value, null, 2));
41
+ setCopied(true);
42
+ setTimeout(() => setCopied(false), 2000);
43
+ };
44
+
45
+ const getValueColor = (val: unknown): string => {
46
+ if (val === null) return 'text-gray-500';
47
+ if (typeof val === 'string') return 'text-green-400';
48
+ if (typeof val === 'number') return 'text-blue-400';
49
+ if (typeof val === 'boolean') return 'text-purple-400';
50
+ return 'text-gray-300';
51
+ };
52
+
53
+ const renderValue = (val: unknown): string => {
54
+ if (val === null) return 'null';
55
+ if (val === undefined) return 'undefined';
56
+ if (typeof val === 'string') return `"${val}"`;
57
+ if (typeof val === 'number' || typeof val === 'boolean') return String(val);
58
+ return '';
59
+ };
60
+
61
+ if (!isExpandable) {
62
+ return (
63
+ <div className="flex items-center gap-2 py-0.5 hover:bg-white/5 rounded px-1" style={{ paddingLeft: indent }}>
64
+ {name && (
65
+ <>
66
+ <span className="text-cyan-400">{name}</span>
67
+ <span className="text-gray-500">:</span>
68
+ </>
69
+ )}
70
+ <span className={getValueColor(value)}>{renderValue(value)}</span>
71
+ </div>
72
+ );
73
+ }
74
+
75
+ const entries = isArray
76
+ ? (value as unknown[]).map((v, i) => [String(i), v] as [string, unknown])
77
+ : Object.entries(value as Record<string, unknown>);
78
+
79
+ const preview = isArray
80
+ ? `Array(${entries.length})`
81
+ : `Object {${entries.length} ${entries.length === 1 ? 'key' : 'keys'}}`;
82
+
83
+ return (
84
+ <div>
85
+ <div
86
+ className="flex items-center gap-2 py-0.5 hover:bg-white/5 rounded px-1 cursor-pointer group"
87
+ style={{ paddingLeft: indent }}
88
+ onClick={() => setIsExpanded(!isExpanded)}
89
+ >
90
+ <button className="text-gray-500 hover:text-white">
91
+ {isExpanded ? (
92
+ <ChevronDown className="w-3 h-3" />
93
+ ) : (
94
+ <ChevronRight className="w-3 h-3" />
95
+ )}
96
+ </button>
97
+
98
+ {name && (
99
+ <>
100
+ <span className="text-cyan-400">{name}</span>
101
+ <span className="text-gray-500">:</span>
102
+ </>
103
+ )}
104
+
105
+ <span className="text-gray-500">
106
+ {isArray ? '[' : '{'}
107
+ {!isExpanded && <span className="text-gray-600 ml-1">{preview}</span>}
108
+ </span>
109
+
110
+ <button
111
+ onClick={(e) => {
112
+ e.stopPropagation();
113
+ handleCopy();
114
+ }}
115
+ className="ml-auto opacity-0 group-hover:opacity-100 transition-opacity"
116
+ title="Copy to clipboard"
117
+ >
118
+ {copied ? (
119
+ <Check className="w-3 h-3 text-green-400" />
120
+ ) : (
121
+ <Copy className="w-3 h-3 text-gray-500 hover:text-white" />
122
+ )}
123
+ </button>
124
+ </div>
125
+
126
+ {isExpanded && (
127
+ <>
128
+ {entries.map(([key, val]) => (
129
+ <JsonNode
130
+ key={key}
131
+ name={isArray ? undefined : key}
132
+ value={val}
133
+ depth={depth + 1}
134
+ initiallyExpanded={depth < 1}
135
+ />
136
+ ))}
137
+ <div className="text-gray-500 py-0.5 px-1" style={{ paddingLeft: indent }}>
138
+ {isArray ? ']' : '}'}
139
+ </div>
140
+ </>
141
+ )}
142
+
143
+ {!isExpanded && (
144
+ <div className="text-gray-500 inline">{isArray ? ']' : '}'}</div>
145
+ )}
146
+ </div>
147
+ );
148
+ }
@@ -2,12 +2,14 @@ import { useState, useRef, useEffect } from 'react';
2
2
  import { Send, Loader2, Sparkles } from 'lucide-react';
3
3
  import { usePromptStore } from '../../stores/promptStore';
4
4
  import { PromptHistoryPanel } from './PromptHistoryPanel';
5
+ import { getModKey } from '../../utils/platform';
5
6
 
6
7
  export function PromptInput() {
7
8
  const [prompt, setPrompt] = useState('');
8
9
  const textareaRef = useRef<HTMLTextAreaElement>(null);
9
10
 
10
11
  const { isProcessing, history, sendPrompt } = usePromptStore();
12
+ const modKey = getModKey();
11
13
 
12
14
  // Auto-resize textarea
13
15
  useEffect(() => {
@@ -95,7 +97,7 @@ export function PromptInput() {
95
97
 
96
98
  {/* Keyboard hint */}
97
99
  <div className="px-4 pb-2 text-xs text-gray-500">
98
- Press <kbd className="px-1.5 py-0.5 bg-node-bg rounded text-gray-400">⌘</kbd> +{' '}
100
+ Press <kbd className="px-1.5 py-0.5 bg-node-bg rounded text-gray-400">{modKey}</kbd> +{' '}
99
101
  <kbd className="px-1.5 py-0.5 bg-node-bg rounded text-gray-400">Enter</kbd> to send
100
102
  {history.length > 0 && (
101
103
  <span className="ml-4">