pyre-cli 3.4.0 → 3.6.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": "3.4.0",
3
+ "version": "3.6.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",
@@ -0,0 +1,164 @@
1
+ # src/formatters/ — Dashboard Formatting Module
2
+
3
+ The formatters module is responsible for turning raw system-metric data (`StatsData`) into styled terminal output. It provides three categories of functionality:
4
+
5
+ 1. **Dashboard rendering** — `formatTable` draws the full terminal dashboard with panels, cards, tables, and tab navigation.
6
+ 2. **Data export** — `formatJson`, `formatCsv`, `formatTsv` serialise a `StatsData` snapshot to structured text formats.
7
+ 3. **Graph rendering** — `formatGraphs` draws sparkline/bar graphs of historical metrics.
8
+
9
+ ## Files
10
+
11
+ | File | Purpose |
12
+ |------|---------|
13
+ | `index.ts` | Barrel export — re-exports all public functions and types |
14
+ | `types.ts` | Public-facing type definitions (`StatsData`, `VisibleItems`, `TableOptions`, `ThemeColors`, `ThemeName`) |
15
+ | `themes.ts` | Six built-in colour themes (`THEMES` record) mapping UI roles to chalk colour functions |
16
+ | `render.ts` | Core dashboard rendering: panels, cards, tables, tab bar, process tree, and the `formatTable` orchestrator |
17
+ | `output.ts` | JSON/CSV/TSV serialisation and sparkline/bar graph rendering |
18
+
19
+ ## External Dependencies
20
+
21
+ The formatters module imports from two sibling modules:
22
+
23
+ - **`src/monitors/types.ts`** — defines `StatsData`, the aggregate data structure that every formatter consumes.
24
+ - **`src/history.ts`** — defines the `History` class, a rolling window of metric samples used by `formatGraphs`.
25
+ - **`src/sparkline.ts`** — exports the `sparkline()` function that renders Unicode block-character sparklines.
26
+
27
+ ## `index.ts` — Barrel Exports
28
+
29
+ Re-exports everything public from the sub-modules:
30
+
31
+ - From `render.ts`: `formatTable`, `clampWidth`, `gridColumns`, `thermalColor`, `capacityColor`, `getTabHitboxes`, `TAB_BAR_ROW`, `TAB_DEFS`, `panel`
32
+ - From `output.ts`: `formatJson`, `formatCsv`, `formatTsv`, `formatGraphs`
33
+ - From `types.ts`: `ThemeName`, `ThemeColors`, `VisibleItems`, `TableOptions` (types only)
34
+ - From `themes.ts`: `THEMES`
35
+
36
+ ## `types.ts` — Public Type Definitions
37
+
38
+ Defines the interfaces that control dashboard layout and visibility. All types are re-exported from `index.ts`.
39
+
40
+ ### `ThemeName`
41
+ Union type re-exported from `themes.ts`: `'default' | 'dracula' | 'cyberpunk' | 'monochrome' | 'nord' | 'gruvbox'`.
42
+
43
+ ### `ThemeColors`
44
+ Interface mapping 11 UI element roles to chalk colour functions: `border`, `cpu`, `mem`, `gpu`, `power`, `battery`, `thermal`, `network`, `disk`, `graphs`, `process`.
45
+
46
+ ### `VisibleItems`
47
+ Optional boolean flags controlling which dashboard cards are shown. All default to visible when `undefined`:
48
+ - `cpu`, `mem`, `gpu`, `power`, `battery`, `thermal`, `network`, `packets`, `tasks`, `disk`, `process`, `tree`
49
+
50
+ ### `TableOptions`
51
+ Options object for `formatTable`:
52
+ - `width?` — terminal width in columns (default 80, clamped to 60–240)
53
+ - `sortBy?` — process sort key: `'cpu' | 'mem' | 'pid' | 'user' | 'command' | 'state' | 'threads' | 'runtime'`
54
+ - `filter?` — case-insensitive substring filter on process command
55
+ - `processLimit?` — max process rows to display
56
+ - `theme?` — visual theme name
57
+ - `visible?` — `VisibleItems` override
58
+ - `treeView?` — show process tree instead of flat table
59
+ - `activePanel?` — detail panel to show: `'grid' | 'cpu' | 'mem' | 'gpu' | 'power' | 'battery' | 'thermal' | 'network' | 'packets' | 'tasks' | 'disk' | 'process'`
60
+
61
+ ### `StatsData` (re-exported from `monitors/types.ts`)
62
+ The aggregate data structure consumed by all formatters. Contains: `header`, `cpu`, `gpu`, `memory`, `disk`, `battery`, `thermal`, `network`, `processes`, `power`, `timestamp`, `packets`, `tasks`.
63
+
64
+ ## `themes.ts` — Colour Themes
65
+
66
+ Exports `THEMES`, a `Record<ThemeName, ThemeColors>` containing six built-in themes. Each theme maps the 11 UI roles to a chalk colour function.
67
+
68
+ ### Built-in Themes
69
+
70
+ | Theme | Description |
71
+ |-------|-------------|
72
+ | `default` | Muted greys and cyan accents; safe for any terminal |
73
+ | `dracula` | Popular Dracula dark palette |
74
+ | `cyberpunk` | High-contrast neon on black |
75
+ | `monochrome` | White-only; useful for colour-blind users or terminals without 256-colour support |
76
+ | `nord` | Nord colour palette |
77
+ | `gruvbox` | Gruvbox retro colour palette |
78
+
79
+ Each theme's `ThemeColors` object has keys: `border`, `cpu`, `mem`, `gpu`, `power`, `battery`, `thermal`, `network`, `disk`, `graphs`, `process`.
80
+
81
+ ## `render.ts` — Dashboard Rendering
82
+
83
+ The largest file in the module (660 lines). Contains every function that turns a `StatsData` snapshot into styled terminal output.
84
+
85
+ ### Low-Level Utilities
86
+
87
+ - `visLen(s: string): number` — computes the visible length of a string by stripping ANSI escape codes.
88
+ - `padVisible(s: string, width: number): string` — pads a string with spaces to a target visible width.
89
+ - `truncatePlain(s: string, width: number): string` — truncates a plain string with `…` if it exceeds `width`.
90
+
91
+ ### Panel & Layout
92
+
93
+ - `panel(title, lines, width, accent, borderAccent, height?): string[]` — draws a rounded panel box of exactly `width` columns. Returns an array of strings (one per line).
94
+ - `hstack(blocks, gap?): string[]` — joins equal-or-uneven-height panel blocks side by side with a gap.
95
+ - `statRow(label, value, width?): string` — renders a label/value pair with dim label.
96
+ - `gaugeRow(label, percent, contentWidth): string` — renders a horizontal bar gauge.
97
+ - `bar(percent, width?): string` — renders a filled/empty block bar coloured by threshold.
98
+
99
+ ### Colour Helpers
100
+
101
+ - `thermalColor(pressureLevel: number): Chalk` — maps 0–3 pressure level to green/yellow/yellowBright/red.
102
+ - `capacityColor(percent: number): Chalk` — maps battery capacity to red (<80%)/yellow (<90%)/green.
103
+ - `formatBytes(bytes: number): string` — converts bytes to human-readable `B`/`KB`/`MB`/`GB`.
104
+ - `celsiusToFahrenheit(c: number): number` — converts °C to °F.
105
+ - `formatTemp(c: number): string` — renders `"62.0°C / 143.6°F"`.
106
+
107
+ ### Card Renderers (each returns `string[] | null`)
108
+
109
+ - `cpuCard(data, contentWidth)` — brand, cores, frequency, usage gauge, load avg, optional temp.
110
+ - `memCard(data, contentWidth)` — total, used, cached, free, usage gauge, swap.
111
+ - `gpuCard(data, contentWidth)` — model, memory, utilization, optional temp, process count. Returns `null` if no GPU data.
112
+ - `powerCard(data)` — CPU/GPU/combined watts, battery watts if discharging. Returns `null` if no power data.
113
+ - `batteryCard(data, contentWidth)` — level/state, time remaining, cycles, condition, capacity, source, estimated empty, discharge rate. Returns `null` if no battery data.
114
+ - `thermalCard(data, contentWidth)` — state (coloured by pressure), per-sensor temps, optional error.
115
+ - `networkCard(data, contentWidth)` — interface name/IP, RX/TX bytes and packets.
116
+ - `packetCard(data, contentWidth)` — total/rx/tx packets, connections, top process, interface count. Returns `null` if no packet data.
117
+ - `tasksCard(data, contentWidth)` — up to 6 tasks with pid, user, cpu%, mem%, state, command. Returns `null` if no tasks.
118
+
119
+ ### Table Renderers
120
+
121
+ - `processTableLines(processes, contentWidth, borderAccent?): string[]` — renders a fixed-column process table with PID, PPID, USER, CPU%, MEM%, STATE, THREADS, RUNTIME, COMMAND columns. Colour-codes CPU% and MEM% by threshold.
122
+ - `diskTableLines(disks, contentWidth, borderAccent?): string[]` — renders a filesystem table with FILESYSTEM, SIZE, USED, AVAIL, CAP, MOUNT columns. Colour-codes capacity by threshold.
123
+ - `buildProcessTree(processes): string[]` — renders a tree view of processes using Unicode box-drawing characters, sorted by CPU usage within each subtree.
124
+
125
+ ### Tab Bar
126
+
127
+ - `TAB_DEFS` — canonical array of 12 tab definitions: `{ id, label, key }` mapping panel IDs to display labels and keyboard shortcuts (1–9, 0, P, R).
128
+ - `TAB_BAR_ROW` — constant `4` (1-indexed terminal row where the tab bar renders).
129
+ - `getTabHitboxes(): { id, start, end }[]` — computes column ranges for each tab so mouse clicks can be mapped back to panel IDs.
130
+ - `tabBar(activePanel, width, theme): string` — renders the tab bar line with active tab bold and inactive tabs dim.
131
+
132
+ ### Header
133
+
134
+ - `header(data, width, badges?, borderAccent?): string[]` — renders the top header line with PYRE branding, hostname, OS, uptime, current time, and optional badges.
135
+
136
+ ### Main Orchestrator
137
+
138
+ - `formatTable(data, opts?): string` — the primary entry point for dashboard rendering.
139
+ 1. Clamps width via `clampWidth`.
140
+ 2. If `activePanel !== 'grid'`, renders a single detail panel for that tab.
141
+ 3. If `activePanel === 'grid'`, lays out visible cards in a grid (1–3 columns depending on width via `gridColumns`), then optionally appends Disk and Process panels.
142
+ 4. Process panel supports `filter`, `sortBy`, `processLimit`, and `treeView` options.
143
+
144
+ ### Exported Helpers
145
+
146
+ - `clampWidth(width?): number` — clamps terminal width to 60–240, defaulting to 80.
147
+ - `gridColumns(width: number): number` — returns 1/2/3 cards per row based on width thresholds (≥150→3, ≥96→2, else 1).
148
+
149
+ ## `output.ts` — Data Export & Graphs
150
+
151
+ ### Export Functions
152
+
153
+ - `formatJson(data: StatsData): string` — serialises `StatsData` to pretty-printed JSON (2-space indent).
154
+ - `formatCsv(data: StatsData): string` — serialises `StatsData` to CSV with a `property,key,value` header row. Includes all fields from cpu, memory, thermal, network, battery, power, system, packets (if present), gpu (if present), and every process (pid, ppid, user, cpu, mem, state, threads, runtime, command). String values are double-quote-escaped.
155
+ - `formatTsv(data: StatsData): string` — converts CSV output to TSV by replacing commas with tabs.
156
+
157
+ ### Graph Rendering
158
+
159
+ - `formatGraphs(history, width?, themeName?, graphMode?): string` — renders a panel of sparkline/bar graphs for CPU, memory, temperature, network RX/TX, power, and packet rates. Uses `panel()` for the border and `graphRow()` for each metric line.
160
+
161
+ ### Internal Helpers
162
+
163
+ - `graphRow(label, values, bounds, fmt, sparkWidth?, emptyMessage?, mode?): string` — renders a single graph line with label, sparkline/bar, and current value. Colour-codes by percentage of the bounds range.
164
+ - `barGraph(values, bounds, width): string` — renders a bar-graph using Unicode block characters (`█` `▇` `▆` `▄` ` `).
@@ -104,21 +104,21 @@ export function formatGraphs(history: History, width = 80, themeName: ThemeName
104
104
  const sparkWidth = Math.max(10, contentWidth - 28);
105
105
 
106
106
  const lines: string[] = [];
107
- lines.push(graphRow('CPU %', history.cpuUsage, { min: 0, max: 100 }, v => `${v.toFixed(0)}%`, sparkWidth, graphMode));
108
- lines.push(graphRow('Mem %', history.memUsage, { min: 0, max: 100 }, v => `${v.toFixed(0)}%`, sparkWidth, graphMode));
107
+ lines.push(graphRow('CPU %', history.cpuUsage, { min: 0, max: 100 }, v => `${v.toFixed(0)}%`, sparkWidth, 'collecting data...', graphMode));
108
+ lines.push(graphRow('Mem %', history.memUsage, { min: 0, max: 100 }, v => `${v.toFixed(0)}%`, sparkWidth, 'collecting data...', graphMode));
109
109
  lines.push(
110
110
  graphRow('Temp', history.temp, {}, formatTemp, sparkWidth, 'no sensor access (needs sudo powermetrics)', graphMode)
111
111
  );
112
- lines.push(graphRow('Net RX/s', history.netRxRate, {}, v => `${formatBytes(v)}/s`, sparkWidth, graphMode));
113
- lines.push(graphRow('Net TX/s', history.netTxRate, {}, v => `${formatBytes(v)}/s`, sparkWidth, graphMode));
112
+ lines.push(graphRow('Net RX/s', history.netRxRate, {}, v => `${formatBytes(v)}/s`, sparkWidth, 'collecting data...', graphMode));
113
+ lines.push(graphRow('Net TX/s', history.netTxRate, {}, v => `${formatBytes(v)}/s`, sparkWidth, 'collecting data...', graphMode));
114
114
  if (history.powerWatts.length) {
115
- lines.push(graphRow('Power W', history.powerWatts, {}, v => `${v.toFixed(1)} W`, sparkWidth, graphMode));
115
+ lines.push(graphRow('Power W', history.powerWatts, {}, v => `${v.toFixed(1)} W`, sparkWidth, 'collecting data...', graphMode));
116
116
  }
117
117
  if (history.rxPacketRate.length) {
118
- lines.push(graphRow('RX pkt/s', history.rxPacketRate, {}, v => `${v.toFixed(0)} pkt/s`, sparkWidth, graphMode));
118
+ lines.push(graphRow('RX pkt/s', history.rxPacketRate, {}, v => `${v.toFixed(0)} pkt/s`, sparkWidth, 'collecting data...', graphMode));
119
119
  }
120
120
  if (history.txPacketRate.length) {
121
- lines.push(graphRow('TX pkt/s', history.txPacketRate, {}, v => `${v.toFixed(0)} pkt/s`, sparkWidth, graphMode));
121
+ lines.push(graphRow('TX pkt/s', history.txPacketRate, {}, v => `${v.toFixed(0)} pkt/s`, sparkWidth, 'collecting data...', graphMode));
122
122
  }
123
123
 
124
124
  const title = `Graphs · mode:${graphMode} · last ${sampleCount} sample${sampleCount === 1 ? '' : 's'}`;
@@ -151,8 +151,14 @@ function graphRow(
151
151
  }
152
152
 
153
153
  function barGraph(values: number[], bounds: { min?: number; max?: number }, width: number): string {
154
- const min = bounds.min ?? Math.min(...values);
155
- const max = bounds.max ?? Math.max(...values);
154
+ let min = bounds.min ?? Infinity;
155
+ let max = bounds.max ?? -Infinity;
156
+ if (bounds.min === undefined || bounds.max === undefined) {
157
+ for (const v of values) {
158
+ if (v < min) min = v;
159
+ if (v > max) max = v;
160
+ }
161
+ }
156
162
  const range = max - min || 1;
157
163
  return values.map(v => {
158
164
  const pct = (v - min) / range;
@@ -56,7 +56,7 @@ export function panel(
56
56
  }
57
57
 
58
58
  /** Join equal-or-uneven-height panel blocks side by side with a gap. */
59
- function hstack(blocks: string[][], gap = 2): string[] {
59
+ function hstack(blocks: string[][], gap = 2, targetWidth?: number): string[] {
60
60
  if (!blocks.length) return [];
61
61
  const height = Math.max(...blocks.map(b => b.length));
62
62
  const widths = blocks.map(b => visLen(b[0] ?? ''));
@@ -68,7 +68,12 @@ function hstack(blocks: string[][], gap = 2): string[] {
68
68
  });
69
69
  const out: string[] = [];
70
70
  for (let row = 0; row < height; row++) {
71
- out.push(padded.map(a => a[row]).join(' '.repeat(gap)));
71
+ let line = padded.map(a => a[row]).join(' '.repeat(gap));
72
+ if (targetWidth !== undefined) {
73
+ const pad = Math.max(0, targetWidth - visLen(line));
74
+ line += ' '.repeat(pad);
75
+ }
76
+ out.push(line);
72
77
  }
73
78
  return out;
74
79
  }
@@ -393,36 +398,54 @@ function processTableLines(
393
398
  return `${hrs}h ${mins % 60}m`;
394
399
  }
395
400
 
396
- function buildProcessTree(
401
+ function buildProcessTree(
397
402
  processes: { pid: number; ppid: number; user: string; cpu: number; mem: number; command: string; state: string; threads: number; runtime: number }[]
398
- ): string[] {
403
+ ): string[] {
399
404
  const map = new Map<number, typeof processes[0]>();
400
405
  const roots: typeof processes[0][] = [];
401
406
  for (const p of processes) map.set(p.pid, p);
402
407
  for (const p of processes) {
403
408
  if (p.ppid === 0 || !map.has(p.ppid)) roots.push(p);
404
409
  }
405
- const lines: string[] = [];
406
- const visited = new Set<number>();
407
- function renderNode(proc: typeof processes[0], prefix: string, isLast: boolean) {
408
- if (visited.has(proc.pid)) return;
409
- visited.add(proc.pid);
410
- const connector = isLast ? '└── ' : '├── ';
411
- const pidStr = String(proc.pid).padEnd(8);
412
- const cpuStr = pctColor(proc.cpu)(`${proc.cpu.toFixed(1)}%`.padEnd(8));
413
- const memStr = pctColor(proc.mem)(`${proc.mem.toFixed(1)}%`.padEnd(8));
414
- const stateStr = chalk.dim(proc.state.padEnd(8));
415
- const thrStr = String(proc.threads).padEnd(6);
416
-
417
- const cmd = truncatePlain(proc.command, Math.max(10, 100 - pidStr.length - cpuStr.length - memStr.length - stateStr.length - thrStr.length - 10));
418
- lines.push(`${prefix}${connector}${pidStr}${cpuStr}${memStr}${stateStr}${thrStr} ${cmd}`);
419
- const children = processes.filter(p => p.ppid === proc.pid && !visited.has(p.pid));
420
- children.sort((a, b) => b.cpu - a.cpu);
421
- children.forEach((child, i) => {
422
- const nextPrefix = prefix + (isLast ? ' ' : '│ ');
423
- renderNode(child, nextPrefix, i === children.length - 1);
424
- });
410
+
411
+ const childrenMap = new Map<number, typeof processes[0][]>();
412
+ for (const p of processes) {
413
+ const list = childrenMap.get(p.ppid) || [];
414
+ list.push(p);
415
+ childrenMap.set(p.ppid, list);
425
416
  }
417
+ for (const list of childrenMap.values()) {
418
+ list.sort((a, b) => b.cpu - a.cpu);
419
+ }
420
+
421
+ const lines: string[] = [];
422
+ const visited = new Set<number>();
423
+ function renderNode(proc: typeof processes[0], prefix: string, isLast: boolean) {
424
+ if (visited.has(proc.pid)) return;
425
+ visited.add(proc.pid);
426
+ const connector = isLast ? '└── ' : '├── ';
427
+ const pidStr = String(proc.pid).padEnd(8);
428
+ const cpuStr = pctColor(proc.cpu)(`${proc.cpu.toFixed(1)}%`.padEnd(8));
429
+ const memStr = pctColor(proc.mem)(`${proc.mem.toFixed(1)}%`.padEnd(8));
430
+ const stateStr = chalk.dim(proc.state.padEnd(8));
431
+ const thrStr = String(proc.threads).padEnd(6);
432
+
433
+ const cmd = truncatePlain(proc.command, Math.max(10, 100 - pidStr.length - cpuStr.length - memStr.length - stateStr.length - thrStr.length - 10));
434
+ lines.push(`${prefix}${connector}${pidStr}${cpuStr}${memStr}${stateStr}${thrStr} ${cmd}`);
435
+ const children = childrenMap.get(proc.pid);
436
+ if (children) {
437
+ let idx = 0;
438
+ let total = 0;
439
+ for (const c of children) {
440
+ if (!visited.has(c.pid)) total++;
441
+ }
442
+ for (const child of children) {
443
+ if (visited.has(child.pid)) continue;
444
+ const nextPrefix = prefix + (isLast ? ' ' : '│ ');
445
+ renderNode(child, nextPrefix, ++idx === total);
446
+ }
447
+ }
448
+ }
426
449
  roots.sort((a, b) => b.cpu - a.cpu);
427
450
  roots.forEach((root, i) => renderNode(root, '', i === roots.length - 1));
428
451
  return lines;
@@ -478,8 +501,10 @@ function detailPanelTitle(panel: string): string {
478
501
  return map[panel] || panel;
479
502
  }
480
503
 
481
- function activeDetailLines(data: StatsData, activePanel: string, width: number): string[] | null {
504
+ function activeDetailLines(data: StatsData, activePanel: string, width: number, opts: TableOptions = {}): string[] | null {
482
505
  const contentWidth = width - 4;
506
+ const themeName = opts.theme || 'default';
507
+ const theme = THEMES[themeName] || THEMES.default;
483
508
  switch (activePanel) {
484
509
  case 'cpu':
485
510
  return cpuCard(data, contentWidth);
@@ -529,10 +554,17 @@ function activeDetailLines(data: StatsData, activePanel: string, width: number):
529
554
  case 'tasks':
530
555
  return data.tasks?.length ? tasksCard(data, contentWidth) : ['No task data available'];
531
556
  case 'disk':
532
- return data.disk.length ? diskTableLines(data.disk, width - 4, THEMES.default.border) : ['No disk data available'];
557
+ return data.disk.length ? diskTableLines(data.disk, width - 4, theme.border) : ['No disk data available'];
533
558
  case 'process': {
534
- const sorted = sortProcesses([...data.processes], 'cpu');
535
- return processTableLines(sorted.slice(0, 50), width - 4, THEMES.default.border);
559
+ const filtered = opts.filter
560
+ ? data.processes.filter(p => p.command.toLowerCase().includes(opts.filter!.toLowerCase()))
561
+ : data.processes;
562
+ const sorted = sortProcesses(filtered, opts.sortBy ?? 'cpu');
563
+ const limited = opts.processLimit ? sorted.slice(0, opts.processLimit) : sorted;
564
+ if (opts.treeView) {
565
+ return buildProcessTree(limited);
566
+ }
567
+ return processTableLines(limited, width - 4, theme.border);
536
568
  }
537
569
  default:
538
570
  return null;
@@ -558,7 +590,7 @@ export function formatTable(data: StatsData, opts: TableOptions = {}): string {
558
590
  out.push('');
559
591
 
560
592
  if (activePanel !== 'grid') {
561
- const lines = activeDetailLines(data, activePanel, width);
593
+ const lines = activeDetailLines(data, activePanel, width, opts);
562
594
  if (lines) {
563
595
  out.push(...panel(detailPanelTitle(activePanel), lines, width, theme[activePanel as keyof ThemeColors] ?? theme.cpu, theme.border));
564
596
  }
@@ -601,13 +633,13 @@ export function formatTable(data: StatsData, opts: TableOptions = {}): string {
601
633
  if (tasks) cards.push({ title: 'Tasks', accent: theme.process, lines: tasks });
602
634
  }
603
635
 
604
- for (let i = 0; i < cards.length; i += columns) {
605
- const row = cards.slice(i, i + columns);
606
- const rowHeight = Math.max(...row.map(c => c.lines.length));
607
- const blocks = row.map(c => panel(c.title, c.lines, cardWidth, c.accent, theme.border, rowHeight));
608
- out.push(...hstack(blocks, gap));
609
- out.push('');
610
- }
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
+ }
611
643
 
612
644
  if (vis.disk !== false && data.disk.length) {
613
645
  const dw = width - 4;
package/src/history.ts CHANGED
@@ -16,6 +16,9 @@ export class History {
16
16
  /** Maximum number of samples retained in the rolling window. */
17
17
  maxLen: number;
18
18
 
19
+ /** Monotonically increasing version, bumped on every push. */
20
+ version = 0;
21
+
19
22
  /** Rolling window of CPU usage percentages (0–100). */
20
23
  cpuUsage: number[] = [];
21
24
 
@@ -113,6 +116,8 @@ export class History {
113
116
  if (sample.rxPackets !== undefined) this.pushBounded(this.rxPacketRate, rxPktRate);
114
117
  if (sample.txPackets !== undefined) this.pushBounded(this.txPacketRate, txPktRate);
115
118
  if (sample.connections !== undefined) this.pushBounded(this.connections, sample.connections);
119
+
120
+ this.version++;
116
121
  }
117
122
 
118
123
  /** Clear all rolling windows and reset cumulative byte counters. */
@@ -132,6 +137,7 @@ export class History {
132
137
  this.lastRxPackets = 0;
133
138
  this.lastTxPackets = 0;
134
139
  this.lastTs = 0;
140
+ this.version = 0;
135
141
  }
136
142
 
137
143
  /**
package/src/index.ts CHANGED
@@ -254,7 +254,26 @@ async function runP2PConnect(): Promise<void> {
254
254
  try {
255
255
  await client.connect();
256
256
  } catch (err: any) {
257
- console.log(chalk.red(`Connection failed: ${err.message}`));
257
+ const code = (err as any).code;
258
+ const msg = err.message || String(err);
259
+ console.log(chalk.red(`Connection failed: ${msg}`));
260
+ if (code === 'ECONNREFUSED') {
261
+ console.log(chalk.yellow(' Hints:'));
262
+ console.log(chalk.dim(' 1. Start the server: pyre p2p server --p2p-host 0.0.0.0 --p2p-port ' + port + ' --p2p-password <password>'));
263
+ console.log(chalk.dim(' 2. Check the server IP with: hostname -I'));
264
+ console.log(chalk.dim(' 3. Open the port on the server firewall: sudo ufw allow ' + port + '/tcp'));
265
+ } else if (code === 'ETIMEDOUT') {
266
+ console.log(chalk.yellow(' Hints:'));
267
+ console.log(chalk.dim(' 1. Verify the server IP is correct'));
268
+ console.log(chalk.dim(' 2. Check that both machines are on the same network/subnet'));
269
+ console.log(chalk.dim(' 3. Open the port on the server firewall'));
270
+ } else if (code === 'EHOSTDOWN' || code === 'EHOSTUNREACH') {
271
+ console.log(chalk.yellow(' Hints:'));
272
+ console.log(chalk.dim(' 1. The server at ' + host + ' is not responding'));
273
+ console.log(chalk.dim(' 2. Start the server first: pyre p2p server --p2p-host 0.0.0.0 --p2p-port ' + port + ' --p2p-password <password>'));
274
+ console.log(chalk.dim(' 3. Check connectivity: ping ' + host));
275
+ console.log(chalk.dim(' 4. Ensure both machines are on the same LAN (ping each other first)'));
276
+ }
258
277
  process.exit(1);
259
278
  }
260
279