@evolve.labs/devflow 0.8.0

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 (106) hide show
  1. package/.claude/commands/agents/architect.md +1162 -0
  2. package/.claude/commands/agents/architect.meta.yaml +124 -0
  3. package/.claude/commands/agents/builder.md +1432 -0
  4. package/.claude/commands/agents/builder.meta.yaml +117 -0
  5. package/.claude/commands/agents/chronicler.md +633 -0
  6. package/.claude/commands/agents/chronicler.meta.yaml +217 -0
  7. package/.claude/commands/agents/guardian.md +456 -0
  8. package/.claude/commands/agents/guardian.meta.yaml +127 -0
  9. package/.claude/commands/agents/strategist.md +483 -0
  10. package/.claude/commands/agents/strategist.meta.yaml +158 -0
  11. package/.claude/commands/agents/system-designer.md +1137 -0
  12. package/.claude/commands/agents/system-designer.meta.yaml +156 -0
  13. package/.claude/commands/devflow-help.md +93 -0
  14. package/.claude/commands/devflow-status.md +60 -0
  15. package/.claude/commands/quick/create-adr.md +82 -0
  16. package/.claude/commands/quick/new-feature.md +57 -0
  17. package/.claude/commands/quick/security-check.md +54 -0
  18. package/.claude/commands/quick/system-design.md +58 -0
  19. package/.claude_project +52 -0
  20. package/.devflow/agents/architect.meta.yaml +122 -0
  21. package/.devflow/agents/builder.meta.yaml +116 -0
  22. package/.devflow/agents/chronicler.meta.yaml +222 -0
  23. package/.devflow/agents/guardian.meta.yaml +127 -0
  24. package/.devflow/agents/strategist.meta.yaml +158 -0
  25. package/.devflow/agents/system-designer.meta.yaml +265 -0
  26. package/.devflow/project.yaml +242 -0
  27. package/.gitignore-template +84 -0
  28. package/LICENSE +21 -0
  29. package/README.md +249 -0
  30. package/bin/devflow.js +54 -0
  31. package/lib/autopilot.js +235 -0
  32. package/lib/autopilotConstants.js +213 -0
  33. package/lib/constants.js +95 -0
  34. package/lib/init.js +200 -0
  35. package/lib/update.js +181 -0
  36. package/lib/utils.js +157 -0
  37. package/lib/web.js +119 -0
  38. package/package.json +57 -0
  39. package/web/CHANGELOG.md +192 -0
  40. package/web/README.md +156 -0
  41. package/web/app/api/autopilot/execute/route.ts +102 -0
  42. package/web/app/api/autopilot/terminal-execute/route.ts +124 -0
  43. package/web/app/api/files/route.ts +280 -0
  44. package/web/app/api/files/tree/route.ts +160 -0
  45. package/web/app/api/git/route.ts +201 -0
  46. package/web/app/api/health/route.ts +94 -0
  47. package/web/app/api/project/open/route.ts +134 -0
  48. package/web/app/api/search/route.ts +247 -0
  49. package/web/app/api/specs/route.ts +405 -0
  50. package/web/app/api/terminal/route.ts +222 -0
  51. package/web/app/globals.css +160 -0
  52. package/web/app/ide/layout.tsx +43 -0
  53. package/web/app/ide/page.tsx +216 -0
  54. package/web/app/layout.tsx +34 -0
  55. package/web/app/page.tsx +303 -0
  56. package/web/components/agents/AgentIcons.tsx +281 -0
  57. package/web/components/autopilot/AutopilotConfigModal.tsx +245 -0
  58. package/web/components/autopilot/AutopilotPanel.tsx +299 -0
  59. package/web/components/dashboard/DashboardPanel.tsx +393 -0
  60. package/web/components/editor/Breadcrumbs.tsx +134 -0
  61. package/web/components/editor/EditorPanel.tsx +120 -0
  62. package/web/components/editor/EditorTabs.tsx +229 -0
  63. package/web/components/editor/MarkdownPreview.tsx +154 -0
  64. package/web/components/editor/MermaidDiagram.tsx +113 -0
  65. package/web/components/editor/MonacoEditor.tsx +177 -0
  66. package/web/components/editor/TabContextMenu.tsx +207 -0
  67. package/web/components/git/GitPanel.tsx +534 -0
  68. package/web/components/layout/Shell.tsx +15 -0
  69. package/web/components/layout/StatusBar.tsx +100 -0
  70. package/web/components/modals/CommandPalette.tsx +393 -0
  71. package/web/components/modals/GlobalSearch.tsx +348 -0
  72. package/web/components/modals/QuickOpen.tsx +241 -0
  73. package/web/components/modals/RecentFiles.tsx +208 -0
  74. package/web/components/projects/ProjectSelector.tsx +147 -0
  75. package/web/components/settings/SettingItem.tsx +150 -0
  76. package/web/components/settings/SettingsPanel.tsx +323 -0
  77. package/web/components/specs/SpecsPanel.tsx +1091 -0
  78. package/web/components/terminal/TerminalPanel.tsx +683 -0
  79. package/web/components/ui/ContextMenu.tsx +182 -0
  80. package/web/components/ui/LoadingSpinner.tsx +66 -0
  81. package/web/components/ui/ResizeHandle.tsx +110 -0
  82. package/web/components/ui/Skeleton.tsx +108 -0
  83. package/web/components/ui/SkipLinks.tsx +37 -0
  84. package/web/components/ui/Toaster.tsx +57 -0
  85. package/web/hooks/useFocusTrap.ts +141 -0
  86. package/web/hooks/useKeyboardShortcuts.ts +169 -0
  87. package/web/hooks/useListNavigation.ts +237 -0
  88. package/web/lib/autopilotConstants.ts +213 -0
  89. package/web/lib/constants/agents.ts +67 -0
  90. package/web/lib/git.ts +339 -0
  91. package/web/lib/ptyManager.ts +191 -0
  92. package/web/lib/specsParser.ts +299 -0
  93. package/web/lib/stores/autopilotStore.ts +288 -0
  94. package/web/lib/stores/fileStore.ts +550 -0
  95. package/web/lib/stores/gitStore.ts +386 -0
  96. package/web/lib/stores/projectStore.ts +196 -0
  97. package/web/lib/stores/settingsStore.ts +126 -0
  98. package/web/lib/stores/specsStore.ts +297 -0
  99. package/web/lib/stores/uiStore.ts +175 -0
  100. package/web/lib/types/index.ts +177 -0
  101. package/web/lib/utils.ts +98 -0
  102. package/web/next.config.js +50 -0
  103. package/web/package.json +54 -0
  104. package/web/postcss.config.js +6 -0
  105. package/web/tailwind.config.ts +68 -0
  106. package/web/tsconfig.json +41 -0
