project-compass 3.1.0 → 3.2.1

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/package.json +1 -1
  2. package/src/cli.js +97 -79
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-compass",
3
- "version": "3.1.0",
3
+ "version": "3.2.1",
4
4
  "description": "Ink-based project explorer that detects local repos and lets you build/test/run them without memorizing commands.",
5
5
  "main": "src/cli.js",
6
6
  "type": "module",
package/src/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
2
+ import React, {useCallback, useEffect, useMemo, useRef, useState, memo} from 'react';
3
3
  import {render, Box, Text, useApp, useInput} from 'ink';
4
4
  import path from 'path';
5
5
  import fs from 'fs';
@@ -90,7 +90,7 @@ function buildDetailCommands(project, config) {
90
90
  return [...builtins, ...custom];
91
91
  }
92
92
 
93
- function Studio() {
93
+ const Studio = memo(() => {
94
94
  const [runtimes, setRuntimes] = useState([]);
95
95
  const [loading, setLoading] = useState(true);
96
96
 
@@ -145,7 +145,25 @@ function Studio() {
145
145
  create(Text, {dimColor: true}, 'Press Shift+A to return to Navigator.')
146
146
  )
147
147
  );
148
- }
148
+ });
149
+
150
+ const TaskManager = memo(({tasks, activeTaskId, renameMode, renameInput, renameCursor}) => {
151
+ return create(
152
+ Box,
153
+ {flexDirection: 'column', borderStyle: 'round', borderColor: 'yellow', padding: 1},
154
+ create(Text, {bold: true, color: 'yellow'}, '🛰️ Task Manager | Background Processes'),
155
+ create(Text, {dimColor: true, marginBottom: 1}, 'Up/Down: focus, Shift+K: Kill/Delete, Shift+R: Rename'),
156
+ ...tasks.map(t => create(
157
+ Box,
158
+ {key: t.id, marginBottom: 0, flexDirection: 'column'},
159
+ t.id === activeTaskId && renameMode
160
+ ? create(Box, {flexDirection: 'row'}, create(Text, {color: 'cyan'}, '→ Rename to: '), create(CursorText, {value: renameInput, cursorIndex: renameCursor}))
161
+ : create(Text, {color: t.id === activeTaskId ? 'cyan' : 'white', bold: t.id === activeTaskId}, `${t.id === activeTaskId ? '→' : ' '} [${t.status.toUpperCase()}] ${t.name}`)
162
+ )),
163
+ !tasks.length && create(Text, {dimColor: true}, 'No active or background tasks.'),
164
+ create(Text, {marginTop: 1, dimColor: true}, 'Press Enter or Shift+T to return to Navigator.')
165
+ );
166
+ });
149
167
 
150
168
  function CursorText({value, cursorIndex, active = true}) {
151
169
  const before = value.slice(0, cursorIndex);
@@ -161,6 +179,32 @@ function CursorText({value, cursorIndex, active = true}) {
161
179
  );
162
180
  }
163
181
 
182
+ const OutputPanel = memo(({activeTask, logOffset}) => {
183
+ const logs = activeTask?.logs || [];
184
+ const logWindowStart = Math.max(0, logs.length - OUTPUT_WINDOW_SIZE - logOffset);
185
+ const logWindowEnd = Math.max(0, logs.length - logOffset);
186
+ const visibleLogs = logs.slice(logWindowStart, logWindowEnd);
187
+
188
+ const logNodes = visibleLogs.length
189
+ ? visibleLogs.map((line, i) => create(Text, {key: i}, line))
190
+ : [create(Text, {key: 'empty', dimColor: true}, 'Select a task or run a command to see logs.')];
191
+
192
+ return create(
193
+ Box,
194
+ {
195
+ flexDirection: 'column',
196
+ borderStyle: 'round',
197
+ borderColor: 'yellow',
198
+ padding: 1,
199
+ minHeight: OUTPUT_WINDOW_HEIGHT,
200
+ maxHeight: OUTPUT_WINDOW_HEIGHT,
201
+ height: OUTPUT_WINDOW_HEIGHT,
202
+ overflow: 'hidden'
203
+ },
204
+ ...logNodes
205
+ );
206
+ });
207
+
164
208
  function Compass({rootPath, initialView = 'navigator'}) {
165
209
  const {exit} = useApp();
166
210
  const {projects, loading, error} = useScanner(rootPath);
@@ -279,8 +323,8 @@ function Compass({rootPath, initialView = 'navigator'}) {
279
323
  const exportPath = path.resolve(process.cwd(), `compass-${activeTask.id}.txt`);
280
324
  fs.writeFileSync(exportPath, activeTask.logs.join('\n'));
281
325
  addLogToTask(activeTaskId, kleur.green(`✓ Logs exported to ${exportPath}`));
282
- } catch (err) {
283
- addLogToTask(activeTaskId, kleur.red(`✗ Export failed: ${err.message}`));
326
+ } catch {
327
+ addLogToTask(activeTaskId, kleur.red('✗ Export failed'));
284
328
  }
285
329
  }, [activeTask, activeTaskId, addLogToTask]);
