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.
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')
@@ -51,14 +61,15 @@ program
51
61
  .option('--p2p-allow <ips>', 'Comma-separated list of allowed IPs (empty = all)')
52
62
  .option('--p2p-deny <ips>', 'Comma-separated list of denied IPs')
53
63
  .option('--p2p-audit-log <dir>', 'Directory for P2P audit logs')
54
- .option('--p2p-hmac-key <key>', 'HMAC key for message signing (default: derived from password)');
64
+ .option('--p2p-hmac-key <key>', 'HMAC key for message signing (default: derived from password)')
65
+ .option('--port <port>', 'Port number for web server mode', '3000');
55
66
 
56
67
  program.parse(process.argv);
57
68
 
58
69
  const opts = program.opts();
59
70
 
60
71
  function isExportMode() {
61
- return opts.json || opts.csv || opts.tsv;
72
+ return opts.json || opts.csv || opts.tsv || opts.html || opts.md;
62
73
  }
63
74
 
64
75
  function stripAnsi(s: string): string {
@@ -112,23 +123,82 @@ async function runServerCommand(): Promise<void> {
112
123
  async function main() {
113
124
  const cmd = program.args[0];
114
125
 
126
+ if (cmd === 'info') {
127
+ await runInfoCommand();
128
+ return;
129
+ }
130
+
131
+ if (cmd === 'ssh') {
132
+ const host = program.args[1];
133
+ if (!host) {
134
+ console.log(chalk.red('Usage: pyre ssh <host>'));
135
+ process.exit(1);
136
+ }
137
+ await runSshCommand(host);
138
+ return;
139
+ }
140
+
141
+ if (cmd === 'web') {
142
+ await runWebCommand();
143
+ return;
144
+ }
145
+
146
+ if (cmd === 'bench') {
147
+ const benchCmd = program.args.slice(1).join(' ');
148
+ if (!benchCmd) {
149
+ console.log(chalk.red('Usage: pyre bench <command>'));
150
+ process.exit(1);
151
+ }
152
+ await runBenchCommand(benchCmd);
153
+ return;
154
+ }
155
+
156
+ if (cmd === 'config') {
157
+ const sub = program.args[1];
158
+ if (sub === 'show') {
159
+ const cfg = readConfig();
160
+ console.log(JSON.stringify(cfg, null, 2));
161
+ } else if (sub === 'reset') {
162
+ fs.rmSync(CONFIG_FILE, { force: true });
163
+ console.log(chalk.green('Config reset to defaults'));
164
+ } else {
165
+ console.log('Usage: pyre config <show|reset>');
166
+ }
167
+ return;
168
+ }
169
+
115
170
  if (cmd === 'server') {
116
171
  await runServerCommand();
117
172
  return;
118
173
  }
119
174
 
175
+ const alertCpu = opts.alertCpu ? parseInt(opts.alertCpu, 10) : undefined;
176
+ const alertTemp = opts.alertTemp ? parseInt(opts.alertTemp, 10) : undefined;
177
+ const tempUnit = opts.tempUnit === 'f' ? 'f' : 'c';
178
+
120
179
  if (cmd === 'live' || (!isExportMode() && !opts.once && cmd !== 'p2p')) {
180
+ if (!process.stdout.isTTY && cmd !== 'live') {
181
+ await runWebCommand();
182
+ return;
183
+ }
121
184
  if (cmd === 'live') {
122
185
  program.args.shift();
123
186
  }
124
- const splashPromise = showSplash();
187
+ const splashPromise = showSplash({
188
+ enabled: config.splashEnabled,
189
+ colorScheme: config.splashColorScheme as any,
190
+ animation: config.splashAnimation as any,
191
+ });
125
192
  const interval = parseFloat(opts.interval) || 2;
126
193
  await startLive({
127
194
  interval,
128
- detailed: opts.detailed,
129
- theme: opts.theme,
130
- exportDir: opts.exportDir,
131
- autoLog: opts.log,
195
+ detailed: opts.detailed ?? config.detailed,
196
+ theme: opts.theme || config.theme,
197
+ exportDir: opts.exportDir || config.exportDir,
198
+ autoLog: opts.log || config.autoLog,
199
+ alertCpu,
200
+ alertTemp,
201
+ tempUnit,
132
202
  }, splashPromise);
133
203
  return;
134
204
  }
@@ -156,9 +226,11 @@ async function main() {
156
226
 
157
227
  let output: string;
158
228
  if (opts.json) output = formatJson(data);
229
+ else if (opts.html) output = formatHtml(data);
230
+ else if (opts.md) output = formatMarkdown(data);
159
231
  else if (opts.csv) output = formatCsv(data);
160
232
  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 } });
233
+ else output = formatTable(data, { width: process.stdout.columns || 80, sortBy: opts.sort, treeView: opts.tree ?? config.treeView, visible: { packets: opts.packets ? true : undefined } });
162
234
 
163
235
  console.log(output);
164
236
 
@@ -363,4 +435,145 @@ async function runP2PConnect(): Promise<void> {
363
435
  process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
364
436
  process.exit(0);
365
437
  });
