projax 3.3.58 → 3.3.63

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 (84) hide show
  1. package/README.md +10 -1
  2. package/dist/electron/preload.d.ts +1 -0
  3. package/dist/electron/renderer/assets/index-CmtZriN5.js +66 -0
  4. package/dist/electron/renderer/index.html +1 -1
  5. package/dist/electron/script-runner.js +52 -20
  6. package/dist/index.js +14 -10
  7. package/dist/prxi.js +877 -109
  8. package/dist/prxi.tsx +1249 -177
  9. package/dist/script-runner.js +52 -20
  10. package/package.json +1 -1
  11. package/coverage/base.css +0 -224
  12. package/coverage/block-navigation.js +0 -87
  13. package/coverage/core-bridge.ts.html +0 -292
  14. package/coverage/favicon.png +0 -0
  15. package/coverage/index.html +0 -191
  16. package/coverage/lcov-report/base.css +0 -224
  17. package/coverage/lcov-report/block-navigation.js +0 -87
  18. package/coverage/lcov-report/core-bridge.ts.html +0 -292
  19. package/coverage/lcov-report/favicon.png +0 -0
  20. package/coverage/lcov-report/index.html +0 -191
  21. package/coverage/lcov-report/port-extractor.ts.html +0 -1174
  22. package/coverage/lcov-report/port-scanner.ts.html +0 -301
  23. package/coverage/lcov-report/port-utils.ts.html +0 -670
  24. package/coverage/lcov-report/prettify.css +0 -1
  25. package/coverage/lcov-report/prettify.js +0 -2
  26. package/coverage/lcov-report/script-runner.ts.html +0 -3346
  27. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  28. package/coverage/lcov-report/sorter.js +0 -210
  29. package/coverage/lcov-report/test-parser.ts.html +0 -799
  30. package/coverage/lcov.info +0 -1338
  31. package/coverage/port-extractor.ts.html +0 -1174
  32. package/coverage/port-scanner.ts.html +0 -301
  33. package/coverage/port-utils.ts.html +0 -670
  34. package/coverage/prettify.css +0 -1
  35. package/coverage/prettify.js +0 -2
  36. package/coverage/script-runner.ts.html +0 -3346
  37. package/coverage/sort-arrow-sprite.png +0 -0
  38. package/coverage/sorter.js +0 -210
  39. package/coverage/test-parser.ts.html +0 -799
  40. package/dist/__tests__/core-bridge.test.d.ts +0 -1
  41. package/dist/__tests__/core-bridge.test.js +0 -135
  42. package/dist/__tests__/port-extractor.test.d.ts +0 -1
  43. package/dist/__tests__/port-extractor.test.js +0 -407
  44. package/dist/__tests__/port-scanner.test.d.ts +0 -1
  45. package/dist/__tests__/port-scanner.test.js +0 -170
  46. package/dist/__tests__/port-utils.test.d.ts +0 -1
  47. package/dist/__tests__/port-utils.test.js +0 -127
  48. package/dist/__tests__/script-runner.test.d.ts +0 -1
  49. package/dist/__tests__/script-runner.test.js +0 -491
  50. package/dist/__tests__/test-parser.test.d.ts +0 -1
  51. package/dist/__tests__/test-parser.test.js +0 -276
  52. package/dist/api/__tests__/database.test.d.ts +0 -2
  53. package/dist/api/__tests__/database.test.d.ts.map +0 -1
  54. package/dist/api/__tests__/database.test.js +0 -485
  55. package/dist/api/__tests__/database.test.js.map +0 -1
  56. package/dist/api/__tests__/routes.test.d.ts +0 -2
  57. package/dist/api/__tests__/routes.test.d.ts.map +0 -1
  58. package/dist/api/__tests__/routes.test.js +0 -484
  59. package/dist/api/__tests__/routes.test.js.map +0 -1
  60. package/dist/api/__tests__/scanner.test.d.ts +0 -2
  61. package/dist/api/__tests__/scanner.test.d.ts.map +0 -1
  62. package/dist/api/__tests__/scanner.test.js +0 -403
  63. package/dist/api/__tests__/scanner.test.js.map +0 -1
  64. package/dist/core/__tests__/database.test.d.ts +0 -1
  65. package/dist/core/__tests__/database.test.js +0 -557
  66. package/dist/core/__tests__/detector.test.d.ts +0 -1
  67. package/dist/core/__tests__/detector.test.js +0 -375
  68. package/dist/core/__tests__/index.test.d.ts +0 -1
  69. package/dist/core/__tests__/index.test.js +0 -469
  70. package/dist/core/__tests__/scanner.test.d.ts +0 -1
  71. package/dist/core/__tests__/scanner.test.js +0 -406
  72. package/dist/core/__tests__/settings.test.d.ts +0 -1
  73. package/dist/core/__tests__/settings.test.js +0 -280
  74. package/dist/electron/core/__tests__/database.test.d.ts +0 -1
  75. package/dist/electron/core/__tests__/database.test.js +0 -557
  76. package/dist/electron/core/__tests__/detector.test.d.ts +0 -1
  77. package/dist/electron/core/__tests__/detector.test.js +0 -375
  78. package/dist/electron/core/__tests__/index.test.d.ts +0 -1
  79. package/dist/electron/core/__tests__/index.test.js +0 -469
  80. package/dist/electron/core/__tests__/scanner.test.d.ts +0 -1
  81. package/dist/electron/core/__tests__/scanner.test.js +0 -406
  82. package/dist/electron/core/__tests__/settings.test.d.ts +0 -1
  83. package/dist/electron/core/__tests__/settings.test.js +0 -280
  84. package/jest.config.js +0 -26
package/dist/prxi.tsx CHANGED
@@ -1,15 +1,33 @@
1
1
  import React, { useState, useEffect } from 'react';
2
2
  import { render, Box, Text, useInput, useApp, useFocus, useFocusManager } from 'ink';
3
+
4
+ // Handle EPIPE errors gracefully to prevent crashes when output streams close
5
+ process.stdout.on('error', (err: NodeJS.ErrnoException) => {
6
+ if (err.code === 'EPIPE') {
7
+ // Output stream closed, exit gracefully
8
+ process.exit(0);
9
+ }
10
+ });
11
+ process.stderr.on('error', (err: NodeJS.ErrnoException) => {
12
+ if (err.code === 'EPIPE') {
13
+ process.exit(0);
14
+ }
15
+ });
16
+
3
17
  import {
4
18
  getDatabaseManager,
5
19
  getAllProjects,
6
20
  scanProject,
21
+ addProject,
22
+ removeProject,
23
+ getCurrentBranch,
7
24
  Project,
8
25
  } from './core-bridge';
9
26
  import { getProjectScripts, getRunningProcessesClean, runScript, runScriptInBackground, stopScript } from './script-runner';
10
27
  import { spawn } from 'child_process';
11
28
  import * as path from 'path';
12
29
  import * as os from 'os';
30
+ import * as fs from 'fs';
13
31
 
14
32
  // Color scheme matching desktop app
