pyre-cli 5.1.0 → 6.1.0

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.
@@ -10,22 +10,58 @@
10
10
  * all layout-related code is co-located.
11
11
  */
12
12
  import chalk from 'chalk';
13
- import { sparkline } from '../sparkline.js';
13
+ import { sparkline, multiRowSparkline } from '../sparkline.js';
14
14
  import type { History } from '../history.js';
15
15
  import type { StatsData, VisibleItems, TableOptions, AnomalyAlert } from './types.js';
16
- import { THEMES, type ThemeName } from './themes.js';
16
+ import { THEMES, type ThemeName, type ThemeColors } from './themes.js';
17
+
17
18
 
18
19
  // --- low-level box drawing ---------------------------------------------------
19
20
 
20
- function visLen(s: string): number {
21
+ export function visLen(s: string): number {
21
22
  // eslint-disable-next-line no-control-regex
22
23
  return s.replace(/\x1b\[[0-9;]*m/g, '').length;
23
24
  }
24
25
 
25
- function padVisible(s: string, width: number): string {
26
+ export function fitVisible(s: string, width: number): string {
27
+ const w = Math.max(0, width);
26
28
  const len = visLen(s);
27
- if (len >= width) return s;
28
- return s + ' '.repeat(width - len);
29
+ if (len === w) return s;
30
+ if (len < w) return s + ' '.repeat(w - len);
31
+
32
+ let visibleCount = 0;
33
+ let out = '';
34
+ let inAnsi = false;
35
+
36
+ for (let i = 0; i < s.length; i++) {
37
+ const char = s[i];
38
+ if (char === '\x1b') {
39
+ inAnsi = true;
40
+ out += char;
41
+ } else if (inAnsi) {
42
+ out += char;
43
+ if ((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')) {
44
+ inAnsi = false;
45
+ }
46
+ } else {
47
+ if (visibleCount < w) {
48
+ out += char;
49
+ visibleCount++;
50
+ } else {
51
+ break;
52
+ }
53
+ }
54
+ }
55
+ out += '\x1b[0m';
56
+ const resLen = visLen(out);
57
+ if (resLen < w) {
58
+ out += ' '.repeat(w - resLen);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ export function padVisible(s: string, width: number): string {
64
+ return fitVisible(s, width);
29
65
  }
30
66
 
31
67
  function truncatePlain(s: string, width: number): string {
@@ -40,29 +76,50 @@ export function panel(
40
76
  width: number,
41
77
  accent: (s: string) => string,
42
78
  borderAccent: (s: string) => string,
43
- height?: number
79
+ height?: number,
80
+ rightTitle?: string
44
81
  ): string[] {
45
82
  const h = height ?? lines.length;
46
83
  const contentWidth = Math.max(1, width - 4);
47
- const label = ` ${title} `;
48
- const top = borderAccent('┌' + label + '─'.repeat(width - label.length - 2) + '┐');
49
- const bottom = borderAccent('└' + '─'.repeat(width - 2) + '┘');
84
+ const leftLabel = title ? ` ${title} ` : '';
85
+ const rightLabel = rightTitle ? ` ${rightTitle} ` : '';
86
+ const innerW = Math.max(0, width - 2);
87
+ let topInner = leftLabel + '─'.repeat(Math.max(0, innerW - visLen(leftLabel) - visLen(rightLabel))) + rightLabel;
88
+ if (visLen(topInner) > innerW) {
89
+ topInner = fitVisible(topInner, innerW);
90
+ } else if (visLen(topInner) < innerW) {
91
+ topInner = topInner + '─'.repeat(innerW - visLen(topInner));
92
+ }
93
+ const top = borderAccent('┌' + topInner + '┐');
94
+ const bottom = borderAccent('└' + '─'.repeat(innerW) + '┘');
50
95
  const body: string[] = [];
51
96
  for (let i = 0; i < h; i++) {
52
97
  const line = lines[i] ?? '';
53
- body.push(`${borderAccent('│')} ${padVisible(line, contentWidth)} ${borderAccent('│')}`);
98
+ body.push(`${borderAccent('│')} ${fitVisible(line, contentWidth)} ${borderAccent('│')}`);
54
99
  }
55
100
  return [top, ...body, bottom];
56
101
  }
57
102
 
103
+ export function gaugeBar(percent: number, width = 12): string {
104
+ const w = Math.max(1, width);
105
+ const pct = Math.min(100, Math.max(0, percent));
106
+ const filled = Math.round((pct / 100) * w);
107
+ const empty = w - filled;
108
+ const filledStr = '█'.repeat(filled);
109
+ const emptyStr = '░'.repeat(empty);
110
+ const color = pct > 85 ? chalk.red : pct > 65 ? chalk.yellow : chalk.cyan;
111
+ return color(filledStr) + chalk.dim(emptyStr);
112
+ }
113
+
58
114
  /** Join equal-or-uneven-height panel blocks side by side with a gap. */
59
115
  function hstack(blocks: string[][], gap = 2, targetWidth?: number): string[] {
60
116
  if (!blocks.length) return [];
61
117
  const height = Math.max(...blocks.map(b => b.length));
62
- const widths = blocks.map(b => visLen(b[0] ?? ''));
118
+ const widths = blocks.map(b => Math.max(0, ...b.map(l => visLen(l))));
63
119
  const padded = blocks.map((b, i) => {
64
- const filler = ' '.repeat(widths[i]);
65
- const arr = [...b];
120
+ const w = widths[i];
121
+ const filler = ' '.repeat(w);
122
+ const arr = b.map(line => fitVisible(line, w));
66
123
  while (arr.length < height) arr.push(filler);
67
124
  return arr;
68
125
  });
@@ -70,14 +127,14 @@ function hstack(blocks: string[][], gap = 2, targetWidth?: number): string[] {
70
127
  for (let row = 0; row < height; row++) {
71
128
  let line = padded.map(a => a[row]).join(' '.repeat(gap));
72
129
  if (targetWidth !== undefined) {
73
- const pad = Math.max(0, targetWidth - visLen(line));
74
- line += ' '.repeat(pad);
130
+ line = fitVisible(line, targetWidth);
75
131
  }
76
132
  out.push(line);
77
133
  }
78
134
  return out;
79
135
  }
80
136
 
137
+
81
138
  function statRow(label: string, value: string, width = 9): string {
82
139
  const w = Math.max(width, label.length + 1);
83
140
  return `${chalk.dim(label.padEnd(w))}${value}`;
@@ -117,7 +174,8 @@ export function capacityColor(percent: number) {
117
174
  }
118
175
 
119
176
  export function formatBytes(bytes: number): string {
120
- if (bytes < 1024) return `${bytes} B`;
177
+ if (isNaN(bytes) || bytes < 0) return '0 B';
178
+ if (bytes < 1024) return `${Math.round(bytes)} B`;
121
179
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
122
180
  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
123
181
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
@@ -280,7 +338,7 @@ function header(data: StatsData, width: number, badges: string[] = [], borderAcc
280
338
  const left = `${chalk.hex('#ff5500').bold(' PYRE ')} ${chalk.bold(data.header.hostname)} ${chalk.dim(data.header.os)} ${chalk.dim('up ' + data.header.uptime)}`;
281
339
  const right = `${chalk.dim(now)} ${badges.join(' ')}`;
282
340
  const gap = Math.max(1, width - visLen(left) - visLen(right));
283
- const line = left + ' '.repeat(gap) + right;
341
+ const line = fitVisible(left + ' '.repeat(gap) + right, width);
284
342
  return [line, borderAccent('─'.repeat(width))];
285
343
  }
286
344
 
@@ -342,9 +400,27 @@ function tabBar(activePanel: string, width: number, theme: ThemeColors): string
342
400
  return `${keyHint}${label}`;
343
401
  });
344
402
 
345
- const joined = rendered.join(chalk.dim(' │ '));
346
- const padded = visLen(joined) < width ? joined + ' '.repeat(width - visLen(joined)) : joined;
347
- return chalk.dim(' ') + padded;
403
+ const separator = chalk.dim(' │ ');
404
+ const joined = rendered.join(separator);
405
+ const indent = ' ';
406
+ const available = width - visLen(indent);
407
+ let result = joined;
408
+ if (visLen(joined) > available) {
409
+ let used = 0;
410
+ const kept: string[] = [];
411
+ for (let i = 0; i < rendered.length; i++) {
412
+ const tabW = visLen(rendered[i]) + (kept.length > 0 ? visLen(separator) : 0);
413
+ if (used + tabW > available && kept.length > 0) break;
414
+ kept.push(rendered[i]);
415
+ used += tabW;
416
+ }
417
+ if (kept.length < rendered.length) {
418
+ result = kept.join(separator) + chalk.dim('⋯');
419
+ }
420
+ }
421
+
422
+ const pad = Math.max(0, available - visLen(result));
423
+ return fitVisible(chalk.dim(indent) + result + ' '.repeat(pad), width);
348
424
  }
349
425
 
350
426
  // --- process / disk tables ---------------------------------------------------
@@ -596,126 +672,241 @@ function anomalyPanel(anomalies: AnomalyAlert[], width: number, theme: ThemeColo
596
672
  for (const a of anomalies) {
597
673
  const color = a.severity === 'critical' ? chalk.red.bold : chalk.yellow;
598
674
  const direction = a.zScore > 0 ? '↑ spike' : '↓ drop';
599
- lines.push(color(` ⚠ ${a.metric}: ${a.value.toFixed(1)} (${direction}, σ=${a.zScore.toFixed(1)})`));
675
+ const valStr = a.metric.toLowerCase().includes('net') ? `${formatBytes(a.value)}/s` : `${a.value.toFixed(1)}%`;
676
+ lines.push(color(` ⚠ ${a.metric}: ${valStr} (${direction}, σ=${a.zScore.toFixed(1)})`));
600
677
  }
601
678
  return lines;
602
679
  }
603
680
 
604
- export function formatTable(data: StatsData, opts: TableOptions = {}): string {
605
- const width = clampWidth(opts.width);
606
- const activePanel = opts.activePanel || 'grid';
607
- const columns = gridColumns(width);
608
- const gap = 2;
609
- const cardWidth = Math.floor((width - gap * (columns - 1)) / columns);
610
- const contentWidth = cardWidth - 4;
611
-
612
- const themeName = opts.theme || 'default';
613
- const theme = THEMES[themeName] || THEMES.default;
614
- const vis = opts.visible ?? {};
615
-
616
- const out: string[] = [];
617
- out.push(...header(data, width, [], theme.border));
618
- out.push('');
619
-
620
- const anomalies = opts.anomalies ?? [];
621
- if (anomalies.length && activePanel === 'grid') {
622
- out.push(...anomalyPanel(anomalies, width, theme));
623
- out.push('');
624
- }
681
+ function btopCpuBox(data: StatsData, width: number, theme: ThemeColors, opts: TableOptions): string[] {
682
+ const contentWidth = width - 4;
683
+ const rightTitle = `${data.header.hostname} · up ${data.header.uptime} · ${data.cpu.usage.toFixed(0)}%`;
625
684
 
626
- out.push(tabBar(activePanel, width, theme));
627
- out.push('');
685
+ if (contentWidth >= 65) {
686
+ const leftW = Math.floor(contentWidth * 0.48);
687
+ const rightW = contentWidth - leftW - 2;
628
688
 
629
- if (activePanel !== 'grid') {
630
- const lines = activeDetailLines(data, activePanel, width, opts);
631
- if (lines) {
632
- out.push(...panel(detailPanelTitle(activePanel), lines, width, theme[activePanel as keyof ThemeColors] ?? theme.cpu, theme.border));
689
+ const leftLines: string[] = [];
690
+ leftLines.push(fitVisible(theme.cpu('CPU Usage History'), leftW));
691
+
692
+ const cpuHistory = opts.history?.cpuUsage || [];
693
+ if (cpuHistory.length > 1) {
694
+ const graphRows = multiRowSparkline(cpuHistory, 3, { min: 0, max: 100 });
695
+ for (const gr of graphRows) {
696
+ leftLines.push(theme.cpu(fitVisible(gr, leftW)));
697
+ }
698
+ } else {
699
+ leftLines.push(theme.cpu(fitVisible(gaugeBar(data.cpu.usage, leftW), leftW)));
700
+ leftLines.push(' '.repeat(leftW));
701
+ leftLines.push(' '.repeat(leftW));
633
702
  }
634
- return out.join('\n');
703
+
704
+ const rightLines: string[] = [];
705
+ rightLines.push(fitVisible(chalk.bold(truncatePlain(data.cpu.brand, rightW)), rightW));
706
+
707
+ const cpuBarW = Math.max(6, rightW - 18);
708
+ const tempStr = data.cpu.temperature ? formatTempUnit(data.cpu.temperature, opts.tempUnit) : '';
709
+ rightLines.push(fitVisible(`CPU ${gaugeBar(data.cpu.usage, cpuBarW)} ${data.cpu.usage.toFixed(0).padStart(3)}% ${tempStr}`, rightW));
710
+
711
+ const loadStr = data.cpu.loadAvg.map(l => l.toFixed(2)).join(' ');
712
+ rightLines.push(fitVisible(`${chalk.dim('Load:')} ${loadStr}`, rightW));
713
+
714
+ if (data.cpu.coreUsage && data.cpu.coreUsage.length) {
715
+ const coreColW = Math.floor((rightW - 1) / 2);
716
+ const half = Math.ceil(data.cpu.coreUsage.length / 2);
717
+ for (let i = 0; i < half; i++) {
718
+ const u1 = data.cpu.coreUsage[i];
719
+ const u2 = data.cpu.coreUsage[i + half];
720
+ const c1BarW = Math.max(3, coreColW - 8);
721
+ const c1Str = `C${i} ${gaugeBar(u1, c1BarW)} ${Math.round(u1)}%`;
722
+ const c2Str = u2 !== undefined ? `C${i + half} ${gaugeBar(u2, c1BarW)} ${Math.round(u2)}%` : '';
723
+ rightLines.push(fitVisible(fitVisible(c1Str, coreColW) + ' ' + fitVisible(c2Str, coreColW), rightW));
724
+ }
725
+ }
726
+
727
+ if (data.gpu) {
728
+ rightLines.push(fitVisible(`GPU ${gaugeBar(data.gpu.utilization, cpuBarW)} ${data.gpu.utilization}% ${formatBytes(data.gpu.memory)}`, rightW));
729
+ }
730
+
731
+ const combinedBody = hstack([leftLines, rightLines], 2, contentWidth);
732
+ return panel('1 cpu', combinedBody, width, theme.cpu, theme.border, combinedBody.length, rightTitle);
733
+ } else {
734
+ const lines: string[] = [];
735
+ lines.push(chalk.bold(truncatePlain(data.cpu.brand, contentWidth)));
736
+ lines.push(`CPU ${gaugeBar(data.cpu.usage, Math.max(4, contentWidth - 12))} ${data.cpu.usage.toFixed(0)}%`);
737
+ if (opts.history?.cpuUsage.length) {
738
+ lines.push(theme.cpu(sparkline(opts.history.cpuUsage, { min: 0, max: 100 })));
739
+ }
740
+ lines.push(`Load: ${data.cpu.loadAvg.map(l => l.toFixed(2)).join(' ')}`);
741
+ return panel('1 cpu', lines, width, theme.cpu, theme.border, lines.length, rightTitle);
635
742
  }
743
+ }
636
744
 
637
- type Card = { title: string; accent: (s: string) => string; lines: string[] };
638
- const cards: Card[] = [];
745
+ function btopLeftColumn(data: StatsData, width: number, theme: ThemeColors, opts: TableOptions): string[] {
746
+ const contentWidth = width - 4;
747
+ const out: string[] = [];
639
748
 
640
- if (vis.cpu !== false) {
641
- cards.push({ title: 'CPU', accent: theme.cpu, lines: cpuCard(data, contentWidth) });
642
- }
643
- if (vis.mem !== false) {
644
- cards.push({ title: 'Memory', accent: theme.mem, lines: memCard(data, contentWidth) });
645
- }
646
- if (vis.gpu !== false) {
647
- const gpu = gpuCard(data, contentWidth);
648
- if (gpu) cards.push({ title: 'GPU', accent: theme.gpu, lines: gpu });
649
- }
650
- if (vis.power !== false) {
651
- const power = powerCard(data);
652
- if (power) cards.push({ title: 'Power', accent: theme.power, lines: power });
653
- }
654
- if (vis.battery !== false) {
655
- const battery = batteryCard(data, contentWidth);
656
- if (battery) cards.push({ title: 'Battery', accent: theme.battery, lines: battery });
749
+ // --- Box 1: 2 mem ---
750
+ const memLines: string[] = [];
751
+ memLines.push(`RAM [${gaugeBar(data.memory.usagePercent, Math.max(4, contentWidth - 12))}] ${data.memory.usagePercent}%`);
752
+ memLines.push(`Used: ${formatBytes(data.memory.used)} / ${formatBytes(data.memory.total)}`);
753
+ memLines.push(`Free: ${formatBytes(data.memory.free)} · Cache: ${formatBytes(data.memory.total - data.memory.free - data.memory.used)}`);
754
+ if (data.memory.swapTotal > 0) {
755
+ const swapPct = Math.round((data.memory.swapUsed / data.memory.swapTotal) * 100);
756
+ memLines.push(`Swap [${gaugeBar(swapPct, Math.max(4, contentWidth - 12))}] ${swapPct}%`);
757
+ }
758
+ out.push(...panel('2 mem', memLines, width, theme.mem, theme.border, memLines.length, `${formatBytes(data.memory.used)}`));
759
+
760
+ // --- Box 2: 0 disk ---
761
+ if (data.disk.length) {
762
+ const diskLines: string[] = [];
763
+ for (const d of data.disk.slice(0, 2)) {
764
+ const capNum = parseInt(d.capacity, 10) || 0;
765
+ const mountShort = truncatePlain(d.mountpoint, 8);
766
+ let ioStr = '';
767
+ if (d.readBytesSec !== undefined || d.writeBytesSec !== undefined) {
768
+ const rStr = formatBytes(d.readBytesSec || 0);
769
+ const wStr = formatBytes(d.writeBytesSec || 0);
770
+ ioStr = ` R:${rStr}/s W:${wStr}/s`;
771
+ }
772
+ diskLines.push(`${mountShort} ${d.used}/${d.size} [${gaugeBar(capNum, Math.max(3, contentWidth - 18 - ioStr.length))}] ${d.capacity}${ioStr}`);
773
+ }
774
+ const totalRead = data.disk.reduce((acc, d) => acc + (d.readBytesSec || 0), 0);
775
+ const totalWrite = data.disk.reduce((acc, d) => acc + (d.writeBytesSec || 0), 0);
776
+ const diskHeaderRight = `R:${formatBytes(totalRead)}/s W:${formatBytes(totalWrite)}/s`;
777
+ out.push(...panel('0 disk io', diskLines, width, theme.disk, theme.border, diskLines.length, diskHeaderRight));
657
778
  }
658
- if (vis.thermal !== false) {
659
- cards.push({ title: 'Thermal', accent: theme.thermal, lines: thermalCard(data, contentWidth) });
779
+
780
+ // --- Box 3: 7 net ---
781
+ const netLines: string[] = [];
782
+ const rxRate = opts.history?.netRxRate.length ? opts.history.netRxRate[opts.history.netRxRate.length - 1] : 0;
783
+ const txRate = opts.history?.netTxRate.length ? opts.history.netTxRate[opts.history.netTxRate.length - 1] : 0;
784
+
785
+ netLines.push(`down ▼ ${formatBytes(rxRate)}/s (${formatBytes(data.network.rxBytes)})`);
786
+ if (opts.history?.netRxRate.length) {
787
+ netLines.push(chalk.cyan(fitVisible(sparkline(opts.history.netRxRate, {}), contentWidth)));
660
788
  }
661
- if (vis.network !== false) {
662
- cards.push({ title: 'Network', accent: theme.network, lines: networkCard(data, contentWidth) });
789
+
790
+ netLines.push(`up ▲ ${formatBytes(txRate)}/s (${formatBytes(data.network.txBytes)})`);
791
+ if (opts.history?.netTxRate.length) {
792
+ netLines.push(theme.network(fitVisible(sparkline(opts.history.netTxRate, {}), contentWidth)));
663
793
  }
664
- if (vis.packets !== false) {
665
- const packets = packetCard(data, contentWidth);
666
- if (packets) cards.push({ title: 'Packets', accent: theme.network, lines: packets });
794
+
795
+ out.push(...panel('7 net', netLines, width, theme.network, theme.border, netLines.length, `${data.network.interface}`));
796
+
797
+ return out;
798
+ }
799
+
800
+ function btopProcBox(data: StatsData, width: number, targetHeight: number, theme: ThemeColors, opts: TableOptions): string[] {
801
+ const contentWidth = width - 4;
802
+ const filtered = opts.filter
803
+ ? data.processes.filter(p => p.command.toLowerCase().includes(opts.filter!.toLowerCase()))
804
+ : data.processes;
805
+ const sorted = sortProcesses(filtered, opts.sortBy ?? 'cpu');
806
+
807
+ const headerRowOverhead = 2; // top & bottom border
808
+ const tableHeaderOverhead = 2; // header + separator line
809
+ const availableRows = Math.max(1, targetHeight - headerRowOverhead - tableHeaderOverhead);
810
+
811
+ const limited = sorted.slice(0, availableRows);
812
+
813
+ const pidW = 6, userW = 7, thrW = 3, memW = 6, cpuW = 6;
814
+ const fixedW = pidW + userW + thrW + memW + cpuW + 5;
815
+ const cmdW = Math.max(8, contentWidth - fixedW);
816
+
817
+ const head =
818
+ chalk.bold('PID'.padEnd(pidW)) + ' ' +
819
+ chalk.bold('PROGRAM'.padEnd(cmdW)) + ' ' +
820
+ chalk.bold('USER'.padEnd(userW)) + ' ' +
821
+ chalk.bold('THR'.padStart(thrW)) + ' ' +
822
+ chalk.bold('MEM%'.padStart(memW)) + ' ' +
823
+ chalk.bold('CPU%'.padStart(cpuW));
824
+
825
+ if (opts.treeView) {
826
+ const treeLines = buildProcessTree(limited);
827
+ const rightTitle = opts.filter ? `filter: "${opts.filter}"` : `tree · sort: ${opts.sortBy || 'cpu'}`;
828
+ return panel('P proc', [head, theme.border('─'.repeat(contentWidth)), ...treeLines], width, theme.process, theme.border, targetHeight - 2, rightTitle);
667
829
  }
668
- if (vis.tasks !== false) {
669
- const tasks = tasksCard(data, contentWidth);
670
- if (tasks) cards.push({ title: 'Tasks', accent: theme.process, lines: tasks });
830
+
831
+ const rows = limited.map(p => {
832
+ const cpuVal = p.cpu.toFixed(1) + '%';
833
+ const cpuStr = p.cpu > 50 ? chalk.red.bold(cpuVal.padStart(cpuW)) : p.cpu > 20 ? chalk.yellow(cpuVal.padStart(cpuW)) : chalk.green(cpuVal.padStart(cpuW));
834
+ const memStr = (p.mem.toFixed(1) + '%').padStart(memW);
835
+ const userStr = truncatePlain(p.user, userW).padEnd(userW);
836
+ const thrStr = String(p.threads).padStart(thrW);
837
+ const pidStr = String(p.pid).padEnd(pidW);
838
+ const cmdStr = truncatePlain(p.command, cmdW).padEnd(cmdW);
839
+
840
+ return `${pidStr} ${cmdStr} ${userStr} ${thrStr} ${memStr} ${cpuStr}`;
841
+ });
842
+
843
+ const lines = [head, theme.border('─'.repeat(contentWidth)), ...rows];
844
+ if (rows.length === 0) {
845
+ lines.push(chalk.dim(`No processes match "${opts.filter || ''}"`));
671
846
  }
672
847
 
673
- for (let i = 0; i < cards.length; i += columns) {
674
- const row = cards.slice(i, i + columns);
675
- const rowHeight = Math.max(...row.map(c => c.lines.length));
676
- const blocks = row.map(c => panel(c.title, c.lines, cardWidth, c.accent, theme.border, rowHeight));
677
- out.push(...hstack(blocks, gap, width));
678
- out.push('');
679
- }
848
+ const rightTitle = opts.filter ? `filter: "${opts.filter}"` : `sort: ${opts.sortBy || 'cpu'}`;
849
+ return panel('P proc', lines, width, theme.process, theme.border, targetHeight - 2, rightTitle);
850
+ }
851
+
852
+ export function formatTable(data: StatsData, opts: TableOptions = {}): string {
853
+ const width = clampWidth(opts.width);
854
+ const activePanel = opts.activePanel || 'grid';
855
+
856
+ const themeName = opts.theme || 'default';
857
+ const theme = THEMES[themeName] || THEMES.default;
858
+
859
+ const out: string[] = [];
860
+ out.push(...header(data, width, [], theme.border));
861
+ out.push('');
680
862
 
681
- if (vis.disk !== false && data.disk.length) {
682
- const dw = width - 4;
683
- out.push(...panel('Disk', diskTableLines(data.disk, dw, theme.border), width, theme.disk, theme.border));
863
+ const anomalies = opts.anomalies ?? [];
864
+ if (anomalies.length && activePanel === 'grid') {
865
+ out.push(...anomalyPanel(anomalies, width, theme));
684
866
  out.push('');
685
867
  }
686
868
 
687
- if (vis.process !== false) {
688
- const filteredProcesses = opts.filter
689
- ? data.processes.filter(p => p.command.toLowerCase().includes(opts.filter!.toLowerCase()))
690
- : data.processes;
691
- const sortedProcesses = sortProcesses(filteredProcesses, opts.sortBy ?? 'cpu');
692
- const limited = opts.processLimit ? sortedProcesses.slice(0, opts.processLimit) : sortedProcesses;
693
-
694
- const procTitle = opts.filter
695
- ? `Processes · filter "${opts.filter}" · sort ${opts.sortBy ?? 'cpu'}`
696
- : `Processes · sort ${opts.sortBy ?? 'cpu'}`;
697
-
698
- if (opts.treeView) {
699
- const treeLines = buildProcessTree(limited);
700
- if (treeLines.length) {
701
- out.push(...panel(procTitle + ' · tree', treeLines, width, theme.process, theme.border));
702
- } else if (opts.filter) {
703
- out.push(...panel(procTitle, [chalk.dim(`No processes match "${opts.filter}"`)], width, theme.process, theme.border));
704
- }
705
- } else if (limited.length) {
706
- out.push(...panel(procTitle, processTableLines(limited, width - 4, theme.border), width, theme.process, theme.border));
707
- } else if (opts.filter) {
708
- out.push(...panel(procTitle, [chalk.dim(`No processes match "${opts.filter}"`)], width, theme.process, theme.border));
709
- }
710
- }
869
+ out.push(tabBar(activePanel, width, theme));
870
+ out.push('');
871
+
872
+ if (activePanel !== 'grid') {
873
+ const lines = activeDetailLines(data, activePanel, width, opts);
874
+ if (lines) {
875
+ out.push(...panel(detailPanelTitle(activePanel), lines, width, theme[activePanel as keyof ThemeColors] ?? theme.cpu, theme.border));
876
+ }
877
+ return out.join('\n');
878
+ }
879
+
880
+ // --- btop Dashboard Grid ---
881
+ // 1. Top CPU & System Overview Box
882
+ out.push(...btopCpuBox(data, width, theme, opts));
883
+ out.push('');
884
+
885
+ // 2. Bottom Split Section
886
+ if (width >= 85) {
887
+ const leftColW = Math.max(32, Math.floor(width * 0.38));
888
+ const rightColW = width - leftColW - 2;
889
+
890
+ const leftLines = btopLeftColumn(data, leftColW, theme, opts);
891
+ const procLines = btopProcBox(data, rightColW, leftLines.length, theme, opts);
892
+
893
+ out.push(...hstack([leftLines, procLines], 2, width));
894
+ } else {
895
+ const leftLines = btopLeftColumn(data, width, theme, opts);
896
+ out.push(...leftLines);
897
+ out.push('');
898
+ const procLines = btopProcBox(data, width, 12, theme, opts);
899
+ out.push(...procLines);
900
+ }
711
901
 
712
902
  return out.join('\n');
713
903
  }
714
904
 
905
+
715
906
  /** Clamp/normalize a terminal width to a sane range, defaulting to 80 cols. */
716
907
  export function clampWidth(width?: number): number {
717
908
  if (!width || Number.isNaN(width)) return 80;
718
- return Math.max(60, Math.min(width, 240));
909
+ return Math.max(40, Math.min(width, 240));
719
910
  }
720
911
 
721
912
  /**
@@ -8,7 +8,11 @@
8
8
  */
9
9
  import chalk from 'chalk';
10
10
 
11
- export type ThemeName = 'default' | 'dracula' | 'cyberpunk' | 'monochrome' | 'nord' | 'gruvbox';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ export type ThemeName = string;
12
16
 
13
17
  export interface ThemeColors {
14
18
  border: (s: string) => string;
@@ -24,16 +28,7 @@ export interface ThemeColors {
24
28
  process: (s: string) => string;
25
29
  }
26
30
 
27
- /**
28
- * Built-in colour themes.
29
- *
30
- * - **default** — muted greys and cyan accents, safe for any terminal.
31
- * - **dracula** — the popular Dracula dark palette.
32
- * - **cyberpunk** — high-contrast neon on black.
33
- * - **monochrome** — white-only, useful for colour-blind users or
34
- * terminals without 256-colour support.
35
- */
36
- export const THEMES: Record<ThemeName, ThemeColors> = {
31
+ export const BUILTIN_THEMES: Record<string, ThemeColors> = {
37
32
  default: {
38
33
  border: chalk.hex('#4b5563'),
39
34
  cpu: chalk.hex('#22d3ee'),
@@ -112,4 +107,44 @@ export const THEMES: Record<ThemeName, ThemeColors> = {
112
107
  graphs: chalk.hex('#fb4934'),
113
108
  process: chalk.hex('#ebdbb2'),
114
109
  },
115
- };
110
+ };
111
+
112
+ export function loadCustomThemes(): Record<string, ThemeColors> {
113
+ const themes = { ...BUILTIN_THEMES };
114
+ const themesDir = path.join(os.homedir(), '.config', 'pyre', 'themes');
115
+ try {
116
+ if (fs.existsSync(themesDir)) {
117
+ const files = fs.readdirSync(themesDir);
118
+ for (const file of files) {
119
+ if (file.endsWith('.json')) {
120
+ const name = path.basename(file, '.json');
121
+ try {
122
+ const raw = fs.readFileSync(path.join(themesDir, file), 'utf-8');
123
+ const data = JSON.parse(raw);
124
+ const buildColor = (val?: string) => (val ? (val.startsWith('#') ? chalk.hex(val) : (chalk as any)[val] || chalk.white) : chalk.white);
125
+ themes[name] = {
126
+ border: buildColor(data.border),
127
+ cpu: buildColor(data.cpu),
128
+ mem: buildColor(data.mem),
129
+ gpu: buildColor(data.gpu),
130
+ power: buildColor(data.power),
131
+ battery: buildColor(data.battery),
132
+ thermal: buildColor(data.thermal),
133
+ network: buildColor(data.network),
134
+ disk: buildColor(data.disk),
135
+ graphs: buildColor(data.graphs),
136
+ process: buildColor(data.process),
137
+ };
138
+ } catch {
139
+ // ignore bad theme file
140
+ }
141
+ }
142
+ }
143
+ }
144
+ } catch {
145
+ // ignore directory read error
146
+ }
147
+ return themes;
148
+ }
149
+
150
+ export const THEMES: Record<string, ThemeColors> = loadCustomThemes();
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import type { ThemeName } from './themes.js';
10
10
  import type { StatsData } from '../monitors/types.js';
11
+ import type { History } from '../history.js';
11
12
 
12
13
  export type { ThemeName };
13
14
 
@@ -70,4 +71,8 @@ export interface TableOptions {
70
71
  anomalies?: AnomalyAlert[];
71
72
  /** Temperature display unit: 'c' or 'f'. */
72
73
  tempUnit?: 'c' | 'f';
73
- }
74
+ /** Rolling history instance for live graphs. */
75
+ history?: History;
76
+ /** Graph mode: 'spark' or 'bar'. */
77
+ graphMode?: 'spark' | 'bar';
78
+ }