pyre-cli 4.17.0 → 5.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.
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.
package/src/index.ts CHANGED
@@ -8,14 +8,19 @@
8
8
  */
9
9
  import fs from 'node:fs';
10
10
  import os from 'node:os';
11
+ import path from 'node:path';
11
12
  import readline from 'node:readline';
12
13
  import { Command } from 'commander';
13
14
  import chalk from 'chalk';
14
15
  import { collectAll } from './monitors/index.js';
15
- import { formatTable, formatJson, formatCsv, formatTsv } from './formatters/index.js';
16
+ import { formatTable, formatJson, formatCsv, formatTsv, formatHtml, formatMarkdown, formatBytes } from './formatters/index.js';
16
17
  import { startLive, stopLive } from './live/index.js';
17
18
  import { showSplash } from './splash.js';
18
19
  import { P2PServer, P2PClient } from './p2p/index.js';
20
+ import { readConfig } from './state/config.js';
21
+ import { CONFIG_FILE } from './state/config.js';
22
+
23
+ const config = readConfig();
19
24
 
20
25
  const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
21
26
 
@@ -26,19 +31,24 @@ program
26
31
  .version(pkg.version)
27
32
  .description('Mac system monitoring CLI: interactive live dashboard, stats, graphs, export, packet monitor, battery predictor')
28
33
  .option('-j, --json', 'Output as JSON')
34
+ .option('--html', 'Output as HTML')
35
+ .option('--md', 'Output as Markdown')
29
36
  .option('-c, --csv', 'Output as CSV')
30
37
  .option('-t, --tsv', 'Output as TSV')
31
38
  .option('--detailed', 'Include detailed system info and sensor readings')
32
- .option('--theme <name>', 'Default theme for live mode (default, dracula, cyberpunk, monochrome, nord, gruvbox)', 'default')
33
- .option('--interval <seconds>', 'Refresh interval for live mode', '2')
39
+ .option('--theme <name>', 'Default theme for live mode (default, dracula, cyberpunk, monochrome, nord, gruvbox)', config.theme)
40
+ .option('--interval <seconds>', 'Refresh interval for live mode', String(config.interval))
34
41
  .option('--once', 'Show a single static snapshot instead of live feed')
35
42
  .option('--out <file>', 'Also write the snapshot output to a file (--once/--json/--csv/--tsv modes)')
36
- .option('--export-dir <dir>', 'Directory used for live-mode snapshot exports and logs', './pyre-exports')
43
+ .option('--export-dir <dir>', 'Directory used for live-mode snapshot exports and logs', config.exportDir)
37
44
  .option('--log', 'Start continuous CSV logging immediately when live mode starts')
38
45
  .option('--tree', 'Show process tree view instead of flat list')
39
- .option('--sort <key>', 'Sort processes by: cpu, mem, pid, user, command, state, threads, runtime', 'cpu')
46
+ .option('--sort <key>', 'Sort processes by: cpu, mem, pid, user, command, state, threads, runtime', config.sortMode)
40
47
  .option('--packets', 'Include packet monitor panel in output')
41
48
  .option('--limit <n>', 'Max number of processes to include in --once/--json/--csv/--tsv snapshots (0 = all)', '10')
49
+ .option('--alert-cpu <pct>', 'CPU usage alert threshold (default: 90)', String(config.cpuAlertPct))
50
+ .option('--alert-temp <c>', 'CPU temperature alert threshold in Celsius (default: 95)', String(config.tempAlertC))
51
+ .option('--temp-unit <unit>', 'Temperature display unit: c or f (default: c)', 'c')
42
52
  .option('--p2p-host <host>', 'P2P host address (server: bind address, client: server address)')
43
53
  .option('--p2p-port <port>', 'P2P port number', '9876')
44
54
  .option('--p2p-password <password>', 'Password for P2P authentication')
@@ -58,7 +68,7 @@ program.parse(process.argv);
58
68
  const opts = program.opts();
59
69
 
60
70
  function isExportMode() {
61
- return opts.json || opts.csv || opts.tsv;
71
+ return opts.json || opts.csv || opts.tsv || opts.html || opts.md;
62
72
  }
