pyre-cli 8.2.0 → 9.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.
@@ -7,10 +7,13 @@
7
7
  * `state.ts`.
8
8
  */
9
9
  import { execFile, exec } from 'node:child_process';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
10
13
  import chalk from 'chalk';
11
- import { formatTable, formatGraphs, gridColumns, THEMES, type ThemeName, panel, fitVisible } from '../formatters/index.js';
14
+ import { formatTable, formatGraphs, gridColumns, THEMES, type ThemeName, panel, fitVisible, visLen } from '../formatters/index.js';
12
15
  import { detectAnomalies } from '../anomalies.js';
13
- import { state, setStatus, SIGNAL_OPTIONS, getToggleKey } from './state.js';
16
+ import { state, setStatus, SIGNAL_OPTIONS, getToggleKey, MENU_OPTIONS } from './state.js';
14
17
  import type { StatsData } from '../monitors/index.js';
15
18
 
16
19
  function formatTempForUnit(c: number): string {
@@ -48,6 +51,9 @@ function tableCacheParams(): string {
48
51
  return [
49
52
  state.sortMode,
50
53
  state.processFilter || '',
54
+ state.processSelectionIndex,
55
+ state.trackedPid || '',
56
+ state.inspectingProcess ? state.inspectingProcess.pid : '',
51
57
  limit,
52
58
  state.currentTheme,
53
59
  state.activePanel,
@@ -68,8 +74,7 @@ function graphsCacheParams(): string {
68
74
 
69
75
  function processRowBudget(): number {
70
76
  let overlayRows = 0;
71
- if (state.inputMode === 'customizer') overlayRows = state.CUSTOMIZER_OPTIONS.length + 3;
72
- else if (state.inputMode === 'signal') overlayRows = 6;
77
+ if (state.inputMode === 'signal') overlayRows = 6;
73
78
  else if (state.inputMode === 'filter' || state.inputMode === 'kill' || state.inputMode === 'p2p') overlayRows = 3;
74
79
 
75
80
  if (state.activePanel === 'process') {
@@ -198,13 +203,41 @@ function render() {
198
203
  writeFrame(out);
199
204
  return;
200
205
  }
206
+
207
+ if (state.inputMode === 'menu') {
208
+ writeFrame(renderSplashMenu(cols, rows));
209
+ return;
210
+ }
211
+ if (state.inputMode === 'readme') {
212
+ writeFrame(renderReadme(cols, rows));
213
+ return;
214
+ }
215
+ if (state.inputMode === 'credits') {
216
+ writeFrame(renderCredits(cols, rows));
217
+ return;
218
+ }
201
219
  const lines: string[] = [];
202
220
  const theme = THEMES[state.currentTheme] || THEMES.default;
203
221
 
204
222
  const params = tableCacheParams();
205
223
  const anomalies = detectAnomalies(state.lastData, state.history);
224
+ if (anomalies.length > 0) {
225
+ for (const a of anomalies) {
226
+ const now = new Date();
227
+ const alertWithTime = { ...a, timestamp: now };
228
+ const exists = state.anomalyHistory.some(
229
+ h => h.metric === a.metric && Math.abs(h.zScore - a.zScore) < 0.1 && (now.getTime() - (h.timestamp?.getTime() || 0)) < 10000
230
+ );
231
+ if (!exists) {
232
+ state.anomalyHistory.unshift(alertWithTime);
233
+ }
234
+ }
235
+ if (state.anomalyHistory.length > 100) {
236
+ state.anomalyHistory.length = 100;
237
+ }
238
+ }
206
239
  const anomalyCacheKey = anomalies.map(a => `${a.metric}:${a.zScore.toFixed(2)}`).join(',');
207
- const cacheParams = params + '|' + anomalyCacheKey;
240
+ const cacheParams = params + '|' + anomalyCacheKey + '|' + state.anomalyHistory.length;
208
241
  const tableStr = _tableDataDirty || state.lastData !== _cachedTableData || cacheParams !== _cachedTableParams
209
242
  ? (() => {
210
243
  const out = formatTable(state.lastData, {
@@ -222,6 +255,9 @@ function render() {
222
255
  history: state.history,
223
256
  graphMode: state.graphMode,
224
257
  panelLayout: state.panelLayout,
258
+ selectedProcessIndex: state.processSelectionIndex,
259
+ trackedPid: state.trackedPid,
260
+ inspectProcess: state.inspectingProcess,
225
261
  });
226
262
  _cachedTableData = state.lastData;
227
263
  _cachedTableParams = cacheParams;
@@ -265,17 +301,26 @@ function render() {
265
301
  const targetBodyHeight = Math.max(1, state.termHeight - footerHeight);
266
302
 
267
303
  if (state.inputMode === 'customizer') {
268
- const preOverlayCount = bodyLines.length;
269
- const availableForOverlay = targetBodyHeight - preOverlayCount;
270
304
  const totalItems = state.CUSTOMIZER_OPTIONS.length;
271
- const headerLines = 1;
272
- const blankLines = availableForOverlay >= 2 ? 1 : 0;
273
- const maxVisibleItems = Math.max(0, availableForOverlay - headerLines - blankLines);
305
+ const headerLines = 1; // the "UI CUSTOMIZER" header
306
+ const blankLines = 1; // blank separator line
307
+ // Reserve a fixed height for the customizer: show all items if they fit,
308
+ // otherwise guarantee at least 10 visible items (or half the screen).
309
+ const idealOverlayHeight = totalItems + headerLines + blankLines;
310
+ const minOverlayHeight = Math.min(10 + headerLines + blankLines, Math.floor(targetBodyHeight / 2));
311
+ const overlayHeight = Math.min(idealOverlayHeight, Math.max(minOverlayHeight, Math.floor(targetBodyHeight * 0.6)));
312
+ // Truncate table body to make room for the customizer overlay
313
+ const maxBodyForTable = Math.max(3, targetBodyHeight - overlayHeight);
314
+ if (bodyLines.length > maxBodyForTable) {
315
+ bodyLines.length = maxBodyForTable;
316
+ }
317
+ const availableForOverlay = targetBodyHeight - bodyLines.length;
318
+ const maxVisibleItems = Math.max(1, availableForOverlay - headerLines - blankLines);
274
319
  const scrollOffset = totalItems > maxVisibleItems
275
320
  ? Math.max(0, Math.min(state.customizerIndex, totalItems - maxVisibleItems))
276
321
  : 0;
277
322
 
278
- if (blankLines > 0) bodyLines.push('');
323
+ bodyLines.push('');
279
324
  bodyLines.push(...renderCustomizerOverlay(scrollOffset, maxVisibleItems).split('\n'));
280
325
  } else if (state.inputMode === 'filter') {
281
326
  bodyLines.push(chalk.cyan(` Filter processes: ${state.inputBuffer}_`));
@@ -414,4 +459,109 @@ function checkAlerts(data: StatsData) {
414
459
  }
415
460
  }
416
461
 
462
+ function renderSplashMenu(cols: number, rows: number): string[] {
463
+ const out: string[] = [];
464
+ const contentLines: string[] = [];
465
+ const logo = [
466
+ chalk.hex('#ff6004').bold(' ██████╗ ██╗ ██╗██████╗ ███████╗'),
467
+ chalk.hex('#ff8004').bold(' ██╔══██╗╚██╗ ██╔╝██╔══██╗██╔════╝'),
468
+ chalk.hex('#ffa004').bold(' ██████╔╝ ╚████╔╝ ██████╔╝█████╗ '),
469
+ chalk.hex('#ffc004').bold(' ██╔═══╝ ╚██╔╝ ██╔══██╗██╔══╝ '),
470
+ chalk.hex('#ffe004').bold(' ██║ ██║ ██║ ██║███████╗'),
471
+ chalk.hex('#ffff40').bold(' ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝')
472
+ ];
473
+
474
+ const menuTop = Math.max(0, Math.floor(rows / 2) - 8);
475
+ for (let i = 0; i < menuTop; i++) out.push('');
476
+
477
+ const strip = (s: string) => s.replace(/^(\x1b\[[0-9;]*m)+( +)/, '$1');
478
+ for (const line of logo) contentLines.push(strip(line));
479
+ contentLines.push('');
480
+ contentLines.push(strip(chalk.dim(' system monitor v9.0.0')));
481
+ contentLines.push('');
482
+ contentLines.push('');
483
+
484
+ for (let i = 0; i < MENU_OPTIONS.length; i++) {
485
+ const isSelected = i === state.menuSelectionIndex;
486
+ const opt = MENU_OPTIONS[i];
487
+ const prefix = isSelected ? chalk.cyan.bold(' ▶ ') : ' ';
488
+ const text = isSelected ? chalk.bgCyan.black(` ${opt} `) : chalk.dim(` ${opt} `);
489
+ contentLines.push(prefix + text);
490
+ }
491
+
492
+ contentLines.push('');
493
+ contentLines.push('');
494
+ contentLines.push(strip(chalk.dim(' Use ↑/↓ to navigate, Enter to select')));
495
+
496
+ while (contentLines.length < rows) contentLines.push('');
497
+ const maxWidth = Math.max(...contentLines.map(visLen));
498
+ const padding = Math.max(0, Math.floor((cols - maxWidth) / 2));
499
+ const padStr = ' '.repeat(padding);
500
+ for (const line of contentLines) out.push(padStr + fitVisible(line, cols));
501
+ return out;
502
+ }
503
+
504
+ let cachedReadmeLines: string[] | null = null;
505
+
506
+ function getReadmeLines(): string[] {
507
+ if (cachedReadmeLines) return cachedReadmeLines;
508
+ try {
509
+ const __filename = fileURLToPath(import.meta.url);
510
+ const __dirname = path.dirname(__filename);
511
+ const rootDir = path.resolve(__dirname, '..', '..');
512
+ const readmePath = path.join(rootDir, 'README.md');
513
+ const content = fs.readFileSync(readmePath, 'utf8');
514
+ cachedReadmeLines = content.split('\n');
515
+ return cachedReadmeLines;
516
+ } catch (err) {
517
+ return [chalk.red('Failed to load README.md')];
518
+ }
519
+ }
520
+
521
+ function renderReadme(cols: number, rows: number): string[] {
522
+ const lines = getReadmeLines();
523
+ const availableLines = Math.max(1, rows - 2); // Body height of the panel
524
+
525
+ const startIdx = Math.min(state.readmeScrollOffset, Math.max(0, lines.length - availableLines));
526
+ state.readmeScrollOffset = startIdx; // Clamp the state value
527
+
528
+ const content = lines.slice(startIdx, startIdx + availableLines).map(l => {
529
+ let out = l;
530
+ if (out.startsWith('#')) {
531
+ out = chalk.cyan.bold(out);
532
+ } else if (out.startsWith('```')) {
533
+ out = chalk.dim(out);
534
+ } else if (out.startsWith('- ') || out.startsWith('* ')) {
535
+ out = ' ' + chalk.yellow(out.substring(0, 2)) + out.substring(2);
536
+ }
537
+ return out;
538
+ });
539
+
540
+ const out: string[] = [];
541
+ const pct = Math.round((startIdx / Math.max(1, lines.length - availableLines)) * 100);
542
+ const title = lines.length > availableLines
543
+ ? `README (${pct}%)`
544
+ : 'README';
545
+ out.push(...panel(title, content, cols, chalk.cyan, chalk.dim, availableLines));
546
+ return out;
547
+ }
548
+
549
+ function renderCredits(cols: number, rows: number): string[] {
550
+ const out: string[] = [];
551
+ const content = [
552
+ chalk.bold.hex('#ff6004')('Pyre - The Terminal System Monitor'),
553
+ '',
554
+ 'Created with ❤️ using Node.js',
555
+ '',
556
+ chalk.cyan('Libraries used:'),
557
+ ' - chalk (Terminal styling)',
558
+ ' - systeminformation (System metrics)',
559
+ '',
560
+ chalk.dim('Press Esc or Enter to return to menu.')
561
+ ];
562
+
563
+ out.push(...panel('CREDITS', content, cols, chalk.magenta, chalk.dim, rows - 2));
564
+ return out;
565
+ }
566
+
417
567
  export { render, footerLine, renderCustomizerOverlay, checkAlerts, processRowBudget, invalidateTableCache, invalidateFrame };
package/src/live/state.ts CHANGED
@@ -19,6 +19,8 @@ import { type SplashColorScheme, type SplashAnimation } from '../splash.js';
19
19
 
20
20
  export { type SplashColorScheme, type SplashAnimation };
21
21
 
22
+ export const MENU_OPTIONS = ['Resume Dashboard', 'Settings (Customizer)', 'Readme', 'Credits', 'Quit pyre'] as const;
23
+
22
24
  const config = readConfig();
23
25
 
24
26
  const state = {
@@ -60,6 +62,10 @@ const state = {
60
62
  termHeight: process.stdout.rows || 24,
61
63
  sortMode: config.sortMode as SortMode,
62
64
  processFilter: '',
65
+ processSelectionIndex: 0,
66
+ processScrollOffset: 0,
67
+ trackedPid: null as number | null,
68
+ inspectingProcess: null as any | null,
63
69
  inputMode: null as InputMode,
64
70
  inputBuffer: '',
65
71
  targetPid: '',
@@ -126,6 +132,7 @@ const state = {
126
132
  splashEnabled: config.splashEnabled,
127
133
  splashColorScheme: config.splashColorScheme as SplashColorScheme,
128
134
  splashAnimation: config.splashAnimation as SplashAnimation,
135
+ readmeScrollOffset: 0,
129
136
  };
130
137
 
131
138
  function setStatus(msg: string, ms = 3000) {
package/src/live/types.ts CHANGED
@@ -20,7 +20,7 @@ export interface LiveOptions {
20
20
 
21
21
  export type ExportFormat = 'json' | 'csv' | 'tsv' | 'html' | 'md';
22
22
 
23
- type InputMode = null | 'filter' | 'kill' | 'signal' | 'customizer' | 'p2p';
23
+ type InputMode = null | 'filter' | 'kill' | 'signal' | 'customizer' | 'p2p' | 'menu' | 'readme' | 'credits';
24
24
 
25
25
  type SortMode = 'cpu' | 'mem' | 'pid' | 'user' | 'command' | 'state' | 'threads' | 'runtime';
26
26
 
@@ -20,6 +20,7 @@ import type { StatsData, CpuData, MemoryData, ThermalData, BatteryData, PowerDat
20
20
  const SP_TTL_MS = 10_000;
21
21
  const NETSTAT_TTL_MS = 1000;
22
22
  let prevCpuTimes: { total: number; idle: number }[] | null = null;
23
+ let prevCpuTotal = { total: 0, idle: 0, ts: 0 };
23
24
  const ROUTE_TTL_MS = 5_000;
24
25
  const SYSCTL_TTL_MS = 60_000;
25
26
 
@@ -236,7 +237,7 @@ export async function collectPower(): Promise<PowerData | null> {
236
237
  if (spGpuCache && now - spGpuCache.ts < SP_TTL_MS) {
237
238
  spRaw = spGpuCache.raw;
238
239
  } else {
239
- spRaw = (await run('system_profiler SPDisplaysDataType 2>/dev/null')).trim();
240
+ spRaw = (await run('system_profiler SPDisplaysDataType 2>/dev/null', '', 3000)).trim();
240
241
  spGpuCache = { raw: spRaw, ts: now };
241
242
  }
242
243
 
@@ -400,18 +401,36 @@ export async function collectCpu(): Promise<CpuData> {
400
401
  }
401
402
  }
402
403
 
404
+ if (frequency === 0) {
405
+ try {
406
+ const cpus = os.cpus();
407
+ if (cpus && cpus.length > 0 && cpus[0].speed) {
408
+ frequency = cpus[0].speed;
409
+ }
410
+ } catch {
411
+ // ignore
412
+ }
413
+ }
414
+
415
+ // Use os.cpus() delta instead of the very slow `top -l 1 -n 0` which
416
+ // blocks for ~1-2s waiting for a sampling window. os.cpus() is instant.
403
417
  let usage = 0;
404
418
  try {
405
- const top = (await run('top -l 1 -n 0')).trim();
406
- // Real `top -l 1 -n 0` output is comma-separated and newline-terminated,
407
- // e.g. "CPU usage: 5.26% user, 10.52% sys, 84.21% idle" — there is no
408
- // semicolon anywhere on that line. The old regex required one to
409
- // terminate the match, so it never matched and usage stayed at 0.
410
- const cpuLine = top.match(/CPU usage:\s*[\d.]+%\s*user,\s*[\d.]+%\s*sys,\s*([\d.]+)%\s*idle/);
411
- if (cpuLine) {
412
- const idle = parseFloat(cpuLine[1]);
413
- usage = Math.round(100 - idle);
419
+ const cpus = os.cpus();
420
+ let totalNow = 0;
421
+ let idleNow = 0;
422
+ for (const cpu of cpus) {
423
+ totalNow += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq;
424
+ idleNow += cpu.times.idle;
425
+ }
426
+ if (prevCpuTotal.ts > 0) {
427
+ const totalDelta = totalNow - prevCpuTotal.total;
428
+ const idleDelta = idleNow - prevCpuTotal.idle;
429
+ if (totalDelta > 0) {
430
+ usage = Math.round(((totalDelta - idleDelta) / totalDelta) * 100);
431
+ }
414
432
  }
433
+ prevCpuTotal = { total: totalNow, idle: idleNow, ts: Date.now() };
415
434
  } catch {
416
435
  // ignore
417
436
  }
@@ -534,7 +553,7 @@ async function getDiskIoRates(): Promise<{ readBytesSec: number; writeBytesSec:
534
553
  return { readBytesSec: cachedDiskIo.readBytesSec, writeBytesSec: cachedDiskIo.writeBytesSec };
535
554
  }
536
555
  try {
537
- const raw = (await run('iostat -d -c 2 1 2', '')).trim();
556
+ const raw = (await run('iostat -d -c 2 1 2', '', 3000)).trim();
538
557
  const blocks = raw.split(/\n\s*\n/).filter(Boolean);
539
558
  const targetBlock = blocks.length > 1 ? blocks[1] : blocks[0];
540
559
  if (targetBlock) {
@@ -606,7 +625,7 @@ export async function collectBattery(): Promise<BatteryData | null> {
606
625
  if (spBatteryCache && now - spBatteryCache.ts < SP_TTL_MS) {
607
626
  battInfo = spBatteryCache.raw;
608
627
  } else {
609
- battInfo = (await run('system_profiler SPPowerDataType 2>/dev/null')).trim();
628
+ battInfo = (await run('system_profiler SPPowerDataType 2>/dev/null', '', 3000)).trim();
610
629
  spBatteryCache = { raw: battInfo, ts: now };
611
630
  }
612
631
 
@@ -821,7 +840,7 @@ export async function collectPackets(): Promise<PacketData | null> {
821
840
 
822
841
  let connections = 0;
823
842
  try {
824
- const tcpConns = (await run('netstat -an | grep ESTABLISHED | wc -l', '0')).trim();
843
+ const tcpConns = (await run('netstat -an | grep ESTABLISHED | wc -l', '0', 2000)).trim();
825
844
  connections = parseInt(tcpConns) || 0;
826
845
  } catch {
827
846
  connections = 0;
@@ -830,7 +849,7 @@ export async function collectPackets(): Promise<PacketData | null> {
830
849
  let topProcesses: { pid: number; command: string; rxBytes: number; txBytes: number }[] = [];
831
850
  let allProcesses: { pid: number; command: string; rxBytes: number; txBytes: number; rxPackets?: number; txPackets?: number }[] = [];
832
851
  try {
833
- const lsof = (await run('lsof -i -n -P 2>/dev/null | tail -n +2 | awk \'{print $1, $2}\' | sort | uniq -c | sort -rn', '')).trim();
852
+ const lsof = (await run('lsof -i -n -P 2>/dev/null | tail -n +2 | awk \'{print $1, $2}\' | sort | uniq -c | sort -rn', '', 2000)).trim();
834
853
  const lsofLines = lsof.split('\n');
835
854
  topProcesses = lsofLines.map(line => {
836
855
  const parts = line.trim().split(/\s+/);
@@ -11,7 +11,7 @@ import { promisify } from 'node:util';
11
11
 
12
12
  const execAsync = promisify(exec);
13
13
 
14
- async function run(cmd: string, fallback: string = '', timeout = 5000): Promise<string> {
14
+ async function run(cmd: string, fallback: string = '', timeout = 3000): Promise<string> {
15
15
  try {
16
16
  return (await execAsync(cmd, { encoding: 'utf8', timeout })).stdout.trim();
17
17
  } catch {
@@ -41,7 +41,8 @@ export async function getSmcMetrics(): Promise<SmcMetrics> {
41
41
  const pm = (
42
42
  await run(
43
43
  'sudo -n powermetrics --samplers smc,cpu_power -n 1 --format text 2>/dev/null',
44
- ''
44
+ '',
45
+ 2000
45
46
  )
46
47
  ).trim();
47
48
 
@@ -116,14 +117,10 @@ export async function getSmcMetrics(): Promise<SmcMetrics> {
116
117
 
117
118
  // 4. Smart load-based thermal estimation if hardware sensors return nothing
118
119
  if (result.temps['cpu_die'] === undefined) {
119
- let loadUsage = 0;
120
- try {
121
- const top = await run('top -l 1 -n 0 2>/dev/null', '');
122
- const m = top.match(/CPU usage:\s*[\d.]+\%\s*user,\s*[\d.]+\%\s*sys,\s*([\d.]+)\%\s*idle/);
123
- if (m) loadUsage = Math.max(0, 100 - parseFloat(m[1]));
124
- } catch {
125
- // ignore
126
- }
120
+ // Use os.loadavg() (instant) instead of the slow `top -l 1` (~1-2s)
121
+ const loadAvg = (await import('node:os')).loadavg();
122
+ const ncpus = (await import('node:os')).cpus().length || 1;
123
+ const loadUsage = Math.min(100, (loadAvg[0] / ncpus) * 100);
127
124
 
128
125
  const estCpu = Math.round((38 + (loadUsage * 0.42)) * 10) / 10;
129
126
  result.temps['cpu_die'] = estCpu;
package/src/splash.ts CHANGED
@@ -198,6 +198,6 @@ export async function showSplash(config?: SplashConfig): Promise<void> {
198
198
  process.stdin.resume();
199
199
  process.stdin.on('data', onEnter);
200
200
 
201
- setTimeout(onEnter, 5000);
201
+ setTimeout(onEnter, 1500);
202
202
  });
203
203
  }