@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.
- package/.claude/commands/agents/architect.md +1162 -0
- package/.claude/commands/agents/architect.meta.yaml +124 -0
- package/.claude/commands/agents/builder.md +1432 -0
- package/.claude/commands/agents/builder.meta.yaml +117 -0
- package/.claude/commands/agents/chronicler.md +633 -0
- package/.claude/commands/agents/chronicler.meta.yaml +217 -0
- package/.claude/commands/agents/guardian.md +456 -0
- package/.claude/commands/agents/guardian.meta.yaml +127 -0
- package/.claude/commands/agents/strategist.md +483 -0
- package/.claude/commands/agents/strategist.meta.yaml +158 -0
- package/.claude/commands/agents/system-designer.md +1137 -0
- package/.claude/commands/agents/system-designer.meta.yaml +156 -0
- package/.claude/commands/devflow-help.md +93 -0
- package/.claude/commands/devflow-status.md +60 -0
- package/.claude/commands/quick/create-adr.md +82 -0
- package/.claude/commands/quick/new-feature.md +57 -0
- package/.claude/commands/quick/security-check.md +54 -0
- package/.claude/commands/quick/system-design.md +58 -0
- package/.claude_project +52 -0
- package/.devflow/agents/architect.meta.yaml +122 -0
- package/.devflow/agents/builder.meta.yaml +116 -0
- package/.devflow/agents/chronicler.meta.yaml +222 -0
- package/.devflow/agents/guardian.meta.yaml +127 -0
- package/.devflow/agents/strategist.meta.yaml +158 -0
- package/.devflow/agents/system-designer.meta.yaml +265 -0
- package/.devflow/project.yaml +242 -0
- package/.gitignore-template +84 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/bin/devflow.js +54 -0
- package/lib/autopilot.js +235 -0
- package/lib/autopilotConstants.js +213 -0
- package/lib/constants.js +95 -0
- package/lib/init.js +200 -0
- package/lib/update.js +181 -0
- package/lib/utils.js +157 -0
- package/lib/web.js +119 -0
- package/package.json +57 -0
- package/web/CHANGELOG.md +192 -0
- package/web/README.md +156 -0
- package/web/app/api/autopilot/execute/route.ts +102 -0
- package/web/app/api/autopilot/terminal-execute/route.ts +124 -0
- package/web/app/api/files/route.ts +280 -0
- package/web/app/api/files/tree/route.ts +160 -0
- package/web/app/api/git/route.ts +201 -0
- package/web/app/api/health/route.ts +94 -0
- package/web/app/api/project/open/route.ts +134 -0
- package/web/app/api/search/route.ts +247 -0
- package/web/app/api/specs/route.ts +405 -0
- package/web/app/api/terminal/route.ts +222 -0
- package/web/app/globals.css +160 -0
- package/web/app/ide/layout.tsx +43 -0
- package/web/app/ide/page.tsx +216 -0
- package/web/app/layout.tsx +34 -0
- package/web/app/page.tsx +303 -0
- package/web/components/agents/AgentIcons.tsx +281 -0
- package/web/components/autopilot/AutopilotConfigModal.tsx +245 -0
- package/web/components/autopilot/AutopilotPanel.tsx +299 -0
- package/web/components/dashboard/DashboardPanel.tsx +393 -0
- package/web/components/editor/Breadcrumbs.tsx +134 -0
- package/web/components/editor/EditorPanel.tsx +120 -0
- package/web/components/editor/EditorTabs.tsx +229 -0
- package/web/components/editor/MarkdownPreview.tsx +154 -0
- package/web/components/editor/MermaidDiagram.tsx +113 -0
- package/web/components/editor/MonacoEditor.tsx +177 -0
- package/web/components/editor/TabContextMenu.tsx +207 -0
- package/web/components/git/GitPanel.tsx +534 -0
- package/web/components/layout/Shell.tsx +15 -0
- package/web/components/layout/StatusBar.tsx +100 -0
- package/web/components/modals/CommandPalette.tsx +393 -0
- package/web/components/modals/GlobalSearch.tsx +348 -0
- package/web/components/modals/QuickOpen.tsx +241 -0
- package/web/components/modals/RecentFiles.tsx +208 -0
- package/web/components/projects/ProjectSelector.tsx +147 -0
- package/web/components/settings/SettingItem.tsx +150 -0
- package/web/components/settings/SettingsPanel.tsx +323 -0
- package/web/components/specs/SpecsPanel.tsx +1091 -0
- package/web/components/terminal/TerminalPanel.tsx +683 -0
- package/web/components/ui/ContextMenu.tsx +182 -0
- package/web/components/ui/LoadingSpinner.tsx +66 -0
- package/web/components/ui/ResizeHandle.tsx +110 -0
- package/web/components/ui/Skeleton.tsx +108 -0
- package/web/components/ui/SkipLinks.tsx +37 -0
- package/web/components/ui/Toaster.tsx +57 -0
- package/web/hooks/useFocusTrap.ts +141 -0
- package/web/hooks/useKeyboardShortcuts.ts +169 -0
- package/web/hooks/useListNavigation.ts +237 -0
- package/web/lib/autopilotConstants.ts +213 -0
- package/web/lib/constants/agents.ts +67 -0
- package/web/lib/git.ts +339 -0
- package/web/lib/ptyManager.ts +191 -0
- package/web/lib/specsParser.ts +299 -0
- package/web/lib/stores/autopilotStore.ts +288 -0
- package/web/lib/stores/fileStore.ts +550 -0
- package/web/lib/stores/gitStore.ts +386 -0
- package/web/lib/stores/projectStore.ts +196 -0
- package/web/lib/stores/settingsStore.ts +126 -0
- package/web/lib/stores/specsStore.ts +297 -0
- package/web/lib/stores/uiStore.ts +175 -0
- package/web/lib/types/index.ts +177 -0
- package/web/lib/utils.ts +98 -0
- package/web/next.config.js +50 -0
- package/web/package.json +54 -0
- package/web/postcss.config.js +6 -0
- package/web/tailwind.config.ts +68 -0
- 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
|
+
}
|