438
+ }
439
+
440
+ async function runInfoCommand(): Promise<void> {
441
+ const data = await collectAll({ detailed: true });
442
+ const lines: string[] = [];
443
+ lines.push(chalk.bold(' pyre info'));
444
+ lines.push('');
445
+ lines.push(` Hostname: ${data.header.hostname}`);
446
+ lines.push(` OS: ${data.header.os}`);
447
+ lines.push(` Uptime: ${data.header.uptime}`);
448
+ lines.push(` CPU: ${data.cpu.brand}`);
449
+ lines.push(` Cores: ${data.cpu.physicalCores}/${data.cpu.cores} (phys/log)`);
450
+ lines.push(` Frequency: ${data.cpu.frequency} MHz`);
451
+ lines.push(` Memory: ${formatBytes(data.memory.total)}`);
452
+ if (data.gpu) lines.push(` GPU: ${data.gpu.model} (${formatBytes(data.gpu.memory)})`);
453
+ if (data.battery) {
454
+ lines.push(` Battery: ${data.battery.level}% (${data.battery.state})`);
455
+ lines.push(` Health: ${data.battery.health}`);
456
+ }
457
+ lines.push(` Thermal: ${data.thermal.state}`);
458
+ console.log(lines.join('\n'));
459
+ }
460
+
461
+ async function runSshCommand(host: string): Promise<void> {
462
+ const password = opts.p2pPassword || 'mysecret';
463
+ const cmd = `ssh -o LogLevel=QUIET ${host} "pyre --json --once"`;
464
+ console.log(chalk.bold(`\n pyre ssh — ${host}`));
465
+ console.log(chalk.dim(` Running: ${cmd}\n`));
466
+
467
+ const { execFile } = await import('node:child_process');
468
+ const child = execFile('ssh', ['-o', 'LogLevel=QUIET', host, 'pyre', '--json', '--once'], (err, stdout, stderr) => {
469
+ if (err) {
470
+ console.log(chalk.red(` SSH failed: ${err.message}`));
471
+ console.log(chalk.dim(' Ensure SSH keys are set up or the host is reachable.'));
472
+ process.exit(1);
473
+ }
474
+ if (stderr) console.log(chalk.dim(stderr));
475
+ try {
476
+ const remote = JSON.parse(stdout);
477
+ console.log(formatTable(remote, { width: process.stdout.columns || 80, sortBy: 'cpu', treeView: false, visible: { packets: true } }));
478
+ } catch {
479
+ console.log(stdout);
480
+ }
481
+ process.exit(0);
482
+ });
483
+ child.stdin?.end();
484
+
485
+ process.once('SIGINT', () => {
486
+ child.kill('SIGTERM');
487
+ process.exit(0);
488
+ });
489
+ }
490
+
491
+ async function runWebCommand(): Promise<void> {
492
+ const http = await import('node:http');
493
+ const port = parseInt(opts.port || process.env.PORT || '3000', 10) || 3000;
494
+
495
+ const server = http.createServer(async (req, res) => {
496
+ const url = req.url || '/';
497
+ if (url === '/' || url === '/index.html') {
498
+ res.writeHead(200, { 'Content-Type': 'text/html' });
499
+ const indexPath = path.join(process.cwd(), 'index.html');
500
+ let htmlContent = '';
501
+ if (fs.existsSync(indexPath)) {
502
+ htmlContent = fs.readFileSync(indexPath, 'utf-8');
503
+ } else {
504
+ const data = await collectAll({ detailed: true });
505
+ htmlContent = formatHtml(data);
506
+ }
507
+ res.end(htmlContent);
508
+ } else if (url === '/api' || url === '/api/stats' || url === '/data') {
509
+ const data = await collectAll({ detailed: true });
510
+ res.writeHead(200, {
511
+ 'Content-Type': 'application/json',
512
+ 'Access-Control-Allow-Origin': '*',
513
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
514
+ });
515
+ res.end(JSON.stringify(data, null, 2));
516
+ } else {
517
+ res.writeHead(404);
518
+ res.end('Not found');
519
+ }
520
+ });
521
+
522
+ server.listen(port, '0.0.0.0', () => {
523
+ console.log(chalk.bold(`\n pyre web server running on port ${port}`));
524
+ console.log(chalk.dim(` Dashboard: http://0.0.0.0:${port}/`));
525
+ console.log(chalk.dim(` API: http://0.0.0.0:${port}/api`));
526
+ console.log(chalk.dim(' Press Ctrl+C to stop\n'));
527
+ });
528
+
529
+ process.once('SIGINT', () => {
530
+ server.close();
531
+ process.exit(0);
532
+ });
533
+ }
534
+
535
+ async function runBenchCommand(benchCmd: string): Promise<void> {
536
+ const { spawn } = await import('node:child_process');
537
+ const logDir = opts.exportDir || './pyre-exports';
538
+ const file = path.join(logDir, `pyre-bench-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`);
539
+
540
+ if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
541
+ const stream = fs.createWriteStream(file, { flags: 'a' });
542
+ 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');
543
+
544
+ console.log(chalk.bold(`\n pyre bench`));
545
+ console.log(chalk.dim(` Command: ${benchCmd}`));
546
+ console.log(chalk.dim(` Log: ${file}\n`));
547
+
548
+ const child = spawn('sh', ['-c', benchCmd]);
549
+ child.stdout.on('data', d => process.stdout.write(d));
550
+ child.stderr.on('data', d => process.stdout.write(d));
551
+
552
+ const interval = parseFloat(opts.interval) || 2;
553
+ const handle = setInterval(async () => {
554
+ try {
555
+ const data = await collectAll({ detailed: opts.detailed });
556
+ const temp = data.cpu.temperature ?? data.thermal.temperatures?.cpu_die ?? '';
557
+ const rxPackets = data.network.rxPackets ?? 0;
558
+ const txPackets = data.network.txPackets ?? 0;
559
+ const connections = data.network.connections ?? 0;
560
+ stream.write(`${data.timestamp},${data.cpu.usage},${data.memory.usagePercent},${temp},${data.network.rxBytes},${data.network.txBytes},${rxPackets},${txPackets},${connections},${data.thermal.state}\n`);
561
+ } catch {
562
+ // skip bad tick
563
+ }
564
+ }, interval * 1000);
565
+
566
+ child.on('close', () => {
567
+ clearInterval(handle);
568
+ stream.end();
569
+ console.log(chalk.green(`\n Bench complete. Log saved to ${file}`));
570
+ process.exit(0);
571
+ });
572
+
573
+ process.once('SIGINT', () => {
574
+ clearInterval(handle);
575
+ child.kill('SIGTERM');
576
+ stream.end();
577
+ process.exit(0);
578
+ });
366
579
  }
