@hartvig/developer-control-center 0.8.5 → 0.8.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 (97) hide show
  1. package/.developer-control-center/metrics.json +1 -1
  2. package/.developer-control-center/status.json +1 -1
  3. package/.developer-control-center/timings.jsonl +24 -0
  4. package/.github/workflows/ci.yml +1 -7
  5. package/coverage/Developer Control Center/dcc.config.js.html +628 -0
  6. package/coverage/Developer Control Center/index.html +116 -0
  7. package/coverage/Developer Control Center/src/config/index.html +116 -0
  8. package/coverage/Developer Control Center/src/config/loader.ts.html +454 -0
  9. package/coverage/Developer Control Center/src/core/ci.ts.html +163 -0
  10. package/coverage/Developer Control Center/src/core/event-bus.ts.html +187 -0
  11. package/coverage/Developer Control Center/src/core/index.html +191 -0
  12. package/coverage/Developer Control Center/src/core/notifier.ts.html +187 -0
  13. package/coverage/Developer Control Center/src/core/persistence.ts.html +88 -0
  14. package/coverage/Developer Control Center/src/core/task-runner.ts.html +1498 -0
  15. package/coverage/Developer Control Center/src/core/workspaces.ts.html +304 -0
  16. package/coverage/Developer Control Center/src/plugins/index.html +116 -0
  17. package/coverage/Developer Control Center/src/plugins/manager.ts.html +259 -0
  18. package/coverage/Developer Control Center/src/status/index.html +116 -0
  19. package/coverage/Developer Control Center/src/status/store.ts.html +349 -0
  20. package/coverage/Developer Control Center/src/ui/command-list.tsx.html +574 -0
  21. package/coverage/Developer Control Center/src/ui/index.html +161 -0
  22. package/coverage/Developer Control Center/src/ui/metrics-panel.tsx.html +787 -0
  23. package/coverage/Developer Control Center/src/ui/panel.tsx.html +313 -0
  24. package/coverage/Developer Control Center/src/ui/status-panel.tsx.html +565 -0
  25. package/coverage/base.css +224 -0
  26. package/coverage/block-navigation.js +87 -0
  27. package/coverage/clover.xml +588 -0
  28. package/coverage/coverage-final.json +15 -0
  29. package/coverage/favicon.png +0 -0
  30. package/coverage/index.html +191 -0
  31. package/coverage/prettify.css +1 -0
  32. package/coverage/prettify.js +2 -0
  33. package/coverage/sort-arrow-sprite.png +0 -0
  34. package/coverage/sorter.js +210 -0
  35. package/dcc.config.js +8 -4
  36. package/dist/cli.js +0 -0
  37. package/dist/core/persistence.d.ts +2 -0
  38. package/dist/core/persistence.d.ts.map +1 -0
  39. package/dist/core/persistence.js +2 -0
  40. package/dist/core/persistence.js.map +1 -0
  41. package/dist/core/runtime.d.ts.map +1 -1
  42. package/dist/core/runtime.js +5 -2
  43. package/dist/core/runtime.js.map +1 -1
  44. package/dist/core/task-runner.d.ts +1 -0
  45. package/dist/core/task-runner.d.ts.map +1 -1
  46. package/dist/core/task-runner.js +24 -4
  47. package/dist/core/task-runner.js.map +1 -1
  48. package/dist/core/task-runner.test.d.ts +2 -0
  49. package/dist/core/task-runner.test.d.ts.map +1 -0
  50. package/dist/core/task-runner.test.js +326 -0
  51. package/dist/core/task-runner.test.js.map +1 -0
  52. package/dist/core/timer-plugin.d.ts.map +1 -1
  53. package/dist/core/timer-plugin.js +2 -1
  54. package/dist/core/timer-plugin.js.map +1 -1
  55. package/dist/plugins/manager.d.ts +2 -0
  56. package/dist/plugins/manager.d.ts.map +1 -1
  57. package/dist/plugins/manager.js +6 -2
  58. package/dist/plugins/manager.js.map +1 -1
  59. package/dist/plugins/manager.test.js +5 -2
  60. package/dist/plugins/manager.test.js.map +1 -1
  61. package/dist/ui/app.d.ts.map +1 -1
  62. package/dist/ui/app.js +106 -19
  63. package/dist/ui/app.js.map +1 -1
  64. package/dist/ui/command-list.test.d.ts +2 -0
  65. package/dist/ui/command-list.test.d.ts.map +1 -0
  66. package/dist/ui/command-list.test.js +104 -0
  67. package/dist/ui/command-list.test.js.map +1 -0
  68. package/dist/ui/metrics-panel.d.ts.map +1 -1
  69. package/dist/ui/metrics-panel.js +10 -9
  70. package/dist/ui/metrics-panel.js.map +1 -1
  71. package/dist/ui/metrics-panel.test.d.ts +2 -0
  72. package/dist/ui/metrics-panel.test.d.ts.map +1 -0
  73. package/dist/ui/metrics-panel.test.js +111 -0
  74. package/dist/ui/metrics-panel.test.js.map +1 -0
  75. package/dist/ui/panel.test.d.ts +2 -0
  76. package/dist/ui/panel.test.d.ts.map +1 -0
  77. package/dist/ui/panel.test.js +51 -0
  78. package/dist/ui/panel.test.js.map +1 -0
  79. package/dist/ui/status-panel.test.d.ts +2 -0
  80. package/dist/ui/status-panel.test.d.ts.map +1 -0
  81. package/dist/ui/status-panel.test.js +88 -0
  82. package/dist/ui/status-panel.test.js.map +1 -0
  83. package/package.json +3 -1
  84. package/src/core/persistence.ts +1 -0
  85. package/src/core/runtime.ts +7 -2
  86. package/src/core/task-runner.test.ts +395 -0
  87. package/src/core/task-runner.ts +26 -5
  88. package/src/core/timer-plugin.ts +2 -1
  89. package/src/plugins/manager.test.ts +5 -2
  90. package/src/plugins/manager.ts +6 -2
  91. package/src/ui/app.tsx +151 -31
  92. package/src/ui/command-list.test.tsx +124 -0
  93. package/src/ui/metrics-panel.test.tsx +128 -0
  94. package/src/ui/metrics-panel.tsx +10 -10
  95. package/src/ui/panel.test.tsx +84 -0
  96. package/src/ui/status-panel.test.tsx +116 -0
  97. package/vitest.config.ts +1 -1