63
73
 
64
74
  function stripAnsi(s: string): string {
@@ -112,11 +122,59 @@ async function runServerCommand(): Promise<void> {
112
122
  async function main() {
113
123
  const cmd = program.args[0];
114
124
 
125
+ if (cmd === 'info') {
126
+ await runInfoCommand();
127
+ return;
128
+ }
129
+
130
+ if (cmd === 'ssh') {
131
+ const host = program.args[1];
132
+ if (!host) {
133
+ console.log(chalk.red('Usage: pyre ssh <host>'));
134
+ process.exit(1);
135
+ }
136
+ await runSshCommand(host);
137
+ return;
138
+ }
139
+
140
+ if (cmd === 'web') {
141
+ await runWebCommand();
142
+ return;
143
+ }
144
+
145
+ if (cmd === 'bench') {
146
+ const benchCmd = program.args.slice(1).join(' ');
147
+ if (!benchCmd) {
148
+ console.log(chalk.red('Usage: pyre bench <command>'));
149
+ process.exit(1);
150
+ }
151
+ await runBenchCommand(benchCmd);
152
+ return;
153
+ }
154
+
155
+ if (cmd === 'config') {
156
+ const sub = program.args[1];
157
+ if (sub === 'show') {
158
+ const cfg = readConfig();
159
+ console.log(JSON.stringify(cfg, null, 2));
160
+ } else if (sub === 'reset') {
161
+ fs.rmSync(CONFIG_FILE, { force: true });
162
+ console.log(chalk.green('Config reset to defaults'));
163
+ } else {
164
+ console.log('Usage: pyre config <show|reset>');
165
+ }
166
+ return;
167
+ }
168
+
115
169
  if (cmd === 'server') {
116
170
  await runServerCommand();
117
171
  return;
118
172
  }
119
173
 
174
+ const alertCpu = opts.alertCpu ? parseInt(opts.alertCpu, 10) : undefined;
175
+ const alertTemp = opts.alertTemp ? parseInt(opts.alertTemp, 10) : undefined;
176
+ const tempUnit = opts.tempUnit === 'f' ? 'f' : 'c';
177
+
120
178
  if (cmd === 'live' || (!isExportMode() && !opts.once && cmd !== 'p2p')) {
121
179
  if (cmd === 'live') {
122
180
  program.args.shift();
@@ -125,10 +183,13 @@ async function main() {
125
183
  const interval = parseFloat(opts.interval) || 2;
126
184
  await startLive({
127
185
  interval,
128
- detailed: opts.detailed,
129
- theme: opts.theme,
130
- exportDir: opts.exportDir,
131
- autoLog: opts.log,
186
+ detailed: opts.detailed ?? config.detailed,
187
+ theme: opts.theme || config.theme,
188
+ exportDir: opts.exportDir || config.exportDir,
189
+ autoLog: opts.log || config.autoLog,
190
+ alertCpu,
191
+ alertTemp,
192
+ tempUnit,
132
193
  }, splashPromise);
133
194
  return;
134
195
  }
@@ -156,9 +217,11 @@ async function main() {
156
217
 
157
218
  let output: string;
158
219
  if (opts.json) output = formatJson(data);
220
+ else if (opts.html) output = formatHtml(data);
221
+ else if (opts.md) output = formatMarkdown(data);
159
222
  else if (opts.csv) output = formatCsv(data);
160
223
  else if (opts.tsv) output = formatTsv(data);
161
- else output = formatTable(data, { width: process.stdout.columns || 80, sortBy: opts.sort, treeView: opts.tree, visible: { packets: opts.packets ? true : undefined } });
224
+ else output = formatTable(data, { width: process.stdout.columns || 80, sortBy: opts.sort, treeView: opts.tree ?? config.treeView, visible: { packets: opts.packets ? true : undefined } });
162
225
 
163
226
  console.log(output);
164
227
 
@@ -363,4 +426,134 @@ async function runP2PConnect(): Promise<void> {
363
426
  process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
364
427
  process.exit(0);
365
428
  });
429
+ }
430
+
431
+ async function runInfoCommand(): Promise<void> {
432
+ const data = await collectAll({ detailed: true });
433
+ const lines: string[] = [];
434
+ lines.push(chalk.bold(' pyre info'));
435
+ lines.push('');
436
+ lines.push(` Hostname: ${data.header.hostname}`);
437
+ lines.push(` OS: ${data.header.os}`);
438
+ lines.push(` Uptime: ${data.header.uptime}`);
439
+ lines.push(` CPU: ${data.cpu.brand}`);
440
+ lines.push(` Cores: ${data.cpu.physicalCores}/${data.cpu.cores} (phys/log)`);
441
+ lines.push(` Frequency: ${data.cpu.frequency} MHz`);
442
+ lines.push(` Memory: ${formatBytes(data.memory.total)}`);
443
+ if (data.gpu) lines.push(` GPU: ${data.gpu.model} (${formatBytes(data.gpu.memory)})`);
444
+ if (data.battery) {
445
+ lines.push(` Battery: ${data.battery.level}% (${data.battery.state})`);
446
+ lines.push(` Health: ${data.battery.health}`);
447
+ }
448
+ lines.push(` Thermal: ${data.thermal.state}`);
449
+ console.log(lines.join('\n'));
450
+ }
451
+
452
+ async function runSshCommand(host: string): Promise<void> {
453
+ const password = opts.p2pPassword || 'mysecret';
454
+ const cmd = `ssh -o LogLevel=QUIET ${host} "pyre --json --once"`;
455
+ console.log(chalk.bold(`\n pyre ssh — ${host}`));
456
+ console.log(chalk.dim(` Running: ${cmd}\n`));
457
+
458
+ const { execFile } = await import('node:child_process');
459
+ const child = execFile('ssh', ['-o', 'LogLevel=QUIET', host, 'pyre', '--json', '--once'], (err, stdout, stderr) => {
460
+ if (err) {
461
+ console.log(chalk.red(` SSH failed: ${err.message}`));
462
+ console.log(chalk.dim(' Ensure SSH keys are set up or the host is reachable.'));
463
+ process.exit(1);
464
+ }
465
+ if (stderr) console.log(chalk.dim(stderr));
466
+ try {
467
+ const remote = JSON.parse(stdout);
468
+ console.log(formatTable(remote, { width: process.stdout.columns || 80, sortBy: 'cpu', treeView: false, visible: { packets: true } }));
469
+ } catch {
470
+ console.log(stdout);
471
+ }
472
+ process.exit(0);
473
+ });
474
+ child.stdin.end();
475
+
476
+ process.once('SIGINT', () => {
477
+ child.kill('SIGTERM');
478
+ process.exit(0);
479
+ });
480
+ }
481
+
482
+ async function runWebCommand(): Promise<void> {
483
+ const http = await import('node:http');
484
+ const data = await collectAll({ detailed: true });
485
+
486
+ const server = http.createServer((req, res) => {
487
+ if (req.url === '/' || req.url === '/index.html') {
488
+ res.writeHead(200, { 'Content-Type': 'text/html' });
489
+ res.end(formatHtml(data));
490
+ } else if (req.url === '/api') {
491
+ res.writeHead(200, { 'Content-Type': 'application/json' });
492
+ res.end(JSON.stringify(data, null, 2));
493
+ } else {
494
+ res.writeHead(404);
495
+ res.end('Not found');
496
+ }
497
+ });
498
+
499
+ server.listen(0, '127.0.0.1', async () => {
500
+ const address = server.address();
501
+ const port = typeof address === 'object' && address ? address.port : 0;
502
+ const url = `http://127.0.0.1:${port}`;
503
+ console.log(chalk.bold(`\n pyre web`));
504
+ console.log(chalk.dim(` Dashboard: ${url}`));
505
+ console.log(chalk.dim(` API: ${url}/api`));
506
+ console.log(chalk.dim(' Press Ctrl+C to stop\n'));
507
+
508
+ process.once('SIGINT', () => {
509
+ server.close();
510
+ process.exit(0);
511
+ });
512
+ });
513
+ }
514
+
515
+ async function runBenchCommand(benchCmd: string): Promise<void> {
516
+ const { spawn } = await import('node:child_process');
517
+ const logDir = opts.exportDir || './pyre-exports';
518
+ const file = path.join(logDir, `pyre-bench-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`);
519
+
520
+ if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
521
+ const stream = fs.createWriteStream(file, { flags: 'a' });
522
+ stream.write('timestamp,cpu_usage,mem_usage_percent,temp_c,net_rx_bytes,net_tx_bytes,net_rx_packets,net_tx_packets,connections,thermal_state\n');
523
+
524
+ console.log(chalk.bold(`\n pyre bench`));
525
+ console.log(chalk.dim(` Command: ${benchCmd}`));
526
+ console.log(chalk.dim(` Log: ${file}\n`));
527
+
528
+ const child = spawn('sh', ['-c', benchCmd]);
529
+ child.stdout.on('data', d => process.stdout.write(d));
530
+ child.stderr.on('data', d => process.stdout.write(d));
531
+
532
+ const interval = parseFloat(opts.interval) || 2;
533
+ const handle = setInterval(async () => {
534
+ try {
535
+ const data = await collectAll({ detailed: opts.detailed });
536
+ const temp = data.cpu.temperature ?? data.thermal.temperatures?.cpu_die ?? '';
537
+ const rxPackets = data.network.rxPackets ?? 0;
538
+ const txPackets = data.network.txPackets ?? 0;
539
+ const connections = data.network.connections ?? 0;
540
+ stream.write(`${data.timestamp},${data.cpu.usage},${data.memory.usagePercent},${temp},${data.network.rxBytes},${data.network.txBytes},${rxPackets},${txPackets},${connections},${data.thermal.state}\n`);
541
+ } catch {
542
+ // skip bad tick
543
+ }
544
+ }, interval * 1000);
545
+
546
+ child.on('close', () => {
547
+ clearInterval(handle);
548
+ stream.end();
549
+ console.log(chalk.green(`\n Bench complete. Log saved to ${file}`));
550
+ process.exit(0);
551
+ });
552
+
553
+ process.once('SIGINT', () => {
554
+ clearInterval(handle);
555
+ child.kill('SIGTERM');
556
+ stream.end();
557
+ process.exit(0);
558
+ });
366
559
  }
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Live dashboard export and logging helpers.
3
3
  *
4
- * Handles snapshot export (JSON/CSV/TSV), continuous
5
- * CSV logging, and the supporting file-system utilities.
4
+ * Handles snapshot export (JSON/CSV/TSV/HTML/Markdown), continuous
5
+ * CSV logging with rotation, and the supporting file-system utilities.
6
6
  * All state variables are imported from `state.ts`.
7
7
  */
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
- import { formatJson, formatCsv, formatTsv } from '../formatters/index.js';
10
+ import { formatJson, formatCsv, formatTsv, formatHtml, formatMarkdown } from '../formatters/index.js';
11
11
  import { state, setStatus } from './state.js';
12
12
 
13
13
  /** Ensure a directory exists, creating it recursively if needed. */
@@ -27,9 +27,16 @@ function exportSnapshot() {
27
27
  }
28
28
  try {
29
29
  ensureDir(state.exportDir);
30
- const file = path.join(state.exportDir, `pyre-${timestampForFile()}.${state.exportFormat}`);
31
- const content =
32
- state.exportFormat === 'json' ? formatJson(state.lastData) : state.exportFormat === 'csv' ? formatCsv(state.lastData) : formatTsv(state.lastData);
30
+ const ext = state.exportFormat === 'json' ? 'json' : state.exportFormat === 'csv' ? 'csv' : state.exportFormat === 'tsv' ? 'tsv' : state.exportFormat === 'html' ? 'html' : 'md';
31
+ const file = path.join(state.exportDir, `pyre-${timestampForFile()}.${ext}`);
32
+ let content: string;
33
+ switch (state.exportFormat) {
34
+ case 'json': content = formatJson(state.lastData); break;
35
+ case 'csv': content = formatCsv(state.lastData); break;
36
+ case 'tsv': content = formatTsv(state.lastData); break;
37
+ case 'html': content = formatHtml(state.lastData); break;
38
+ case 'md': content = formatMarkdown(state.lastData); break;
39
+ }
33
40
  fs.writeFileSync(file, content);
34
41
  setStatus(`Exported snapshot -> ${file}`);
35
42
  } catch (err: any) {
@@ -40,6 +47,7 @@ function exportSnapshot() {
40
47
  function startLogging() {
41
48
  try {
42
49
  ensureDir(state.exportDir);
50
+ rotateLogs();
43
51
  const file = path.join(state.exportDir, `pyre-log-${timestampForFile()}.csv`);
44
52
  state.logStream = fs.createWriteStream(file, { flags: 'a' });
45
53
  state.logStream.write('timestamp,cpu_usage,mem_usage_percent,temp_c,net_rx_bytes,net_tx_bytes,net_rx_packets,net_tx_packets,connections,thermal_state\n');
@@ -50,6 +58,52 @@ function startLogging() {
50
58
  }
51
59
  }
52
60
 
61
+ const MAX_LOG_AGE_MS = 7 * 24 * 60 * 60 * 1000;
62
+ const MAX_LOG_COUNT = 20;
63
+ const MAX_LOG_BYTES = 50 * 1024 * 1024;
64
+
65
+ function rotateLogs() {
66
+ try {
67
+ const dir = state.exportDir;
68
+ if (!fs.existsSync(dir)) return;
69
+ const files = fs.readdirSync(dir)
70
+ .filter(f => f.startsWith('pyre-log-') && f.endsWith('.csv'))
71
+ .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtimeMs, size: fs.statSync(path.join(dir, f)).size }))
72
+ .sort((a, b) => a.mtime - b.mtime);
73
+
74
+ const now = Date.now();
75
+ for (const f of files) {
76
+ if (now - f.mtime > MAX_LOG_AGE_MS) {
77
+ try { fs.unlinkSync(f.path); } catch { /* ignore */ }
78
+ }
79
+ }
80
+
81
+ const remaining = fs.readdirSync(dir).filter(f => f.startsWith('pyre-log-') && f.endsWith('.csv'));
82
+ if (remaining.length > MAX_LOG_COUNT) {
83
+ const toDelete = remaining.sort().slice(0, remaining.length - MAX_LOG_COUNT);
84
+ for (const f of toDelete) {
85
+ try { fs.unlinkSync(path.join(dir, f)); } catch { /* ignore */ }
86
+ }
87
+ }
88
+
89
+ let totalSize = 0;
90
+ for (const f of remaining) {
91
+ totalSize += fs.statSync(path.join(dir, f)).size;
92
+ }
93
+ if (totalSize > MAX_LOG_BYTES) {
94
+ const sorted = remaining.sort();
95
+ for (const f of sorted) {
96
+ const sz = fs.statSync(path.join(dir, f)).size;
97
+ try { fs.unlinkSync(path.join(dir, f)); } catch { /* ignore */ }
98
+ totalSize -= sz;
99
+ if (totalSize <= MAX_LOG_BYTES * 0.8) break;
100
+ }
101
+ }
102
+ } catch {
103
+ // ignore rotation errors
104
+ }
105
+ }
106
+
53
107
  function stopLogging() {
54
108
  state.logging = false;
55
109
  state.logStream?.end();
@@ -64,7 +118,10 @@ function toggleLogging() {
64
118
 
65
119
  function writeLogRow(data: any) {
66
120
  if (!state.logging || !state.logStream) return;
67
- const temp = data.cpu.temperature ?? data.thermal.temperatures?.cpu_die ?? '';
121
+ let temp = data.cpu.temperature ?? data.thermal.temperatures?.cpu_die ?? '';
122
+ if (temp !== '' && state.tempUnit === 'f') {
123
+ temp = ((temp * 9 / 5) + 32).toFixed(1);
124
+ }
68
125
  const rxPackets = data.network.rxPackets ?? 0;
69
126
  const txPackets = data.network.txPackets ?? 0;
70
127
  const connections = data.network.connections ?? 0;
package/src/live/input.ts CHANGED
@@ -15,8 +15,33 @@ import { exportSnapshot, startLogging, stopLogging, toggleLogging, writeLogRow }
15
15
  import { render, footerLine, checkAlerts, invalidateTableCache, invalidateFrame } from './render.js';
16
16
  import type { LiveOptions, ExportFormat, InputMode, SortMode, GraphMode } from './types.js';
17
17
  import { startP2PServer } from '../p2p/index.js';
18
+ import { writeConfig } from '../state/config.js';
19
+
20
+ function persistConfig() {
21
+ writeConfig({
22
+ theme: state.currentTheme,
23
+ interval: state.interval,
24
+ exportDir: state.exportDir,
25
+ detailed: state.detailed,
26
+ sortMode: state.sortMode,
27
+ treeView: state.treeView,
28
+ graphMode: state.graphMode,
29
+ showGraphs: state.showGraphs,
30
+ autoLog: state.logging,
31
+ mouseEnabled: state.mouseEnabled,
32
+ cpuAlertPct: state.CPU_ALERT_PCT,
33
+ tempAlertC: state.TEMP_ALERT_C,
34
+ p2pPort: state.p2pPort,
35
+ p2pPassword: state.p2pPassword,
36
+ splashEnabled: state.splashEnabled,
37
+ splashColorScheme: state.splashColorScheme,
38
+ splashAnimation: state.splashAnimation,
39
+ visiblePanels: { ...state.visiblePanels },
40
+ notificationsEnabled: state.notificationsEnabled,
41
+ });
42
+ }
18
43
 
19
- function isUpKey(key: readline.Key, str?: string): boolean {
44
+ function isUpKey(key: readline.Key, str?: string): boolean {
20
45
  return key.name === 'up' || str === 'k' || key.sequence === '\x1b[A' || key.sequence === '\x1bOA';
21
46
  }
22
47
  function isDownKey(key: readline.Key, str?: string): boolean {
@@ -59,28 +84,35 @@ function handleInputModeKey(str: string, key: readline.Key) {
59
84
  const curIdx = schemes.indexOf(state.splashColorScheme);
60
85
  state.splashColorScheme = schemes[(curIdx + 1) % schemes.length];
61
86
  setStatus(`Splash color: ${state.splashColorScheme}`);
62
- } else if (selected === 'Splash Animation') {
63
- const animations: SplashAnimation[] = ['classic', 'wave', 'sparks'];
64
- const curIdx = animations.indexOf(state.splashAnimation);
65
- state.splashAnimation = animations[(curIdx + 1) % animations.length];
66
- setStatus(`Splash animation: ${state.splashAnimation}`);
67
- } else {
68
- const toggleKey = getToggleKey(selected);
69
- if (toggleKey) {
70
- if (toggleKey === 'tree') {
71
- state.treeView = !state.treeView;
72
- state.visiblePanels.tree = state.treeView;
73
- } else {
74
- const isVisible = state.visiblePanels[toggleKey] !== false;
75
- state.visiblePanels[toggleKey] = !isVisible;
87
+ } else if (selected === 'Splash Animation') {
88
+ const animations: SplashAnimation[] = ['classic', 'wave', 'sparks'];
89
+ const curIdx = animations.indexOf(state.splashAnimation);
90
+ state.splashAnimation = animations[(curIdx + 1) % animations.length];
91
+ setStatus(`Splash animation: ${state.splashAnimation}`);
92
+ } else if (selected === 'Notifications') {
93
+ state.notificationsEnabled = !state.notificationsEnabled;
94
+ setStatus(`Notifications: ${state.notificationsEnabled ? 'on' : 'off'}`);
95
+ } else if (selected === 'Temperature Unit') {
96
+ state.tempUnit = state.tempUnit === 'c' ? 'f' : 'c';
97
+ setStatus(`Temperature unit: ${state.tempUnit.toUpperCase()}`);
98
+ } else {
99
+ const toggleKey = getToggleKey(selected);
100
+ if (toggleKey) {
101
+ if (toggleKey === 'tree') {
102
+ state.treeView = !state.treeView;
103
+ state.visiblePanels.tree = state.treeView;
104
+ } else {
105
+ const isVisible = state.visiblePanels[toggleKey] !== false;
106
+ state.visiblePanels[toggleKey] = !isVisible;
107
+ }
76
108
  }
77
109
  }
78
- }
79
- invalidateTableCache();
80
- invalidateFrame();
81
- }
82
- render();
83
- return;
110
+ invalidateTableCache();
111
+ invalidateFrame();
112
+ persistConfig();
113
+ }
114
+ render();
115
+ return;
84
116
  }
85
117
 
86
118
  if (state.inputMode === 'signal') {
@@ -310,18 +342,21 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
310
342
  * rendering interval. Idempotent — calling while already
311
343
  * running is a no-op.
312
344
  */
313
- export async function startLive(opts: LiveOptions, splashPromise?: Promise<void>) {
314
- if (state.running) return;
315
- state.running = true;
316
- state.paused = false;
317
- state.detailed = !!opts.detailed;
318
- if (opts.theme) state.currentTheme = opts.theme;
319
- state.interval = opts.interval;
320
- if (opts.exportDir) state.exportDir = opts.exportDir;
321
- state.termWidth = process.stdout.columns || 80;
322
- state.termHeight = process.stdout.rows || 24;
323
- state.history.reset();
324
- state.history.setMaxLen(Math.max(20, Math.min(200, state.termWidth - 30)));
345
+ export async function startLive(opts: LiveOptions, splashPromise?: Promise<void>) {
346
+ if (state.running) return;
347
+ state.running = true;
348
+ state.paused = false;
349
+ state.detailed = !!opts.detailed;
350
+ if (opts.theme) state.currentTheme = opts.theme;
351
+ state.interval = opts.interval;
352
+ if (opts.exportDir) state.exportDir = opts.exportDir;
353
+ if (opts.alertCpu !== undefined) state.CPU_ALERT_PCT = opts.alertCpu;
354
+ if (opts.alertTemp !== undefined) state.TEMP_ALERT_C = opts.alertTemp;
355
+ if (opts.tempUnit) state.tempUnit = opts.tempUnit;
356
+ state.termWidth = process.stdout.columns || 80;
357
+ state.termHeight = process.stdout.rows || 24;
358
+ state.history.reset();
359
+ state.history.setMaxLen(Math.max(20, Math.min(200, state.termWidth - 30)));
325
360
 
326
361
  const warmupPromise = doWarmup();
327
362
 
@@ -467,6 +502,12 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
467
502
  state.detailed = !state.detailed;
468
503
  setStatus(`Detailed sensor mode: ${state.detailed ? 'on' : 'off'}`);
469
504
  break;
505
+ case 'T':
506
+ state.tempUnit = state.tempUnit === 'c' ? 'f' : 'c';
507
+ setStatus(`Temperature unit: ${state.tempUnit.toUpperCase()}`);
508
+ invalidateTableCache();
509
+ render();
510
+ break;
470
511
  case 's':
471
512
  const sortCycle: SortMode[] = ['cpu', 'mem', 'pid', 'user', 'command', 'state', 'threads', 'runtime'];
472
513
  const curIdx = sortCycle.indexOf(state.sortMode);
@@ -498,7 +539,9 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
498
539
  render();
499
540
  break;
500
541
  case 'f':
501
- state.exportFormat = state.exportFormat === 'json' ? 'csv' : state.exportFormat === 'csv' ? 'tsv' : 'json';
542
+ const fmtCycle: ExportFormat[] = ['json', 'csv', 'tsv', 'html', 'md'];
543
+ const curFmt = fmtCycle.indexOf(state.exportFormat);
544
+ state.exportFormat = fmtCycle[(curFmt + 1) % fmtCycle.length];
502
545
  setStatus(`Export format: ${state.exportFormat}`);
503
546
  render();
504
547
  break;
@@ -574,6 +617,7 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
574
617
  }
575
618
 
576
619
  export function stopLive() {
620
+ persistConfig();
577
621
  if (state.intervalHandle) {
578
622
  clearInterval(state.intervalHandle);
579
623
  state.intervalHandle = null;