@@ -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
@@ -13,10 +13,35 @@ import { THEMES, type ThemeName, type VisibleItems, getTabHitboxes, TAB_BAR_ROW
13
13
  import { state, setStatus, getToggleKey, type SplashColorScheme, type SplashAnimation } from './state.js';
14
14
  import { exportSnapshot, startLogging, stopLogging, toggleLogging, writeLogRow } from './export.js';
15
15
  import { render, footerLine, checkAlerts, invalidateTableCache, invalidateFrame } from './render.js';
16
- import type { LiveOptions, ExportFormat, InputMode, SortMode, GraphMode } from './types.js';
16
+ import type { LiveOptions, ExportFormat, InputMode, SortMode, GraphMode, ActivePanel } 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
 
@@ -369,8 +404,9 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
369
404
  return;
370
405
  }
371
406
 
372
- if (key.mouse) {
373
- handleMouseClick(key.mouse);
407
+ const mouse = (key as { mouse?: { x: number; y: number } }).mouse;
408
+ if (mouse) {
409
+ handleMouseClick(mouse.y, mouse.x);
374
410
  return;
375
411
  }
376
412
 
@@ -402,7 +438,7 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
402
438
  return;
403
439
  case '0': case '1': case '2': case '3': case '4':
404
440
  case '5': case '6': case '7': case '8': case '9': {
405
- const tabMap: Record<string, string> = {
441
+ const tabMap: Record<string, ActivePanel> = {
406
442
  '0': 'disk', '1': 'cpu', '2': 'mem', '3': 'gpu', '4': 'power',
407
443
  '5': 'battery', '6': 'thermal', '7': 'network', '8': 'packets', '9': 'tasks'
408
444
  };
@@ -467,6 +503,12 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
467
503
  state.detailed = !state.detailed;
468
504
  setStatus(`Detailed sensor mode: ${state.detailed ? 'on' : 'off'}`);
469
505
  break;
506
+ case 'T':
507
+ state.tempUnit = state.tempUnit === 'c' ? 'f' : 'c';
508
+ setStatus(`Temperature unit: ${state.tempUnit.toUpperCase()}`);
509
+ invalidateTableCache();
510
+ render();
511
+ break;
470
512
  case 's':
471
513
  const sortCycle: SortMode[] = ['cpu', 'mem', 'pid', 'user', 'command', 'state', 'threads', 'runtime'];
472
514
  const curIdx = sortCycle.indexOf(state.sortMode);
@@ -498,7 +540,9 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
498
540
  render();
499
541
  break;
500
542
  case 'f':
501
- state.exportFormat = state.exportFormat === 'json' ? 'csv' : state.exportFormat === 'csv' ? 'tsv' : 'json';
543
+ const fmtCycle: ExportFormat[] = ['json', 'csv', 'tsv', 'html', 'md'];
544
+ const curFmt = fmtCycle.indexOf(state.exportFormat);
545
+ state.exportFormat = fmtCycle[(curFmt + 1) % fmtCycle.length];
502
546
  setStatus(`Export format: ${state.exportFormat}`);
503
547
  render();
504
548
  break;
@@ -515,7 +559,7 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
515
559
  if (str && str.length === 1) {
516
560
  const tabId = tabKeyToId(str);
517
561
  if (tabId) {
518
- state.activePanel = tabId;
562
+ state.activePanel = tabId as ActivePanel;
519
563
  setStatus(`Panel: ${tabId.toUpperCase()}`);
520
564
  render();
521
565
  }
@@ -574,6 +618,7 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
574
618
  }
575
619
 
576
620
  export function stopLive() {
621
+ persistConfig();
577
622
  if (state.intervalHandle) {
578
623
  clearInterval(state.intervalHandle);
579
624
  state.intervalHandle = null;