pyre-cli 5.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pyre-cli",
3
- "version": "5.1.0",
3
+ "version": "6.0.0",
4
4
  "description": "CLI Mac monitoring tool: temps, system stats, live dashboard, packet monitor, battery predictor, export",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -11,7 +11,7 @@
11
11
  * - `render.ts` — dashboard layout, cards, tables, and the main `formatTable`
12
12
  * - `output.ts` — JSON/CSV/TSV export and sparkline graph rendering
13
13
  */
14
- export { formatTable, clampWidth, gridColumns, thermalColor, capacityColor, getTabHitboxes, TAB_BAR_ROW, TAB_DEFS, panel, formatBytes } from './render.js';
14
+ export { formatTable, clampWidth, gridColumns, thermalColor, capacityColor, getTabHitboxes, TAB_BAR_ROW, TAB_DEFS, panel, formatBytes, padVisible, visLen, fitVisible } from './render.js';
15
15
  export { formatJson, formatCsv, formatTsv, formatGraphs, formatHtml, formatMarkdown } from './output.js';
16
16
  export type { ThemeName, ThemeColors, VisibleItems, TableOptions, AnomalyAlert } from './types.js';
17
17
  export { THEMES } from './themes.js';
@@ -10,8 +10,7 @@ import { sparkline } from '../sparkline.js';
10
10
  import type { History } from '../history.js';
11
11
  import type { StatsData } from './types.js';
12
12
  import { THEMES, type ThemeName } from './themes.js';
13
- import { formatBytes, celsiusToFahrenheit, formatTemp } from './render.js';
14
- import { panel } from './render.js';
13
+ import { formatBytes, celsiusToFahrenheit, formatTemp, panel, fitVisible } from './render.js';
15
14
 
16
15
  export function formatJson(data: StatsData): string {
17
16
  return JSON.stringify(data, null, 2);
@@ -434,24 +433,24 @@ export function formatGraphs(history: History, width = 80, themeName: ThemeName
434
433
  const theme = THEMES[themeName] || THEMES.default;
435
434
  const contentWidth = width - 4;
436
435
  const sampleCount = history.cpuUsage.length;
437
- const sparkWidth = Math.max(10, contentWidth - 28);
436
+ const sparkWidth = Math.max(10, contentWidth - 24);
438
437
 
439
438
  const lines: string[] = [];
440
- lines.push(graphRow('CPU %', history.cpuUsage, { min: 0, max: 100 }, v => `${v.toFixed(0)}%`, sparkWidth, 'collecting data...', graphMode));
441
- lines.push(graphRow('Mem %', history.memUsage, { min: 0, max: 100 }, v => `${v.toFixed(0)}%`, sparkWidth, 'collecting data...', graphMode));
439
+ lines.push(graphRow('CPU %', history.cpuUsage, { min: 0, max: 100 }, v => `${v.toFixed(0)}%`, sparkWidth, 'collecting data...', graphMode, contentWidth));
440
+ lines.push(graphRow('Mem %', history.memUsage, { min: 0, max: 100 }, v => `${v.toFixed(0)}%`, sparkWidth, 'collecting data...', graphMode, contentWidth));
442
441
  lines.push(
443
- graphRow('Temp', history.temp, {}, formatTemp, sparkWidth, 'no sensor access (needs sudo powermetrics)', graphMode)
442
+ graphRow('Temp', history.temp, {}, v => `${v.toFixed(1)}°C`, sparkWidth, 'no sensor access', graphMode, contentWidth)
444
443
  );
445
- lines.push(graphRow('Net RX/s', history.netRxRate, {}, v => `${formatBytes(v)}/s`, sparkWidth, 'collecting data...', graphMode));
446
- lines.push(graphRow('Net TX/s', history.netTxRate, {}, v => `${formatBytes(v)}/s`, sparkWidth, 'collecting data...', graphMode));
444
+ lines.push(graphRow('Net RX/s', history.netRxRate, {}, v => `${formatBytes(v)}/s`, sparkWidth, 'collecting data...', graphMode, contentWidth));
445
+ lines.push(graphRow('Net TX/s', history.netTxRate, {}, v => `${formatBytes(v)}/s`, sparkWidth, 'collecting data...', graphMode, contentWidth));
447
446
  if (history.powerWatts.length) {
448
- lines.push(graphRow('Power W', history.powerWatts, {}, v => `${v.toFixed(1)} W`, sparkWidth, 'collecting data...', graphMode));
447
+ lines.push(graphRow('Power W', history.powerWatts, {}, v => `${v.toFixed(1)} W`, sparkWidth, 'collecting data...', graphMode, contentWidth));
449
448
  }
450
449
  if (history.rxPacketRate.length) {
451
- lines.push(graphRow('RX pkt/s', history.rxPacketRate, {}, v => `${v.toFixed(0)} pkt/s`, sparkWidth, 'collecting data...', graphMode));
450
+ lines.push(graphRow('RX pkt/s', history.rxPacketRate, {}, v => `${v.toFixed(0)} pkt/s`, sparkWidth, 'collecting data...', graphMode, contentWidth));
452
451
  }
453
452
  if (history.txPacketRate.length) {
454
- lines.push(graphRow('TX pkt/s', history.txPacketRate, {}, v => `${v.toFixed(0)} pkt/s`, sparkWidth, 'collecting data...', graphMode));
453
+ lines.push(graphRow('TX pkt/s', history.txPacketRate, {}, v => `${v.toFixed(0)} pkt/s`, sparkWidth, 'collecting data...', graphMode, contentWidth));
455
454
  }
456
455
 
457
456
  const title = `Graphs · mode:${graphMode} · last ${sampleCount} sample${sampleCount === 1 ? '' : 's'}`;
@@ -465,7 +464,8 @@ function graphRow(
465
464
  fmt: (v: number) => string,
466
465
  sparkWidth = 40,
467
466
  emptyMessage = 'collecting data...',
468
- mode: 'spark' | 'bar' = 'spark'
467
+ mode: 'spark' | 'bar' = 'spark',
468
+ contentWidth = 76
469
469
  ): string {
470
470
  if (!values.length) {
471
471
  return `${chalk.dim(label.padEnd(10))} ${chalk.dim(emptyMessage)}`;
@@ -480,7 +480,8 @@ function graphRow(
480
480
  color = pct > 90 ? chalk.red : pct > 70 ? chalk.yellow : chalk.green;
481
481
  }
482
482
 
483
- return `${chalk.dim(label.padEnd(10))}${color(spark)} ${chalk.bold(fmt(current))}`;
483
+ const raw = `${chalk.dim(label.padEnd(10))}${color(spark)} ${chalk.bold(fmt(current))}`;
484
+ return fitVisible(raw, contentWidth);
484
485
  }
485
486
 
486
487
  function barGraph(values: number[], bounds: { min?: number; max?: number }, width: number): string {
@@ -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,232 @@ 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 < 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);
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}%`);
657
757
  }
658
- if (vis.thermal !== false) {
659
- cards.push({ title: 'Thermal', accent: theme.thermal, lines: thermalCard(data, contentWidth) });
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));
660
769
  }
661
- if (vis.network !== false) {
662
- cards.push({ title: 'Network', accent: theme.network, lines: networkCard(data, contentWidth) });
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)));
663
784
  }
664
- if (vis.packets !== false) {
665
- const packets = packetCard(data, contentWidth);
666
- if (packets) cards.push({ title: 'Packets', accent: theme.network, lines: packets });
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);
667
820
  }
668
- if (vis.tasks !== false) {
669
- const tasks = tasksCard(data, contentWidth);
670
- if (tasks) cards.push({ title: 'Tasks', accent: theme.process, lines: tasks });
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 || ''}"`));
671
837
  }
