@hartvig/developer-control-center 0.8.6 → 0.8.8

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 (103) 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 +25 -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 +1 -1
  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 -3
  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 +81 -24
  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 +124 -30
  63. package/dist/ui/app.js.map +1 -1
  64. package/dist/ui/app.test.d.ts +2 -0
  65. package/dist/ui/app.test.d.ts.map +1 -0
  66. package/dist/ui/app.test.js +157 -0
  67. package/dist/ui/app.test.js.map +1 -0
  68. package/dist/ui/command-list.test.d.ts +2 -0
  69. package/dist/ui/command-list.test.d.ts.map +1 -0
  70. package/dist/ui/command-list.test.js +104 -0
  71. package/dist/ui/command-list.test.js.map +1 -0
  72. package/dist/ui/metrics-panel.d.ts.map +1 -1
  73. package/dist/ui/metrics-panel.js +10 -9
  74. package/dist/ui/metrics-panel.js.map +1 -1
  75. package/dist/ui/metrics-panel.test.d.ts +2 -0
  76. package/dist/ui/metrics-panel.test.d.ts.map +1 -0
  77. package/dist/ui/metrics-panel.test.js +111 -0
  78. package/dist/ui/metrics-panel.test.js.map +1 -0
  79. package/dist/ui/panel.test.d.ts +2 -0
  80. package/dist/ui/panel.test.d.ts.map +1 -0
  81. package/dist/ui/panel.test.js +51 -0
  82. package/dist/ui/panel.test.js.map +1 -0
  83. package/dist/ui/status-panel.test.d.ts +2 -0
  84. package/dist/ui/status-panel.test.d.ts.map +1 -0
  85. package/dist/ui/status-panel.test.js +88 -0
  86. package/dist/ui/status-panel.test.js.map +1 -0
  87. package/package.json +4 -2
  88. package/src/cli.ts +1 -1
  89. package/src/core/persistence.ts +1 -0
  90. package/src/core/runtime.ts +7 -3
  91. package/src/core/task-runner.test.ts +395 -0
  92. package/src/core/task-runner.ts +80 -24
  93. package/src/core/timer-plugin.ts +2 -1
  94. package/src/plugins/manager.test.ts +5 -2
  95. package/src/plugins/manager.ts +6 -2
  96. package/src/ui/app.test.tsx +177 -0
  97. package/src/ui/app.tsx +167 -41
  98. package/src/ui/command-list.test.tsx +124 -0
  99. package/src/ui/metrics-panel.test.tsx +128 -0
  100. package/src/ui/metrics-panel.tsx +10 -10
  101. package/src/ui/panel.test.tsx +84 -0
  102. package/src/ui/status-panel.test.tsx +116 -0
  103. package/vitest.config.ts +1 -1
package/src/ui/app.tsx CHANGED
@@ -3,6 +3,7 @@ import { Box, Text, useInput, useApp, useStdout } from 'ink';
3
3
  import { Runtime } from '../core/index.js';
4
4
  import { ProkomConfig, ProkomCommand, mergeCommands } from '../config/index.js';
5
5
  import { TaskState } from '../status/types.js';
6
+ import { sendNotification } from '../core/notifier.js';
6
7
  import { CommandList, MenuGroup, MenuItem, ProfileOption } from './command-list.js';
7
8
  import { MetricsPanel } from './metrics-panel.js';
8
9
  import { Panel } from './panel.js';
@@ -13,7 +14,7 @@ interface AppProps {
13
14
  runtime: Runtime;
14
15
  }
15
16
 
16
- type Mode = 'normal' | 'search' | 'confirm' | 'input' | 'popup';
17
+ type Mode = 'normal' | 'search' | 'confirm' | 'input' | 'popup' | 'help';
17
18
 
