projax 3.3.4 → 3.3.7

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 (2) hide show
  1. package/dist/prxi.tsx +1483 -0
  2. package/package.json +2 -2
package/dist/prxi.tsx ADDED
@@ -0,0 +1,1483 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { render, Box, Text, useInput, useApp, useFocus, useFocusManager } from 'ink';
3
+ import {
4
+ getDatabaseManager,
5
+ getAllProjects,
6
+ scanProject,
7
+ Project,
8
+ } from '../../cli/src/core-bridge';
9
+ import { getProjectScripts, getRunningProcessesClean, runScriptInBackground, stopScript } from '../../cli/src/script-runner';
10
+ import { spawn } from 'child_process';
11
+ import * as path from 'path';
12
+ import * as os from 'os';
13
+
14
+ // Color scheme matching desktop app
15
+ const colors = {
16
+ bgPrimary: '#0d1117',
17
+ bgSecondary: '#161b22',
18
+ bgTertiary: '#1c2128',
19
+ bgHover: '#21262d',
20
+ borderColor: '#30363d',
21
+ textPrimary: '#c9d1d9',
22
+ textSecondary: '#8b949e',
23
+ textTertiary: '#6e7681',
24
+ accentCyan: '#39c5cf',
25
+ accentBlue: '#58a6ff',
26
+ accentGreen: '#3fb950',
27
+ accentPurple: '#bc8cff',
28
+ accentOrange: '#ffa657',
29
+ };
30
+
31
+ // Helper function to get display path
32
+ function getDisplayPath(fullPath: string): string {
33
+ const parts = fullPath.split('/').filter(Boolean);
34
+ if (parts.length === 0) return fullPath;
35
+
36
+ const lastDir = parts[parts.length - 1];
37
+
38
+ // If last directory is "src", go one up
39
+ if (lastDir === 'src' && parts.length > 1) {
40
+ return parts[parts.length - 2];
41
+ }
42
+
43
+ return lastDir;
44
+ }
45
+
46
+ // Helper function to truncate text
47
+ function truncateText(text: string, maxLength: number): string {
48
+ if (text.length <= maxLength) return text;
49
+ return text.substring(0, maxLength - 3) + '...';
50
+ }
51
+
52
+ interface HelpModalProps {
53
+ onClose: () => void;
54
+ }
55
+
56
+ const HelpModal: React.FC<HelpModalProps> = ({ onClose }) => {
57
+ useInput((input: string, key: any) => {
58
+ if (input === 'q' || key.escape || key.return) {
59
+ onClose();
60
+ }
61
+ });
62
+
63
+ return (
64
+ <Box
65
+ flexDirection="column"
66
+ borderStyle="round"
67
+ borderColor={colors.accentCyan}
68
+ padding={1}
69
+ width={70}
70
+ >
71
+ <Text bold color={colors.accentCyan}>
72
+ PROJAX Terminal UI - Help
73
+ </Text>
74
+ <Text> </Text>
75
+ <Text color={colors.accentCyan}>Navigation:</Text>
76
+ <Text> ↑/k Move up in project list</Text>
77
+ <Text> ↓/j Move down in project list</Text>
78
+ <Text> Tab/←→ Switch between list and details</Text>
79
+ <Text> </Text>
80
+ <Text color={colors.accentCyan}>List Panel Actions:</Text>
81
+ <Text> / Search projects (fuzzy search)</Text>
82
+ <Text> s Scan selected project for tests</Text>
83
+ <Text> </Text>
84
+ <Text color={colors.accentCyan}>Details Panel Actions:</Text>
85
+ <Text> ↑↓/kj Scroll details</Text>
86
+ <Text> e Edit project name</Text>
87
+ <Text> t Add/edit tags</Text>
88
+ <Text> o Open project in editor</Text>
89
+ <Text> f Open project directory</Text>
90
+ <Text> u Show detected URLs</Text>
91
+ <Text> s Scan project for tests</Text>
92
+ <Text> p Scan ports for project</Text>
93
+ <Text> r Show scripts (use CLI to run)</Text>
94
+ <Text> x Stop all scripts for project</Text>
95
+ <Text> d Delete project</Text>
96
+ <Text> </Text>
97
+ <Text color={colors.accentCyan}>Editing:</Text>
98
+ <Text> Enter Save changes</Text>
99
+ <Text> Esc Cancel editing</Text>
100
+ <Text> </Text>
101
+ <Text color={colors.accentCyan}>General:</Text>
102
+ <Text> q/Esc Quit</Text>
103
+ <Text> ? Show this help</Text>
104
+ <Text> </Text>
105
+ <Text color={colors.textSecondary}>Press any key to close...</Text>
106
+ </Box>
107
+ );
108
+ };
109
+
110
+ interface LoadingModalProps {
111
+ message: string;
112
+ }
113
+
114
+ const LoadingModal: React.FC<LoadingModalProps> = ({ message }) => {
115
+ return (
116
+ <Box
117
+ flexDirection="column"
118
+ borderStyle="round"
119
+ borderColor={colors.accentCyan}
120
+ padding={1}
121
+ width={40}
122
+ >
123
+ <Text>{message}</Text>
124
+ <Text color={colors.textSecondary}>Please wait...</Text>
125
+ </Box>
126
+ );
127
+ };
128
+
129
+ interface ErrorModalProps {
130
+ message: string;
131
+ onClose: () => void;
132
+ }
133
+
134
+ const ErrorModal: React.FC<ErrorModalProps> = ({ message, onClose }) => {
135
+ useInput((input: string, key: any) => {
136
+ if (key.escape || key.return) {
137
+ onClose();
138
+ }
139
+ });
140
+
141
+ return (
142
+ <Box
143
+ flexDirection="column"
144
+ borderStyle="round"
145
+ borderColor="#f85149"
146
+ padding={1}
147
+ width={60}
148
+ >
149
+ <Text color="#f85149" bold>
150
+ Error
151
+ </Text>
152
+ <Text> </Text>
153
+ <Text>{message}</Text>
154
+ <Text> </Text>
155
+ <Text color={colors.textSecondary}>Press any key to close...</Text>
156
+ </Box>
157
+ );
158
+ };
159
+
160
+ interface ScriptSelectionModalProps {
161
+ scripts: Map<string, any>;
162
+ projectName: string;
163
+ projectPath: string;
164
+ onSelect: (scriptName: string, background: boolean) => void;
165
+ onClose: () => void;
166
+ }
167
+
168
+ const ScriptSelectionModal: React.FC<ScriptSelectionModalProps> = ({
169
+ scripts,
170
+ projectName,
171
+ projectPath,
172
+ onSelect,
173
+ onClose
174
+ }) => {
175
+ const [selectedIndex, setSelectedIndex] = useState(0);
176
+ const scriptArray = Array.from(scripts.entries());
177
+
178
+ useInput((input: string, key: any) => {
179
+ if (key.escape || input === 'q') {
180
+ onClose();
181
+ return;
182
+ }
183
+
184
+ if (key.upArrow || input === 'k') {
185
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
186
+ return;
187
+ }
188
+
189
+ if (key.downArrow || input === 'j') {
190
+ setSelectedIndex((prev) => Math.min(scriptArray.length - 1, prev + 1));
191
+ return;
192
+ }
193
+
194
+ if (key.return) {
195
+ const [scriptName] = scriptArray[selectedIndex];
196
+ onSelect(scriptName, false);
197
+ return;
198
+ }
199
+
200
+ if (input === 'b') {
201
+ const [scriptName] = scriptArray[selectedIndex];
202
+ onSelect(scriptName, true);
203
+ return;
204
+ }
205
+ });
206
+
207
+ return (
208
+ <Box
209
+ flexDirection="column"
210
+ borderStyle="round"
211
+ borderColor={colors.accentCyan}
212
+ padding={1}
213
+ width={80}
214
+ >
215
+ <Text bold color={colors.accentCyan}>
216
+ Run Script - {projectName}
217
+ </Text>
218
+ <Text> </Text>
219
+ {scriptArray.map(([name, script], index) => {
220
+ const isSelected = index === selectedIndex;
221
+ return (
222
+ <Text key={name} color={isSelected ? colors.accentCyan : colors.textPrimary} bold={isSelected}>
223
+ {isSelected ? '▶ ' : ' '}
224
+ <Text color={colors.accentGreen}>{name}</Text>
225
+ {' - '}
226
+ <Text color={colors.textSecondary}>{truncateText(script.command, 50)}</Text>
227
+ </Text>
228
+ );
229
+ })}
230
+ <Text> </Text>
231
+ <Text color={colors.textSecondary}>↑↓/kj: Navigate | Enter: Run | b: Background | Esc/q: Cancel</Text>
232
+ </Box>
233
+ );
234
+ };
235
+
236
+ interface ProjectListProps {
237
+ projects: Project[];
238
+ selectedIndex: number;
239
+ runningProcesses: any[];
240
+ isFocused: boolean;
241
+ height: number;
242
+ scrollOffset: number;
243
+ }
244
+
245
+ const ProjectListComponent: React.FC<ProjectListProps> = ({
246
+ projects,
247
+ selectedIndex,
248
+ runningProcesses,
249
+ isFocused,
250
+ height,
251
+ scrollOffset,
252
+ }) => {
253
+ const { focus } = useFocus({ id: 'projectList' });
254
+
255
+ // Calculate visible range
256
+ const startIndex = Math.max(0, scrollOffset);
257
+ const hasMoreAbove = startIndex > 0;
258
+ const headerHeight = 1; // "Projects (12)" line
259
+ const paddingHeight = 2; // top + bottom padding
260
+ const scrollIndicatorHeight = (hasMoreAbove ? 1 : 0) + 1; // "more above" + "more below" (always reserve space)
261
+ const visibleHeight = Math.max(5, height - paddingHeight - headerHeight - scrollIndicatorHeight);
262
+ const endIndex = Math.min(projects.length, startIndex + visibleHeight);
263
+ const visibleProjects = projects.slice(startIndex, endIndex);
264
+ const hasMoreBelow = endIndex < projects.length;
265
+
266
+ return (
267
+ <Box
268
+ flexDirection="column"
269
+ width="35%"
270
+ height={height}
271
+ borderStyle="round"
272
+ borderColor={isFocused ? colors.accentCyan : colors.borderColor}
273
+ padding={1}
274
+ flexShrink={0}
275
+ flexGrow={0}
276
+ >
277
+ <Text bold color={colors.textPrimary}>
278
+ Projects ({projects.length})
279
+ </Text>
280
+ <Box flexDirection="column" flexGrow={1}>
281
+ {projects.length === 0 ? (
282
+ <Text color={colors.textTertiary}>No projects found</Text>
283
+ ) : (
284
+ <>
285
+ {hasMoreAbove && (
286
+ <Text color={colors.textTertiary}>↑ {startIndex} more above</Text>
287
+ )}
288
+ {visibleProjects.map((project, localIndex) => {
289
+ const index = startIndex + localIndex;
290
+ const isSelected = index === selectedIndex;
291
+
292
+ // Check if this project has running scripts
293
+ const projectRunning = runningProcesses.filter(
294
+ (p: any) => p.projectPath === project.path
295
+ );
296
+ const hasRunningScripts = projectRunning.length > 0;
297
+
298
+ return (
299
+ <Text key={project.id} color={isSelected ? colors.accentCyan : colors.textPrimary} bold={isSelected}>
300
+ {isSelected ? '▶ ' : ' '}
301
+ {hasRunningScripts && <Text color={colors.accentGreen}>● </Text>}
302
+ {truncateText(project.name, 30)}
303
+ {hasRunningScripts && <Text color={colors.accentGreen}> ({projectRunning.length})</Text>}
304
+ </Text>
305
+ );
306
+ })}
307
+ {hasMoreBelow && (
308
+ <Text color={colors.textTertiary}>↓ {projects.length - endIndex} more below</Text>
309
+ )}
310
+ </>
311
+ )}
312
+ </Box>
313
+ </Box>
314
+ );
315
+ };
316
+
317
+ interface ProjectDetailsProps {
318
+ project: Project | null;
319
+ runningProcesses: any[];
320
+ isFocused: boolean;
321
+ editingName: boolean;
322
+ editingDescription: boolean;
323
+ editingTags: boolean;
324
+ editInput: string;
325
+ allTags: string[];
326
+ onTagRemove?: (tag: string) => void;
327
+ height: number;
328
+ scrollOffset: number;
329
+ }
330
+
331
+ const ProjectDetailsComponent: React.FC<ProjectDetailsProps> = ({
332
+ project,
333
+ runningProcesses,
334
+ isFocused,
335
+ editingName,
336
+ editingDescription,
337
+ editingTags,
338
+ editInput,
339
+ allTags,
340
+ onTagRemove,
341
+ height,
342
+ scrollOffset,
343
+ }) => {
344
+ const { focus } = useFocus({ id: 'projectDetails' });
345
+ const [scripts, setScripts] = useState<any>(null);
346
+ const [ports, setPorts] = useState<any[]>([]);
347
+ const [tests, setTests] = useState<any[]>([]);
348
+
349
+ useEffect(() => {
350
+ if (!project) {
351
+ setScripts(null);
352
+ setPorts([]);
353
+ setTests([]);
354
+ return;
355
+ }
356
+
357
+ // Load scripts
358
+ try {
359
+ const projectScripts = getProjectScripts(project.path);
360
+ setScripts(projectScripts);
361
+ } catch (error) {
362
+ setScripts(null);
363
+ }
364
+
365
+ // Load ports
366
+ try {
367
+ const db = getDatabaseManager();
368
+ const projectPorts = db.getProjectPorts(project.id);
369
+ setPorts(projectPorts);
370
+ } catch (error) {
371
+ setPorts([]);
372
+ }
373
+
374
+ // Load tests
375
+ try {
376
+ const db = getDatabaseManager();
377
+ const projectTests = db.getTestsByProject(project.id);
378
+ setTests(projectTests);
379
+ } catch (error) {
380
+ setTests([]);
381
+ }
382
+ }, [project]);
383
+
384
+ if (!project) {
385
+ return (
386
+ <Box
387
+ flexDirection="column"
388
+ flexGrow={1}
389
+ borderStyle="round"
390
+ borderColor={isFocused ? colors.accentCyan : colors.borderColor}
391
+ padding={1}
392
+ >
393
+ <Text color={colors.textSecondary}>Select a project to view details</Text>
394
+ </Box>
395
+ );
396
+ }
397
+
398
+ const lastScanned = project.last_scanned
399
+ ? new Date(project.last_scanned * 1000).toLocaleString()
400
+ : 'Never';
401
+
402
+ // Get running processes for this project
403
+ const projectProcesses = runningProcesses.filter((p: any) => p.projectPath === project.path);
404
+
405
+ // Count tests by framework
406
+ const testsByFramework = tests.reduce((acc: any, test: any) => {
407
+ const framework = test.framework || 'unknown';
408
+ acc[framework] = (acc[framework] || 0) + 1;
409
+ return acc;
410
+ }, {});
411
+
412
+ // Build content lines for virtual scrolling
413
+ const contentLines: React.ReactNode[] = [];
414
+
415
+ // Header section (always visible at top)
416
+ contentLines.push(
417
+ editingName ? (
418
+ <Box key="edit-name">
419
+ <Text color={colors.accentCyan}>Editing name: </Text>
420
+ <Text color={colors.textPrimary}>{editInput}</Text>
421
+ <Text color={colors.textSecondary}> (Press Enter to save, Esc to cancel)</Text>
422
+ </Box>
423
+ ) : (
424
+ <Text key="name" bold color={colors.textPrimary}>
425
+ {project.name}
426
+ </Text>
427
+ )
428
+ );
429
+
430
+ if (editingDescription) {
431
+ contentLines.push(
432
+ <Box key="edit-desc">
433
+ <Text color={colors.accentCyan}>Editing description: </Text>
434
+ <Text color={colors.textPrimary}>{editInput}</Text>
435
+ <Text color={colors.textSecondary}> (Press Enter to save, Esc to cancel)</Text>
436
+ </Box>
437
+ );
438
+ } else if (project.description) {
439
+ contentLines.push(
440
+ <Text key="desc" color={colors.textSecondary}>{truncateText(project.description, 100)}</Text>
441
+ );
442
+ }
443
+
444
+ contentLines.push(
445
+ <Text key="path" color={colors.textTertiary}>{truncateText(project.path, 100)}</Text>
446
+ );
447
+
448
+ // Tags (simplified to single line)
449
+ if (project.tags && project.tags.length > 0) {
450
+ const tagsText = project.tags.join(', ');
451
+ contentLines.push(
452
+ <Text key="tags">
453
+ <Text color={colors.textSecondary}>Tags: </Text>
454
+ <Text color={colors.accentPurple}>{truncateText(tagsText, 80)}</Text>
455
+ </Text>
456
+ );
457
+ }
458
+
459
+ if (editingTags) {
460
+ contentLines.push(
461
+ <Text key="edit-tags">
462
+ <Text color={colors.accentCyan}>Add tag: </Text>
463
+ <Text color={colors.textPrimary}>{editInput}</Text>
464
+ </Text>
465
+ );
466
+ const suggestions = allTags.filter(t => t.toLowerCase().includes(editInput.toLowerCase()) && !project.tags?.includes(t)).slice(0, 3);
467
+ if (editInput && suggestions.length > 0) {
468
+ contentLines.push(
469
+ <Text key="suggestions">
470
+ <Text color={colors.textTertiary}>Suggestions: </Text>
471
+ <Text color={colors.accentPurple}>{suggestions.join(', ')}</Text>
472
+ </Text>
473
+ );
474
+ }
475
+ }
476
+
477
+ contentLines.push(<Text key="spacer1"> </Text>);
478
+
479
+ // Stats
480
+ contentLines.push(
481
+ <Box key="stats">
482
+ <Text>Tests: <Text color={colors.accentCyan}>{tests.length}</Text></Text>
483
+ <Text> | </Text>
484
+ <Text>Frameworks: <Text color={colors.accentCyan}>{Object.keys(testsByFramework).length}</Text></Text>
485
+ <Text> | </Text>
486
+ <Text>Ports: <Text color={colors.accentCyan}>{ports.length}</Text></Text>
487
+ <Text> | </Text>
488
+ <Text>Scripts: <Text color={colors.accentCyan}>{scripts?.scripts?.size || 0}</Text></Text>
489
+ </Box>
490
+ );
491
+
492
+ contentLines.push(<Text key="spacer2"> </Text>);
493
+
494
+ if (project.framework) {
495
+ contentLines.push(
496
+ <Text key="framework">
497
+ Framework: <Text color={colors.accentCyan}>{project.framework}</Text>
498
+ </Text>
499
+ );
500
+ }
501
+
502
+ contentLines.push(
503
+ <Text key="last-scanned">Last Scanned: {lastScanned}</Text>
504
+ );
505
+ contentLines.push(<Text key="spacer3"> </Text>);
506
+
507
+ // Running Processes
508
+ if (projectProcesses.length > 0) {
509
+ contentLines.push(
510
+ <Text key="proc-header" bold color={colors.accentGreen}>
511
+ Running Processes ({projectProcesses.length}):
512
+ </Text>
513
+ );
514
+ projectProcesses.forEach((process: any) => {
515
+ const uptime = Math.floor((Date.now() - process.startedAt) / 1000);
516
+ const minutes = Math.floor(uptime / 60);
517
+ const seconds = uptime % 60;
518
+ const uptimeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
519
+
520
+ contentLines.push(
521
+ <Text key={`proc-${process.pid}`}>
522
+ {' '}
523
+ <Text color={colors.accentGreen}>●</Text>
524
+ {' '}
525
+ <Text color={colors.textPrimary}>{process.scriptName}</Text>
526
+ <Text color={colors.textSecondary}> (PID: {process.pid}, {uptimeStr})</Text>
527
+ </Text>
528
+ );
529
+ });
530
+ contentLines.push(<Text key="spacer4"> </Text>);
531
+ }
532
+
533
+ // Scripts
534
+ if (scripts && scripts.scripts && scripts.scripts.size > 0) {
535
+ contentLines.push(
536
+ <Text key="scripts-header" bold>
537
+ Available Scripts (<Text color={colors.accentCyan}>{scripts.scripts.size}</Text>):
538
+ </Text>
539
+ );
540
+ Array.from(scripts.scripts.entries() as IterableIterator<[string, any]>).slice(0, 5).forEach(([name, script]) => {
541
+ contentLines.push(
542
+ <Text key={`script-${name}`}>
543
+ {' '}
544
+ <Text color={colors.accentGreen}>{name}</Text>
545
+ {' - '}
546
+ <Text color={colors.textSecondary}>{truncateText(script.command, 60)}</Text>
547
+ </Text>
548
+ );
549
+ });
550
+ if (scripts.scripts.size > 5) {
551
+ contentLines.push(
552
+ <Text key="scripts-more" color={colors.textTertiary}> ... and {scripts.scripts.size - 5} more</Text>
553
+ );
554
+ }
555
+ contentLines.push(<Text key="spacer5"> </Text>);
556
+ }
557
+
558
+ // Ports
559
+ if (ports.length > 0) {
560
+ contentLines.push(
561
+ <Text key="ports-header" bold>
562
+ Detected Ports (<Text color={colors.accentCyan}>{ports.length}</Text>):
563
+ </Text>
564
+ );
565
+ ports.slice(0, 5).forEach((port: any) => {
566
+ contentLines.push(
567
+ <Text key={`port-${port.id}`}>
568
+ {' '}Port <Text color={colors.accentCyan}>{port.port}</Text>
569
+ <Text color={colors.textSecondary}> - {truncateText(port.config_source, 50)}</Text>
570
+ </Text>
571
+ );
572
+ });
573
+ if (ports.length > 5) {
574
+ contentLines.push(
575
+ <Text key="ports-more" color={colors.textTertiary}> ... and {ports.length - 5} more</Text>
576
+ );
577
+ }
578
+ contentLines.push(<Text key="spacer6"> </Text>);
579
+ }
580
+
581
+ // Test Files
582
+ if (tests.length > 0) {
583
+ contentLines.push(
584
+ <Text key="tests-header" bold>
585
+ Test Files (<Text color={colors.accentCyan}>{tests.length}</Text>):
586
+ </Text>
587
+ );
588
+ Object.entries(testsByFramework).forEach(([framework, count]) => {
589
+ contentLines.push(
590
+ <Text key={`test-${framework}`}>
591
+ {' '}
592
+ <Text color={colors.accentPurple}>{framework}</Text>
593
+ {': '}
594
+ <Text color={colors.textSecondary}>{count as number}</Text>
595
+ </Text>
596
+ );
597
+ });
598
+ contentLines.push(<Text key="spacer7"> </Text>);
599
+ }
600
+
601
+ // Calculate visible range for virtual scrolling
602
+ // Render enough items to fill the available space
603
+ const startIndex = Math.max(0, scrollOffset);
604
+ const hasMoreAbove = startIndex > 0;
605
+ const paddingHeight = 2; // top + bottom padding
606
+ // Reserve space for scroll indicators
607
+ const reservedForIndicators = (hasMoreAbove ? 1 : 0) + 1;
608
+ // Available space for content - be less conservative now that we have truncation
609
+ const availableContentHeight = height - paddingHeight - reservedForIndicators;
610
+ const visibleHeight = Math.max(5, availableContentHeight); // Render enough to fill space
611
+ const endIndex = Math.min(contentLines.length, startIndex + visibleHeight);
612
+ const visibleContent = contentLines.slice(startIndex, endIndex);
613
+ const hasMoreBelow = endIndex < contentLines.length;
614
+
615
+ return (
616
+ <Box
617
+ flexDirection="column"
618
+ width="65%"
619
+ height={height}
620
+ borderStyle="round"
621
+ borderColor={isFocused ? colors.accentCyan : colors.borderColor}
622
+ padding={1}
623
+ flexShrink={0}
624
+ flexGrow={0}
625
+ >
626
+ <Box flexDirection="column" flexGrow={1}>
627
+ {hasMoreAbove && (
628
+ <Text color={colors.textTertiary}>↑ {startIndex} more above</Text>
629
+ )}
630
+ {visibleContent}
631
+ {hasMoreBelow && (
632
+ <Text color={colors.textTertiary}>↓ {contentLines.length - endIndex} more below</Text>
633
+ )}
634
+ </Box>
635
+ </Box>
636
+ );
637
+ };
638
+
639
+ interface StatusBarProps {
640
+ focusedPanel: 'list' | 'details';
641
+ selectedProject: Project | null;
642
+ }
643
+
644
+ const StatusBar: React.FC<StatusBarProps> = ({ focusedPanel, selectedProject }) => {
645
+ if (focusedPanel === 'list') {
646
+ return (
647
+ <Box flexDirection="column">
648
+ <Box>
649
+ <Text color={colors.accentGreen}>● API</Text>
650
+ <Text color={colors.textSecondary}> | </Text>
651
+ <Text color={colors.textSecondary}>Focus: </Text>
652
+ <Text color={colors.accentCyan}>Projects</Text>
653
+ </Box>
654
+ <Box>
655
+ <Text bold>/</Text>
656
+ <Text color={colors.textSecondary}> Search | </Text>
657
+ <Text bold>↑↓/kj</Text>
658
+ <Text color={colors.textSecondary}> Navigate | </Text>
659
+ <Text bold>Tab/←→</Text>
660
+ <Text color={colors.textSecondary}> Switch | </Text>
661
+ <Text bold>s</Text>
662
+ <Text color={colors.textSecondary}> Scan | </Text>
663
+ <Text bold>?</Text>
664
+ <Text color={colors.textSecondary}> Help | </Text>
665
+ <Text bold>q</Text>
666
+ <Text color={colors.textSecondary}> Quit</Text>
667
+ </Box>
668
+ </Box>
669
+ );
670
+ }
671
+
672
+ // Details panel - show project-specific actions
673
+ return (
674
+ <Box flexDirection="column">
675
+ <Box>
676
+ <Text color={colors.accentGreen}>● API</Text>
677
+ <Text color={colors.textSecondary}> | </Text>
678
+ <Text color={colors.textSecondary}>Focus: </Text>
679
+ <Text color={colors.accentCyan}>Details</Text>
680
+ {selectedProject && (
681
+ <>
682
+ <Text color={colors.textSecondary}> | </Text>
683
+ <Text color={colors.textPrimary}>{selectedProject.name}</Text>
684
+ </>
685
+ )}
686
+ </Box>
687
+ <Box>
688
+ <Text bold>↑↓/kj</Text>
689
+ <Text color={colors.textSecondary}> Scroll | </Text>
690
+ <Text bold>e</Text>
691
+ <Text color={colors.textSecondary}> Edit | </Text>
692
+ <Text bold>t</Text>
693
+ <Text color={colors.textSecondary}> Tags | </Text>
694
+ <Text bold>o</Text>
695
+ <Text color={colors.textSecondary}> Editor | </Text>
696
+ <Text bold>f</Text>
697
+ <Text color={colors.textSecondary}> Files | </Text>
698
+ <Text bold>u</Text>
699
+ <Text color={colors.textSecondary}> URLs | </Text>
700
+ <Text bold>s</Text>
701
+ <Text color={colors.textSecondary}> Scan | </Text>
702
+ <Text bold>p</Text>
703
+ <Text color={colors.textSecondary}> Ports | </Text>
704
+ <Text bold>r</Text>
705
+ <Text color={colors.textSecondary}> Scripts | </Text>
706
+ <Text bold>x</Text>
707
+ <Text color={colors.textSecondary}> Stop | </Text>
708
+ <Text bold>d</Text>
709
+ <Text color={colors.textSecondary}> Delete | </Text>
710
+ <Text bold>Tab</Text>
711
+ <Text color={colors.textSecondary}> Switch | </Text>
712
+ <Text bold>?</Text>
713
+ <Text color={colors.textSecondary}> Help | </Text>
714
+ <Text bold>q</Text>
715
+ <Text color={colors.textSecondary}> Quit</Text>
716
+ </Box>
717
+ </Box>
718
+ );
719
+ };
720
+
721
+ // Simple fuzzy search function
722
+ function fuzzyMatch(query: string, text: string): boolean {
723
+ const queryLower = query.toLowerCase();
724
+ const textLower = text.toLowerCase();
725
+
726
+ if (queryLower === '') return true;
727
+
728
+ let queryIndex = 0;
729
+ for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
730
+ if (textLower[i] === queryLower[queryIndex]) {
731
+ queryIndex++;
732
+ }
733
+ }
734
+
735
+ return queryIndex === queryLower.length;
736
+ }
737
+
738
+ const App: React.FC = () => {
739
+ const { exit } = useApp();
740
+ const { focusNext, focusPrevious } = useFocusManager();
741
+ const [allProjects, setAllProjects] = useState<Project[]>([]);
742
+ const [projects, setProjects] = useState<Project[]>([]);
743
+ const [selectedIndex, setSelectedIndex] = useState(0);
744
+ const [showHelp, setShowHelp] = useState(false);
745
+ const [isLoading, setIsLoading] = useState(false);
746
+ const [loadingMessage, setLoadingMessage] = useState('');
747
+ const [error, setError] = useState<string | null>(null);
748
+ const [runningProcesses, setRunningProcesses] = useState<any[]>([]);
749
+ const [focusedPanel, setFocusedPanel] = useState<'list' | 'details'>('list');
750
+
751
+ // Editing state
752
+ const [editingName, setEditingName] = useState(false);
753
+ const [editingDescription, setEditingDescription] = useState(false);
754
+ const [editingTags, setEditingTags] = useState(false);
755
+ const [editInput, setEditInput] = useState('');
756
+ const [showUrls, setShowUrls] = useState(false);
757
+ const [allTags, setAllTags] = useState<string[]>([]);
758
+
759
+ // Search state
760
+ const [showSearch, setShowSearch] = useState(false);
761
+ const [searchQuery, setSearchQuery] = useState('');
762
+ const [listScrollOffset, setListScrollOffset] = useState(0);
763
+ const [detailsScrollOffset, setDetailsScrollOffset] = useState(0);
764
+
765
+ // Script selection state
766
+ const [showScriptModal, setShowScriptModal] = useState(false);
767
+ const [scriptModalData, setScriptModalData] = useState<{ scripts: Map<string, any>; projectName: string; projectPath: string } | null>(null);
768
+
769
+ // Get terminal dimensions
770
+ const terminalHeight = process.stdout.rows || 24;
771
+ const availableHeight = terminalHeight - 3; // Subtract status bar
772
+
773
+ useEffect(() => {
774
+ loadProjects();
775
+ loadRunningProcesses();
776
+ loadAllTags();
777
+
778
+ // Refresh running processes every 5 seconds
779
+ const interval = setInterval(() => {
780
+ loadRunningProcesses();
781
+ }, 5000);
782
+
783
+ return () => clearInterval(interval);
784
+ }, []);
785
+
786
+ // Reset editing state and scroll when project changes
787
+ useEffect(() => {
788
+ setEditingName(false);
789
+ setEditingDescription(false);
790
+ setEditingTags(false);
791
+ setEditInput('');
792
+ setDetailsScrollOffset(0); // Reset scroll when switching projects
793
+ }, [selectedIndex]);
794
+
795
+ // Update scroll offset when selected index changes
796
+ useEffect(() => {
797
+ const visibleHeight = Math.max(1, availableHeight - 3);
798
+ setListScrollOffset(prevOffset => {
799
+ if (selectedIndex < prevOffset) {
800
+ return Math.max(0, selectedIndex);
801
+ } else if (selectedIndex >= prevOffset + visibleHeight) {
802
+ return Math.max(0, selectedIndex - visibleHeight + 1);
803
+ }
804
+ return prevOffset;
805
+ });
806
+ }, [selectedIndex, availableHeight]);
807
+
808
+ const loadAllTags = () => {
809
+ try {
810
+ const db = getDatabaseManager();
811
+ const allProjects = getAllProjects();
812
+ const tagsSet = new Set<string>();
813
+ allProjects.forEach(project => {
814
+ if (project.tags && Array.isArray(project.tags)) {
815
+ project.tags.forEach(tag => tagsSet.add(tag));
816
+ }
817
+ });
818
+ setAllTags(Array.from(tagsSet));
819
+ } catch (error) {
820
+ setAllTags([]);
821
+ }
822
+ };
823
+
824
+ const loadProjects = () => {
825
+ const loadedProjects = getAllProjects();
826
+ setAllProjects(loadedProjects);
827
+ filterProjects(loadedProjects, searchQuery);
828
+ };
829
+
830
+ const filterProjects = (projectsToFilter: Project[], query: string) => {
831
+ if (!query.trim()) {
832
+ setProjects(projectsToFilter);
833
+ return;
834
+ }
835
+
836
+ const filtered = projectsToFilter.filter(project => {
837
+ const nameMatch = fuzzyMatch(query, project.name);
838
+ const descMatch = project.description ? fuzzyMatch(query, project.description) : false;
839
+ const pathMatch = fuzzyMatch(query, project.path);
840
+ const tagsMatch = project.tags?.some(tag => fuzzyMatch(query, tag)) || false;
841
+
842
+ return nameMatch || descMatch || pathMatch || tagsMatch;
843
+ });
844
+
845
+ setProjects(filtered);
846
+
847
+ // Adjust selected index if current selection is out of bounds
848
+ if (selectedIndex >= filtered.length) {
849
+ setSelectedIndex(Math.max(0, filtered.length - 1));
850
+ }
851
+ };
852
+
853
+ const loadRunningProcesses = async () => {
854
+ try {
855
+ const processes = await getRunningProcessesClean();
856
+ setRunningProcesses(processes);
857
+ } catch (error) {
858
+ setRunningProcesses([]);
859
+ }
860
+ };
861
+
862
+ const selectedProject = projects.length > 0 ? projects[selectedIndex] : null;
863
+
864
+ // Helper function to get editor command
865
+ const getEditorCommand = (): { command: string; args: string[] } => {
866
+ try {
867
+ // Try to load settings from core
868
+ const corePath = path.join(__dirname, '..', '..', 'core', 'dist', 'settings');
869
+ const settingsPath = path.join(__dirname, '..', '..', '..', 'core', 'dist', 'settings');
870
+ let settings: any;
871
+
872
+ try {
873
+ settings = require(corePath);
874
+ } catch {
875
+ try {
876
+ settings = require(settingsPath);
877
+ } catch {
878
+ // Fallback to default
879
+ return { command: 'code', args: [] };
880
+ }
881
+ }
882
+
883
+ const editorSettings = settings.getEditorSettings();
884
+ let command: string;
885
+ const args: string[] = [];
886
+
887
+ if (editorSettings.type === 'custom' && editorSettings.customPath) {
888
+ command = editorSettings.customPath;
889
+ } else {
890
+ switch (editorSettings.type) {
891
+ case 'vscode':
892
+ command = 'code';
893
+ break;
894
+ case 'cursor':
895
+ command = 'cursor';
896
+ break;
897
+ case 'windsurf':
898
+ command = 'windsurf';
899
+ break;
900
+ case 'zed':
901
+ command = 'zed';
902
+ break;
903
+ default:
904
+ command = 'code';
905
+ }
906
+ }
907
+
908
+ return { command, args };
909
+ } catch (error) {
910
+ return { command: 'code', args: [] };
911
+ }
912
+ };
913
+
914
+ // Helper function to open project in editor
915
+ const openInEditor = (projectPath: string) => {
916
+ try {
917
+ const { command, args } = getEditorCommand();
918
+ spawn(command, [...args, projectPath], {
919
+ detached: true,
920
+ stdio: 'ignore',
921
+ }).unref();
922
+ } catch (error) {
923
+ setError(`Failed to open editor: ${error instanceof Error ? error.message : String(error)}`);
924
+ }
925
+ };
926
+
927
+ // Helper function to open project directory
928
+ const openInFiles = (projectPath: string) => {
929
+ try {
930
+ let command: string;
931
+ const args: string[] = [projectPath];
932
+
933
+ if (os.platform() === 'darwin') {
934
+ command = 'open';
935
+ } else if (os.platform() === 'win32') {
936
+ command = 'explorer';
937
+ } else {
938
+ command = 'xdg-open';
939
+ }
940
+
941
+ spawn(command, args, {
942
+ detached: true,
943
+ stdio: 'ignore',
944
+ }).unref();
945
+ } catch (error) {
946
+ setError(`Failed to open file manager: ${error instanceof Error ? error.message : String(error)}`);
947
+ }
948
+ };
949
+
950
+ // Helper function to get URLs from project
951
+ const getProjectUrls = (project: Project): string[] => {
952
+ const urls = new Set<string>();
953
+
954
+ // Add URLs from running processes
955
+ const projectProcesses = runningProcesses.filter((p: any) => p.projectPath === project.path);
956
+ for (const process of projectProcesses) {
957
+ if (process.detectedUrls && Array.isArray(process.detectedUrls)) {
958
+ for (const url of process.detectedUrls) {
959
+ urls.add(url);
960
+ }
961
+ }
962
+ }
963
+
964
+ // Add URLs from detected ports
965
+ try {
966
+ const db = getDatabaseManager();
967
+ const projectPorts = db.getProjectPorts(project.id);
968
+ for (const portInfo of projectPorts) {
969
+ const url = `http://localhost:${portInfo.port}`;
970
+ urls.add(url);
971
+ }
972
+ } catch (error) {
973
+ // Ignore
974
+ }
975
+
976
+ return Array.from(urls).sort();
977
+ };
978
+
979
+ // Handler for script selection
980
+ const handleScriptSelect = async (scriptName: string, background: boolean) => {
981
+ if (!scriptModalData) return;
982
+
983
+ setShowScriptModal(false);
984
+ setIsLoading(true);
985
+ setLoadingMessage(`Running ${scriptName}${background ? ' in background' : ''}...`);
986
+
987
+ try {
988
+ if (background) {
989
+ await runScriptInBackground(scriptModalData.projectPath, scriptModalData.projectName, scriptName, [], false);
990
+ setIsLoading(false);
991
+ await loadRunningProcesses();
992
+ } else {
993
+ setIsLoading(false);
994
+ // Run in foreground - the CLI will exit and terminal control will be handed over
995
+ await runScript(scriptModalData.projectPath, scriptName, [], false);
996
+ exit();
997
+ }
998
+ } catch (err) {
999
+ setIsLoading(false);
1000
+ setError(err instanceof Error ? err.message : String(err));
1001
+ }
1002
+ };
1003
+
1004
+ useInput((input: string, key: any) => {
1005
+ // Handle search mode
1006
+ if (showSearch) {
1007
+ if (key.escape) {
1008
+ setShowSearch(false);
1009
+ setSearchQuery('');
1010
+ filterProjects(allProjects, '');
1011
+ return;
1012
+ }
1013
+
1014
+ if (key.return) {
1015
+ setShowSearch(false);
1016
+ return;
1017
+ }
1018
+
1019
+ if (key.backspace || key.delete) {
1020
+ const newQuery = searchQuery.slice(0, -1);
1021
+ setSearchQuery(newQuery);
1022
+ filterProjects(allProjects, newQuery);
1023
+ return;
1024
+ }
1025
+
1026
+ if (input && input.length === 1 && !key.ctrl && !key.meta) {
1027
+ const newQuery = searchQuery + input;
1028
+ setSearchQuery(newQuery);
1029
+ filterProjects(allProjects, newQuery);
1030
+ return;
1031
+ }
1032
+
1033
+ return;
1034
+ }
1035
+
1036
+ // Don't process input if modal is showing
1037
+ if (showHelp || isLoading || error || showUrls || showScriptModal) {
1038
+ // Handle URLs modal
1039
+ if (showUrls && (key.escape || key.return || input === 'q' || input === 'u')) {
1040
+ setShowUrls(false);
1041
+ return;
1042
+ }
1043
+ return;
1044
+ }
1045
+
1046
+ // Search shortcut
1047
+ if (input === '/') {
1048
+ setShowSearch(true);
1049
+ setSearchQuery('');
1050
+ return;
1051
+ }
1052
+
1053
+ // Handle editing modes
1054
+ if (editingName || editingDescription || editingTags) {
1055
+ if (key.escape) {
1056
+ setEditingName(false);
1057
+ setEditingDescription(false);
1058
+ setEditingTags(false);
1059
+ setEditInput('');
1060
+ return;
1061
+ }
1062
+
1063
+ if (key.return) {
1064
+ // Save changes
1065
+ if (selectedProject) {
1066
+ if (editingName && editInput.trim()) {
1067
+ try {
1068
+ const db = getDatabaseManager();
1069
+ db.updateProjectName(selectedProject.id, editInput.trim());
1070
+ loadProjects();
1071
+ setEditingName(false);
1072
+ setEditInput('');
1073
+ } catch (err) {
1074
+ setError(err instanceof Error ? err.message : String(err));
1075
+ }
1076
+ } else if (editingDescription) {
1077
+ try {
1078
+ const db = getDatabaseManager();
1079
+ db.updateProject(selectedProject.id, { description: editInput.trim() || null });
1080
+ loadProjects();
1081
+ setEditingDescription(false);
1082
+ setEditInput('');
1083
+ } catch (err) {
1084
+ setError(err instanceof Error ? err.message : String(err));
1085
+ }
1086
+ } else if (editingTags) {
1087
+ // Handle tag input
1088
+ const newTag = editInput.trim();
1089
+ if (newTag) {
1090
+ try {
1091
+ const db = getDatabaseManager();
1092
+ const currentTags = selectedProject.tags || [];
1093
+ if (!currentTags.includes(newTag)) {
1094
+ db.updateProject(selectedProject.id, { tags: [...currentTags, newTag] });
1095
+ loadProjects();
1096
+ loadAllTags();
1097
+ }
1098
+ setEditInput('');
1099
+ } catch (err) {
1100
+ setError(err instanceof Error ? err.message : String(err));
1101
+ }
1102
+ }
1103
+ }
1104
+ }
1105
+ return;
1106
+ }
1107
+
1108
+ // Handle backspace
1109
+ if (key.backspace || key.delete) {
1110
+ setEditInput(prev => prev.slice(0, -1));
1111
+ return;
1112
+ }
1113
+
1114
+ // Handle regular input
1115
+ if (input && input.length === 1) {
1116
+ setEditInput(prev => prev + input);
1117
+ return;
1118
+ }
1119
+
1120
+ return;
1121
+ }
1122
+
1123
+ // Quit
1124
+ if (input === 'q' || key.escape) {
1125
+ exit();
1126
+ return;
1127
+ }
1128
+
1129
+ // Help
1130
+ if (input === '?') {
1131
+ setShowHelp(true);
1132
+ return;
1133
+ }
1134
+
1135
+ // Switch panels with Tab or Left/Right arrows
1136
+ if (key.tab || key.leftArrow || key.rightArrow) {
1137
+ setFocusedPanel((prev) => prev === 'list' ? 'details' : 'list');
1138
+ return;
1139
+ }
1140
+
1141
+ // Navigation (only when focused on list)
1142
+ if (focusedPanel === 'list') {
1143
+ if (key.upArrow || input === 'k') {
1144
+ setSelectedIndex((prev) => {
1145
+ const newIndex = Math.max(0, prev - 1);
1146
+ // Update scroll offset
1147
+ const visibleHeight = Math.max(1, availableHeight - 3);
1148
+ if (newIndex < listScrollOffset) {
1149
+ setListScrollOffset(Math.max(0, newIndex));
1150
+ }
1151
+ return newIndex;
1152
+ });
1153
+ return;
1154
+ }
1155
+
1156
+ if (key.downArrow || input === 'j') {
1157
+ setSelectedIndex((prev) => {
1158
+ const newIndex = Math.min(projects.length - 1, prev + 1);
1159
+ // Update scroll offset
1160
+ const visibleHeight = Math.max(1, availableHeight - 3);
1161
+ if (newIndex >= listScrollOffset + visibleHeight) {
1162
+ setListScrollOffset(Math.max(0, newIndex - visibleHeight + 1));
1163
+ }
1164
+ return newIndex;
1165
+ });
1166
+ return;
1167
+ }
1168
+ }
1169
+
1170
+ // Details panel actions
1171
+ if (focusedPanel === 'details' && selectedProject) {
1172
+ // Scroll details panel
1173
+ if (key.upArrow || input === 'k') {
1174
+ setDetailsScrollOffset(prev => Math.max(0, prev - 1));
1175
+ return;
1176
+ }
1177
+
1178
+ if (key.downArrow || input === 'j') {
1179
+ const visibleHeight = Math.max(1, availableHeight - 3);
1180
+ // Estimate content height (rough calculation)
1181
+ setDetailsScrollOffset(prev => prev + 1);
1182
+ return;
1183
+ }
1184
+
1185
+ // Edit name
1186
+ if (input === 'e') {
1187
+ setEditingName(true);
1188
+ setEditingDescription(false);
1189
+ setEditingTags(false);
1190
+ setEditInput(selectedProject.name);
1191
+ return;
1192
+ }
1193
+
1194
+ // Edit tags
1195
+ if (input === 't') {
1196
+ setEditingTags(true);
1197
+ setEditingName(false);
1198
+ setEditingDescription(false);
1199
+ setEditInput('');
1200
+ return;
1201
+ }
1202
+
1203
+ // Open in editor
1204
+ if (input === 'o') {
1205
+ openInEditor(selectedProject.path);
1206
+ return;
1207
+ }
1208
+
1209
+ // Open in file manager
1210
+ if (input === 'f') {
1211
+ openInFiles(selectedProject.path);
1212
+ return;
1213
+ }
1214
+
1215
+ // Show URLs
1216
+ if (input === 'u') {
1217
+ setShowUrls(true);
1218
+ return;
1219
+ }
1220
+
1221
+ // Delete project
1222
+ if (input === 'd') {
1223
+ setIsLoading(true);
1224
+ setLoadingMessage(`Deleting ${selectedProject.name}...`);
1225
+ setTimeout(async () => {
1226
+ try {
1227
+ const db = getDatabaseManager();
1228
+ db.removeProject(selectedProject.id);
1229
+ loadProjects();
1230
+ if (selectedIndex >= projects.length - 1) {
1231
+ setSelectedIndex(Math.max(0, projects.length - 2));
1232
+ }
1233
+ setIsLoading(false);
1234
+ } catch (err) {
1235
+ setIsLoading(false);
1236
+ setError(err instanceof Error ? err.message : String(err));
1237
+ }
1238
+ }, 100);
1239
+ return;
1240
+ }
1241
+ }
1242
+
1243
+ // Scan project
1244
+ if (input === 's' && selectedProject) {
1245
+ setIsLoading(true);
1246
+ setLoadingMessage(`Scanning ${selectedProject.name}...`);
1247
+
1248
+ setTimeout(async () => {
1249
+ try {
1250
+ await scanProject(selectedProject.id);
1251
+ loadProjects();
1252
+ setIsLoading(false);
1253
+ } catch (err) {
1254
+ setIsLoading(false);
1255
+ setError(err instanceof Error ? err.message : String(err));
1256
+ }
1257
+ }, 100);
1258
+ return;
1259
+ }
1260
+
1261
+ // Scan ports
1262
+ if (input === 'p' && selectedProject) {
1263
+ setIsLoading(true);
1264
+ setLoadingMessage(`Scanning ports for ${selectedProject.name}...`);
1265
+
1266
+ setTimeout(async () => {
1267
+ try {
1268
+ // Import port scanner dynamically
1269
+ const { scanProjectPorts } = await import('../../cli/src/port-scanner');
1270
+ await scanProjectPorts(selectedProject.id);
1271
+ loadProjects();
1272
+ setIsLoading(false);
1273
+ } catch (err) {
1274
+ setIsLoading(false);
1275
+ setError(err instanceof Error ? err.message : String(err));
1276
+ }
1277
+ }, 100);
1278
+ return;
1279
+ }
1280
+
1281
+ // Run script
1282
+ if (input === 'r' && selectedProject) {
1283
+ try {
1284
+ const projectScripts = getProjectScripts(selectedProject.path);
1285
+ if (projectScripts.scripts.size === 0) {
1286
+ setError(`No scripts found in ${selectedProject.name}`);
1287
+ return;
1288
+ }
1289
+
1290
+ setScriptModalData({
1291
+ scripts: projectScripts.scripts,
1292
+ projectName: selectedProject.name,
1293
+ projectPath: selectedProject.path,
1294
+ });
1295
+ setShowScriptModal(true);
1296
+ } catch (err) {
1297
+ setError(err instanceof Error ? err.message : String(err));
1298
+ }
1299
+ return;
1300
+ }
1301
+
1302
+ // Stop all scripts for project
1303
+ if (input === 'x' && selectedProject) {
1304
+ setIsLoading(true);
1305
+ setLoadingMessage(`Stopping scripts for ${selectedProject.name}...`);
1306
+
1307
+ setTimeout(async () => {
1308
+ try {
1309
+ const projectProcesses = runningProcesses.filter((p: any) => p.projectPath === selectedProject.path);
1310
+ if (projectProcesses.length === 0) {
1311
+ setIsLoading(false);
1312
+ setError(`No running scripts for ${selectedProject.name}`);
1313
+ return;
1314
+ }
1315
+
1316
+ for (const proc of projectProcesses) {
1317
+ await stopScript(proc.pid);
1318
+ }
1319
+
1320
+ await loadRunningProcesses();
1321
+ setIsLoading(false);
1322
+ } catch (err) {
1323
+ setIsLoading(false);
1324
+ setError(err instanceof Error ? err.message : String(err));
1325
+ }
1326
+ }, 100);
1327
+ return;
1328
+ }
1329
+
1330
+ });
1331
+
1332
+ if (showHelp) {
1333
+ return (
1334
+ <Box flexDirection="column" padding={1}>
1335
+ <HelpModal onClose={() => setShowHelp(false)} />
1336
+ </Box>
1337
+ );
1338
+ }
1339
+
1340
+ if (isLoading) {
1341
+ return (
1342
+ <Box flexDirection="column" padding={1}>
1343
+ <LoadingModal message={loadingMessage} />
1344
+ </Box>
1345
+ );
1346
+ }
1347
+
1348
+ if (error) {
1349
+ return (
1350
+ <Box flexDirection="column" padding={1}>
1351
+ <ErrorModal message={error} onClose={() => setError(null)} />
1352
+ </Box>
1353
+ );
1354
+ }
1355
+
1356
+ if (showSearch) {
1357
+ return (
1358
+ <Box flexDirection="column" padding={1}>
1359
+ <Box
1360
+ flexDirection="column"
1361
+ borderStyle="round"
1362
+ borderColor={colors.accentCyan}
1363
+ padding={1}
1364
+ width={60}
1365
+ >
1366
+ <Text bold color={colors.accentCyan}>
1367
+ Search Projects
1368
+ </Text>
1369
+ <Text> </Text>
1370
+ <Text color={colors.textPrimary}>
1371
+ /{searchQuery}
1372
+ <Text color={colors.textTertiary}>_</Text>
1373
+ </Text>
1374
+ <Text> </Text>
1375
+ {projects.length === 0 ? (
1376
+ <Text color={colors.textTertiary}>No projects match "{searchQuery}"</Text>
1377
+ ) : (
1378
+ <Text color={colors.textSecondary}>
1379
+ Found {projects.length} project{projects.length !== 1 ? 's' : ''}
1380
+ </Text>
1381
+ )}
1382
+ <Text> </Text>
1383
+ <Text color={colors.textSecondary}>Press Enter to confirm, Esc to cancel</Text>
1384
+ </Box>
1385
+ </Box>
1386
+ );
1387
+ }
1388
+
1389
+ if (showUrls && selectedProject) {
1390
+ const urls = getProjectUrls(selectedProject);
1391
+ return (
1392
+ <Box flexDirection="column" padding={1}>
1393
+ <Box
1394
+ flexDirection="column"
1395
+ borderStyle="round"
1396
+ borderColor={colors.accentCyan}
1397
+ padding={1}
1398
+ width={70}
1399
+ >
1400
+ <Text bold color={colors.accentCyan}>
1401
+ URLs for {selectedProject.name}
1402
+ </Text>
1403
+ <Text> </Text>
1404
+ {urls.length === 0 ? (
1405
+ <Text color={colors.textTertiary}>No URLs detected</Text>
1406
+ ) : (
1407
+ urls.map((url, idx) => (
1408
+ <Text key={idx} color={colors.textPrimary}>
1409
+ {idx + 1}. {url}
1410
+ </Text>
1411
+ ))
1412
+ )}
1413
+ <Text> </Text>
1414
+ <Text color={colors.textSecondary}>Press Esc or u to close...</Text>
1415
+ </Box>
1416
+ </Box>
1417
+ );
1418
+ }
1419
+
1420
+ if (showScriptModal && scriptModalData) {
1421
+ return (
1422
+ <Box flexDirection="column" padding={1}>
1423
+ <ScriptSelectionModal
1424
+ scripts={scriptModalData.scripts}
1425
+ projectName={scriptModalData.projectName}
1426
+ projectPath={scriptModalData.projectPath}
1427
+ onSelect={handleScriptSelect}
1428
+ onClose={() => setShowScriptModal(false)}
1429
+ />
1430
+ </Box>
1431
+ );
1432
+ }
1433
+
1434
+ const handleTagRemove = (tag: string) => {
1435
+ if (selectedProject) {
1436
+ try {
1437
+ const db = getDatabaseManager();
1438
+ const currentTags = selectedProject.tags || [];
1439
+ db.updateProject(selectedProject.id, { tags: currentTags.filter(t => t !== tag) });
1440
+ loadProjects();
1441
+ loadAllTags();
1442
+ } catch (err) {
1443
+ setError(err instanceof Error ? err.message : String(err));
1444
+ }
1445
+ }
1446
+ };
1447
+
1448
+ return (
1449
+ <Box flexDirection="column" height={terminalHeight}>
1450
+ <Box flexDirection="row" height={availableHeight} flexGrow={0} flexShrink={0}>
1451
+ <ProjectListComponent
1452
+ projects={projects}
1453
+ selectedIndex={selectedIndex}
1454
+ runningProcesses={runningProcesses}
1455
+ isFocused={focusedPanel === 'list'}
1456
+ height={availableHeight}
1457
+ scrollOffset={listScrollOffset}
1458
+ />
1459
+ <Box width={1} />
1460
+ <ProjectDetailsComponent
1461
+ project={selectedProject}
1462
+ runningProcesses={runningProcesses}
1463
+ isFocused={focusedPanel === 'details'}
1464
+ editingName={editingName}
1465
+ editingDescription={editingDescription}
1466
+ editingTags={editingTags}
1467
+ editInput={editInput}
1468
+ allTags={allTags}
1469
+ onTagRemove={handleTagRemove}
1470
+ height={availableHeight}
1471
+ scrollOffset={detailsScrollOffset}
1472
+ />
1473
+ </Box>
1474
+
1475
+ <Box paddingX={1} borderStyle="single" borderColor={colors.borderColor} flexShrink={0} height={3}>
1476
+ <StatusBar focusedPanel={focusedPanel} selectedProject={selectedProject} />
1477
+ </Box>
1478
+ </Box>
1479
+ );
1480
+ };
1481
+
1482
+ // Render the app
1483
+ render(<App />);