pyre-cli 5.0.0 → 6.0.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
- import type { StatsData, VisibleItems, TableOptions } from './types.js';
16
- import { THEMES, type ThemeName } from './themes.js';
15
+ import type { StatsData, VisibleItems, TableOptions, AnomalyAlert } from './types.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`;
@@ -132,6 +190,14 @@ export function formatTemp(c: number): string {
132
190
  return `${c.toFixed(1)}°C / ${celsiusToFahrenheit(c).toFixed(1)}°F`;
133
191
  }
134
192
 
193
+ /** Render a Celsius reading in the requested unit. */
194
+ export function formatTempUnit(c: number, unit: 'c' | 'f' = 'c'): string {
195
+ if (unit === 'f') {
196
+ return `${(c * (9 / 5) + 32).toFixed(1)}°F`;
197
+ }
198
+ return `${c.toFixed(1)}°C`;
199
+ }
200
+
135
201
  // --- cards -------------------------------------------------------------------
136
202
 
137
203
  function cpuCard(data: StatsData, contentWidth: number): string[] {
@@ -142,6 +208,18 @@ function cpuCard(data: StatsData, contentWidth: number): string[] {
142
208
  gaugeRow('Usage', data.cpu.usage, contentWidth),
143
209
  statRow('Load', data.cpu.loadAvg.map(l => l.toFixed(2)).join(' ')),
144
210
  ];
211
+ if (data.cpu.coreUsage && data.cpu.coreUsage.length) {
212
+ const slotW = 2;
213
+ const maxCores = Math.max(1, Math.floor((contentWidth - 10) / (slotW + 1)));
214
+ const cores = data.cpu.coreUsage.slice(0, maxCores);
215
+ const bars = cores.map(u => {
216
+ const fill = Math.round((Math.min(100, u) / 100) * slotW);
217
+ const str = '█'.repeat(Math.max(0, fill)) + '░'.repeat(slotW - fill);
218
+ const color = u > 90 ? chalk.red : u > 70 ? chalk.yellow : chalk.green;
219
+ return color(str);
220
+ });
221
+ lines.push(statRow('Cores', bars.join(' ')));
222
+ }
145
223
  if (data.cpu.temperature) lines.push(statRow('Temp', formatTemp(data.cpu.temperature)));
146
224
  return lines;
147
225
  }
@@ -260,7 +338,7 @@ function header(data: StatsData, width: number, badges: string[] = [], borderAcc
260
338
  const left = `${chalk.hex('#ff5500').bold(' PYRE ')} ${chalk.bold(data.header.hostname)} ${chalk.dim(data.header.os)} ${chalk.dim('up ' + data.header.uptime)}`;
261
339
  const right = `${chalk.dim(now)} ${badges.join(' ')}`;
262
340
  const gap = Math.max(1, width - visLen(left) - visLen(right));
263
- const line = left + ' '.repeat(gap) + right;
341
+ const line = fitVisible(left + ' '.repeat(gap) + right, width);
264
342
  return [line, borderAccent('─'.repeat(width))];
265
343
  }
266
344
 
@@ -322,9 +400,27 @@ function tabBar(activePanel: string, width: number, theme: ThemeColors): string
322
400
  return `${keyHint}${label}`;
323
401
  });
324
402
 
325
- const joined = rendered.join(chalk.dim(' │ '));
326
- const padded = visLen(joined) < width ? joined + ' '.repeat(width - visLen(joined)) : joined;
327
- 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);
328
424
  }
329
425
 
330
426
  // --- process / disk tables ---------------------------------------------------
@@ -571,21 +667,196 @@ function activeDetailLines(data: StatsData, activePanel: string, width: number,
571
667
  }
572
668
  }
573
669
 