15
33
  const colors = {
@@ -49,6 +67,52 @@ function truncateText(text: string, maxLength: number): string {
49
67
  return text.substring(0, maxLength - 3) + '...';
50
68
  }
51
69
 
70
+ // Type definitions for views and filters
71
+ type ViewType = 'projects' | 'workspaces' | 'processes' | 'settings';
72
+ type FilterType = 'all' | 'name' | 'path' | 'ports' | 'tags' | 'running';
73
+ type SortType = 'name-asc' | 'name-desc' | 'recent' | 'oldest' | 'running';
74
+
75
+ const FILTER_TYPES: FilterType[] = ['all', 'name', 'path', 'ports', 'tags', 'running'];
76
+ const SORT_TYPES: SortType[] = ['name-asc', 'name-desc', 'recent', 'oldest', 'running'];
77
+
78
+ const FILTER_LABELS: Record<FilterType, string> = {
79
+ 'all': 'All',
80
+ 'name': 'Name',
81
+ 'path': 'Path',
82
+ 'ports': 'Ports',
83
+ 'tags': 'Tags',
84
+ 'running': 'Running',
85
+ };
86
+
87
+ const SORT_LABELS: Record<SortType, string> = {
88
+ 'name-asc': 'Name A-Z',
89
+ 'name-desc': 'Name Z-A',
90
+ 'recent': 'Recently Scanned',
91
+ 'oldest': 'Oldest First',
92
+ 'running': 'Running First',
93
+ };
94
+
95
+ // Workspace type
96
+ interface Workspace {
97
+ id: number;
98
+ name: string;
99
+ description?: string;
100
+ workspace_file_path: string;
101
+ created_at: number;
102
+ }
103
+
104
+ // Settings type
105
+ interface AppSettings {
106
+ editor: {
107
+ type: 'vscode' | 'cursor' | 'windsurf' | 'zed' | 'custom';
108
+ customPath?: string;
109
+ };
110
+ browser: {
111
+ type: 'chrome' | 'firefox' | 'safari' | 'edge' | 'custom';
112
+ customPath?: string;
113
+ };
114
+ }
115
+
52
116
  interface HelpModalProps {
53
117
  onClose: () => void;
54
118
  }
@@ -66,23 +130,31 @@ const HelpModal: React.FC<HelpModalProps> = ({ onClose }) => {
66
130
  borderStyle="round"
67
131
  borderColor={colors.accentCyan}
68
132
  padding={1}
69
- width={70}
133
+ width={75}
70
134
  >
71
135
  <Text bold color={colors.accentCyan}>
72
136
  PROJAX Terminal UI - Help
73
137
  </Text>
74
138
  <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>
139
+ <Text color={colors.accentCyan}>View Navigation:</Text>
140
+ <Text> 1 Projects view</Text>
141
+ <Text> 2 Workspaces view</Text>
142
+ <Text> 3 Global processes view</Text>
143
+ <Text> 4 Settings</Text>
144
+ <Text> T Toggle terminal output panel</Text>
79
145
  <Text> </Text>
80
- <Text color={colors.accentCyan}>List Panel Actions:</Text>
146
+ <Text color={colors.accentCyan}>Projects View - Navigation:</Text>
147
+ <Text> ↑/k Move up in list</Text>
148
+ <Text> ↓/j Move down in list</Text>
149
+ <Text> Tab/←→ Switch between list and details</Text>
81
150
  <Text> / Search projects (fuzzy search)</Text>
82
- <Text> s Scan selected project for tests</Text>
151
+ <Text> F Cycle filter type (all/name/path/ports/tags/running)</Text>
152
+ <Text> S Cycle sort order (name/recent/oldest/running)</Text>
153
+ <Text> g Refresh git branches</Text>
154
+ <Text> R Full refresh (projects + branches + processes)</Text>
83
155
  <Text> </Text>
84
- <Text color={colors.accentCyan}>Details Panel Actions:</Text>
85
- <Text> ↑↓/kj Scroll details</Text>
156
+ <Text color={colors.accentCyan}>Projects View - Actions:</Text>
157
+ <Text> a Add new project</Text>
86
158
  <Text> e Edit project name</Text>
87
159
  <Text> t Add/edit tags</Text>
88
160
  <Text> o Open project in editor</Text>
@@ -90,16 +162,12 @@ const HelpModal: React.FC<HelpModalProps> = ({ onClose }) => {
90
162
  <Text> u Show detected URLs</Text>
91
163
  <Text> s Scan project for tests</Text>
92
164
  <Text> p Scan ports for project</Text>
93
- <Text> r Show scripts (use CLI to run)</Text>
165
+ <Text> r Run scripts (select from list)</Text>
94
166
  <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>
167
+ <Text> d Delete project (with confirmation)</Text>
100
168
  <Text> </Text>
101
169
  <Text color={colors.accentCyan}>General:</Text>
102
- <Text> q/Esc Quit</Text>
170
+ <Text> q/Esc Quit (or close modal)</Text>
103
171
  <Text> ? Show this help</Text>
104
172
  <Text> </Text>
105
173
  <Text color={colors.textSecondary}>Press any key to close...</Text>
@@ -126,6 +194,215 @@ const LoadingModal: React.FC<LoadingModalProps> = ({ message }) => {
126
194
  );
127
195
  };
128
196
 
197
+ // Confirmation Modal
198
+ interface ConfirmModalProps {
199
+ title: string;
200
+ message: string;
201
+ onConfirm: () => void;
202
+ onCancel: () => void;
203
+ }
204
+
205
+ const ConfirmModal: React.FC<ConfirmModalProps> = ({ title, message, onConfirm, onCancel }) => {
206
+ const [selected, setSelected] = useState<'yes' | 'no'>('no');
207
+
208
+ useInput((input: string, key: any) => {
209
+ if (key.escape || input === 'n') {
210
+ onCancel();
211
+ return;
212
+ }
213
+ if (key.return) {
214
+ if (selected === 'yes') {
215
+ onConfirm();
216
+ } else {
217
+ onCancel();
218
+ }
219
+ return;
220
+ }
221
+ if (input === 'y') {
222
+ onConfirm();
223
+ return;
224
+ }
225
+ if (key.leftArrow || key.rightArrow || input === 'h' || input === 'l') {
226
+ setSelected(prev => prev === 'yes' ? 'no' : 'yes');
227
+ }
228
+ });
229
+
230
+ return (
231
+ <Box
232
+ flexDirection="column"
233
+ borderStyle="round"
234
+ borderColor={colors.accentOrange}
235
+ padding={1}
236
+ width={60}
237
+ >
238
+ <Text bold color={colors.accentOrange}>{title}</Text>
239
+ <Text> </Text>
240
+ <Text>{message}</Text>
241
+ <Text> </Text>
242
+ <Box>
243
+ <Text color={selected === 'yes' ? colors.accentCyan : colors.textSecondary}>
244
+ {selected === 'yes' ? '▶ ' : ' '}Yes
245
+ </Text>
246
+ <Text> </Text>
247
+ <Text color={selected === 'no' ? colors.accentCyan : colors.textSecondary}>
248
+ {selected === 'no' ? '▶ ' : ' '}No
249
+ </Text>
250
+ </Box>
251
+ <Text> </Text>
252
+ <Text color={colors.textTertiary}>y/n or ←→ to select, Enter to confirm</Text>
253
+ </Box>
254
+ );
255
+ };
256
+
257
+ // Add Project Modal
258
+ interface AddProjectModalProps {
259
+ onAdd: (projectPath: string, projectName?: string) => void;
260
+ onCancel: () => void;
261
+ }
262
+
263
+ const AddProjectModal: React.FC<AddProjectModalProps> = ({ onAdd, onCancel }) => {
264
+ const [step, setStep] = useState<'path' | 'name'>('path');
265
+ const [pathInput, setPathInput] = useState('');
266
+ const [nameInput, setNameInput] = useState('');
267
+ const [error, setError] = useState<string | null>(null);
268
+
269
+ useInput((input: string, key: any) => {
270
+ if (key.escape) {
271
+ onCancel();
272
+ return;
273
+ }
274
+
275
+ if (step === 'path') {
276
+ if (key.return) {
277
+ // Validate path
278
+ const resolvedPath = pathInput.startsWith('~')
279
+ ? path.join(os.homedir(), pathInput.slice(1))
280
+ : path.resolve(pathInput);
281
+
282
+ if (!fs.existsSync(resolvedPath)) {
283
+ setError('Path does not exist');
284
+ return;
285
+ }
286
+
287
+ if (!fs.statSync(resolvedPath).isDirectory()) {
288
+ setError('Path is not a directory');
289
+ return;
290
+ }
291
+
292
+ // Check if project already exists
293
+ const db = getDatabaseManager();
294
+ const existing = db.getProjectByPath(resolvedPath);
295
+ if (existing) {
296
+ setError(`Project already exists: ${existing.name}`);
297
+ return;
298
+ }
299
+
300
+ setError(null);
301
+ setNameInput(path.basename(resolvedPath));
302
+ setStep('name');
303
+ return;
304
+ }
305
+
306
+ if (key.backspace || key.delete) {
307
+ setPathInput(prev => prev.slice(0, -1));
308
+ setError(null);
309
+ return;
310
+ }
311
+
312
+ if (key.tab) {
313
+ // Tab completion - get directories in current path
314
+ try {
315
+ const currentPath = pathInput.startsWith('~')
316
+ ? path.join(os.homedir(), pathInput.slice(1))
317
+ : pathInput || '.';
318
+ const dir = path.dirname(currentPath);
319
+ const base = path.basename(currentPath);
320
+
321
+ if (fs.existsSync(dir)) {
322
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
323
+ .filter(e => e.isDirectory() && e.name.startsWith(base) && !e.name.startsWith('.'))
324
+ .map(e => e.name);
325
+
326
+ if (entries.length === 1) {
327
+ const completed = path.join(dir, entries[0]) + '/';
328
+ setPathInput(completed.replace(os.homedir(), '~'));
329
+ }
330
+ }
331
+ } catch {
332
+ // Ignore tab completion errors
333
+ }
334
+ return;
335
+ }
336
+
337
+ if (input && input.length === 1 && !key.ctrl && !key.meta) {
338
+ setPathInput(prev => prev + input);
339
+ setError(null);
340
+ }
341
+ } else if (step === 'name') {
342
+ if (key.return) {
343
+ const resolvedPath = pathInput.startsWith('~')
344
+ ? path.join(os.homedir(), pathInput.slice(1))
345
+ : path.resolve(pathInput);
346
+ onAdd(resolvedPath, nameInput.trim() || undefined);
347
+ return;
348
+ }
349
+
350
+ if (key.backspace || key.delete) {
351
+ setNameInput(prev => prev.slice(0, -1));
352
+ return;
353
+ }
354
+
355
+ if (input && input.length === 1 && !key.ctrl && !key.meta) {
356
+ setNameInput(prev => prev + input);
357
+ }
358
+ }
359
+ });
360
+
361
+ return (
362
+ <Box
363
+ flexDirection="column"
364
+ borderStyle="round"
365
+ borderColor={colors.accentCyan}
366
+ padding={1}
367
+ width={70}
368
+ >
369
+ <Text bold color={colors.accentCyan}>Add Project</Text>
370
+ <Text> </Text>
371
+
372
+ {step === 'path' && (
373
+ <>
374
+ <Text>Enter project path:</Text>
375
+ <Box>
376
+ <Text color={colors.accentGreen}>&gt; </Text>
377
+ <Text>{pathInput}</Text>
378
+ <Text color={colors.textTertiary}>_</Text>
379
+ </Box>
380
+ {error && (
381
+ <Text color="#f85149">{error}</Text>
382
+ )}
383
+ <Text> </Text>
384
+ <Text color={colors.textSecondary}>Tab: autocomplete | Enter: next | Esc: cancel</Text>
385
+ </>
386
+ )}
387
+
388
+ {step === 'name' && (
389
+ <>
390
+ <Text color={colors.textSecondary}>Path: {pathInput}</Text>
391
+ <Text> </Text>
392
+ <Text>Enter project name:</Text>
393
+ <Box>
394
+ <Text color={colors.accentGreen}>&gt; </Text>
395
+ <Text>{nameInput}</Text>
396
+ <Text color={colors.textTertiary}>_</Text>
397
+ </Box>
398
+ <Text> </Text>
399
+ <Text color={colors.textSecondary}>Enter: add project | Esc: cancel</Text>
400
+ </>
401
+ )}
402
+ </Box>
403
+ );
404
+ };
405
+
129
406
  interface ErrorModalProps {
130
407
  message: string;
131
408
  onClose: () => void;
@@ -240,15 +517,21 @@ interface ProjectListProps {
240
517
  isFocused: boolean;
241
518
  height: number;
242
519
  scrollOffset: number;
520
+ gitBranches: Map<number, string | null>;
521
+ filterType: FilterType;
522
+ sortType: SortType;
243
523
  }
244
524
 
245
- const ProjectListComponent: React.FC<ProjectListProps> = ({
246
- projects,
247
- selectedIndex,
248
- runningProcesses,
525
+ const ProjectListComponent: React.FC<ProjectListProps> = ({
526
+ projects,
527
+ selectedIndex,
528
+ runningProcesses,
249
529
  isFocused,
250
530
  height,
251
531
  scrollOffset,
532
+ gitBranches,
533
+ filterType,
534
+ sortType,
252
535
  }) => {
253
536
  const { focus } = useFocus({ id: 'projectList' });
254
537
 
@@ -264,51 +547,72 @@ const ProjectListComponent: React.FC<ProjectListProps> = ({
264
547
  const hasMoreBelow = endIndex < projects.length;
265
548
 
266
549
  return (
267
- <Box
268
- flexDirection="column"
269
- width="35%"
550
+ <Box
551
+ flexDirection="column"
552
+ width="35%"
270
553
  height={height}
271
- borderStyle="round"
272
- borderColor={isFocused ? colors.accentCyan : colors.borderColor}
554
+ borderStyle="round"
555
+ borderColor={isFocused ? colors.accentCyan : colors.borderColor}
273
556
  padding={1}
274
557
  flexShrink={0}
275
558
  flexGrow={0}
276
559
  >
277
- <Text bold color={colors.textPrimary}>
278
- Projects ({projects.length})
279
- </Text>
560
+ <Box flexDirection="column">
561
+ <Text bold color={colors.textPrimary}>
562
+ Projects ({projects.length})
563
+ </Text>
564
+ <Text color={colors.textTertiary}>
565
+ <Text color={colors.accentPurple}>{FILTER_LABELS[filterType]}</Text>
566
+ {' | '}
567
+ <Text color={colors.accentOrange}>{SORT_LABELS[sortType]}</Text>
568
+ </Text>
569
+ </Box>
280
570
  <Box flexDirection="column" flexGrow={1}>
281
- {projects.length === 0 ? (
282
- <Text color={colors.textTertiary}>No projects found</Text>
283
- ) : (
571
+ {projects.length === 0 ? (
572
+ <Text color={colors.textTertiary}>No projects found</Text>
573
+ ) : (
284
574
  <>
285
575
  {hasMoreAbove && (
286
576
  <Text color={colors.textTertiary}>↑ {startIndex} more above</Text>
287
577
  )}
288
578
  {visibleProjects.map((project, localIndex) => {
289
579
  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
- );
580
+ const isSelected = index === selectedIndex;
581
+
582
+ // Check if this project has running scripts
583
+ const projectRunning = runningProcesses.filter(
584
+ (p: any) => p.projectPath === project.path
585
+ );
586
+ const hasRunningScripts = projectRunning.length > 0;
587
+
588
+ // Get git branch
589
+ const branch = gitBranches.get(project.id);
590
+ const isMainBranch = branch === 'main' || branch === 'master';
591
+
592
+ return (
593
+ <Box key={project.id} flexDirection="column">
594
+ <Text color={isSelected ? colors.accentCyan : colors.textPrimary} bold={isSelected}>
595
+ {isSelected ? '▶ ' : ' '}
596
+ {hasRunningScripts && <Text color={colors.accentGreen}>● </Text>}
597
+ {truncateText(project.name, 22)}
598
+ {hasRunningScripts && <Text color={colors.accentGreen}> ({projectRunning.length})</Text>}
599
+ </Text>
600
+ {branch && (
601
+ <Text color={colors.textTertiary}>
602
+ {' '}
603
+ <Text color={isMainBranch ? colors.accentGreen : colors.accentBlue}>
604
+ {truncateText(branch, 18)}
605
+ </Text>
606
+ </Text>
607
+ )}
608
+ </Box>
609
+ );
306
610
  })}
307
611
  {hasMoreBelow && (
308
612
  <Text color={colors.textTertiary}>↓ {projects.length - endIndex} more below</Text>
309
613
  )}
310
614
  </>
311
- )}
615
+ )}
312
616
  </Box>
313
617
  </Box>
314
618
  );
@@ -326,11 +630,12 @@ interface ProjectDetailsProps {
326
630
  onTagRemove?: (tag: string) => void;
327
631
  height: number;
328
632
  scrollOffset: number;
633
+ gitBranch: string | null;
329
634
  }
330
635
 
331
- const ProjectDetailsComponent: React.FC<ProjectDetailsProps> = ({
332
- project,
333
- runningProcesses,
636
+ const ProjectDetailsComponent: React.FC<ProjectDetailsProps> = ({
637
+ project,
638
+ runningProcesses,
334
639
  isFocused,
335
640
  editingName,
336
641
  editingDescription,
@@ -340,15 +645,18 @@ const ProjectDetailsComponent: React.FC<ProjectDetailsProps> = ({
340
645
  onTagRemove,
341
646
  height,
342
647
  scrollOffset,
648
+ gitBranch,
343
649
  }) => {
344
650
  const { focus } = useFocus({ id: 'projectDetails' });
345
651
  const [scripts, setScripts] = useState<any>(null);
346
652
  const [ports, setPorts] = useState<any[]>([]);
653
+ const [npmPackage, setNpmPackage] = useState<string | null>(null);
347
654
 
348
655
  useEffect(() => {
349
656
  if (!project) {
350
657
  setScripts(null);
351
658
  setPorts([]);
659
+ setNpmPackage(null);
352
660
  return;
353
661
  }
354
662
 
@@ -368,6 +676,25 @@ const ProjectDetailsComponent: React.FC<ProjectDetailsProps> = ({
368
676
  } catch (error) {
369
677
  setPorts([]);
370
678
  }
679
+
680
+ // Check if npm package (live registry check)
681
+ setNpmPackage(null);
682
+ try {
683
+ const packageJsonPath = path.join(project.path, 'package.json');
684
+ if (fs.existsSync(packageJsonPath)) {
685
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
686
+ if (pkg.name && !pkg.private) {
687
+ fetch(`https://registry.npmjs.org/${encodeURIComponent(pkg.name)}`, {
688
+ method: 'HEAD',
689
+ signal: AbortSignal.timeout(2000),
690
+ })
691
+ .then(res => {
692
+ if (res.ok) setNpmPackage(pkg.name);
693
+ })
694
+ .catch(() => {});
695
+ }
696
+ }
697
+ } catch {}
371
698
  }, [project]);
372
699
 
373
700
  if (!project) {
@@ -464,19 +791,35 @@ const ProjectDetailsComponent: React.FC<ProjectDetailsProps> = ({
464
791
  <Text>Ports: <Text color={colors.accentCyan}>{ports.length}</Text></Text>
465
792
  <Text> | </Text>
466
793
  <Text>Scripts: <Text color={colors.accentCyan}>{scripts?.scripts?.size || 0}</Text></Text>
794
+ {npmPackage && (
795
+ <>
796
+ <Text> | </Text>
797
+ <Text>NPM: <Text color="#f85149">{npmPackage}</Text></Text>
798
+ </>
799
+ )}
467
800
  </Box>
468
801
  );
469
802
 
470
803
  contentLines.push(<Text key="spacer2"> </Text>);
471
804
 
805
+ // Git branch
806
+ if (gitBranch) {
807
+ const isMainBranch = gitBranch === 'main' || gitBranch === 'master';
808
+ contentLines.push(
809
+ <Text key="git-branch">
810
+ Branch: <Text color={isMainBranch ? colors.accentGreen : colors.accentBlue}>{gitBranch}</Text>
811
+ </Text>
812
+ );
813
+ }
814
+
472
815
  if (project.framework) {
473
816
  contentLines.push(
474
817
  <Text key="framework">
475
- Framework: <Text color={colors.accentCyan}>{project.framework}</Text>
476
- </Text>
818
+ Framework: <Text color={colors.accentCyan}>{project.framework}</Text>
819
+ </Text>
477
820
  );
478
821
  }
479
-
822
+
480
823
  contentLines.push(
481
824
  <Text key="last-scanned">Last Scanned: {lastScanned}</Text>
482
825
  );
@@ -508,14 +851,14 @@ const ProjectDetailsComponent: React.FC<ProjectDetailsProps> = ({
508
851
  contentLines.push(<Text key="spacer4"> </Text>);
509
852
  }
510
853
 
511
- // Scripts
854
+ // Scripts - show all, let virtual scrolling handle visibility
512
855
  if (scripts && scripts.scripts && scripts.scripts.size > 0) {
513
856
  contentLines.push(
514
857
  <Text key="scripts-header" bold>
515
858
  Available Scripts (<Text color={colors.accentCyan}>{scripts.scripts.size}</Text>):
516
859
  </Text>
517
860
  );
518
- Array.from(scripts.scripts.entries() as IterableIterator<[string, any]>).slice(0, 5).forEach(([name, script]) => {
861
+ Array.from(scripts.scripts.entries() as IterableIterator<[string, any]>).forEach(([name, script]) => {
519
862
  contentLines.push(
520
863
  <Text key={`script-${name}`}>
521
864
  {' '}
@@ -525,22 +868,17 @@ const ProjectDetailsComponent: React.FC<ProjectDetailsProps> = ({
525
868
  </Text>
526
869
  );
527
870
  });
528
- if (scripts.scripts.size > 5) {
529
- contentLines.push(
530
- <Text key="scripts-more" color={colors.textTertiary}> ... and {scripts.scripts.size - 5} more</Text>
531
- );
532
- }
533
871
  contentLines.push(<Text key="spacer5"> </Text>);
534
872
  }
535
873
 
536
- // Ports
874
+ // Ports - show all, let virtual scrolling handle visibility
537
875
  if (ports.length > 0) {
538
876
  contentLines.push(
539
877
  <Text key="ports-header" bold>
540
878
  Detected Ports (<Text color={colors.accentCyan}>{ports.length}</Text>):
541
879
  </Text>
542
880
  );
543
- ports.slice(0, 5).forEach((port: any) => {
881
+ ports.forEach((port: any) => {
544
882
  contentLines.push(
545
883
  <Text key={`port-${port.id}`}>
546
884
  {' '}Port <Text color={colors.accentCyan}>{port.port}</Text>
@@ -548,11 +886,6 @@ const ProjectDetailsComponent: React.FC<ProjectDetailsProps> = ({
548
886
  </Text>
549
887
  );
550
888
  });
551
- if (ports.length > 5) {
552
- contentLines.push(
553
- <Text key="ports-more" color={colors.textTertiary}> ... and {ports.length - 5} more</Text>
554
- );
555
- }
556
889
  contentLines.push(<Text key="spacer6"> </Text>);
557
890
  }
558
891
 
@@ -571,12 +904,12 @@ const ProjectDetailsComponent: React.FC<ProjectDetailsProps> = ({
571
904
  const hasMoreBelow = endIndex < contentLines.length;
572
905
 
573
906
  return (
574
- <Box
575
- flexDirection="column"
907
+ <Box
908
+ flexDirection="column"
576
909
  width="65%"
577
910
  height={height}
578
- borderStyle="round"
579
- borderColor={isFocused ? colors.accentCyan : colors.borderColor}
911
+ borderStyle="round"
912
+ borderColor={isFocused ? colors.accentCyan : colors.borderColor}
580
913
  padding={1}
581
914
  flexShrink={0}
582
915
  flexGrow={0}
@@ -588,7 +921,118 @@ const ProjectDetailsComponent: React.FC<ProjectDetailsProps> = ({
588
921
  {visibleContent}
589
922
  {hasMoreBelow && (
590
923
  <Text color={colors.textTertiary}>↓ {contentLines.length - endIndex} more below</Text>
924
+ )}
925
+ </Box>
926
+ </Box>
927
+ );
928
+ };
929
+
930
+ // Terminal Output Panel for showing live logs
931
+ interface TerminalOutputPanelProps {
932
+ processes: any[];
933
+ selectedPid: number | null;
934
+ height: number;
935
+ onSelectProcess: (pid: number) => void;
936
+ }
937
+
938
+ const TerminalOutputPanel: React.FC<TerminalOutputPanelProps> = ({
939
+ processes,
940
+ selectedPid,
941
+ height,
942
+ onSelectProcess,
943
+ }) => {
944
+ const [logs, setLogs] = useState<string[]>([]);
945
+ const [scrollOffset, setScrollOffset] = useState(0);
946
+
947
+ // Find the selected process or default to first
948
+ const activeProcess = selectedPid
949
+ ? processes.find((p: any) => p.pid === selectedPid)
950
+ : processes[0];
951
+
952
+ useEffect(() => {
953
+ if (!activeProcess?.logFile) {
954
+ setLogs(['No active process selected']);
955
+ return;
956
+ }
957
+
958
+ // Read initial logs
959
+ try {
960
+ if (fs.existsSync(activeProcess.logFile)) {
961
+ const content = fs.readFileSync(activeProcess.logFile, 'utf-8');
962
+ const lines = content.split('\n').slice(-50); // Last 50 lines
963
+ setLogs(lines);
964
+ setScrollOffset(Math.max(0, lines.length - 10));
965
+ } else {
966
+ setLogs(['Log file not found']);
967
+ }
968
+ } catch {
969
+ setLogs(['Error reading logs']);
970
+ }
971
+
972
+ // Watch for changes
973
+ let watcher: fs.FSWatcher | null = null;
974
+ try {
975
+ watcher = fs.watch(activeProcess.logFile, () => {
976
+ try {
977
+ const content = fs.readFileSync(activeProcess.logFile, 'utf-8');
978
+ const lines = content.split('\n').slice(-100);
979
+ setLogs(lines);
980
+ // Auto-scroll to bottom
981
+ setScrollOffset(Math.max(0, lines.length - 10));
982
+ } catch {
983
+ // Ignore read errors during watch
984
+ }
985
+ });
986
+ } catch {
987
+ // Ignore watch errors
988
+ }
989
+
990
+ return () => {
991
+ if (watcher) {
992
+ watcher.close();
993
+ }
994
+ };
995
+ }, [activeProcess?.logFile, activeProcess?.pid]);
996
+
997
+ const visibleLines = logs.slice(scrollOffset, scrollOffset + height - 4);
998
+ const hasMoreAbove = scrollOffset > 0;
999
+ const hasMoreBelow = scrollOffset + height - 4 < logs.length;
1000
+
1001
+ return (
1002
+ <Box
1003
+ flexDirection="column"
1004
+ width="30%"
1005
+ height={height}
1006
+ borderStyle="round"
1007
+ borderColor={colors.accentGreen}
1008
+ padding={1}
1009
+ flexShrink={0}
1010
+ >
1011
+ <Box flexDirection="row" justifyContent="space-between">
1012
+ <Text bold color={colors.accentGreen}>Terminal Output</Text>
1013
+ {processes.length > 1 && (
1014
+ <Text color={colors.textTertiary}>
1015
+ [{processes.findIndex((p: any) => p.pid === activeProcess?.pid) + 1}/{processes.length}]
1016
+ </Text>
1017
+ )}
1018
+ </Box>
1019
+ {activeProcess && (
1020
+ <Text color={colors.textSecondary}>
1021
+ {truncateText(activeProcess.scriptName, 20)} (PID: {activeProcess.pid})
1022
+ </Text>
591
1023
  )}
1024
+ <Box flexDirection="column" flexGrow={1} marginTop={1}>
1025
+ {hasMoreAbove && (
1026
+ <Text color={colors.textTertiary}>↑ more above</Text>
1027
+ )}
1028
+ {visibleLines.map((line, idx) => (
1029
+ <Text key={idx} color={colors.textPrimary}>
1030
+ {truncateText(line, 40)}
1031
+ </Text>
1032
+ ))}
1033
+ {hasMoreBelow && (
1034
+ <Text color={colors.textTertiary}>↓ more below</Text>
1035
+ )}
592
1036
  </Box>
593
1037
  </Box>
594
1038
  );
@@ -601,23 +1045,29 @@ interface StatusBarProps {
601
1045
 
602
1046
  const StatusBar: React.FC<StatusBarProps> = ({ focusedPanel, selectedProject }) => {
603
1047
  if (focusedPanel === 'list') {
604
- return (
605
- <Box flexDirection="column">
606
- <Box>
607
- <Text color={colors.accentGreen}>● API</Text>
608
- <Text color={colors.textSecondary}> | </Text>
609
- <Text color={colors.textSecondary}>Focus: </Text>
610
- <Text color={colors.accentCyan}>Projects</Text>
611
- </Box>
612
- <Box>
1048
+ return (
1049
+ <Box flexDirection="column">
1050
+ <Box>
1051
+ <Text color={colors.accentGreen}>● API</Text>
1052
+ <Text color={colors.textSecondary}> | </Text>
1053
+ <Text color={colors.textSecondary}>Focus: </Text>
1054
+ <Text color={colors.accentCyan}>List</Text>
1055
+ </Box>
1056
+ <Box>
1057
+ <Text bold>a</Text>
1058
+ <Text color={colors.textSecondary}> Add | </Text>
613
1059
  <Text bold>/</Text>
614
1060
  <Text color={colors.textSecondary}> Search | </Text>
615
- <Text bold>↑↓/kj</Text>
616
- <Text color={colors.textSecondary}> Navigate | </Text>
617
- <Text bold>Tab/←→</Text>
1061
+ <Text bold>F</Text>
1062
+ <Text color={colors.textSecondary}> Filter | </Text>
1063
+ <Text bold>S</Text>
1064
+ <Text color={colors.textSecondary}> Sort | </Text>
1065
+ <Text bold>↑↓</Text>
1066
+ <Text color={colors.textSecondary}> Nav | </Text>
1067
+ <Text bold>Tab</Text>
618
1068
  <Text color={colors.textSecondary}> Switch | </Text>
619
- <Text bold>s</Text>
620
- <Text color={colors.textSecondary}> Scan | </Text>
1069
+ <Text bold>T</Text>
1070
+ <Text color={colors.textSecondary}> Terminal | </Text>
621
1071
  <Text bold>?</Text>
622
1072
  <Text color={colors.textSecondary}> Help | </Text>
623
1073
  <Text bold>q</Text>
@@ -638,29 +1088,21 @@ const StatusBar: React.FC<StatusBarProps> = ({ focusedPanel, selectedProject })
638
1088
  {selectedProject && (
639
1089
  <>
640
1090
  <Text color={colors.textSecondary}> | </Text>
641
- <Text color={colors.textPrimary}>{selectedProject.name}</Text>
1091
+ <Text color={colors.textPrimary}>{truncateText(selectedProject.name, 20)}</Text>
642
1092
  </>
643
1093
  )}
644
1094
  </Box>
645
1095
  <Box>
646
- <Text bold>↑↓/kj</Text>
647
- <Text color={colors.textSecondary}> Scroll | </Text>
648
1096
  <Text bold>e</Text>
649
1097
  <Text color={colors.textSecondary}> Edit | </Text>
650
1098
  <Text bold>t</Text>
651
1099
  <Text color={colors.textSecondary}> Tags | </Text>
652
1100
  <Text bold>o</Text>
653
1101
  <Text color={colors.textSecondary}> Editor | </Text>
654
- <Text bold>f</Text>
655
- <Text color={colors.textSecondary}> Files | </Text>
656
- <Text bold>u</Text>
657
- <Text color={colors.textSecondary}> URLs | </Text>
1102
+ <Text bold>r</Text>
1103
+ <Text color={colors.textSecondary}> Run | </Text>
658
1104
  <Text bold>s</Text>
659
1105
  <Text color={colors.textSecondary}> Scan | </Text>
660
- <Text bold>p</Text>
661
- <Text color={colors.textSecondary}> Ports | </Text>
662
- <Text bold>r</Text>
663
- <Text color={colors.textSecondary}> Scripts | </Text>
664
1106
  <Text bold>x</Text>
665
1107
  <Text color={colors.textSecondary}> Stop | </Text>
666
1108
  <Text bold>d</Text>
@@ -668,9 +1110,7 @@ const StatusBar: React.FC<StatusBarProps> = ({ focusedPanel, selectedProject })
668
1110
  <Text bold>Tab</Text>
669
1111
  <Text color={colors.textSecondary}> Switch | </Text>
670
1112
  <Text bold>?</Text>
671
- <Text color={colors.textSecondary}> Help | </Text>
672
- <Text bold>q</Text>
673
- <Text color={colors.textSecondary}> Quit</Text>
1113
+ <Text color={colors.textSecondary}> Help</Text>
674
1114
  </Box>
675
1115
  </Box>
676
1116
  );
@@ -705,7 +1145,17 @@ const App: React.FC = () => {
705
1145
  const [error, setError] = useState<string | null>(null);
706
1146
  const [runningProcesses, setRunningProcesses] = useState<any[]>([]);
707
1147
  const [focusedPanel, setFocusedPanel] = useState<'list' | 'details'>('list');
708
-
1148
+
1149
+ // View state
1150
+ const [currentView, setCurrentView] = useState<ViewType>('projects');
1151
+
1152
+ // Git branches
1153
+ const [gitBranches, setGitBranches] = useState<Map<number, string | null>>(new Map());
1154
+
1155
+ // Filter and sort state
1156
+ const [filterType, setFilterType] = useState<FilterType>('all');
1157
+ const [sortType, setSortType] = useState<SortType>('name-asc');
1158
+
709
1159
  // Editing state
710
1160
  const [editingName, setEditingName] = useState(false);
711
1161
  const [editingDescription, setEditingDescription] = useState(false);
@@ -713,43 +1163,108 @@ const App: React.FC = () => {
713
1163
  const [editInput, setEditInput] = useState('');
714
1164
  const [showUrls, setShowUrls] = useState(false);
715
1165
  const [allTags, setAllTags] = useState<string[]>([]);
716
-
1166
+
1167
+ // Modal state
1168
+ const [showAddProjectModal, setShowAddProjectModal] = useState(false);
1169
+ const [showConfirmDelete, setShowConfirmDelete] = useState(false);
1170
+
717
1171
  // Search state
718
1172
  const [showSearch, setShowSearch] = useState(false);
719
1173
  const [searchQuery, setSearchQuery] = useState('');
720
1174
  const [listScrollOffset, setListScrollOffset] = useState(0);
721
1175
  const [detailsScrollOffset, setDetailsScrollOffset] = useState(0);
722
-
1176
+
723
1177
  // Script selection state
724
1178
  const [showScriptModal, setShowScriptModal] = useState(false);
725
1179
  const [scriptModalData, setScriptModalData] = useState<{ scripts: Map<string, any>; projectName: string; projectPath: string } | null>(null);
726
-
1180
+
1181
+ // Workspace state
1182
+ const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
1183
+ const [selectedWorkspaceIndex, setSelectedWorkspaceIndex] = useState(0);
1184
+
1185
+ // Terminal panel state
1186
+ const [showTerminalPanel, setShowTerminalPanel] = useState(false);
1187
+ const [terminalLogs, setTerminalLogs] = useState<string[]>([]);
1188
+ const [selectedProcessPid, setSelectedProcessPid] = useState<number | null>(null);
1189
+
1190
+ // Settings state
1191
+ const [settings, setSettings] = useState<AppSettings>({
1192
+ editor: { type: 'vscode' },
1193
+ browser: { type: 'chrome' },
1194
+ });
1195
+ const [settingsSection, setSettingsSection] = useState<'editor' | 'browser'>('editor');
1196
+ const [settingsOptionIndex, setSettingsOptionIndex] = useState(0);
1197
+ const settingsEditorOptions: Array<'vscode' | 'cursor' | 'windsurf' | 'zed' | 'custom'> = [
1198
+ 'vscode', 'cursor', 'windsurf', 'zed', 'custom',
1199
+ ];
1200
+ const settingsBrowserOptions: Array<'chrome' | 'firefox' | 'safari' | 'edge' | 'custom'> = [
1201
+ 'chrome', 'firefox', 'safari', 'edge', 'custom',
1202
+ ];
1203
+
727
1204
  // Get terminal dimensions
728
1205
  const terminalHeight = process.stdout.rows || 24;
729
- const availableHeight = terminalHeight - 3; // Subtract status bar
1206
+ const availableHeight = terminalHeight - 4; // Subtract status bar (increased for view indicator)
730
1207
 
731
1208
  useEffect(() => {
732
1209
  loadProjects();
733
1210
  loadRunningProcesses();
734
1211
  loadAllTags();
735
-
736
- // Refresh running processes every 5 seconds
1212
+
1213
+ // Load settings
1214
+ try {
1215
+ const settingsPath = path.join(os.homedir(), '.projax', 'settings.json');
1216
+ if (fs.existsSync(settingsPath)) {
1217
+ const data = fs.readFileSync(settingsPath, 'utf-8');
1218
+ setSettings(JSON.parse(data));
1219
+ }
1220
+ } catch {
1221
+ // Use defaults
1222
+ }
1223
+
1224
+ // Refresh running processes and git branches every 5 seconds
737
1225
  const interval = setInterval(() => {
738
1226
  loadRunningProcesses();
739
1227
  }, 5000);
740
-
1228
+
741
1229
  return () => clearInterval(interval);
742
1230
  }, []);
743
1231
 
744
- // Reset editing state and scroll when project changes
1232
+ // Load git branches when projects change
745
1233
  useEffect(() => {
746
- setEditingName(false);
1234
+ if (allProjects.length > 0) {
1235
+ loadGitBranches();
1236
+ }
1237
+ }, [allProjects]);
1238
+
1239
+ const loadGitBranches = async () => {
1240
+ const branches = new Map<number, string | null>();
1241
+ for (const project of allProjects) {
1242
+ try {
1243
+ const branch = getCurrentBranch(project.path);
1244
+ branches.set(project.id, branch);
1245
+ } catch {
1246
+ branches.set(project.id, null);
1247
+ }
1248
+ }
1249
+ setGitBranches(branches);
1250
+ };
1251
+
1252
+ // Reset editing state and scroll when project changes
1253
+ useEffect(() => {
1254
+ setEditingName(false);
747
1255
  setEditingDescription(false);
748
1256
  setEditingTags(false);
749
1257
  setEditInput('');
750
1258
  setDetailsScrollOffset(0); // Reset scroll when switching projects
751
1259
  }, [selectedIndex]);
752
1260
 
1261
+ // Load workspaces when switching to workspaces view
1262
+ useEffect(() => {
1263
+ if (currentView === 'workspaces' && workspaces.length === 0) {
1264
+ loadWorkspacesFromApi();
1265
+ }
1266
+ }, [currentView]);
1267
+
753
1268
  // Update scroll offset when selected index changes
754
1269
  useEffect(() => {
755
1270
  const visibleHeight = Math.max(1, availableHeight - 3);
@@ -782,32 +1297,85 @@ const App: React.FC = () => {
782
1297
  const loadProjects = () => {
783
1298
  const loadedProjects = getAllProjects();
784
1299
  setAllProjects(loadedProjects);
785
- filterProjects(loadedProjects, searchQuery);
1300
+ applyFilterAndSort(loadedProjects, searchQuery, filterType, sortType);
786
1301
  };
787
1302
 
788
- const filterProjects = (projectsToFilter: Project[], query: string) => {
789
- if (!query.trim()) {
790
- setProjects(projectsToFilter);
791
- return;
1303
+ const applyFilterAndSort = (
1304
+ projectsToFilter: Project[],
1305
+ query: string,
1306
+ filter: FilterType,
1307
+ sort: SortType
1308
+ ) => {
1309
+ let filtered = projectsToFilter;
1310
+
1311
+ // Apply search query with filter type
1312
+ if (query.trim()) {
1313
+ const q = query.toLowerCase();
1314
+ filtered = projectsToFilter.filter(project => {
1315
+ switch (filter) {
1316
+ case 'name':
1317
+ return fuzzyMatch(q, project.name);
1318
+ case 'path':
1319
+ return fuzzyMatch(q, project.path);
1320
+ case 'tags':
1321
+ return project.tags?.some((tag: string) => fuzzyMatch(q, tag)) || false;
1322
+ case 'ports': {
1323
+ // Check if project has ports matching the query
1324
+ const db = getDatabaseManager();
1325
+ const ports = db.getProjectPorts(project.id);
1326
+ return ports.some((p: any) => p.port.toString().includes(q));
1327
+ }
1328
+ case 'running': {
1329
+ const isRunning = runningProcesses.some((p: any) => p.projectPath === project.path);
1330
+ return (q === 'running' || q === 'yes' || q === 'true') ? isRunning : !isRunning;
1331
+ }
1332
+ case 'all':
1333
+ default:
1334
+ return (
1335
+ fuzzyMatch(q, project.name) ||
1336
+ (project.description ? fuzzyMatch(q, project.description) : false) ||
1337
+ fuzzyMatch(q, project.path) ||
1338
+ project.tags?.some((tag: string) => fuzzyMatch(q, tag)) ||
1339
+ false
1340
+ );
1341
+ }
1342
+ });
792
1343
  }
793
-
794
- const filtered = projectsToFilter.filter(project => {
795
- const nameMatch = fuzzyMatch(query, project.name);
796
- const descMatch = project.description ? fuzzyMatch(query, project.description) : false;
797
- const pathMatch = fuzzyMatch(query, project.path);
798
- const tagsMatch = project.tags?.some((tag: string) => fuzzyMatch(query, tag)) || false;
799
-
800
- return nameMatch || descMatch || pathMatch || tagsMatch;
1344
+
1345
+ // Apply sorting
1346
+ const sorted = [...filtered].sort((a, b) => {
1347
+ switch (sort) {
1348
+ case 'name-asc':
1349
+ return a.name.localeCompare(b.name);
1350
+ case 'name-desc':
1351
+ return b.name.localeCompare(a.name);
1352
+ case 'recent':
1353
+ return (b.last_scanned || 0) - (a.last_scanned || 0);
1354
+ case 'oldest':
1355
+ return (a.created_at || 0) - (b.created_at || 0);
1356
+ case 'running': {
1357
+ const aRunning = runningProcesses.filter((p: any) => p.projectPath === a.path).length;
1358
+ const bRunning = runningProcesses.filter((p: any) => p.projectPath === b.path).length;
1359
+ return bRunning - aRunning;
1360
+ }
1361
+ default:
1362
+ return 0;
1363
+ }
801
1364
  });
802
-
803
- setProjects(filtered);
804
-
1365
+
1366
+ setProjects(sorted);
1367
+
805
1368
  // Adjust selected index if current selection is out of bounds
806
- if (selectedIndex >= filtered.length) {
807
- setSelectedIndex(Math.max(0, filtered.length - 1));
1369
+ if (selectedIndex >= sorted.length) {
1370
+ setSelectedIndex(Math.max(0, sorted.length - 1));
808
1371
  }
809
1372
  };
810
1373
 
1374
+ // Re-apply filter/sort when dependencies change
1375
+ useEffect(() => {
1376
+ applyFilterAndSort(allProjects, searchQuery, filterType, sortType);
1377
+ }, [filterType, sortType, runningProcesses]);
1378
+
811
1379
  const loadRunningProcesses = async () => {
812
1380
  try {
813
1381
  const processes = await getRunningProcessesClean();
@@ -937,11 +1505,11 @@ const App: React.FC = () => {
937
1505
  // Handler for script selection
938
1506
  const handleScriptSelect = async (scriptName: string, background: boolean) => {
939
1507
  if (!scriptModalData) return;
940
-
1508
+
941
1509
  setShowScriptModal(false);
942
1510
  setIsLoading(true);
943
1511
  setLoadingMessage(`Running ${scriptName}${background ? ' in background' : ''}...`);
944
-
1512
+
945
1513
  try {
946
1514
  if (background) {
947
1515
  await runScriptInBackground(scriptModalData.projectPath, scriptModalData.projectName, scriptName, [], false);
@@ -959,40 +1527,184 @@ const App: React.FC = () => {
959
1527
  }
960
1528
  };
961
1529
 
1530
+ // Handler for adding a project
1531
+ const handleAddProject = async (projectPath: string, projectName?: string) => {
1532
+ setShowAddProjectModal(false);
1533
+ setIsLoading(true);
1534
+ setLoadingMessage('Adding project...');
1535
+
1536
+ try {
1537
+ const name = projectName || path.basename(projectPath);
1538
+ const db = getDatabaseManager();
1539
+ const project = db.addProject(name, projectPath);
1540
+
1541
+ // Scan for tests
1542
+ setLoadingMessage('Scanning for tests...');
1543
+ await scanProject(project.id);
1544
+
1545
+ // Scan for ports
1546
+ setLoadingMessage('Scanning for ports...');
1547
+ try {
1548
+ const { scanProjectPorts } = await import('./port-scanner');
1549
+ await scanProjectPorts(project.id);
1550
+ } catch {
1551
+ // Ignore port scanning errors
1552
+ }
1553
+
1554
+ loadProjects();
1555
+ setIsLoading(false);
1556
+
1557
+ // Select the newly added project
1558
+ const newProjects = getAllProjects();
1559
+ const newIndex = newProjects.findIndex((p: Project) => p.id === project.id);
1560
+ if (newIndex >= 0) {
1561
+ setSelectedIndex(newIndex);
1562
+ }
1563
+ } catch (err) {
1564
+ setIsLoading(false);
1565
+ setError(err instanceof Error ? err.message : String(err));
1566
+ }
1567
+ };
1568
+
1569
+ // Handler for deleting a project
1570
+ const handleDeleteProject = () => {
1571
+ if (!selectedProject) return;
1572
+
1573
+ setShowConfirmDelete(false);
1574
+ setIsLoading(true);
1575
+ setLoadingMessage(`Deleting ${selectedProject.name}...`);
1576
+
1577
+ setTimeout(async () => {
1578
+ try {
1579
+ const db = getDatabaseManager();
1580
+ db.removeProject(selectedProject.id);
1581
+ loadProjects();
1582
+ if (selectedIndex >= projects.length - 1) {
1583
+ setSelectedIndex(Math.max(0, projects.length - 2));
1584
+ }
1585
+ setIsLoading(false);
1586
+ } catch (err) {
1587
+ setIsLoading(false);
1588
+ setError(err instanceof Error ? err.message : String(err));
1589
+ }
1590
+ }, 100);
1591
+ };
1592
+
1593
+ // Cycle filter type
1594
+ const cycleFilterType = () => {
1595
+ const currentIndex = FILTER_TYPES.indexOf(filterType);
1596
+ const nextIndex = (currentIndex + 1) % FILTER_TYPES.length;
1597
+ setFilterType(FILTER_TYPES[nextIndex]);
1598
+ };
1599
+
1600
+ // Cycle sort type
1601
+ const cycleSortType = () => {
1602
+ const currentIndex = SORT_TYPES.indexOf(sortType);
1603
+ const nextIndex = (currentIndex + 1) % SORT_TYPES.length;
1604
+ setSortType(SORT_TYPES[nextIndex]);
1605
+ };
1606
+
962
1607
  useInput((input: string, key: any) => {
1608
+ if (key.mouse) {
1609
+ const { x, y, wheelDown, wheelUp, left } = key.mouse;
1610
+ const { columns: width } = process.stdout;
1611
+
1612
+ // Assuming project list is on the left 35% and details on the right.
1613
+ const projectListWidth = Math.floor(width * 0.35);
1614
+
1615
+ if (x < projectListWidth) {
1616
+ // Mouse is over the project list
1617
+ if (wheelUp) {
1618
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
1619
+ return;
1620
+ }
1621
+ if (wheelDown) {
1622
+ setSelectedIndex((prev) => Math.min(projects.length - 1, prev + 1));
1623
+ return;
1624
+ }
1625
+ if (left) {
1626
+ // It's a click, so we need to calculate which item was clicked.
1627
+ // This is an approximation based on the known layout of the ProjectListComponent.
1628
+ const listTopBorder = 1;
1629
+ const listPadding = 1;
1630
+ const listHeaderHeight = 2; // "Projects (...)" + "Filter | Sort"
1631
+ const listStartY = listTopBorder + listPadding + listHeaderHeight;
1632
+
1633
+ const scrollIndicatorHeight = listScrollOffset > 0 ? 1 : 0;
1634
+ const firstItemY = listStartY + scrollIndicatorHeight;
1635
+
1636
+ const clickYInList = y - firstItemY;
1637
+
1638
+ if (clickYInList >= 0) {
1639
+ let cumulativeHeight = 0;
1640
+ const visibleProjects = projects.slice(listScrollOffset);
1641
+
1642
+ for (let i = 0; i < visibleProjects.length; i++) {
1643
+ const project = visibleProjects[i];
1644
+ const branch = gitBranches.get(project.id);
1645
+ const itemHeight = branch ? 2 : 1;
1646
+
1647
+ if (clickYInList >= cumulativeHeight && clickYInList < cumulativeHeight + itemHeight) {
1648
+ const newIndex = listScrollOffset + i;
1649
+ if (newIndex < projects.length) {
1650
+ setSelectedIndex(newIndex);
1651
+ setFocusedPanel('list');
1652
+ }
1653
+ break;
1654
+ }
1655
+ cumulativeHeight += itemHeight;
1656
+ if (cumulativeHeight > availableHeight) {
1657
+ break;
1658
+ }
1659
+ }
1660
+ }
1661
+ return;
1662
+ }
1663
+ } else {
1664
+ // Mouse is over the details panel
1665
+ if (wheelUp) {
1666
+ setDetailsScrollOffset(prev => Math.max(0, prev - 1));
1667
+ return;
1668
+ }
1669
+ if (wheelDown) {
1670
+ setDetailsScrollOffset(prev => prev + 1);
1671
+ return;
1672
+ }
1673
+ }
1674
+ }
963
1675
  // Handle search mode
964
1676
  if (showSearch) {
965
1677
  if (key.escape) {
966
1678
  setShowSearch(false);
967
1679
  setSearchQuery('');
968
- filterProjects(allProjects, '');
1680
+ applyFilterAndSort(allProjects, '', filterType, sortType);
969
1681
  return;
970
1682
  }
971
-
1683
+
972
1684
  if (key.return) {
973
1685
  setShowSearch(false);
974
1686
  return;
975
1687
  }
976
-
1688
+
977
1689
  if (key.backspace || key.delete) {
978
1690
  const newQuery = searchQuery.slice(0, -1);
979
1691
  setSearchQuery(newQuery);
980
- filterProjects(allProjects, newQuery);
1692
+ applyFilterAndSort(allProjects, newQuery, filterType, sortType);
981
1693
  return;
982
1694
  }
983
1695
 
984
1696
  if (input && input.length === 1 && !key.ctrl && !key.meta) {
985
1697
  const newQuery = searchQuery + input;
986
1698
  setSearchQuery(newQuery);
987
- filterProjects(allProjects, newQuery);
1699
+ applyFilterAndSort(allProjects, newQuery, filterType, sortType);
988
1700
  return;
989
1701
  }
990
-
1702
+
991
1703
  return;
992
1704
  }
993
-
1705
+
994
1706
  // Don't process input if modal is showing
995
- if (showHelp || isLoading || error || showUrls || showScriptModal) {
1707
+ if (showHelp || isLoading || error || showUrls || showScriptModal || showAddProjectModal || showConfirmDelete) {
996
1708
  // Handle URLs modal
997
1709
  if (showUrls && (key.escape || key.return || input === 'q' || input === 'u')) {
998
1710
  setShowUrls(false);
@@ -1001,6 +1713,104 @@ const App: React.FC = () => {
1001
1713
  return;
1002
1714
  }
1003
1715
 
1716
+ // Handle navigation in workspaces view
1717
+ if (currentView === 'workspaces') {
1718
+ if (key.upArrow || input === 'k') {
1719
+ setSelectedWorkspaceIndex((prev) => Math.max(0, prev - 1));
1720
+ return;
1721
+ }
1722
+ if (key.downArrow || input === 'j') {
1723
+ setSelectedWorkspaceIndex((prev) => Math.min(workspaces.length - 1, prev + 1));
1724
+ return;
1725
+ }
1726
+ }
1727
+
1728
+ // Handle navigation in processes view
1729
+ if (currentView === 'processes') {
1730
+ if (input === 'x' && runningProcesses.length > 0) {
1731
+ // Stop all processes (or could select one)
1732
+ setIsLoading(true);
1733
+ setLoadingMessage('Stopping processes...');
1734
+ setTimeout(async () => {
1735
+ try {
1736
+ for (const proc of runningProcesses) {
1737
+ await stopScript(proc.pid);
1738
+ }
1739
+ await loadRunningProcesses();
1740
+ setIsLoading(false);
1741
+ } catch (err) {
1742
+ setIsLoading(false);
1743
+ setError(err instanceof Error ? err.message : String(err));
1744
+ }
1745
+ }, 100);
1746
+ return;
1747
+ }
1748
+ }
1749
+
1750
+ // Handle navigation in settings view
1751
+ if (currentView === 'settings') {
1752
+ if (key.tab) {
1753
+ setSettingsSection((prev) => (prev === 'editor' ? 'browser' : 'editor'));
1754
+ setSettingsOptionIndex(0);
1755
+ return;
1756
+ }
1757
+ if (key.upArrow || input === 'k') {
1758
+ setSettingsOptionIndex((prev) => Math.max(0, prev - 1));
1759
+ return;
1760
+ }
1761
+ if (key.downArrow || input === 'j') {
1762
+ const maxIndex = settingsSection === 'editor' ? settingsEditorOptions.length - 1 : settingsBrowserOptions.length - 1;
1763
+ setSettingsOptionIndex((prev) => Math.min(maxIndex, prev + 1));
1764
+ return;
1765
+ }
1766
+ if (input === ' ' || key.return) {
1767
+ // Select option and save
1768
+ const newSettings = { ...settings };
1769
+ if (settingsSection === 'editor') {
1770
+ newSettings.editor = { type: settingsEditorOptions[settingsOptionIndex] };
1771
+ } else {
1772
+ newSettings.browser = { type: settingsBrowserOptions[settingsOptionIndex] };
1773
+ }
1774
+ setSettings(newSettings);
1775
+ // Save to file
1776
+ try {
1777
+ const settingsDir = path.join(os.homedir(), '.projax');
1778
+ if (!fs.existsSync(settingsDir)) {
1779
+ fs.mkdirSync(settingsDir, { recursive: true });
1780
+ }
1781
+ const settingsPath = path.join(settingsDir, 'settings.json');
1782
+ fs.writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2));
1783
+ } catch {
1784
+ // Ignore save errors
1785
+ }
1786
+ return;
1787
+ }
1788
+ }
1789
+
1790
+ // Global navigation - number keys for view switching
1791
+ if (input === '1') {
1792
+ setCurrentView('projects');
1793
+ return;
1794
+ }
1795
+ if (input === '2') {
1796
+ setCurrentView('workspaces');
1797
+ return;
1798
+ }
1799
+ if (input === '3') {
1800
+ setCurrentView('processes');
1801
+ return;
1802
+ }
1803
+ if (input === '4') {
1804
+ setCurrentView('settings');
1805
+ return;
1806
+ }
1807
+
1808
+ // Terminal panel toggle
1809
+ if (input === 'T') {
1810
+ setShowTerminalPanel(prev => !prev);
1811
+ return;
1812
+ }
1813
+
1004
1814
  // Search shortcut
1005
1815
  if (input === '/') {
1006
1816
  setShowSearch(true);
@@ -1008,6 +1818,38 @@ const App: React.FC = () => {
1008
1818
  return;
1009
1819
  }
1010
1820
 
1821
+ // Add project shortcut (in projects view)
1822
+ if (input === 'a' && currentView === 'projects') {
1823
+ setShowAddProjectModal(true);
1824
+ return;
1825
+ }
1826
+
1827
+ // Filter cycle shortcut
1828
+ if (input === 'F' && currentView === 'projects') {
1829
+ cycleFilterType();
1830
+ return;
1831
+ }
1832
+
1833
+ // Sort cycle shortcut
1834
+ if (input === 'S' && currentView === 'projects') {
1835
+ cycleSortType();
1836
+ return;
1837
+ }
1838
+
1839
+ // Refresh git branches
1840
+ if (input === 'g' && currentView === 'projects') {
1841
+ loadGitBranches();
1842
+ return;
1843
+ }
1844
+
1845
+ // Full refresh
1846
+ if (input === 'R') {
1847
+ loadProjects();
1848
+ loadRunningProcesses();
1849
+ loadGitBranches();
1850
+ return;
1851
+ }
1852
+
1011
1853
  // Handle editing modes
1012
1854
  if (editingName || editingDescription || editingTags) {
1013
1855
  if (key.escape) {
@@ -1176,24 +2018,9 @@ const App: React.FC = () => {
1176
2018
  return;
1177
2019
  }
1178
2020
 
1179
- // Delete project
2021
+ // Delete project (with confirmation)
1180
2022
  if (input === 'd') {
1181
- setIsLoading(true);
1182
- setLoadingMessage(`Deleting ${selectedProject.name}...`);
1183
- setTimeout(async () => {
1184
- try {
1185
- const db = getDatabaseManager();
1186
- db.removeProject(selectedProject.id);
1187
- loadProjects();
1188
- if (selectedIndex >= projects.length - 1) {
1189
- setSelectedIndex(Math.max(0, projects.length - 2));
1190
- }
1191
- setIsLoading(false);
1192
- } catch (err) {
1193
- setIsLoading(false);
1194
- setError(err instanceof Error ? err.message : String(err));
1195
- }
1196
- }, 100);
2023
+ setShowConfirmDelete(true);
1197
2024
  return;
1198
2025
  }
1199
2026
  }
@@ -1295,6 +2122,7 @@ const App: React.FC = () => {
1295
2122
  );
1296
2123
  }
1297
2124
 
2125
+
1298
2126
  if (isLoading) {
1299
2127
  return (
1300
2128
  <Box flexDirection="column" padding={1}>
@@ -1375,6 +2203,30 @@ const App: React.FC = () => {
1375
2203
  );
1376
2204
  }
1377
2205
 
2206
+ if (showAddProjectModal) {
2207
+ return (
2208
+ <Box flexDirection="column" padding={1}>
2209
+ <AddProjectModal
2210
+ onAdd={handleAddProject}
2211
+ onCancel={() => setShowAddProjectModal(false)}
2212
+ />
2213
+ </Box>
2214
+ );
2215
+ }
2216
+
2217
+ if (showConfirmDelete && selectedProject) {
2218
+ return (
2219
+ <Box flexDirection="column" padding={1}>
2220
+ <ConfirmModal
2221
+ title="Delete Project"
2222
+ message={`Are you sure you want to remove "${selectedProject.name}" from the dashboard?`}
2223
+ onConfirm={handleDeleteProject}
2224
+ onCancel={() => setShowConfirmDelete(false)}
2225
+ />
2226
+ </Box>
2227
+ );
2228
+ }
2229
+
1378
2230
  if (showScriptModal && scriptModalData) {
1379
2231
  return (
1380
2232
  <Box flexDirection="column" padding={1}>
@@ -1403,33 +2255,253 @@ const App: React.FC = () => {
1403
2255
  }
1404
2256
  };
1405
2257
 
1406
- return (
1407
- <Box flexDirection="column" height={terminalHeight}>
2258
+ // Render Projects view
2259
+ const renderProjectsView = () => (
2260
+ <Box flexDirection="row" height={availableHeight} flexGrow={0} flexShrink={0}>
2261
+ <ProjectListComponent
2262
+ projects={projects}
2263
+ selectedIndex={selectedIndex}
2264
+ runningProcesses={runningProcesses}
2265
+ isFocused={focusedPanel === 'list'}
2266
+ height={availableHeight}
2267
+ scrollOffset={listScrollOffset}
2268
+ gitBranches={gitBranches}
2269
+ filterType={filterType}
2270
+ sortType={sortType}
2271
+ />
2272
+ <Box width={1} />
2273
+ <ProjectDetailsComponent
2274
+ project={selectedProject}
2275
+ runningProcesses={runningProcesses}
2276
+ isFocused={focusedPanel === 'details'}
2277
+ editingName={editingName}
2278
+ editingDescription={editingDescription}
2279
+ editingTags={editingTags}
2280
+ editInput={editInput}
2281
+ allTags={allTags}
2282
+ onTagRemove={handleTagRemove}
2283
+ height={availableHeight}
2284
+ scrollOffset={detailsScrollOffset}
2285
+ gitBranch={selectedProject ? gitBranches.get(selectedProject.id) || null : null}
2286
+ />
2287
+ {showTerminalPanel && (
2288
+ <>
2289
+ <Box width={1} />
2290
+ <TerminalOutputPanel
2291
+ processes={runningProcesses}
2292
+ selectedPid={selectedProcessPid}
2293
+ height={availableHeight}
2294
+ onSelectProcess={(pid) => setSelectedProcessPid(pid)}
2295
+ />
2296
+ </>
2297
+ )}
2298
+ </Box>
2299
+ );
2300
+
2301
+ // Render Workspaces view
2302
+ const renderWorkspacesView = () => (
1408
2303
  <Box flexDirection="row" height={availableHeight} flexGrow={0} flexShrink={0}>
1409
- <ProjectListComponent
1410
- projects={projects}
1411
- selectedIndex={selectedIndex}
1412
- runningProcesses={runningProcesses}
1413
- isFocused={focusedPanel === 'list'}
2304
+ {/* Workspace List */}
2305
+ <Box
2306
+ flexDirection="column"
2307
+ width="35%"
1414
2308
  height={availableHeight}
1415
- scrollOffset={listScrollOffset}
1416
- />
2309
+ borderStyle="round"
2310
+ borderColor={colors.accentCyan}
2311
+ padding={1}
2312
+ >
2313
+ <Text bold color={colors.textPrimary}>
2314
+ Workspaces ({workspaces.length})
2315
+ </Text>
2316
+ <Box flexDirection="column" flexGrow={1}>
2317
+ {workspaces.length === 0 ? (
2318
+ <Text color={colors.textTertiary}>No workspaces found</Text>
2319
+ ) : (
2320
+ workspaces.map((ws, index) => {
2321
+ const isSelected = index === selectedWorkspaceIndex;
2322
+ return (
2323
+ <Text key={ws.id} color={isSelected ? colors.accentCyan : colors.textPrimary} bold={isSelected}>
2324
+ {isSelected ? '▶ ' : ' '}{truncateText(ws.name, 25)}
2325
+ </Text>
2326
+ );
2327
+ })
2328
+ )}
2329
+ </Box>
2330
+ </Box>
1417
2331
  <Box width={1} />
1418
- <ProjectDetailsComponent
1419
- project={selectedProject}
1420
- runningProcesses={runningProcesses}
1421
- isFocused={focusedPanel === 'details'}
1422
- editingName={editingName}
1423
- editingDescription={editingDescription}
1424
- editingTags={editingTags}
1425
- editInput={editInput}
1426
- allTags={allTags}
1427
- onTagRemove={handleTagRemove}
2332
+ {/* Workspace Details */}
2333
+ <Box
2334
+ flexDirection="column"
2335
+ width="65%"
1428
2336
  height={availableHeight}
1429
- scrollOffset={detailsScrollOffset}
1430
- />
2337
+ borderStyle="round"
2338
+ borderColor={colors.borderColor}
2339
+ padding={1}
2340
+ >
2341
+ {workspaces[selectedWorkspaceIndex] ? (
2342
+ <>
2343
+ <Text bold color={colors.accentCyan}>
2344
+ {workspaces[selectedWorkspaceIndex].name}
2345
+ </Text>
2346
+ <Text> </Text>
2347
+ {workspaces[selectedWorkspaceIndex].description && (
2348
+ <Text color={colors.textSecondary}>
2349
+ {workspaces[selectedWorkspaceIndex].description}
2350
+ </Text>
2351
+ )}
2352
+ <Text> </Text>
2353
+ <Text color={colors.textTertiary}>
2354
+ Path: {getDisplayPath(workspaces[selectedWorkspaceIndex].workspace_file_path)}
2355
+ </Text>
2356
+ </>
2357
+ ) : (
2358
+ <Text color={colors.textTertiary}>Select a workspace</Text>
2359
+ )}
2360
+ </Box>
1431
2361
  </Box>
1432
-
2362
+ );
2363
+
2364
+ // Load workspaces from API
2365
+ const loadWorkspacesFromApi = async () => {
2366
+ try {
2367
+ // Try common API ports
2368
+ const ports = [38124, 38125, 38126, 38127, 38128, 3001];
2369
+ let apiBaseUrl = '';
2370
+
2371
+ for (const port of ports) {
2372
+ try {
2373
+ const response = await fetch(`http://localhost:${port}/health`, {
2374
+ signal: AbortSignal.timeout(500),
2375
+ });
2376
+ if (response.ok) {
2377
+ apiBaseUrl = `http://localhost:${port}/api`;
2378
+ break;
2379
+ }
2380
+ } catch {
2381
+ continue;
2382
+ }
2383
+ }
2384
+
2385
+ if (!apiBaseUrl) {
2386
+ return;
2387
+ }
2388
+
2389
+ const response = await fetch(`${apiBaseUrl}/workspaces`);
2390
+ if (response.ok) {
2391
+ const ws = (await response.json()) as Workspace[];
2392
+ setWorkspaces(ws);
2393
+ }
2394
+ } catch {
2395
+ // Ignore workspace loading errors
2396
+ }
2397
+ };
2398
+
2399
+ // Render Processes view placeholder
2400
+ const renderProcessesView = () => (
2401
+ <Box flexDirection="column" padding={2}>
2402
+ <Text bold color={colors.accentCyan}>Running Processes ({runningProcesses.length})</Text>
2403
+ <Text> </Text>
2404
+ {runningProcesses.length === 0 ? (
2405
+ <Text color={colors.textTertiary}>No running processes</Text>
2406
+ ) : (
2407
+ runningProcesses.map((proc: any) => {
2408
+ const uptime = Math.floor((Date.now() - proc.startedAt) / 1000);
2409
+ const minutes = Math.floor(uptime / 60);
2410
+ const seconds = uptime % 60;
2411
+ const uptimeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
2412
+ return (
2413
+ <Text key={proc.pid} color={colors.textPrimary}>
2414
+ <Text color={colors.accentGreen}>●</Text> PID {proc.pid}: {proc.projectName} ({proc.scriptName}) - {uptimeStr}
2415
+ </Text>
2416
+ );
2417
+ })
2418
+ )}
2419
+ <Text> </Text>
2420
+ <Text color={colors.textTertiary}>Press 1 to return to Projects</Text>
2421
+ </Box>
2422
+ );
2423
+
2424
+ // Render Settings view
2425
+ const renderSettingsView = () => {
2426
+ const currentOptions = settingsSection === 'editor' ? settingsEditorOptions : settingsBrowserOptions;
2427
+ const currentValue = settingsSection === 'editor' ? settings.editor.type : settings.browser.type;
2428
+
2429
+ return (
2430
+ <Box flexDirection="column" padding={2}>
2431
+ <Text bold color={colors.accentCyan}>Settings</Text>
2432
+ <Text> </Text>
2433
+
2434
+ {/* Section tabs */}
2435
+ <Box>
2436
+ <Text
2437
+ color={settingsSection === 'editor' ? colors.accentCyan : colors.textTertiary}
2438
+ bold={settingsSection === 'editor'}
2439
+ >
2440
+ [Editor]
2441
+ </Text>
2442
+ <Text> </Text>
2443
+ <Text
2444
+ color={settingsSection === 'browser' ? colors.accentCyan : colors.textTertiary}
2445
+ bold={settingsSection === 'browser'}
2446
+ >
2447
+ [Browser]
2448
+ </Text>
2449
+ </Box>
2450
+ <Text> </Text>
2451
+
2452
+ {/* Options */}
2453
+ {currentOptions.map((option, index) => {
2454
+ const isSelected = index === settingsOptionIndex;
2455
+ const isActive = option === currentValue;
2456
+ return (
2457
+ <Text key={option} color={isSelected ? colors.accentCyan : colors.textPrimary} bold={isSelected}>
2458
+ {isSelected ? '▶ ' : ' '}
2459
+ {isActive ? '● ' : '○ '}
2460
+ {option.charAt(0).toUpperCase() + option.slice(1)}
2461
+ </Text>
2462
+ );
2463
+ })}
2464
+
2465
+ <Text> </Text>
2466
+ <Text color={colors.textTertiary}>Tab: switch section | ↑↓/jk: select | Space/Enter: choose</Text>
2467
+ </Box>
2468
+ );
2469
+ };
2470
+
2471
+ return (
2472
+ <Box flexDirection="column" height={terminalHeight}>
2473
+ {/* View indicator bar */}
2474
+ <Box paddingX={1} height={1}>
2475
+ <Text color={currentView === 'projects' ? colors.accentCyan : colors.textTertiary}>
2476
+ [1] Projects
2477
+ </Text>
2478
+ <Text> </Text>
2479
+ <Text color={currentView === 'workspaces' ? colors.accentCyan : colors.textTertiary}>
2480
+ [2] Workspaces
2481
+ </Text>
2482
+ <Text> </Text>
2483
+ <Text color={currentView === 'processes' ? colors.accentCyan : colors.textTertiary}>
2484
+ [3] Processes
2485
+ </Text>
2486
+ <Text> </Text>
2487
+ <Text color={currentView === 'settings' ? colors.accentCyan : colors.textTertiary}>
2488
+ [4] Settings
2489
+ </Text>
2490
+ {showTerminalPanel && (
2491
+ <>
2492
+ <Text> | </Text>
2493
+ <Text color={colors.accentGreen}>Terminal [T]</Text>
2494
+ </>
2495
+ )}
2496
+ </Box>
2497
+
2498
+ {/* Main content based on current view */}
2499
+ {currentView === 'projects' && renderProjectsView()}
2500
+ {currentView === 'workspaces' && renderWorkspacesView()}
2501
+ {currentView === 'processes' && renderProcessesView()}
2502
+ {currentView === 'settings' && renderSettingsView()}
2503
+
2504
+ {/* Status bar */}
1433
2505
  <Box paddingX={1} borderStyle="single" borderColor={colors.borderColor} flexShrink={0} height={3}>
1434
2506
  <StatusBar focusedPanel={focusedPanel} selectedProject={selectedProject} />
1435
2507
  </Box>