286
330
 
@@ -464,117 +508,80 @@ function Compass({rootPath, initialView = 'navigator'}) {
464
508
  }
465
509
  });
466
510
 
467
- const projectCountLabel = `${projects.length} project${projects.length === 1 ? '' : 's'}`;
511
+ const projectCountLabel = useMemo(() => `${projects.length} project${projects.length === 1 ? '' : 's'}`, [projects.length]);
468
512
  const toggleHint = config.showHelpCards ? 'Shift+H hide help' : 'Shift+H show help';
469
513
  const statusHint = activeTask ? `[${activeTask.status.toUpperCase()}] ${activeTask.name}` : 'Idle Navigator';
470
514
  const orbitHint = mainView === 'tasks' ? 'Tasks View' : `Orbit: ${tasks.length} tasks`;
471
515
  const artHint = config.showArtBoard ? 'Shift+B hide art' : 'Shift+B show art';
472
516
 
473
- if (quitConfirm) {
474
- return create(Box, {flexDirection: 'column', borderStyle: 'round', borderColor: 'red', padding: 1}, create(Text, {bold: true, color: 'red'}, '⚠️ Confirm Exit'), create(Text, null, `There are ${tasks.filter(t=>t.status==='running').length} tasks still running in the background.`), create(Text, null, 'Are you sure you want to quit and stop all processes?'), create(Text, {marginTop: 1}, kleur.bold('Y') + ' to Quit, ' + kleur.bold('N') + ' to Cancel'));
475
- }
476
-
477
- if (mainView === 'studio') return create(Studio);
478
-
479
- if (mainView === 'tasks') {
480
- return create(
481
- Box,
482
- {flexDirection: 'column', borderStyle: 'round', borderColor: 'yellow', padding: 1},
483
- create(Text, {bold: true, color: 'yellow'}, '🛰️ Task Manager | Background Processes'),
484
- create(Text, {dimColor: true, marginBottom: 1}, 'Up/Down: focus, Shift+K: Kill/Delete, Shift+R: Rename'),
485
- ...tasks.map(t => create(
486
- Box,
487
- {key: t.id, marginBottom: 0, flexDirection: 'column'},
488
- t.id === activeTaskId && renameMode
489
- ? create(Box, {flexDirection: 'row'}, create(Text, {color: 'cyan'}, '→ Rename to: '), create(CursorText, {value: renameInput, cursorIndex: renameCursor}))
490
- : create(Text, {color: t.id === activeTaskId ? 'cyan' : 'white', bold: t.id === activeTaskId}, `${t.id === activeTaskId ? '→' : ' '} [${t.status.toUpperCase()}] ${t.name}`)
491
- )),
492
- !tasks.length && create(Text, {dimColor: true}, 'No active or background tasks.'),
493
- create(Text, {marginTop: 1, dimColor: true}, 'Press Enter or Shift+T to return to Navigator.')
494
- );
495
- }
496
-
497
- const projectRows = [];
498
- if (loading) projectRows.push(create(Text, {key: 'scanning', dimColor: true}, 'Scanning projects…'));
499
- if (error) projectRows.push(create(Text, {key: 'error', color: 'red'}, `Unable to scan: ${error}`));
500
- if (!loading && !error && projects.length === 0) projectRows.push(create(Text, {key: 'empty', dimColor: true}, 'No recognizable project manifests found.'));
501
-
502
- if (!loading) {
503
- projects.forEach((project, index) => {
517
+ const projectRows = useMemo(() => {
518
+ if (loading) return [create(Text, {key: 'scanning', dimColor: true}, 'Scanning projects…')];
519
+ if (error) return [create(Text, {key: 'error', color: 'red'}, `Unable to scan: ${error}`)];
520
+ if (projects.length === 0) return [create(Text, {key: 'empty', dimColor: true}, 'No recognizable project manifests found.')];
521
+
522
+ return projects.map((project, index) => {
504
523
  const isSelected = index === selectedIndex;
505
524
  const frameworkBadges = (project.frameworks || []).map((frame) => `${frame.icon} ${frame.name}`).join(', ');
506
525
  const hasMissingRuntime = project.missingBinaries && project.missingBinaries.length > 0;
507
- projectRows.push(
526
+ return create(
527
+ Box,
528
+ {key: project.id, flexDirection: 'column', marginBottom: 1, padding: 1},
508
529
  create(
509
530
  Box,
510
- {key: project.id, flexDirection: 'column', marginBottom: 1, padding: 1},
511
- create(
512
- Box,
513
- {flexDirection: 'row'},
514
- create(Text, {color: isSelected ? 'cyan' : 'white', bold: isSelected}, `${project.icon} ${project.name}`),
515
- hasMissingRuntime && create(Text, {color: 'red', bold: true}, ' ⚠️ Runtime missing')
516
- ),
517
- create(Text, {dimColor: true}, ` ${project.type} · ${path.relative(rootPath, project.path) || '.'}`),
518
- frameworkBadges && create(Text, {dimColor: true}, ` ${frameworkBadges}`)
519
- )
531
+ {flexDirection: 'row'},
532
+ create(Text, {color: isSelected ? 'cyan' : 'white', bold: isSelected}, `${project.icon} ${project.name}`),
533
+ hasMissingRuntime && create(Text, {color: 'red', bold: true}, ' ⚠️ Runtime missing')
534
+ ),
535
+ create(Text, {dimColor: true}, ` ${project.type} · ${path.relative(rootPath, project.path) || '.'}`),
536
+ frameworkBadges && create(Text, {dimColor: true}, ` ${frameworkBadges}`)
520
537
  );
521
538
  });
522
- }
539
+ }, [loading, error, projects, selectedIndex, rootPath]);
523
540
 
524
- const detailContent = [];
525
- if (viewMode === 'detail' && selectedProject) {
526
- detailContent.push(
541
+ const detailContent = useMemo(() => {
542
+ if (viewMode !== 'detail' || !selectedProject) {
543
+ return [create(Text, {key: 'e-h', dimColor: true}, 'Press Enter on a project to reveal details.')];
544
+ }
545
+
546
+ const content = [
527
547
  create(Box, {key: 'title-row', flexDirection: 'row'},
528
548
  create(Text, {color: 'cyan', bold: true}, `${selectedProject.icon} ${selectedProject.name}`),
529
549
  selectedProject.missingBinaries && selectedProject.missingBinaries.length > 0 && create(Text, {color: 'red', bold: true}, ' ⚠️ MISSING RUNTIME')
530
550
  ),
531
551
  create(Text, {key: 'manifest', dimColor: true}, `${selectedProject.type} · ${selectedProject.manifest || 'detected manifest'}`),
532
552
  create(Text, {key: 'loc', dimColor: true}, `Location: ${path.relative(rootPath, selectedProject.path) || '.'}`)
533
- );
534
- if (selectedProject.description) detailContent.push(create(Text, {key: 'desc'}, selectedProject.description));
553
+ ];
554
+ if (selectedProject.description) content.push(create(Text, {key: 'desc'}, selectedProject.description));
535
555
  const frameworks = (selectedProject.frameworks || []).map((lib) => `${lib.icon} ${lib.name}`).join(', ');
536
- if (frameworks) detailContent.push(create(Text, {key: 'frames', dimColor: true}, `Frameworks: ${frameworks}`));
556
+ if (frameworks) content.push(create(Text, {key: 'frames', dimColor: true}, `Frameworks: ${frameworks}`));
537
557
 
538
558
  if (selectedProject.missingBinaries && selectedProject.missingBinaries.length > 0) {
539
- detailContent.push(
559
+ content.push(
540
560
  create(Text, {key: 'm-t', color: 'red', bold: true, marginTop: 1}, 'MISSING BINARIES:'),
541
561
  create(Text, {key: 'm-l', color: 'red'}, `Please install: ${selectedProject.missingBinaries.join(', ')}`)
542
562
  );
543
563
  }
544
564
 
545
- detailContent.push(create(Text, {key: 'cmd-header', bold: true, marginTop: 1}, 'Commands'));
565
+ content.push(create(Text, {key: 'cmd-header', bold: true, marginTop: 1}, 'Commands'));
546
566
  detailedIndexed.forEach((command) => {
547
- detailContent.push(
567
+ content.push(
548
568
  create(Text, {key: `d-${command.shortcut}`}, `${command.shortcut}. ${command.label} ${command.source === 'custom' ? kleur.magenta('(custom)') : command.source === 'framework' ? kleur.cyan('(framework)') : ''}`),
549
569
  create(Text, {key: `dl-${command.shortcut}`, dimColor: true}, ` ↳ ${command.command.join(' ')}`)
550
570
  );
551
571
  });
552
- detailContent.push(create(Text, {key: 'h-l', dimColor: true, marginTop: 1}, 'Press Shift+C → label|cmd to save custom actions, Enter to close detail view.'));
553
- } else {
554
- detailContent.push(create(Text, {key: 'e-h', dimColor: true}, 'Press Enter on a project to reveal details.'));
555
- }
572
+ content.push(create(Text, {key: 'h-l', dimColor: true, marginTop: 1}, 'Press Shift+C → label|cmd to save custom actions, Enter to close detail view.'));
573
+ return content;
574
+ }, [viewMode, selectedProject, rootPath, detailedIndexed]);
556
575
 
557
- const artTileNodes = [
576
+ const artTileNodes = useMemo(() => [
558
577
  {label: 'Pulse', detail: projectCountLabel, accent: 'magenta', icon: '●', subtext: `Workspace · ${path.basename(rootPath) || rootPath}`},
559
578
  {label: 'Focus', detail: selectedProject?.name || 'Selection', accent: 'cyan', icon: '◆', subtext: `${selectedProject?.type || 'Stack'}`},
560
- {label: 'Orbit', detail: `${tasks.length} active tasks`, accent: 'yellow', icon: '■', subtext: running ? 'Busy streaming...' : 'Idle'}
579
+ {label: 'Orbit', detail: `${tasks.length} tasks`, accent: 'yellow', icon: '■', subtext: running ? 'Busy streaming...' : 'Idle'}
561
580
  ].map(tile => create(Box, {key: tile.label, flexDirection: 'column', padding: 1, marginRight: 1, borderStyle: 'single', borderColor: tile.accent, minWidth: 24},
562
581
  create(Text, {color: tile.accent, bold: true}, `${tile.icon} ${tile.label}`),
563
582
  create(Text, {bold: true}, tile.detail),
564
583
  create(Text, {dimColor: true}, tile.subtext)
565
- ));
566
-
567
- const artBoard = config.showArtBoard ? create(Box, {flexDirection: 'column', marginTop: 1, borderStyle: 'round', borderColor: 'gray', padding: 1},
568
- create(Box, {flexDirection: 'row', justifyContent: 'space-between'}, create(Text, {color: 'magenta', bold: true}, 'Art-coded build atlas'), create(Text, {dimColor: true}, 'press ? for overlay help')),
569
- create(Box, {flexDirection: 'row', marginTop: 1}, ...ART_CHARS.map((char, i) => create(Text, {key: i, color: ART_COLORS[i % ART_COLORS.length]}, char.repeat(2)))),
570
- create(Box, {flexDirection: 'row', marginTop: 1}, ...artTileNodes)
571
- ) : null;
572
-
573
- const logs = activeTask?.logs || [];
574
- const logWindowStart = Math.max(0, logs.length - OUTPUT_WINDOW_SIZE - logOffset);
575
- const logWindowEnd = Math.max(0, logs.length - logOffset);
576
- const visibleLogs = logs.slice(logWindowStart, logWindowEnd);
577
- const logNodes = visibleLogs.length ? visibleLogs.map((line, i) => create(Text, {key: i}, line)) : [create(Text, {dimColor: true}, 'Select a task or run a command to see logs.')];
584
+ )), [projectCountLabel, rootPath, selectedProject, tasks.length, running]);
578
585
 
579
586
  const helpCards = [
580
587
  {label: 'Navigation', color: 'magenta', body: ['↑ / ↓ move focus, Enter: details', 'Shift+↑ / ↓ scroll output', 'Shift+H toggle help cards', 'Shift+D detach from task']},
@@ -582,6 +589,13 @@ function Compass({rootPath, initialView = 'navigator'}) {
582
589
  {label: 'Orbit & Studio', color: 'yellow', body: ['Shift+T task manager', 'Shift+A studio / Shift+B art board', 'Shift+S structure / Shift+Q quit']}
583
590
  ];
584
591
 
592
+ if (quitConfirm) {
593
+ return create(Box, {flexDirection: 'column', borderStyle: 'round', borderColor: 'red', padding: 1}, create(Text, {bold: true, color: 'red'}, '⚠️ Confirm Exit'), create(Text, null, `There are ${tasks.filter(t=>t.status==='running').length} tasks still running in the background.`), create(Text, null, 'Are you sure you want to quit and stop all processes?'), create(Text, {marginTop: 1}, kleur.bold('Y') + ' to Quit, ' + kleur.bold('N') + ' to Cancel'));
594
+ }
595
+
596
+ if (mainView === 'studio') return create(Studio);
597
+ if (mainView === 'tasks') return create(TaskManager, {tasks, activeTaskId, renameMode, renameInput, renameCursor});
598
+
585
599
  return create(Box, {flexDirection: 'column', padding: 1},
586
600
  create(Box, {justifyContent: 'space-between'},
587
601
  create(Box, {flexDirection: 'column'}, create(Text, {color: 'magenta', bold: true}, 'Project Compass'), create(Text, {dimColor: true}, `${projectCountLabel} detected in ${rootPath}`)),
@@ -590,14 +604,18 @@ function Compass({rootPath, initialView = 'navigator'}) {
590
604
  create(Text, {dimColor: true}, `${toggleHint} · ${orbitHint} · ${artHint} · Shift+Q: Quit`)
591
605
  )
592
606
  ),
593
- artBoard,
607
+ config.showArtBoard && create(Box, {flexDirection: 'column', marginTop: 1, borderStyle: 'round', borderColor: 'gray', padding: 1},
608
+ create(Box, {flexDirection: 'row', justifyContent: 'space-between'}, create(Text, {color: 'magenta', bold: true}, 'Art-coded build atlas'), create(Text, {dimColor: true}, 'press ? for overlay help')),
609
+ create(Box, {flexDirection: 'row', marginTop: 1}, ...ART_CHARS.map((char, i) => create(Text, {key: i, color: ART_COLORS[i % ART_COLORS.length]}, char.repeat(2)))),
610
+ create(Box, {flexDirection: 'row', marginTop: 1}, ...artTileNodes)
611
+ ),
594
612
  create(Box, {marginTop: 1, flexDirection: 'row', alignItems: 'stretch', width: '100%', flexWrap: 'wrap'},
595
613
  create(Box, {flexGrow: 1, flexBasis: 0, minWidth: PROJECTS_MIN_WIDTH, marginRight: 1, borderStyle: 'round', borderColor: 'magenta', padding: 1}, create(Text, {bold: true, color: 'magenta'}, 'Projects'), create(Box, {flexDirection: 'column', marginTop: 1}, ...projectRows)),
596
614
  create(Box, {flexGrow: 1.3, flexBasis: 0, minWidth: DETAILS_MIN_WIDTH, borderStyle: 'round', borderColor: 'cyan', padding: 1, flexDirection: 'column'}, create(Text, {bold: true, color: 'cyan'}, 'Details'), ...detailContent)
597
615
  ),
598
616
  create(Box, {marginTop: 1, flexDirection: 'column'},
599
617
  create(Box, {flexDirection: 'row', justifyContent: 'space-between'}, create(Text, {bold: true, color: 'yellow'}, `Output: ${activeTask?.name || 'None'}`), create(Text, {dimColor: true}, logOffset ? `Scrolled ${logOffset} lines` : 'Live log view')),
600
- create(Box, {flexDirection: 'column', borderStyle: 'round', borderColor: 'yellow', padding: 1, minHeight: OUTPUT_WINDOW_HEIGHT, maxHeight: OUTPUT_WINDOW_HEIGHT, height: OUTPUT_WINDOW_HEIGHT, overflow: 'hidden'}, ...logNodes),
618
+ create(OutputPanel, {activeTask, logOffset}),
601
619
  create(Box, {marginTop: 1, flexDirection: 'row', justifyContent: 'space-between'}, create(Text, {dimColor: true}, running ? 'Type to feed stdin; Enter: submit, Ctrl+C: abort.' : 'Run a command or press Shift+T to switch tasks.'), create(Text, {dimColor: true}, `${toggleHint}, Shift+S: Structure Guide`)),
602
620
  create(Box, {marginTop: 1, flexDirection: 'row', borderStyle: 'round', borderColor: running ? 'green' : 'gray', paddingX: 1}, create(Text, {bold: true, color: running ? 'green' : 'white'}, running ? ' Stdin buffer ' : ' Input ready '), create(Box, {marginLeft: 1}, create(CursorText, {value: stdinBuffer || (running ? '' : 'Start a command to feed stdin'), cursorIndex: stdinCursor, active: running})))
603
621
  ),