package/src/ui/app.tsx CHANGED
@@ -13,7 +13,7 @@ interface AppProps {
13
13
  runtime: Runtime;
14
14
  }
15
15
 
16
- type Mode = 'normal' | 'search' | 'confirm' | 'input' | 'popup';
16
+ type Mode = 'normal' | 'search' | 'confirm' | 'input' | 'popup' | 'help';
17
17
 
18
18
  interface PopupState {
19
19
  title: string;
@@ -26,6 +26,35 @@ type Pane = 'commands' | 'status';
26
26
  const PROFILE_GROUP_ID = '__profiles';
27
27
  const GROUP_ORDER = ['Development', 'Build', 'Deploy', 'Management', 'Demo'];
28
28
 
29
+ type HelpEntry =
30
+ | { kind: 'header'; text: string }
31
+ | { kind: 'key'; key: string; desc: string }
32
+ | { kind: 'spacer' }
33
+ | { kind: 'hint' };
34
+
35
+ const helpEntries: HelpEntry[] = [
36
+ { kind: 'header', text: 'Navigation' },
37
+ { kind: 'key', key: '↑/↓', desc: 'Navigate commands' },
38
+ { kind: 'key', key: 'Enter', desc: 'Run selected command' },
39
+ { kind: 'key', key: 'Space', desc: 'Multi-select / open group' },
40
+ { kind: 'key', key: 'Tab', desc: 'Toggle pane focus' },
41
+ { kind: 'key', key: '/', desc: 'Search/filter commands' },
42
+ { kind: 'spacer' },
43
+ { kind: 'header', text: 'Output pane' },
44
+ { kind: 'key', key: '↑/↓', desc: 'Scroll line by line' },
45
+ { kind: 'key', key: 'PgUp/PgDn', desc: 'Scroll 10 lines' },
46
+ { kind: 'key', key: 'Esc', desc: 'Back to commands' },
47
+ { kind: 'spacer' },
48
+ { kind: 'header', text: 'Global' },
49
+ { kind: 'key', key: '?', desc: 'Toggle this help' },
50
+ { kind: 'key', key: 'Esc', desc: 'Back / deselect / quit' },
51
+ { kind: 'key', key: 'Ctrl+C', desc: 'Quit' },
52
+ { kind: 'spacer' },
53
+ { kind: 'hint' },
54
+ ];
55
+
56
+ const HELP_CONTENT_LINES = helpEntries.length;
57
+
29
58
  function commandsForProfile(config: ProkomConfig, profile?: string): ProkomCommand[] {
30
59
  const base = config.baseCommands ?? config.commands;
31
60
  const profileCommands = profile ? (config.profiles?.[profile]?.commands ?? []) : [];
@@ -68,14 +97,14 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
68
97
  );
69
98
  const [currentGroup, setCurrentGroup] = useState<string | null>(null);
70
99
  const [multiSelected, setMultiSelected] = useState<Set<string>>(new Set());
100
+ const [helpScroll, setHelpScroll] = useState(0);
71
101
  const [activeProfile, setActiveProfile] = useState<string | undefined>(config.profile);
72
- const menuRows = config.menuRows ?? 8;
102
+ const terminalRows = stdout?.rows ?? 24;
103
+ const dynamicRows = Math.max(4, terminalRows - 7);
104
+ const menuRows = config.menuRows ?? dynamicRows;
73
105
  const outputRows = config.outputRows ?? menuRows;
74
106
  const terminalColumns = stdout?.columns ?? 120;
75
- const availablePaneWidth = Math.max(90, terminalColumns - 4);
76
107
  const statusPaneWidth = 28;
77
- const commandPaneWidth = Math.min(52, Math.max(34, Math.floor(availablePaneWidth * 0.34)));
78
- const outputPaneWidth = Math.max(30, availablePaneWidth - commandPaneWidth - statusPaneWidth - 2);
79
108
 
80
109
  const activeCommands = useMemo(
81
110
  () => commandsForProfile(config, activeProfile),
@@ -84,7 +113,19 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
84
113
 
85
114
  useEffect(() => {
86
115
  runtime.taskRunner.setCommands(activeCommands);
87
- }, [activeCommands, runtime]);
116
+ }, [activeCommands, runtime] );
117
+
118
+ const commandPaneWidth = useMemo(() => {
119
+ const padding = 6;
120
+ const maxLabel = activeCommands.reduce((max, cmd) => {
121
+ const label = cmd.toggle ? `Start ${cmd.label}` : cmd.label;
122
+ return Math.max(max, label.length);
123
+ }, 0);
124
+ return Math.min(Math.max(maxLabel + padding + 2, 20), terminalColumns - statusPaneWidth - 6);
125
+ }, [activeCommands, terminalColumns, statusPaneWidth]);
126
+
127
+ const outputPaneWidth = Math.max(20, terminalColumns - commandPaneWidth - statusPaneWidth - 4);
128
+ const helpScrollMax = Math.max(0, HELP_CONTENT_LINES - outputRows);
88
129
 