18
19
  interface PopupState {
19
20
  title: string;
@@ -26,6 +27,35 @@ type Pane = 'commands' | 'status';
26
27
  const PROFILE_GROUP_ID = '__profiles';
27
28
  const GROUP_ORDER = ['Development', 'Build', 'Deploy', 'Management', 'Demo'];
28
29
 
30
+ type HelpEntry =
31
+ | { kind: 'header'; text: string }
32
+ | { kind: 'key'; key: string; desc: string }
33
+ | { kind: 'spacer' }
34
+ | { kind: 'hint' };
35
+
36
+ const helpEntries: HelpEntry[] = [
37
+ { kind: 'header', text: 'Navigation' },
38
+ { kind: 'key', key: '↑/↓', desc: 'Navigate commands' },
39
+ { kind: 'key', key: 'Enter', desc: 'Run selected command' },
40
+ { kind: 'key', key: 'Space', desc: 'Multi-select / open group' },
41
+ { kind: 'key', key: 'Tab', desc: 'Toggle pane focus' },
42
+ { kind: 'key', key: '/', desc: 'Search/filter commands' },
43
+ { kind: 'spacer' },
44
+ { kind: 'header', text: 'Output pane' },
45
+ { kind: 'key', key: '↑/↓', desc: 'Scroll line by line' },
46
+ { kind: 'key', key: 'PgUp/PgDn', desc: 'Scroll 10 lines' },
47
+ { kind: 'key', key: 'Esc', desc: 'Back to commands' },
48
+ { kind: 'spacer' },
49
+ { kind: 'header', text: 'Global' },
50
+ { kind: 'key', key: '?', desc: 'Toggle this help' },
51
+ { kind: 'key', key: 'Esc', desc: 'Back / deselect / quit' },
52
+ { kind: 'key', key: 'Ctrl+C', desc: 'Quit' },
53
+ { kind: 'spacer' },
54
+ { kind: 'hint' },
55
+ ];
56
+
57
+ const HELP_CONTENT_LINES = helpEntries.length;
58
+
29
59
  function commandsForProfile(config: ProkomConfig, profile?: string): ProkomCommand[] {
30
60
  const base = config.baseCommands ?? config.commands;
31
61
  const profileCommands = profile ? (config.profiles?.[profile]?.commands ?? []) : [];
@@ -68,10 +98,22 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
68
98
  );
69
99
  const [currentGroup, setCurrentGroup] = useState<string | null>(null);
70
100
  const [multiSelected, setMultiSelected] = useState<Set<string>>(new Set());
101
+ const [helpScroll, setHelpScroll] = useState(0);
71
102
  const [activeProfile, setActiveProfile] = useState<string | undefined>(config.profile);
72
- const menuRows = config.menuRows ?? 8;
103
+ const [termSize, setTermSize] = useState({ rows: stdout?.rows ?? 24, cols: stdout?.columns ?? 120 });
104
+ const terminalRows = termSize.rows;
105
+ const dynamicRows = Math.max(4, terminalRows - 7);
106
+ const menuRows = config.menuRows ?? dynamicRows;
73
107
  const outputRows = config.outputRows ?? menuRows;
74
- const terminalColumns = stdout?.columns ?? 120;
108
+ const terminalColumns = termSize.cols;
109
+
110
+ useEffect(() => {
111
+ const onResize = () => {
112
+ setTermSize({ rows: stdout?.rows ?? 24, cols: stdout?.columns ?? 120 });
113
+ };
114
+ stdout?.on?.('resize', onResize);
115
+ return () => { stdout?.off?.('resize', onResize); };
116
+ }, [stdout]);
75
117
  const statusPaneWidth = 28;
76
118
 
77
119
  const activeCommands = useMemo(
@@ -93,6 +135,7 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
93
135
  }, [activeCommands, terminalColumns, statusPaneWidth]);
94
136
 
95
137
  const outputPaneWidth = Math.max(20, terminalColumns - commandPaneWidth - statusPaneWidth - 4);
138
+ const helpScrollMax = Math.max(0, HELP_CONTENT_LINES - outputRows);
96
139
 
97
140
  const modeRef = useRef(mode);
98
141
  modeRef.current = mode;
@@ -111,8 +154,12 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
111
154
 