672
838
 
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
- }
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
+
843
+ export function formatTable(data: StatsData, opts: TableOptions = {}): string {
844
+ const width = clampWidth(opts.width);
845
+ const activePanel = opts.activePanel || 'grid';
680
846
 
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));
847
+ const themeName = opts.theme || 'default';
848
+ const theme = THEMES[themeName] || THEMES.default;
849
+
850
+ const out: string[] = [];
851
+ out.push(...header(data, width, [], theme.border));
852
+ out.push('');
853
+
854
+ const anomalies = opts.anomalies ?? [];
855
+ if (anomalies.length && activePanel === 'grid') {
856
+ out.push(...anomalyPanel(anomalies, width, theme));
684
857
  out.push('');
685
858
  }
686
859
 
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
- }
860
+ out.push(tabBar(activePanel, width, theme));
861
+ out.push('');
862
+
863
+ if (activePanel !== 'grid') {
864
+ const lines = activeDetailLines(data, activePanel, width, opts);
865
+ if (lines) {
866
+ out.push(...panel(detailPanelTitle(activePanel), lines, width, theme[activePanel as keyof ThemeColors] ?? theme.cpu, theme.border));
867
+ }
868
+ return out.join('\n');
869
+ }
870
+
871
+ // --- btop Dashboard Grid ---
872
+ // 1. Top CPU & System Overview Box
873
+ out.push(...btopCpuBox(data, width, theme, opts));
874
+ out.push('');
875
+
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;
880
+
881
+ const leftLines = btopLeftColumn(data, leftColW, theme, opts);
882
+ const procLines = btopProcBox(data, rightColW, leftLines.length, theme, opts);
883
+
884
+ out.push(...hstack([leftLines, procLines], 2, width));
885
+ } else {
886
+ const leftLines = btopLeftColumn(data, width, theme, opts);
887
+ out.push(...leftLines);
888
+ out.push('');
889
+ const procLines = btopProcBox(data, width, 12, theme, opts);
890
+ out.push(...procLines);
891
+ }
711
892
 
712
893
  return out.join('\n');
713
894
  }
714
895
 
896
+
715
897
  /** Clamp/normalize a terminal width to a sane range, defaulting to 80 cols. */
716
898
  export function clampWidth(width?: number): number {
717
899
  if (!width || Number.isNaN(width)) return 80;
718
- return Math.max(60, Math.min(width, 240));
900
+ return Math.max(40, Math.min(width, 240));
719
901
  }
720
902
 
721
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
 
@@ -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
+ }