89
130
  const modeRef = useRef(mode);
90
131
  modeRef.current = mode;
@@ -198,10 +239,16 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
198
239
 
199
240
  const filteredItems = useMemo((): MenuItem[] => {
200
241
  if (!searchQuery) return menuItems;
201
- return menuItems.filter((item) =>
202
- item.label.toLowerCase().includes(searchQuery.toLowerCase()),
242
+ const q = searchQuery.toLowerCase();
243
+ const topLevel = menuItems.filter((item) =>
244
+ item.label.toLowerCase().includes(q),
245
+ );
246
+ if (!hasGroups || currentGroup) return topLevel;
247
+ const groupCommands = activeCommands.filter(
248
+ (cmd) => cmd.group && cmd.label.toLowerCase().includes(q),
203
249
  );
204
- }, [menuItems, searchQuery]);
250
+ return groupCommands.length > 0 ? [...topLevel, ...groupCommands] : topLevel;
251
+ }, [menuItems, searchQuery, hasGroups, currentGroup, activeCommands]);
205
252
 
206
253
  useEffect(() => {
207
254
  if (selectedIndex >= filteredItems.length && filteredItems.length > 0) {
@@ -215,15 +262,17 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
215
262
 
216
263
  const selCount = multiSelected.size;
217
264
  const selectedItem = filteredItems[selectedIndex];
218
- const footerText = selectedItem
219
- ? isProfileOption(selectedItem)
220
- ? selectedItem.active
221
- ? `Active profile: ${selectedItem.label}`
222
- : `Switch to ${selectedItem.label} profile`
223
- : 'count' in selectedItem
224
- ? `Open ${selectedItem.label}`
225
- : selectedItem.description ?? 'Enter to run, Space to select, / to search, Tab to focus output'
226
- : 'Enter to run, Space to select, / to search, Tab to focus output';
265
+ const footerText = mode === 'help'
266
+ ? 'Press ? or Esc to close help'
267
+ : selectedItem
268
+ ? isProfileOption(selectedItem)
269
+ ? selectedItem.active
270
+ ? `Active profile: ${selectedItem.label}`
271
+ : `Switch to ${selectedItem.label} profile`
272
+ : 'count' in selectedItem
273
+ ? `Open ${selectedItem.label}`
274
+ : selectedItem.description ?? 'Enter to run, Space to select, / to search, Tab focus, ? help'
275
+ : 'Enter to run, Space to select, / to search, Tab focus, ? help';
227
276
 
228
277
  const runSingle = useCallback((cmd: ProkomCommand) => {
229
278
  if (cmd.id === 'demo-confirm-overlay') {
@@ -247,7 +296,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
247
296
  if (task?.status === 'running') {
248
297
  runtime.taskRunner.stop(cmd);
249
298
  } else {
250
- runtime.taskRunner.run(cmd);
299
+ runtime.taskRunner.run(cmd).catch((e: unknown) => {
300
+ process.stderr.write(`[dcc] run error: ${e}\n`);
301
+ });
251
302
  }
252
303
  } else if (cmd.confirm) {
253
304
  setConfirmingCmd(cmd);
@@ -257,7 +308,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
257
308
  setInputValue('');
258
309
  setMode('input');
259
310
  } else {
260
- runtime.taskRunner.run(cmd);
311
+ runtime.taskRunner.run(cmd).catch((e: unknown) => {
312
+ process.stderr.write(`[dcc] run error: ${e}\n`);
313
+ });
261
314
  }
262
315
  }, [runtime]);
263
316
 
@@ -284,7 +337,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
284
337
  for (const cmd of activeCommands) {
285
338
  if (selected.has(cmd.id)) {
286
339
  if (!cmd.confirm && !cmd.input) {
287
- runtime.taskRunner.run(cmd);
340
+ runtime.taskRunner.run(cmd).catch((e: unknown) => {
341
+ process.stderr.write(`[dcc] run error: ${e}\n`);
342
+ });
288
343
  }
289
344
  }
290
345
  }
@@ -362,6 +417,14 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
362
417
  return;
363
418
  }
364
419
 
420
+ if (modeRef.current === 'help') {
421
+ if (key.escape || input === '?') {
422
+ setHelpScroll(0);
423
+ setMode('normal');
424
+ return;
425
+ }
426
+ }
427
+
365
428
  if (mode === 'search') {
366
429
  if (key.escape) {
367
430
  setSearchQuery('');
@@ -394,7 +457,28 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
394
457
  return;
395
458
  }
396
459
 
460
+ if (input === '?' && modeRef.current === 'normal') {
461
+ setHelpScroll(0);
462
+ setMode('help');
463
+ return;
464
+ }
465
+
397
466
  if (focusedPane === 'status') {
467
+ if (modeRef.current === 'help') {
468
+ if (key.upArrow) {
469
+ setHelpScroll((s) => Math.max(0, s - 1));
470
+ } else if (key.downArrow) {
471
+ setHelpScroll((s) => Math.min(helpScrollMax, s + 1));
472
+ } else if (key.pageUp) {
473
+ setHelpScroll((s) => Math.max(0, s - 10));
474
+ } else if (key.pageDown) {
475
+ setHelpScroll((s) => Math.min(helpScrollMax, s + 10));
476
+ } else if (key.escape) {
477
+ setFocusedPane('commands');
478
+ }
479
+ return;
480
+ }
481
+
398
482
  if (key.escape) {
399
483
  setFocusedPane('commands');
400
484
  } else if (key.upArrow) {
@@ -586,16 +670,52 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
586
670
  <Box width={1} />
587
671
  <MetricsPanel tasks={tasks} menuRows={outputRows} width={statusPaneWidth} />
588
672
  <Box width={1} />
589
- <StatusPanel
590
- tasks={tasks}
591
- width={outputPaneWidth}
592
- scrollOffsets={scrollOffsets}
593
- focusedPane={focusedPane}
594
- confirmingCommand={mode === 'confirm' ? confirmingCmd : null}
595
- inputCommand={mode === 'input' ? inputCmd : null}
596
- inputValue={inputValue}
597
- menuRows={outputRows}
598
- />
673
+ {mode === 'help' ? (
674
+ <Panel
675
+ title="Help"
676
+ titleColor={focusedPane === 'status' ? 'cyan' : 'white'}
677
+ titleExtra={
678
+ helpScrollMax > 0
679
+ ? <Text>{` ${helpScroll > 0 ? '' : ''}${helpScroll < helpScrollMax ? ' ▼' : ''}`}</Text>
680
+ : undefined
681
+ }
682
+ titleExtraWidth={helpScrollMax > 0 ? 4 : 0}
683
+ borderColor={focusedPane === 'status' ? 'cyan' : 'white'}
684
+ height={outputRows + 2}
685
+ width={outputPaneWidth}
686
+ >
687
+ <Box flexDirection="column" paddingLeft={2}>
688
+ {helpEntries.slice(helpScroll, helpScroll + outputRows).map((entry, i) => {
689
+ switch (entry.kind) {
690
+ case 'header':
691
+ return <Box key={i + helpScroll}><Text bold color="cyan">{entry.text}</Text></Box>;
692
+ case 'key':
693
+ return (
694
+ <Box key={i + helpScroll}>
695
+ <Text color="yellow"> {entry.key.padEnd(10)}</Text>
696
+ <Text>{entry.desc}</Text>
697
+ </Box>
698
+ );
699
+ case 'spacer':
700
+ return <Box key={i + helpScroll}><Text> </Text></Box>;
701
+ case 'hint':
702
+ return <Box key={i + helpScroll}><Text color="gray">? or Esc to close</Text></Box>;
703
+ }
704
+ })}
705
+ </Box>
706
+ </Panel>
707
+ ) : (
708
+ <StatusPanel
709
+ tasks={tasks}
710
+ width={outputPaneWidth}
711
+ scrollOffsets={scrollOffsets}
712
+ focusedPane={focusedPane}
713
+ confirmingCommand={mode === 'confirm' ? confirmingCmd : null}
714
+ inputCommand={mode === 'input' ? inputCmd : null}
715
+ inputValue={inputValue}
716
+ menuRows={outputRows}
717
+ />
718
+ )}
599
719
  </Box>
600
720
 
601
721
  <Box>
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import React from 'react';
3
+ import { render } from 'ink-testing-library';
4
+ import { CommandList } from './command-list.js';
5
+
6
+ const baseProps = {
7
+ items: [],
8
+ selectedIndex: 0,
9
+ width: 50,
10
+ menuRows: 10,
11
+ };
12
+
13
+ describe('CommandList', () => {
14
+ it('shows no commands configured when empty', () => {
15
+ const { lastFrame } = render(<CommandList {...baseProps} />);
16
+ expect(lastFrame()).toContain('No commands configured');
17
+ });
18
+
19
+ it('renders command items', () => {
20
+ const items = [
21
+ { id: 'build', label: 'Build', command: 'npm run build' },
22
+ { id: 'test', label: 'Test', command: 'npm test' },
23
+ ];
24
+ const { lastFrame } = render(<CommandList {...baseProps} items={items} />);
25
+ const frame = lastFrame();
26
+ expect(frame).toContain('Build');
27
+ expect(frame).toContain('Test');
28
+ });
29
+
30
+ it('shows cursor on selected item', () => {
31
+ const items = [
32
+ { id: 'build', label: 'Build', command: 'npm run build' },
33
+ { id: 'test', label: 'Test', command: 'npm test' },
34
+ ];
35
+ const { lastFrame } = render(
36
+ <CommandList {...baseProps} items={items} selectedIndex={0} />,
37
+ );
38
+ expect(lastFrame()).toContain('❯');
39
+ });
40
+
41
+ it('shows group headers', () => {
42
+ const items = [
43
+ { id: 'dev', label: 'Development', count: 2 },
44
+ { id: 'build', label: 'Build', command: 'npm run build' },
45
+ ];
46
+ const { lastFrame } = render(<CommandList {...baseProps} items={items} />);
47
+ const frame = lastFrame();
48
+ expect(frame).toContain('▶ Development');
49
+ });
50
+
51
+ it('renders profile options', () => {
52
+ const items = [
53
+ { kind: 'profile' as const, id: 'p1', label: 'Production', active: false },
54
+ ];
55
+ const { lastFrame } = render(<CommandList {...baseProps} items={items} />);
56
+ const frame = lastFrame();
57
+ expect(frame).toContain('Production');
58
+ expect(frame).toContain('○');
59
+ });
60
+
61
+ it('shows active profile with filled circle', () => {
62
+ const items = [
63
+ { kind: 'profile' as const, id: 'p1', label: 'Default', active: true },
64
+ ];
65
+ const { lastFrame } = render(<CommandList {...baseProps} items={items} />);
66
+ expect(lastFrame()).toContain('●');
67
+ });
68
+
69
+ it('shows multi-select checkboxes', () => {
70
+ const items = [
71
+ { id: 'a', label: 'A', command: 'echo a' },
72
+ { id: 'b', label: 'B', command: 'echo b' },
73
+ ];
74
+ const { lastFrame } = render(
75
+ <CommandList {...baseProps} items={items} multiSelected={new Set(['a'])} />,
76
+ );
77
+ expect(lastFrame()).toContain('[✓]');
78
+ });
79
+
80
+ it('shows cwd badge when present', () => {
81
+ const items = [
82
+ { id: 'build', label: 'Build', command: 'npm run build', cwd: '/tmp' },
83
+ ];
84
+ const { lastFrame } = render(<CommandList {...baseProps} items={items} />);
85
+ expect(lastFrame()).toContain('/tmp');
86
+ });
87
+
88
+ it('shows pipeline badge', () => {
89
+ const items = [
90
+ { id: 'pipe', label: 'Pipeline', command: '', pipelineSteps: ['a', 'b'] },
91
+ ];
92
+ const { lastFrame } = render(<CommandList {...baseProps} items={items} />);
93
+ expect(lastFrame()).toContain('pipeline');
94
+ });
95
+
96
+ it('shows watch badge', () => {
97
+ const items = [
98
+ { id: 'w', label: 'Watch', command: 'npm run dev', watch: true },
99
+ ];
100
+ const { lastFrame } = render(<CommandList {...baseProps} items={items} />);
101
+ expect(lastFrame()).toContain('watch');
102
+ });
103
+
104
+ it('shows focused border color when focused', () => {
105
+ const items = [
106
+ { id: 'build', label: 'Build', command: 'npm run build' },
107
+ ];
108
+ const { lastFrame } = render(
109
+ <CommandList {...baseProps} items={items} focused={true} />,
110
+ );
111
+ expect(lastFrame()).toContain('Commands');
112
+ });
113
+
114
+ it('shows selection count in title', () => {
115
+ const items = [
116
+ { id: 'a', label: 'A', command: 'echo a' },
117
+ { id: 'b', label: 'B', command: 'echo b' },
118
+ ];
119
+ const { lastFrame } = render(
120
+ <CommandList {...baseProps} items={items} selCount={2} multiSelected={new Set(['a', 'b'])} />,
121
+ );
122
+ expect(lastFrame()).toContain('(2 selected)');
123
+ });
124
+ });
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import React, { act } from 'react';
3
+ import { render } from 'ink-testing-library';
4
+ import fs from 'fs';
5
+ import { MetricsPanel } from './metrics-panel.js';
6
+
7
+ function makeMetricsData(overrides: Record<string, any> = {}) {
8
+ return JSON.stringify({
9
+ version: '1.0.0',
10
+ latestTest: { label: 'Unit Tests', status: 'success', time: Date.now() - 60000 },
11
+ latestBuild: { status: 'failure', time: Date.now() - 120000 },
12
+ ...overrides,
13
+ });
14
+ }
15
+
16
+ vi.mock('fs', () => ({
17
+ default: {
18
+ readFileSync: vi.fn(() => makeMetricsData()),
19
+ writeFileSync: vi.fn(),
20
+ mkdirSync: vi.fn(),
21
+ existsSync: vi.fn(),
22
+ },
23
+ readFileSync: vi.fn(() => makeMetricsData()),
24
+ writeFileSync: vi.fn(),
25
+ mkdirSync: vi.fn(),
26
+ existsSync: vi.fn(),
27
+ }));
28
+
29
+ const mockReadFileSync = () => vi.mocked(fs).readFileSync;
30
+
31
+ const mockTasks = (entries: Record<string, any>): Map<string, any> =>
32
+ new Map(Object.entries(entries)) as Map<string, any>;
33
+
34
+ describe('MetricsPanel', () => {
35
+ const baseProps = {
36
+ tasks: new Map(),
37
+ width: 28,
38
+ menuRows: 10,
39
+ };
40
+
41
+ beforeEach(() => {
42
+ mockReadFileSync().mockReturnValue(makeMetricsData());
43
+ });
44
+
45
+ it('shows idle status when no tasks are running', () => {
46
+ const { lastFrame } = render(<MetricsPanel {...baseProps} />);
47
+ expect(lastFrame()).toContain('idle');
48
+ });
49
+
50
+ it('displays the package version', () => {
51
+ const { lastFrame } = render(<MetricsPanel {...baseProps} />);
52
+ expect(lastFrame()).toContain('Version: 1.0.0');
53
+ });
54
+
55
+ it('shows active task count for running tasks', async () => {
56
+ const tasks = mockTasks({
57
+ build: { id: 'build', label: 'Build', status: 'running', startTime: Date.now() },
58
+ test: { id: 'test', label: 'Test', status: 'running', startTime: Date.now() },
59
+ });
60
+ const { lastFrame, rerender } = render(<MetricsPanel {...baseProps} />);
61
+ await act(async () => {
62
+ rerender(<MetricsPanel {...baseProps} tasks={tasks} />);
63
+ });
64
+ const frame = lastFrame();
65
+ expect(frame).toContain('Active: 2');
66
+ expect(frame).toContain('running');
67
+ });
68
+
69
+ it('shows test passed status with time ago', () => {
70
+ const { lastFrame } = render(<MetricsPanel {...baseProps} />);
71
+ const frame = lastFrame();
72
+ expect(frame).toContain('Tests: passed');
73
+ expect(frame).toContain('1m ago');
74
+ });
75
+
76
+ it('shows test failure status', () => {
77
+ mockReadFileSync().mockReturnValue(makeMetricsData({
78
+ latestTest: { label: 'Tests', status: 'failure', time: Date.now() - 30000 },
79
+ }));
80
+ const { lastFrame } = render(<MetricsPanel {...baseProps} />);
81
+ expect(lastFrame()).toContain('Tests: failed');
82
+ });
83
+
84
+ it('shows no test run when no test data', () => {
85
+ mockReadFileSync().mockReturnValue(makeMetricsData({ latestTest: undefined }));
86
+ const { lastFrame } = render(<MetricsPanel {...baseProps} />);
87
+ expect(lastFrame()).toContain('Tests: no run');
88
+ });
89
+
90
+ it('shows build failed status', () => {
91
+ const { lastFrame } = render(<MetricsPanel {...baseProps} />);
92
+ expect(lastFrame()).toContain('Build: failed');
93
+ });
94
+
95
+ it('shows build passed status', () => {
96
+ mockReadFileSync().mockReturnValue(makeMetricsData({
97
+ latestBuild: { status: 'success', time: Date.now() - 300000 },
98
+ }));
99
+ const { lastFrame } = render(<MetricsPanel {...baseProps} />);
100
+ expect(lastFrame()).toContain('Build: passed');
101
+ });
102
+
103
+ it('shows no build run when no build data', () => {
104
+ mockReadFileSync().mockReturnValue(makeMetricsData({ latestBuild: undefined }));
105
+ const { lastFrame } = render(<MetricsPanel {...baseProps} />);
106
+ expect(lastFrame()).toContain('Build: no run');
107
+ });
108
+
109
+ it('shows last git push timestamp', () => {
110
+ const pushTime = new Date('2026-06-24T10:00:00').getTime();
111
+ mockReadFileSync().mockReturnValue(makeMetricsData({
112
+ lastGitPush: { label: 'main', time: pushTime },
113
+ }));
114
+ const { lastFrame } = render(<MetricsPanel {...baseProps} />);
115
+ expect(lastFrame()).toContain('2026-06-24 10:00');
116
+ });
117
+
118
+ it('shows push dash when no push data', () => {
119
+ const { lastFrame } = render(<MetricsPanel {...baseProps} />);
120
+ expect(lastFrame()).toContain('Push: -');
121
+ });
122
+
123
+ it('shows version dash when package.json is missing', () => {
124
+ mockReadFileSync().mockImplementation(() => { throw new Error('ENOENT'); });
125
+ const { lastFrame } = render(<MetricsPanel {...baseProps} />);
126
+ expect(lastFrame()).toContain('Version: -');
127
+ });
128
+ });
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import React, { useEffect, useState } from 'react';
4
4
  import { Box, Text } from 'ink';
5
5
  import { TaskState, TaskStatus } from '../status/types.js';
6
+ import { PERSISTENCE_DIR } from '../core/persistence.js';
6
7
  import { Panel } from './panel.js';
7
8
 
8
9
  interface MetricsPanelProps {
@@ -10,7 +11,7 @@ interface MetricsPanelProps {
10
11
  menuRows: number;
11
12
  width: number;
12
13
  }
13
- const METRICS_FILE = path.join(process.cwd(), '.developer-control-center', 'metrics.json');
14
+ const METRICS_FILE = path.join(process.cwd(), PERSISTENCE_DIR, 'metrics.json');
14
15
 
15
16
  interface MetricsState {
16
17
  projectRunning: boolean;
@@ -31,12 +32,6 @@ interface MetricsState {
31
32
  packageVersion: string;
32
33
  }
33
34
 
34
- const INITIAL_METRICS: MetricsState = {
35
- projectRunning: false,
36
- activeTasks: 0,
37
- packageVersion: loadPackageVersion(),
38
- };
39
-
40
35
  function loadPackageVersion(): string {
41
36
  try {
42
37
  const packageJson = JSON.parse(
@@ -49,16 +44,21 @@ function loadPackageVersion(): string {
49
44
  }
50
45
 
51
46
  function loadMetrics(): MetricsState {
47
+ const base = {
48
+ projectRunning: false,
49
+ activeTasks: 0,
50
+ packageVersion: loadPackageVersion(),
51
+ };
52
52
  try {
53
53
  const persisted = JSON.parse(fs.readFileSync(METRICS_FILE, 'utf-8')) as MetricsState;
54
54
  return {
55
- ...INITIAL_METRICS,
55
+ ...base,
56
56
  latestTest: persisted.latestTest,
57
57
  latestBuild: persisted.latestBuild,
58
58
  lastGitPush: persisted.lastGitPush,
59
59
  };
60
60
  } catch {
61
- return INITIAL_METRICS;
61
+ return base;
62
62
  }
63
63
  }
64
64
 
@@ -169,7 +169,7 @@ export const MetricsPanel: React.FC<MetricsPanelProps> = ({ tasks, menuRows, wid
169
169
  }, [tasks]);
170
170
 
171
171
  return (
172
- <Panel title="Status" width={width} height={menuRows + 2}>
172
+ <Panel title="Status" titleColor="white" width={width} height={menuRows + 2}>
173
173
  <Box paddingLeft={1}>
174
174
  <Text color={metrics.projectRunning ? 'yellow' : 'gray'}>
175
175
  Project: {metrics.projectRunning ? 'running' : 'idle'}
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import React from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { render } from 'ink-testing-library';
5
+ import { Panel } from './panel.js';
6
+
7
+ describe('Panel', () => {
8
+ it('renders the title in the top border', () => {
9
+ const { lastFrame } = render(
10
+ <Panel title="Test" width={20} height={5}>
11
+ <React.Fragment />
12
+ </Panel>,
13
+ );
14
+ const frame = lastFrame();
15
+ expect(frame).toContain('╭─ Test');
16
+ });
17
+
18
+ it('renders proper bottom border', () => {
19
+ const { lastFrame } = render(
20
+ <Panel title="Test" width={20} height={5}>
21
+ <React.Fragment />
22
+ </Panel>,
23
+ );
24
+ const frame = lastFrame();
25
+ expect(frame).toContain('╰');
26
+ });
27
+
28
+ it('renders side borders for each content row', () => {
29
+ const { lastFrame } = render(
30
+ <Panel title="Test" width={20} height={5}>
31
+ <React.Fragment />
32
+ </Panel>,
33
+ );
34
+ const frame = lastFrame()!;
35
+ const lines = frame.split('\n');
36
+ expect(lines.length).toBe(5);
37
+ expect(lines[1].startsWith('│')).toBe(true);
38
+ expect(lines[1].endsWith('│')).toBe(true);
39
+ expect(lines[2].startsWith('│')).toBe(true);
40
+ expect(lines[2].endsWith('│')).toBe(true);
41
+ expect(lines[3].startsWith('│')).toBe(true);
42
+ expect(lines[3].endsWith('│')).toBe(true);
43
+ });
44
+
45
+ it('renders children in the content area', () => {
46
+ const { lastFrame } = render(
47
+ <Panel title="Test" width={20} height={5}>
48
+ <Box><Text>hello</Text></Box>
49
+ </Panel>,
50
+ );
51
+ const frame = lastFrame();
52
+ expect(frame).toContain('hello');
53
+ });
54
+
55
+ it('shows "↑N more" when hiddenAbove is set', () => {
56
+ const { lastFrame } = render(
57
+ <Panel title="Test" width={30} height={5} hiddenAbove={3}>
58
+ <React.Fragment />
59
+ </Panel>,
60
+ );
61
+ const frame = lastFrame();
62
+ expect(frame).toContain('↑3 more');
63
+ });
64
+
65
+ it('shows "↓N more" when hiddenBelow is set', () => {
66
+ const { lastFrame } = render(
67
+ <Panel title="Test" width={30} height={5} hiddenBelow={2}>
68
+ <React.Fragment />
69
+ </Panel>,
70
+ );
71
+ const frame = lastFrame();
72
+ expect(frame).toContain('↓2 more');
73
+ });
74
+
75
+ it('renders titleExtra next to the title', () => {
76
+ const { lastFrame } = render(
77
+ <Panel title="Main" width={30} height={5} titleExtra={<Text> [extra]</Text>}>
78
+ <React.Fragment />
79
+ </Panel>,
80
+ );
81
+ const frame = lastFrame();
82
+ expect(frame).toContain('[extra]');
83
+ });
84
+ });