112
155
  useEffect(() => {
113
156
  const onComplete = (id: string, exitCode: number | null) => {
157
+ const cmd = commandsRef.current.find((c) => c.id === id);
158
+ if (cmd) {
159
+ const status = exitCode === null ? 'Stopped' : exitCode === 0 ? 'Completed' : 'Failed';
160
+ sendNotification(`DCC: ${cmd.label}`, status);
161
+ }
114
162
  if (exitCode != null && exitCode > 0) {
115
- const cmd = commandsRef.current.find((c) => c.id === id);
116
163
  if (cmd?.onNonZeroExit) {
117
164
  setConfirmingCmd({
118
165
  id: `${cmd.id}:on-nonzero`,
@@ -206,10 +253,16 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
206
253
 
207
254
  const filteredItems = useMemo((): MenuItem[] => {
208
255
  if (!searchQuery) return menuItems;
209
- return menuItems.filter((item) =>
210
- item.label.toLowerCase().includes(searchQuery.toLowerCase()),
256
+ const q = searchQuery.toLowerCase();
257
+ const topLevel = menuItems.filter((item) =>
258
+ item.label.toLowerCase().includes(q),
259
+ );
260
+ if (!hasGroups || currentGroup) return topLevel;
261
+ const groupCommands = activeCommands.filter(
262
+ (cmd) => cmd.group && cmd.label.toLowerCase().includes(q),
211
263
  );
212
- }, [menuItems, searchQuery]);
264
+ return groupCommands.length > 0 ? [...topLevel, ...groupCommands] : topLevel;
265
+ }, [menuItems, searchQuery, hasGroups, currentGroup, activeCommands]);
213
266
 
214
267
  useEffect(() => {
215
268
  if (selectedIndex >= filteredItems.length && filteredItems.length > 0) {
@@ -223,15 +276,17 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
223
276
 
224
277
  const selCount = multiSelected.size;
225
278
  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';
279
+ const footerText = mode === 'help'
280
+ ? 'Press ? or Esc to close help'
281
+ : selectedItem
282
+ ? isProfileOption(selectedItem)
283
+ ? selectedItem.active
284
+ ? `Active profile: ${selectedItem.label}`
285
+ : `Switch to ${selectedItem.label} profile`
286
+ : 'count' in selectedItem
287
+ ? `Open ${selectedItem.label}`
288
+ : selectedItem.description ?? 'Enter to run, Space to select, / to search, Tab focus, ? help'
289
+ : 'Enter to run, Space to select, / to search, Tab focus, ? help';
235
290
 
236
291
  const runSingle = useCallback((cmd: ProkomCommand) => {
237
292
  if (cmd.id === 'demo-confirm-overlay') {
@@ -255,7 +310,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
255
310
  if (task?.status === 'running') {
256
311
  runtime.taskRunner.stop(cmd);
257
312
  } else {
258
- runtime.taskRunner.run(cmd);
313
+ runtime.taskRunner.run(cmd).catch((e: unknown) => {
314
+ process.stderr.write(`[dcc] run error: ${e}\n`);
315
+ });
259
316
  }
260
317
  } else if (cmd.confirm) {
261
318
  setConfirmingCmd(cmd);
@@ -265,7 +322,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
265
322
  setInputValue('');
266
323
  setMode('input');
267
324
  } else {
268
- runtime.taskRunner.run(cmd);
325
+ runtime.taskRunner.run(cmd).catch((e: unknown) => {
326
+ process.stderr.write(`[dcc] run error: ${e}\n`);
327
+ });
269
328
  }
270
329
  }, [runtime]);
271
330
 
@@ -292,7 +351,9 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
292
351
  for (const cmd of activeCommands) {
293
352
  if (selected.has(cmd.id)) {
294
353
  if (!cmd.confirm && !cmd.input) {
295
- runtime.taskRunner.run(cmd);
354
+ runtime.taskRunner.run(cmd).catch((e: unknown) => {
355
+ process.stderr.write(`[dcc] run error: ${e}\n`);
356
+ });
296
357
  }
297
358
  }
298
359
  }
@@ -370,6 +431,14 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
370
431
  return;
371
432
  }
372
433
 
434
+ if (modeRef.current === 'help') {
435
+ if (key.escape || input === '?') {
436
+ setHelpScroll(0);
437
+ setMode('normal');
438
+ return;
439
+ }
440
+ }
441
+
373
442
  if (mode === 'search') {
374
443
  if (key.escape) {
375
444
  setSearchQuery('');
@@ -402,58 +471,79 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
402
471
  return;
403
472
  }
404
473
 
474
+ if (input === '?' && modeRef.current === 'normal') {
475
+ setHelpScroll(0);
476
+ setMode('help');
477
+ return;
478
+ }
479
+
405
480
  if (focusedPane === 'status') {
481
+ if (modeRef.current === 'help') {
482
+ if (key.upArrow) {
483
+ setHelpScroll((s) => Math.max(0, s - 1));
484
+ } else if (key.downArrow) {
485
+ setHelpScroll((s) => Math.min(helpScrollMax, s + 1));
486
+ } else if (key.pageUp) {
487
+ setHelpScroll((s) => Math.max(0, s - 10));
488
+ } else if (key.pageDown) {
489
+ setHelpScroll((s) => Math.min(helpScrollMax, s + 10));
490
+ } else if (key.escape) {
491
+ setFocusedPane('commands');
492
+ }
493
+ return;
494
+ }
495
+
406
496
  if (key.escape) {
407
497
  setFocusedPane('commands');
408
498
  } else if (key.upArrow) {
409
499
  setScrollOffsets((prev) => {
410
500
  const next = new Map(prev);
411
- const entries = Array.from(tasks.values()).sort(
501
+ const entries = Array.from(tasksRef.current.values()).sort(
412
502
  (a, b) => (b.startTime || 0) - (a.startTime || 0),
413
503
  );
414
504
  const target = entries[0];
415
505
  if (target) {
416
- const current = next.get(target.id) ?? 0;
417
- next.set(target.id, current + 1);
506
+ const hiddenFromBottom = next.get(target.id) ?? 0;
507
+ next.set(target.id, hiddenFromBottom + 1);
418
508
  }
419
509
  return next;
420
510
  });
421
511
  } else if (key.downArrow) {
422
512
  setScrollOffsets((prev) => {
423
513
  const next = new Map(prev);
424
- const entries = Array.from(tasks.values()).sort(
514
+ const entries = Array.from(tasksRef.current.values()).sort(
425
515
  (a, b) => (b.startTime || 0) - (a.startTime || 0),
426
516
  );
427
517
  const target = entries[0];
428
518
  if (target) {
429
- const current = next.get(target.id) ?? 0;
430
- next.set(target.id, Math.max(0, current - 1));
519
+ const hiddenFromBottom = next.get(target.id) ?? 0;
520
+ next.set(target.id, Math.max(0, hiddenFromBottom - 1));
431
521
  }
432
522
  return next;
433
523
  });
434
524
  } else if (key.pageUp) {
435
525
  setScrollOffsets((prev) => {
436
526
  const next = new Map(prev);
437
- const entries = Array.from(tasks.values()).sort(
527
+ const entries = Array.from(tasksRef.current.values()).sort(
438
528
  (a, b) => (b.startTime || 0) - (a.startTime || 0),
439
529
  );
440
530
  const target = entries[0];
441
531
  if (target) {
442
- const current = next.get(target.id) ?? 0;
443
- next.set(target.id, current + 10);
532
+ const hiddenFromBottom = next.get(target.id) ?? 0;
533
+ next.set(target.id, hiddenFromBottom + 10);
444
534
  }
445
535
  return next;
446
536
  });
447
537
  } else if (key.pageDown) {
448
538
  setScrollOffsets((prev) => {
449
539
  const next = new Map(prev);
450
- const entries = Array.from(tasks.values()).sort(
540
+ const entries = Array.from(tasksRef.current.values()).sort(
451
541
  (a, b) => (b.startTime || 0) - (a.startTime || 0),
452
542
  );
453
543
  const target = entries[0];
454
544
  if (target) {
455
- const current = next.get(target.id) ?? 0;
456
- next.set(target.id, Math.max(0, current - 10));
545
+ const hiddenFromBottom = next.get(target.id) ?? 0;
546
+ next.set(target.id, Math.max(0, hiddenFromBottom - 10));
457
547
  }
458
548
  return next;
459
549
  });
@@ -594,16 +684,52 @@ export const App: React.FC<AppProps> = ({ config, runtime }) => {
594
684
  <Box width={1} />
595
685
  <MetricsPanel tasks={tasks} menuRows={outputRows} width={statusPaneWidth} />
596
686
  <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
- />
687
+ {mode === 'help' ? (
688
+ <Panel
689
+ title="Help"
690
+ titleColor={focusedPane === 'status' ? 'cyan' : 'white'}
691
+ titleExtra={
692
+ helpScrollMax > 0
693
+ ? <Text>{` ${helpScroll > 0 ? '' : ''}${helpScroll < helpScrollMax ? ' ▼' : ''}`}</Text>
694
+ : undefined
695
+ }
696
+ titleExtraWidth={helpScrollMax > 0 ? 4 : 0}
697
+ borderColor={focusedPane === 'status' ? 'cyan' : 'white'}
698
+ height={outputRows + 2}
699
+ width={outputPaneWidth}
700
+ >
701
+ <Box flexDirection="column" paddingLeft={2}>
702
+ {helpEntries.slice(helpScroll, helpScroll + outputRows).map((entry, i) => {
703
+ switch (entry.kind) {
704
+ case 'header':
705
+ return <Box key={i + helpScroll}><Text bold color="cyan">{entry.text}</Text></Box>;
706
+ case 'key':
707
+ return (
708
+ <Box key={i + helpScroll}>
709
+ <Text color="yellow"> {entry.key.padEnd(10)}</Text>
710
+ <Text>{entry.desc}</Text>
711
+ </Box>
712
+ );
713
+ case 'spacer':
714
+ return <Box key={i + helpScroll}><Text> </Text></Box>;
715
+ case 'hint':
716
+ return <Box key={i + helpScroll}><Text color="gray">? or Esc to close</Text></Box>;
717
+ }
718
+ })}
719
+ </Box>
720
+ </Panel>
721
+ ) : (
722
+ <StatusPanel
723
+ tasks={tasks}
724
+ width={outputPaneWidth}
725
+ scrollOffsets={scrollOffsets}
726
+ focusedPane={focusedPane}
727
+ confirmingCommand={mode === 'confirm' ? confirmingCmd : null}
728
+ inputCommand={mode === 'input' ? inputCmd : null}
729
+ inputValue={inputValue}
730
+ menuRows={outputRows}
731
+ />
732
+ )}
607
733
  </Box>
608
734
 
609
735
  <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'}