670
+ function anomalyPanel(anomalies: AnomalyAlert[], width: number, theme: ThemeColors): string[] {
671
+ const lines: string[] = [];
672
+ for (const a of anomalies) {
673
+ const color = a.severity === 'critical' ? chalk.red.bold : chalk.yellow;
674
+ const direction = a.zScore > 0 ? '↑ spike' : '↓ drop';
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)})`));
677
+ }
678
+ return lines;
679
+ }
680
+
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)}%`;
684
+
685
+ if (contentWidth >= 65) {
686
+ const leftW = Math.floor(contentWidth * 0.48);
687
+ const rightW = contentWidth - leftW - 2;
688
+
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));
702
+ }
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 < Math.min(2, 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);
742
+ }
743
+ }
744
+
745
+ function btopLeftColumn(data: StatsData, width: number, theme: ThemeColors, opts: TableOptions): string[] {
746
+ const contentWidth = width - 4;
747
+ const out: string[] = [];
748
+
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
+ diskLines.push(`${mountShort} ${d.used}/${d.size} [${gaugeBar(capNum, Math.max(3, contentWidth - 18))}] ${d.capacity}`);
767
+ }
768
+ out.push(...panel('0 disk io', diskLines, width, theme.disk, theme.border, diskLines.length));
769
+ }
770
+
771
+ // --- Box 3: 7 net ---
772
+ const netLines: string[] = [];
773
+ const rxRate = opts.history?.netRxRate.length ? opts.history.netRxRate[opts.history.netRxRate.length - 1] : 0;
774
+ const txRate = opts.history?.netTxRate.length ? opts.history.netTxRate[opts.history.netTxRate.length - 1] : 0;
775
+
776
+ netLines.push(`down ▼ ${formatBytes(rxRate)}/s (${formatBytes(data.network.rxBytes)})`);
777
+ if (opts.history?.netRxRate.length) {
778
+ netLines.push(chalk.cyan(fitVisible(sparkline(opts.history.netRxRate, {}), contentWidth)));
779
+ }
780
+
781
+ netLines.push(`up ▲ ${formatBytes(txRate)}/s (${formatBytes(data.network.txBytes)})`);
782
+ if (opts.history?.netTxRate.length) {
783
+ netLines.push(theme.network(fitVisible(sparkline(opts.history.netTxRate, {}), contentWidth)));
784
+ }
785
+
786
+ out.push(...panel('7 net', netLines, width, theme.network, theme.border, netLines.length, `${data.network.interface}`));
787
+
788
+ return out;
789
+ }
790
+
791
+ function btopProcBox(data: StatsData, width: number, targetHeight: number, theme: ThemeColors, opts: TableOptions): string[] {
792
+ const contentWidth = width - 4;
793
+ const filtered = opts.filter
794
+ ? data.processes.filter(p => p.command.toLowerCase().includes(opts.filter!.toLowerCase()))
795
+ : data.processes;
796
+ const sorted = sortProcesses(filtered, opts.sortBy ?? 'cpu');
797
+
798
+ const headerRowOverhead = 2; // top & bottom border
799
+ const tableHeaderOverhead = 2; // header + separator line
800
+ const availableRows = Math.max(1, targetHeight - headerRowOverhead - tableHeaderOverhead);
801
+
802
+ const limited = sorted.slice(0, availableRows);
803
+
804
+ const pidW = 6, userW = 7, thrW = 3, memW = 6, cpuW = 6;
805
+ const fixedW = pidW + userW + thrW + memW + cpuW + 5;
806
+ const cmdW = Math.max(8, contentWidth - fixedW);
807
+
808
+ const head =
809
+ chalk.bold('PID'.padEnd(pidW)) + ' ' +
810
+ chalk.bold('PROGRAM'.padEnd(cmdW)) + ' ' +
811
+ chalk.bold('USER'.padEnd(userW)) + ' ' +
812
+ chalk.bold('THR'.padStart(thrW)) + ' ' +
813
+ chalk.bold('MEM%'.padStart(memW)) + ' ' +
814
+ chalk.bold('CPU%'.padStart(cpuW));
815
+
816
+ if (opts.treeView) {
817
+ const treeLines = buildProcessTree(limited);
818
+ const rightTitle = opts.filter ? `filter: "${opts.filter}"` : `tree · sort: ${opts.sortBy || 'cpu'}`;
819
+ return panel('P proc', [head, theme.border('─'.repeat(contentWidth)), ...treeLines], width, theme.process, theme.border, targetHeight - 2, rightTitle);
820
+ }
821
+
822
+ const rows = limited.map(p => {
823
+ const cpuVal = p.cpu.toFixed(1) + '%';
824
+ const cpuStr = p.cpu > 50 ? chalk.red.bold(cpuVal.padStart(cpuW)) : p.cpu > 20 ? chalk.yellow(cpuVal.padStart(cpuW)) : chalk.green(cpuVal.padStart(cpuW));
825
+ const memStr = (p.mem.toFixed(1) + '%').padStart(memW);
826
+ const userStr = truncatePlain(p.user, userW).padEnd(userW);
827
+ const thrStr = String(p.threads).padStart(thrW);
828
+ const pidStr = String(p.pid).padEnd(pidW);
829
+ const cmdStr = truncatePlain(p.command, cmdW).padEnd(cmdW);
830
+
831
+ return `${pidStr} ${cmdStr} ${userStr} ${thrStr} ${memStr} ${cpuStr}`;
832
+ });
833
+
834
+ const lines = [head, theme.border('─'.repeat(contentWidth)), ...rows];
835
+ if (rows.length === 0) {
836
+ lines.push(chalk.dim(`No processes match "${opts.filter || ''}"`));
837
+ }
838
+
839
+ const rightTitle = opts.filter ? `filter: "${opts.filter}"` : `sort: ${opts.sortBy || 'cpu'}`;
840
+ return panel('P proc', lines, width, theme.process, theme.border, targetHeight - 2, rightTitle);
841
+ }
842
+
574
843
  export function formatTable(data: StatsData, opts: TableOptions = {}): string {
575
844
  const width = clampWidth(opts.width);
576
845
  const activePanel = opts.activePanel || 'grid';
577
- const columns = gridColumns(width);
578
- const gap = 2;
579
- const cardWidth = Math.floor((width - gap * (columns - 1)) / columns);
580
- const contentWidth = cardWidth - 4;
581
846
 
582
847
  const themeName = opts.theme || 'default';
583
848
  const theme = THEMES[themeName] || THEMES.default;
584
- const vis = opts.visible ?? {};
585
849
 
586
850
  const out: string[] = [];
587
851
  out.push(...header(data, width, [], theme.border));
588
852
  out.push('');
853
+
854
+ const anomalies = opts.anomalies ?? [];
855
+ if (anomalies.length && activePanel === 'grid') {
856
+ out.push(...anomalyPanel(anomalies, width, theme));
857
+ out.push('');
858
+ }
859
+
589
860
  out.push(tabBar(activePanel, width, theme));
590
861
  out.push('');
591
862
 
@@ -597,88 +868,36 @@ export function formatTable(data: StatsData, opts: TableOptions = {}): string {
597
868
  return out.join('\n');
598
869
  }
599
870
 
600
- type Card = { title: string; accent: (s: string) => string; lines: string[] };
601
- const cards: Card[] = [];
871
+ // --- btop Dashboard Grid ---
872
+ // 1. Top CPU & System Overview Box
873
+ out.push(...btopCpuBox(data, width, theme, opts));
874
+ out.push('');
602
875
 
603
- if (vis.cpu !== false) {
604
- cards.push({ title: 'CPU', accent: theme.cpu, lines: cpuCard(data, contentWidth) });
605
- }
606
- if (vis.mem !== false) {
607
- cards.push({ title: 'Memory', accent: theme.mem, lines: memCard(data, contentWidth) });
608
- }
609
- if (vis.gpu !== false) {
610
- const gpu = gpuCard(data, contentWidth);
611
- if (gpu) cards.push({ title: 'GPU', accent: theme.gpu, lines: gpu });
612
- }
613
- if (vis.power !== false) {
614
- const power = powerCard(data);
615
- if (power) cards.push({ title: 'Power', accent: theme.power, lines: power });
616
- }
617
- if (vis.battery !== false) {
618
- const battery = batteryCard(data, contentWidth);
619
- if (battery) cards.push({ title: 'Battery', accent: theme.battery, lines: battery });
620
- }
621
- if (vis.thermal !== false) {
622
- cards.push({ title: 'Thermal', accent: theme.thermal, lines: thermalCard(data, contentWidth) });
623
- }
624
- if (vis.network !== false) {
625
- cards.push({ title: 'Network', accent: theme.network, lines: networkCard(data, contentWidth) });
626
- }
627
- if (vis.packets !== false) {
628
- const packets = packetCard(data, contentWidth);
629
- if (packets) cards.push({ title: 'Packets', accent: theme.network, lines: packets });
630
- }
631
- if (vis.tasks !== false) {
632
- const tasks = tasksCard(data, contentWidth);
633
- if (tasks) cards.push({ title: 'Tasks', accent: theme.process, lines: tasks });
634
- }
876
+ // 2. Bottom Split Section
877
+ if (width >= 85) {
878
+ const leftColW = Math.max(32, Math.floor(width * 0.38));
879
+ const rightColW = width - leftColW - 2;
635
880
 
636
- for (let i = 0; i < cards.length; i += columns) {
637
- const row = cards.slice(i, i + columns);
638
- const rowHeight = Math.max(...row.map(c => c.lines.length));
639
- const blocks = row.map(c => panel(c.title, c.lines, cardWidth, c.accent, theme.border, rowHeight));
640
- out.push(...hstack(blocks, gap, width));
641
- out.push('');
642
- }
881
+ const leftLines = btopLeftColumn(data, leftColW, theme, opts);
882
+ const procLines = btopProcBox(data, rightColW, leftLines.length, theme, opts);
643
883
 
644
- if (vis.disk !== false && data.disk.length) {
645
- const dw = width - 4;
646
- out.push(...panel('Disk', diskTableLines(data.disk, dw, theme.border), width, theme.disk, theme.border));
884
+ out.push(...hstack([leftLines, procLines], 2, width));
885
+ } else {
886
+ const leftLines = btopLeftColumn(data, width, theme, opts);
887
+ out.push(...leftLines);
647
888
  out.push('');
889
+ const procLines = btopProcBox(data, width, 12, theme, opts);
890
+ out.push(...procLines);
648
891
  }
649
892
 
650
- if (vis.process !== false) {
651
- const filteredProcesses = opts.filter
652
- ? data.processes.filter(p => p.command.toLowerCase().includes(opts.filter!.toLowerCase()))
653
- : data.processes;
654
- const sortedProcesses = sortProcesses(filteredProcesses, opts.sortBy ?? 'cpu');
655
- const limited = opts.processLimit ? sortedProcesses.slice(0, opts.processLimit) : sortedProcesses;
656
-
657
- const procTitle = opts.filter
658
- ? `Processes · filter "${opts.filter}" · sort ${opts.sortBy ?? 'cpu'}`
659
- : `Processes · sort ${opts.sortBy ?? 'cpu'}`;
660
-
661
- if (opts.treeView) {
662
- const treeLines = buildProcessTree(limited);
663
- if (treeLines.length) {
664
- out.push(...panel(procTitle + ' · tree', treeLines, width, theme.process, theme.border));
665
- } else if (opts.filter) {
666
- out.push(...panel(procTitle, [chalk.dim(`No processes match "${opts.filter}"`)], width, theme.process, theme.border));
667
- }
668
- } else if (limited.length) {
669
- out.push(...panel(procTitle, processTableLines(limited, width - 4, theme.border), width, theme.process, theme.border));
670
- } else if (opts.filter) {
671
- out.push(...panel(procTitle, [chalk.dim(`No processes match "${opts.filter}"`)], width, theme.process, theme.border));
672
- }
673
- }
674
-
675
893
  return out.join('\n');
676
894
  }
677
895
 
896
+
678
897
  /** Clamp/normalize a terminal width to a sane range, defaulting to 80 cols. */
679
898
  export function clampWidth(width?: number): number {
680
899
  if (!width || Number.isNaN(width)) return 80;
681
- return Math.max(60, Math.min(width, 240));
900
+ return Math.max(40, Math.min(width, 240));
682
901
  }
683
902
 
684
903
  /**
@@ -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
 
@@ -42,21 +43,36 @@ export interface VisibleItems {
42
43
  tree?: boolean;
43
44
  }
44
45
 
46
+ export interface AnomalyAlert {
47
+ metric: string;
48
+ value: number;
49
+ zScore: number;
50
+ severity: 'warning' | 'critical';
51
+ }
52
+
45
53
  export interface TableOptions {
46
- /** Terminal width in columns; controls layout, bar sizing, and column count. Defaults to 80. */
47
- width?: number;
48
- /** Process sort key. Defaults to 'cpu'. */
49
- sortBy?: 'cpu' | 'mem' | 'pid' | 'user' | 'command' | 'state' | 'threads' | 'runtime';
50
- /** Case-insensitive substring filter applied to the process command. */
51
- filter?: string;
52
- /** Max process rows to show (the live dashboard sizes this to available terminal height). */
53
- processLimit?: number;
54
- /** Selected visual theme. */
55
- theme?: ThemeName;
56
- /** Visibility toggle settings for individual cards. */
57
- visible?: VisibleItems;
58
- /** Show process tree view instead of flat list. */
59
- treeView?: boolean;
60
- /** Active panel for detail/focus view. 'grid' means show all panels. */
61
- activePanel?: 'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process';
62
- }
54
+ /** Terminal width in columns; controls layout, bar sizing, and column count. Defaults to 80. */
55
+ width?: number;
56
+ /** Process sort key. Defaults to 'cpu'. */
57
+ sortBy?: 'cpu' | 'mem' | 'pid' | 'user' | 'command' | 'state' | 'threads' | 'runtime';
58
+ /** Case-insensitive substring filter applied to the process command. */
59
+ filter?: string;
60
+ /** Max process rows to show (the live dashboard sizes this to available terminal height). */
61
+ processLimit?: number;
62
+ /** Selected visual theme. */
63
+ theme?: ThemeName;
64
+ /** Visibility toggle settings for individual cards. */
65
+ visible?: VisibleItems;
66
+ /** Show process tree view instead of flat list. */
67
+ treeView?: boolean;
68
+ /** Active panel for detail/focus view. 'grid' means show all panels. */
69
+ activePanel?: 'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process' | 'p2p';
70
+ /** Active anomaly alerts from the statistical detector. */
71
+ anomalies?: AnomalyAlert[];
72
+ /** Temperature display unit: 'c' or 'f'. */
73
+ tempUnit?: 'c' | 'f';
74
+ /** Rolling history instance for live graphs. */
75
+ history?: History;
76
+ /** Graph mode: 'spark' or 'bar'. */
77
+ graphMode?: 'spark' | 'bar';
78
+ }
package/src/history.ts CHANGED
@@ -47,21 +47,65 @@ export class History {
47
47
  txPacketRate: number[] = [];
48
48
 
49
49
  /** Rolling window of active TCP connections. */
50
- connections: number[] = [];
50
+ connections: number[] = [];
51
51
 
52
- private lastRxBytes = 0;
53
- private lastTxBytes = 0;
54
- private lastRxPackets = 0;
55
- private lastTxPackets = 0;
56
- private lastTs = 0;
52
+ private lastRxBytes = 0;
53
+ private lastTxBytes = 0;
54
+ private lastRxPackets = 0;
55
+ private lastTxPackets = 0;
56
+ private lastTs = 0;
57
57
 
58
- /**
59
- * Create a new History instance.
60
- * @param maxLen - Maximum number of samples to retain. Defaults to 40.
61
- */
62
- constructor(maxLen = 40) {
63
- this.maxLen = maxLen;
64
- }
58
+ /**
59
+ * Create a new History instance.
60
+ * @param maxLen - Maximum number of samples to retain. Defaults to 40.
61
+ */
62
+ constructor(maxLen = 40) {
63
+ this.maxLen = maxLen;
64
+ }
65
+
66
+ /**
67
+ * Compute the arithmetic mean of a numeric array.
68
+ * Returns 0 if the array is empty.
69
+ */
70
+ static mean(arr: number[]): number {
71
+ if (!arr.length) return 0;
72
+ return arr.reduce((s, v) => s + v, 0) / arr.length;
73
+ }
74
+
75
+ /**
76
+ * Compute the population standard deviation of a numeric array.
77
+ * Returns 0 if the array has fewer than 2 elements.
78
+ */
79
+ static stdDev(arr: number[]): number {
80
+ if (arr.length < 2) return 0;
81
+ const m = History.mean(arr);
82
+ const variance = arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length;
83
+ return Math.sqrt(variance);
84
+ }
85
+
86
+ /**
87
+ * Compute the z-score of a value against a reference array.
88
+ * Returns 0 when the array is empty or has zero std dev.
89
+ */
90
+ static zScore(value: number, arr: number[]): number {
91
+ const m = History.mean(arr);
92
+ const sd = History.stdDev(arr);
93
+ if (sd === 0) return 0;
94
+ return (value - m) / sd;
95
+ }
96
+
97
+ /**
98
+ * Check whether a value is a statistical outlier against a
99
+ * reference array.
100
+ * @param value - The current reading
101
+ * @param arr - The rolling history window
102
+ * @param threshold - Z-score threshold (default 2.5)
103
+ * @returns true if the value deviates from the baseline by more than `threshold` standard deviations
104
+ */
105
+ static isAnomaly(value: number, arr: number[], threshold = 2.5): boolean {
106
+ if (arr.length < 5) return false;
107
+ return Math.abs(History.zScore(value, arr)) > threshold;
108
+ }
65
109
 
66
110
  /**
67
111
  * Push a new sample into the rolling window.