@@ -0,0 +1,683 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState, useCallback } from 'react';
4
+ import {
5
+ Terminal as TerminalIcon,
6
+ Plus,
7
+ X,
8
+ Maximize2,
9
+ Minimize2,
10
+ ChevronDown,
11
+ Zap,
12
+ FileText,
13
+ Shield,
14
+ Bot
15
+ } from 'lucide-react';
16
+ import { toast } from 'sonner';
17
+ import { AgentIcon } from '@/components/agents/AgentIcons';
18
+ import { cn } from '@/lib/utils';
19
+ import { useSettingsStore } from '@/lib/stores/settingsStore';
20
+ import { useAutopilotStore } from '@/lib/stores/autopilotStore';
21
+ import { Terminal } from '@xterm/xterm';
22
+ import { FitAddon } from '@xterm/addon-fit';
23
+ import { WebLinksAddon } from '@xterm/addon-web-links';
24
+ import { WebglAddon } from '@xterm/addon-webgl';
25
+ import '@xterm/xterm/css/xterm.css';
26
+
27
+ interface TerminalTab {
28
+ id: string;
29
+ name: string;
30
+ isActive: boolean;
31
+ sessionId: string;
32
+ }
33
+
34
+ // Agent quick actions
35
+ const AGENT_ACTIONS = [
36
+ { id: 'strategist', label: 'Strategist', command: 'claude /agents:strategist', color: '#3b82f6' },
37
+ { id: 'architect', label: 'Architect', command: 'claude /agents:architect', color: '#8b5cf6' },
38
+ { id: 'system-designer', label: 'Sys Designer', command: 'claude /agents:system-designer', color: '#06b6d4' },
39
+ { id: 'builder', label: 'Builder', command: 'claude /agents:builder', color: '#22c55e' },
40
+ { id: 'guardian', label: 'Guardian', command: 'claude /agents:guardian', color: '#ef4444' },
41
+ { id: 'chronicler', label: 'Chronicler', command: 'claude /agents:chronicler', color: '#f59e0b' },
42
+ ];
43
+
44
+ // Quick commands
45
+ const QUICK_COMMANDS = [
46
+ { label: 'Claude', command: 'claude', icon: Bot },
47
+ { label: 'New Feature', command: 'claude /quick:new-feature', icon: Zap },
48
+ { label: 'Security', command: 'claude /quick:security-check', icon: Shield },
49
+ { label: 'ADR', command: 'claude /quick:create-adr', icon: FileText },
50
+ ];
51
+
52
+ interface TerminalPanelProps {
53
+ projectPath: string;
54
+ isMaximized?: boolean;
55
+ onToggleMaximize?: () => void;
56
+ onClose?: () => void;
57
+ height?: number;
58
+ onHeightChange?: (height: number) => void;
59
+ }
60
+
61
+ const MIN_HEIGHT = 150;
62
+ const MAX_HEIGHT = 600;
63
+ const DEFAULT_HEIGHT = 256;
64
+
65
+ export function TerminalPanel({
66
+ projectPath,
67
+ isMaximized,
68
+ onToggleMaximize,
69
+ onClose,
70
+ height = DEFAULT_HEIGHT,
71
+ onHeightChange,
72
+ }: TerminalPanelProps) {
73
+ const containerRef = useRef<HTMLDivElement>(null);
74
+ const terminalRef = useRef<Terminal | null>(null);
75
+ const fitAddonRef = useRef<FitAddon | null>(null);
76
+ const eventSourceRef = useRef<EventSource | null>(null);
77
+ const resizeObserverRef = useRef<ResizeObserver | null>(null);
78
+
79
+ // Buffer for batching terminal writes (input)
80
+ const writeBufferRef = useRef<string>('');
81
+ const writeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
82
+
83
+ // Buffer for batching terminal reads (output) - reduces flickering
84
+ const readBufferRef = useRef<string>('');
85
+ const readTimeoutRef = useRef<NodeJS.Timeout | null>(null);
86
+
87
+ const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
88
+ const isConnectedRef = useRef(false);
89
+ const hasShownConnectToast = useRef(false);
90
+
91
+ const WRITE_BUFFER_DELAY = 10; // ms - batch input writes
92
+ const READ_BUFFER_DELAY = 16; // ms - batch output reads (~1 frame at 60fps)
93
+ const RESIZE_DEBOUNCE_DELAY = 200; // ms - debounce resize events
94
+
95
+ const { terminalFontSize } = useSettingsStore();
96
+
97
+ const [tabs, setTabs] = useState<TerminalTab[]>([
98
+ { id: '1', name: 'Terminal 1', isActive: true, sessionId: `terminal-${Date.now()}-1` }
99
+ ]);
100
+ const [isConnected, setIsConnected] = useState(false);
101
+ const [isConnecting, setIsConnecting] = useState(false);
102
+ const [isResizing, setIsResizing] = useState(false);
103
+
104
+ const activeTab = tabs.find(t => t.isActive);
105
+
106
+ // Handle resize drag
107
+ const handleResizeStart = useCallback((e: React.MouseEvent) => {
108
+ e.preventDefault();
109
+ setIsResizing(true);
110
+
111
+ const startY = e.clientY;
112
+ const startHeight = height;
113
+
114
+ const handleMouseMove = (moveEvent: MouseEvent) => {
115
+ const deltaY = startY - moveEvent.clientY;
116
+ const newHeight = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, startHeight + deltaY));
117
+ onHeightChange?.(newHeight);
118
+ };
119
+
120
+ const handleMouseUp = () => {
121
+ setIsResizing(false);
122
+ document.removeEventListener('mousemove', handleMouseMove);
123
+ document.removeEventListener('mouseup', handleMouseUp);
124
+ document.body.style.cursor = '';
125
+ document.body.style.userSelect = '';
126
+ };
127
+
128
+ document.addEventListener('mousemove', handleMouseMove);
129
+ document.addEventListener('mouseup', handleMouseUp);
130
+ document.body.style.cursor = 'row-resize';
131
+ document.body.style.userSelect = 'none';
132
+ }, [height, onHeightChange]);
133
+
134
+ // Initialize terminal
135
+ const initTerminal = useCallback(async (sessionId: string) => {
136
+ if (!containerRef.current) return;
137
+
138
+ // Cleanup existing terminal
139
+ if (terminalRef.current) {
140
+ terminalRef.current.dispose();
141
+ }
142
+ if (eventSourceRef.current) {
143
+ eventSourceRef.current.close();
144
+ }
145
+
146
+ // Create new terminal instance
147
+ const terminal = new Terminal({
148
+ theme: {
149
+ background: '#0a0a0f',
150
+ foreground: '#e4e4e7',
151
+ cursor: '#a855f7',
152
+ cursorAccent: '#0a0a0f',
153
+ selectionBackground: '#a855f740',
154
+ selectionForeground: '#ffffff',
155
+ black: '#18181b',
156
+ red: '#ef4444',
157
+ green: '#22c55e',
158
+ yellow: '#eab308',
159
+ blue: '#3b82f6',
160
+ magenta: '#a855f7',
161
+ cyan: '#06b6d4',
162
+ white: '#e4e4e7',
163
+ brightBlack: '#71717a',
164
+ brightRed: '#f87171',
165
+ brightGreen: '#4ade80',
166
+ brightYellow: '#facc15',
167
+ brightBlue: '#60a5fa',
168
+ brightMagenta: '#c084fc',
169
+ brightCyan: '#22d3ee',
170
+ brightWhite: '#ffffff',
171
+ },
172
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
173
+ fontSize: terminalFontSize,
174
+ fontWeight: '400',
175
+ fontWeightBold: '600',
176
+ letterSpacing: 0,
177
+ lineHeight: 1.2,
178
+ cursorBlink: true,
179
+ cursorStyle: 'bar',
180
+ scrollback: 10000,
181
+ allowProposedApi: true,
182
+ });
183
+
184
+ const fitAddon = new FitAddon();
185
+ const webLinksAddon = new WebLinksAddon();
186
+
187
+ terminal.loadAddon(fitAddon);
188
+ terminal.loadAddon(webLinksAddon);
189
+
190
+ terminalRef.current = terminal;
191
+ fitAddonRef.current = fitAddon;
192
+
193
+ terminal.open(containerRef.current);
194
+
195
+ // Load WebGL addon for sharper rendering on high-DPI displays
196
+ try {
197
+ const webglAddon = new WebglAddon();
198
+ webglAddon.onContextLoss(() => {
199
+ webglAddon.dispose();
200
+ });
201
+ terminal.loadAddon(webglAddon);
202
+ } catch {
203
+ // WebGL not supported, fall back to canvas renderer
204
+ console.warn('WebGL not supported, using canvas renderer');
205
+ }
206
+
207
+ // Wait for container to be ready
208
+ setTimeout(() => {
209
+ try {
210
+ fitAddon.fit();
211
+ } catch {
212
+ // Ignore fit errors during init
213
+ }
214
+ }, 100);
215
+
216
+ setIsConnecting(true);
217
+
218
+ // Create PTY session
219
+ try {
220
+ const createResponse = await fetch('/api/terminal', {
221
+ method: 'POST',
222
+ headers: { 'Content-Type': 'application/json' },
223
+ body: JSON.stringify({
224
+ action: 'create',
225
+ sessionId,
226
+ cwd: projectPath,
227
+ cols: terminal.cols,
228
+ rows: terminal.rows,
229
+ }),
230
+ });
231
+
232
+ if (!createResponse.ok) {
233
+ throw new Error('Failed to create terminal session');
234
+ }
235
+
236
+ // Connect to SSE stream
237
+ const eventSource = new EventSource(`/api/terminal?sessionId=${sessionId}`);
238
+ eventSourceRef.current = eventSource;
239
+
240
+ eventSource.onopen = () => {
241
+ setIsConnected(true);
242
+ setIsConnecting(false);
243
+ isConnectedRef.current = true;
244
+ // Only show toast once per session
245
+ if (!hasShownConnectToast.current) {
246
+ hasShownConnectToast.current = true;
247
+ toast.success('Terminal ready', { duration: 1500 });
248
+ }
249
+ };
250
+
251
+ eventSource.onmessage = (event) => {
252
+ try {
253
+ const message = JSON.parse(event.data);
254
+ if (message.type === 'data' && message.data) {
255
+ // Buffer reads to reduce flickering
256
+ readBufferRef.current += message.data;
257
+
258
+ // Clear existing timeout
259
+ if (readTimeoutRef.current) {
260
+ clearTimeout(readTimeoutRef.current);
261
+ }
262
+
263
+ // Flush buffer after delay (batches rapid updates into single repaint)
264
+ readTimeoutRef.current = setTimeout(() => {
265
+ if (readBufferRef.current && terminalRef.current) {
266
+ terminalRef.current.write(readBufferRef.current);
267
+ readBufferRef.current = '';
268
+ }
269
+ }, READ_BUFFER_DELAY);
270
+ } else if (message.type === 'exit') {
271
+ // Flush any pending buffer before exit message
272
+ if (readBufferRef.current && terminalRef.current) {
273
+ terminalRef.current.write(readBufferRef.current);
274
+ readBufferRef.current = '';
275
+ }
276
+ terminal.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
277
+ }
278
+ } catch {
279
+ // Ignore parse errors
280
+ }
281
+ };
282
+
283
+ eventSource.onerror = () => {
284
+ // Use ref to check if was connected (closure issue)
285
+ if (isConnectedRef.current) {
286
+ toast.error('Terminal disconnected');
287
+ }
288
+ setIsConnected(false);
289
+ setIsConnecting(false);
290
+ isConnectedRef.current = false;
291
+ };
292
+
293
+ // Handle terminal input with buffering
294
+ // Batches multiple keystrokes into single API calls for better performance
295
+ terminal.onData((data) => {
296
+ // Add to buffer
297
+ writeBufferRef.current += data;
298
+
299
+ // Clear existing timeout
300
+ if (writeTimeoutRef.current) {
301
+ clearTimeout(writeTimeoutRef.current);
302
+ }
303
+
304
+ // Flush buffer after delay OR immediately for special keys
305
+ const isSpecialKey = data === '\r' || data === '\x03' || data === '\x04'; // Enter, Ctrl+C, Ctrl+D
306
+ const delay = isSpecialKey ? 0 : WRITE_BUFFER_DELAY;
307
+
308
+ writeTimeoutRef.current = setTimeout(() => {
309
+ const bufferedData = writeBufferRef.current;
310
+ writeBufferRef.current = '';
311
+
312
+ if (bufferedData) {
313
+ fetch('/api/terminal', {
314
+ method: 'POST',
315
+ headers: { 'Content-Type': 'application/json' },
316
+ body: JSON.stringify({
317
+ action: 'write',
318
+ sessionId,
319
+ data: bufferedData,
320
+ }),
321
+ }).catch(() => {
322
+ // Ignore write errors
323
+ });
324
+ }
325
+ }, delay);
326
+ });
327
+
328
+ } catch (error) {
329
+ console.error('Terminal init error:', error);
330
+ terminal.write('\x1b[31mFailed to connect to terminal session\x1b[0m\r\n');
331
+ setIsConnecting(false);
332
+ }
333
+ }, [projectPath, terminalFontSize]);
334
+
335
+ // Handle resize with debouncing
336
+ const handleResize = useCallback(() => {
337
+ // Clear existing timeout
338
+ if (resizeTimeoutRef.current) {
339
+ clearTimeout(resizeTimeoutRef.current);
340
+ }
341
+
342
+ // Debounce the resize
343
+ resizeTimeoutRef.current = setTimeout(() => {
344
+ if (!fitAddonRef.current || !terminalRef.current || !activeTab) return;
345
+
346
+ try {
347
+ fitAddonRef.current.fit();
348
+ const { cols, rows } = terminalRef.current;
349
+
350
+ fetch('/api/terminal', {
351
+ method: 'POST',
352
+ headers: { 'Content-Type': 'application/json' },
353
+ body: JSON.stringify({
354
+ action: 'resize',
355
+ sessionId: activeTab.sessionId,
356
+ cols,
357
+ rows,
358
+ }),
359
+ }).catch(() => {
360
+ // Ignore resize errors
361
+ });
362
+ } catch {
363
+ // Ignore fit errors
364
+ }
365
+ }, RESIZE_DEBOUNCE_DELAY);
366
+ }, [activeTab]);
367
+
368
+ // Initialize terminal on mount
369
+ useEffect(() => {
370
+ if (activeTab) {
371
+ initTerminal(activeTab.sessionId);
372
+ }
373
+
374
+ return () => {
375
+ // Cleanup timeouts
376
+ if (writeTimeoutRef.current) {
377
+ clearTimeout(writeTimeoutRef.current);
378
+ }
379
+ if (readTimeoutRef.current) {
380
+ clearTimeout(readTimeoutRef.current);
381
+ }
382
+ if (resizeTimeoutRef.current) {
383
+ clearTimeout(resizeTimeoutRef.current);
384
+ }
385
+ writeBufferRef.current = '';
386
+ readBufferRef.current = '';
387
+ hasShownConnectToast.current = false;
388
+
389
+ if (terminalRef.current) {
390
+ try {
391
+ terminalRef.current.dispose();
392
+ } catch {
393
+ // Terminal may already be disposed
394
+ }
395
+ }
396
+ if (eventSourceRef.current) {
397
+ eventSourceRef.current.close();
398
+ }
399
+ if (activeTab) {
400
+ fetch('/api/terminal', {
401
+ method: 'POST',
402
+ headers: { 'Content-Type': 'application/json' },
403
+ body: JSON.stringify({
404
+ action: 'destroy',
405
+ sessionId: activeTab.sessionId,
406
+ }),
407
+ }).catch(() => {});
408
+ }
409
+ };
410
+ }, [activeTab?.sessionId, initTerminal]);
411
+
412
+ // Setup resize observer
413
+ useEffect(() => {
414
+ if (!containerRef.current) return;
415
+
416
+ resizeObserverRef.current = new ResizeObserver(() => {
417
+ handleResize();
418
+ });
419
+
420
+ resizeObserverRef.current.observe(containerRef.current);
421
+
422
+ return () => {
423
+ resizeObserverRef.current?.disconnect();
424
+ };
425
+ }, [handleResize]);
426
+
427
+ // Handle maximize/minimize
428
+ useEffect(() => {
429
+ // Delay fit to allow layout to settle
430
+ const timer = setTimeout(() => {
431
+ handleResize();
432
+ }, 100);
433
+ return () => clearTimeout(timer);
434
+ }, [isMaximized, handleResize]);
435
+
436
+ // Listen for autopilot start — create dedicated "Autopilot" tab
437
+ const prevAutopilotStatusRef = useRef<string>('idle');
438
+ useEffect(() => {
439
+ const unsub = useAutopilotStore.subscribe((state) => {
440
+ const prevStatus = prevAutopilotStatusRef.current;
441
+ const newStatus = state.status;
442
+ prevAutopilotStatusRef.current = newStatus;
443
+
444
+ // Only react to transitions to 'running'
445
+ if (newStatus === 'running' && prevStatus !== 'running') {
446
+ setTabs((prev) => {
447
+ const existing = prev.find((t) => t.name === 'Autopilot');
448
+ if (existing) {
449
+ // Reuse existing — activate it and pass sessionId to store
450
+ useAutopilotStore.getState().setTerminalSessionId(existing.sessionId);
451
+ return prev.map((t) => ({ ...t, isActive: t.id === existing.id }));
452
+ }
453
+
454
+ // Create new Autopilot tab
455
+ const newId = String(Date.now());
456
+ const newSessionId = `autopilot-${newId}`;
457
+ useAutopilotStore.getState().setTerminalSessionId(newSessionId);
458
+
459
+ return [
460
+ ...prev.map((t) => ({ ...t, isActive: false })),
461
+ { id: newId, name: 'Autopilot', isActive: true, sessionId: newSessionId },
462
+ ];
463
+ });
464
+ }
465
+ });
466
+ return unsub;
467
+ }, []);
468
+
469
+ // On mount, if there's already an active tab, register its sessionId with autopilotStore
470
+ useEffect(() => {
471
+ if (activeTab) {
472
+ // Only set if autopilot is running and no session is set yet
473
+ const { status, terminalSessionId } = useAutopilotStore.getState();
474
+ if (status === 'running' && !terminalSessionId) {
475
+ useAutopilotStore.getState().setTerminalSessionId(activeTab.sessionId);
476
+ }
477
+ }
478
+ }, [activeTab?.sessionId]);
479
+
480
+ // Tab management
481
+ const addTab = () => {
482
+ const newId = String(Date.now());
483
+ const newSessionId = `terminal-${newId}`;
484
+
485
+ // Destroy current session
486
+ if (activeTab) {
487
+ eventSourceRef.current?.close();
488
+ fetch('/api/terminal', {
489
+ method: 'POST',
490
+ headers: { 'Content-Type': 'application/json' },
491
+ body: JSON.stringify({
492
+ action: 'destroy',
493
+ sessionId: activeTab.sessionId,
494
+ }),
495
+ }).catch(() => {});
496
+ }
497
+
498
+ setTabs([
499
+ ...tabs.map(t => ({ ...t, isActive: false })),
500
+ { id: newId, name: `Terminal ${tabs.length + 1}`, isActive: true, sessionId: newSessionId }
501
+ ]);
502
+ };
503
+
504
+ const closeTab = (id: string) => {
505
+ if (tabs.length === 1) return;
506
+
507
+ const tabToClose = tabs.find(t => t.id === id);
508
+ if (tabToClose) {
509
+ fetch('/api/terminal', {
510
+ method: 'POST',
511
+ headers: { 'Content-Type': 'application/json' },
512
+ body: JSON.stringify({
513
+ action: 'destroy',
514
+ sessionId: tabToClose.sessionId,
515
+ }),
516
+ }).catch(() => {});
517
+ }
518
+
519
+ const remaining = tabs.filter(t => t.id !== id);
520
+ if (tabs.find(t => t.id === id)?.isActive && remaining.length > 0) {
521
+ remaining[0].isActive = true;
522
+ }
523
+ setTabs(remaining);
524
+ };
525
+
526
+ const selectTab = (id: string) => {
527
+ if (activeTab?.id === id) return;
528
+
529
+ // Cleanup current session connection
530
+ if (activeTab) {
531
+ eventSourceRef.current?.close();
532
+ }
533
+
534
+ setTabs(tabs.map(t => ({ ...t, isActive: t.id === id })));
535
+ };
536
+
537
+ // Write a command to the terminal (types it and executes)
538
+ const writeCommand = useCallback((command: string) => {
539
+ if (!activeTab) return;
540
+
541
+ // Write the command to the PTY (with Enter key)
542
+ fetch('/api/terminal', {
543
+ method: 'POST',
544
+ headers: { 'Content-Type': 'application/json' },
545
+ body: JSON.stringify({
546
+ action: 'write',
547
+ sessionId: activeTab.sessionId,
548
+ data: command + '\r',
549
+ }),
550
+ }).catch(() => {});
551
+ }, [activeTab]);
552
+
553
+ return (
554
+ <div
555
+ className={cn(
556
+ 'flex flex-col bg-[#0a0a0f] text-white border-t border-white/10',
557
+ isMaximized && 'h-full'
558
+ )}
559
+ style={isMaximized ? undefined : { height }}
560
+ >
561
+ {/* Resize Handle */}
562
+ {!isMaximized && (
563
+ <div
564
+ className={cn(
565
+ 'h-1 cursor-row-resize group flex items-center justify-center',
566
+ 'hover:bg-purple-500/30 transition-colors',
567
+ isResizing && 'bg-purple-500/50'
568
+ )}
569
+ onMouseDown={handleResizeStart}
570
+ >
571
+ <div className={cn(
572
+ 'w-12 h-0.5 rounded-full bg-white/20 group-hover:bg-purple-400/50 transition-colors',
573
+ isResizing && 'bg-purple-400'
574
+ )} />
575
+ </div>
576
+ )}
577
+
578
+ {/* Header */}
579
+ <div className="flex items-center justify-between px-2 py-1 bg-[#12121a] border-b border-white/10 flex-shrink-0">
580
+ <div className="flex items-center gap-1">
581
+ {tabs.map((tab) => (
582
+ <div
583
+ key={tab.id}
584
+ onClick={() => selectTab(tab.id)}
585
+ className={cn(
586
+ 'flex items-center gap-2 px-3 py-1 rounded text-xs cursor-pointer group',
587
+ tab.isActive
588
+ ? 'bg-white/10 text-white'
589
+ : 'text-gray-400 hover:text-white hover:bg-white/5'
590
+ )}
591
+ >
592
+ <TerminalIcon className="w-3 h-3" />
593
+ {tab.name}
594
+ {tab.isActive && (
595
+ <span className={cn(
596
+ 'w-1.5 h-1.5 rounded-full',
597
+ isConnected ? 'bg-green-400' : isConnecting ? 'bg-yellow-400 animate-pulse' : 'bg-red-400'
598
+ )} />
599
+ )}
600
+ {tabs.length > 1 && (
601
+ <button
602
+ onClick={(e) => { e.stopPropagation(); closeTab(tab.id); }}
603
+ className="opacity-0 group-hover:opacity-100 hover:text-red-400 transition-opacity"
604
+ >
605
+ <X className="w-3 h-3" />
606
+ </button>
607
+ )}
608
+ </div>
609
+ ))}
610
+ <button
611
+ onClick={addTab}
612
+ className="p-1 text-gray-400 hover:text-white hover:bg-white/10 rounded transition-colors"
613
+ >
614
+ <Plus className="w-3 h-3" />
615
+ </button>
616
+ </div>
617
+
618
+ <div className="flex items-center gap-1">
619
+ <button
620
+ onClick={onToggleMaximize}
621
+ className="p-1 text-gray-400 hover:text-white hover:bg-white/10 rounded transition-colors"
622
+ >
623
+ {isMaximized ? (
624
+ <Minimize2 className="w-3.5 h-3.5" />
625
+ ) : (
626
+ <Maximize2 className="w-3.5 h-3.5" />
627
+ )}
628
+ </button>
629
+ <button
630
+ onClick={onClose}
631
+ className="p-1 text-gray-400 hover:text-white hover:bg-white/10 rounded transition-colors"
632
+ >
633
+ <ChevronDown className="w-3.5 h-3.5" />
634
+ </button>
635
+ </div>
636
+ </div>
637
+
638
+ {/* Terminal Container */}
639
+ <div
640
+ ref={containerRef}
641
+ className="flex-1 min-h-0 p-1"
642
+ style={{ backgroundColor: '#0a0a0f' }}
643
+ />
644
+
645
+ {/* Quick Actions Bar */}
646
+ <div className="flex items-center gap-2 px-2 py-1.5 bg-[#12121a] border-t border-white/10 flex-shrink-0 overflow-x-auto">
647
+ {/* Quick Commands */}
648
+ <div className="flex items-center gap-1">
649
+ {QUICK_COMMANDS.map((cmd) => (
650
+ <button
651
+ key={cmd.label}
652
+ onClick={() => writeCommand(cmd.command)}
653
+ className="flex items-center gap-1.5 px-2 py-1 text-xs text-gray-400 hover:text-white hover:bg-white/10 rounded transition-colors"
654
+ title={cmd.command}
655
+ >
656
+ <cmd.icon className="w-3 h-3" />
657
+ {cmd.label}
658
+ </button>
659
+ ))}
660
+ </div>
661
+
662
+ <div className="w-px h-4 bg-white/10" />
663
+
664
+ {/* Agent Actions */}
665
+ <div className="flex items-center gap-1">
666
+ <span className="text-xs text-gray-500 mr-1">Agents:</span>
667
+ {AGENT_ACTIONS.map((agent) => (
668
+ <button
669
+ key={agent.id}
670
+ onClick={() => writeCommand(agent.command)}
671
+ className="flex items-center gap-1.5 px-2 py-1 text-xs rounded transition-colors hover:bg-white/10"
672
+ style={{ color: agent.color }}
673
+ title={agent.command}
674
+ >
675
+ <AgentIcon agentId={agent.id} size={12} />
676
+ {agent.label}
677
+ </button>
678
+ ))}
679
+ </div>
680
+ </div>
681
+ </div>
682
+ );
683
+ }