@hartvig/developer-control-center 0.8.6 → 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 +15 -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 +2 -2
  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 +97 -16
  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 +139 -27
  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,8 +97,11 @@ 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
107
  const statusPaneWidth = 28;
@@ -93,6 +125,7 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
93
125
  }, [activeCommands, terminalColumns, statusPaneWidth]);
94
126
 
95
127
  const outputPaneWidth = Math.max(20, terminalColumns - commandPaneWidth - statusPaneWidth - 4);
128
+ const helpScrollMax = Math.max(0, HELP_CONTENT_LINES - outputRows);
96
129
 
97
130
  const modeRef = useRef(mode);
98
131
  modeRef.current = mode;
@@ -206,10 +239,16 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
206
239
 
207
240
  const filteredItems = useMemo((): MenuItem[] => {
208
241
  if (!searchQuery) return menuItems;
209
- return menuItems.filter((item) =>
210
- 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),
211
249
  );
212
- }, [menuItems, searchQuery]);
250
+ return groupCommands.length > 0 ? [...topLevel, ...groupCommands] : topLevel;
251
+ }, [menuItems, searchQuery, hasGroups, currentGroup, activeCommands]);
213
252
 
214
253
  useEffect(() => {
215
254
  if (selectedIndex >= filteredItems.length && filteredItems.length > 0) {
@@ -223,15 +262,17 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
223
262
 
224
263
  const selCount = multiSelected.size;
225
264
  const selectedItem = filteredItems[selectedIndex];
226
- const footerText = selectedItem
227
- ? isProfileOption(selectedItem)
228
- ? selectedItem.active
229
- ? `Active profile: ${selectedItem.label}`
230
- : `Switch to ${selectedItem.label} profile`
231
- : 'count' in selectedItem
232
- ? `Open ${selectedItem.label}`
233
- : selectedItem.description ?? 'Enter to run, Space to select, / to search, Tab to focus output'
234
- : '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';
235
276
 
236
277
  const runSingle = useCallback((cmd: ProkomCommand) => {
237
278
  if (cmd.id === 'demo-confirm-overlay') {
@@ -255,7 +296,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
255
296
  if (task?.status === 'running') {
256
297
  runtime.taskRunner.stop(cmd);
257
298
  } else {
258
- runtime.taskRunner.run(cmd);
299
+ runtime.taskRunner.run(cmd).catch((e: unknown) => {
300
+ process.stderr.write(`[dcc] run error: ${e}\n`);
301
+ });
259
302
  }
260
303
  } else if (cmd.confirm) {
261
304
  setConfirmingCmd(cmd);
@@ -265,7 +308,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
265
308
  setInputValue('');
266
309
  setMode('input');
267
310
  } else {
268
- runtime.taskRunner.run(cmd);
311
+ runtime.taskRunner.run(cmd).catch((e: unknown) => {
312
+ process.stderr.write(`[dcc] run error: ${e}\n`);
313
+ });
269
314
  }
270
315
  }, [runtime]);
271
316
 
@@ -292,7 +337,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
292
337
  for (const cmd of activeCommands) {
293
338
  if (selected.has(cmd.id)) {
294
339
  if (!cmd.confirm && !cmd.input) {
295
- runtime.taskRunner.run(cmd);
340
+ runtime.taskRunner.run(cmd).catch((e: unknown) => {
341
+ process.stderr.write(`[dcc] run error: ${e}\n`);
342
+ });
296
343
  }
297
344
  }
298
345
  }
@@ -370,6 +417,14 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
370
417
  return;
371
418
  }
372
419
 
420
+ if (modeRef.current === 'help') {
421
+ if (key.escape || input === '?') {
422
+ setHelpScroll(0);
423
+ setMode('normal');
424
+ return;
425
+ }
426
+ }
427
+
373
428
  if (mode === 'search') {
374
429
  if (key.escape) {
375
430
  setSearchQuery('');
@@ -402,7 +457,28 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
402
457
  return;
403
458
  }
404
459
 
460
+ if (input === '?' && modeRef.current === 'normal') {
461
+ setHelpScroll(0);
462
+ setMode('help');
463
+ return;
464
+ }
465
+
405
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
+
406
482
  if (key.escape) {
407
483
  setFocusedPane('commands');
408
484
  } else if (key.upArrow) {
@@ -594,16 +670,52 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
594
670
  <Box width={1} />
595
671
  <MetricsPanel tasks={tasks} menuRows={outputRows} width={statusPaneWidth} />
596
672
  <Box width={1} />
597
- <StatusPanel
598
- tasks={tasks}
599
- width={outputPaneWidth}
600
- scrollOffsets={scrollOffsets}
601
- focusedPane={focusedPane}
602
- confirmingCommand={mode === 'confirm' ? confirmingCmd : null}
603
- inputCommand={mode === 'input' ? inputCmd : null}
604
- inputValue={inputValue}
605
- menuRows={outputRows}
606
- />
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
+ )}
607
719
  </Box>
608
720
 
609
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
+ });