pyre-cli 9.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pyre-cli",
3
- "version": "9.0.0",
3
+ "version": "9.1.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",
@@ -20,7 +20,7 @@ export function runAnomaliesDigest(opts: AnomaliesCmdOptions = {}): void {
20
20
  return;
21
21
  }
22
22
 
23
- const files = fs.readdirSync(dir).filter(f => f.startsWith('pyre-log-') && f.endsWith('.csv'));
23
+ const files = fs.readdirSync(dir).filter(f => f.startsWith('pyre-') && f.endsWith('.csv'));
24
24
  if (files.length === 0) {
25
25
  console.log(chalk.yellow(` No CSV logs found for range '${sinceStr}' in ${dir}.`));
26
26
  console.log(chalk.dim(' Enable background logging with `pyre --log` to record history.\n'));
@@ -474,7 +474,8 @@ function pctColor(v: number): (s: string) => string {
474
474
  function processTableLines(
475
475
  processes: { pid: number; ppid: number; user: string; cpu: number; mem: number; command: string; state: string; threads: number; runtime: number }[],
476
476
  contentWidth: number,
477
- borderAccent = THEMES.default.border
477
+ borderAccent = THEMES.default.border,
478
+ selectedIdx: number = -1
478
479
  ): string[] {
479
480
  const pidW = 8, ppidW = 8, userW = 12, cpuW = 8, memW = 8, stateW = 8, thrW = 6, rtW = 10;
480
481
 
@@ -490,13 +491,14 @@ function processTableLines(
490
491
  chalk.bold.hex('#f8f8f2').bgHex('#44475a')(' RUNTIME ') +
491
492
  chalk.bold.hex('#f8f8f2').bgHex('#44475a')(' COMMAND'.padEnd(cmdW + 1));
492
493
 
493
- const rows = processes.map(p => {
494
+ const rows = processes.map((p, idx) => {
495
+ const isSelected = idx === selectedIdx;
494
496
  const cpuStr = pctColor(p.cpu)(`${p.cpu.toFixed(1)}`.padEnd(cpuW));
495
497
  const memStr = pctColor(p.mem)(`${p.mem.toFixed(1)}`.padEnd(memW));
496
498
  const stateStr = chalk.dim(p.state.padEnd(stateW));
497
499
  const thrStr = String(p.threads).padEnd(thrW);
498
500
  const rtStr = formatRuntime(p.runtime).padEnd(rtW);
499
- return (
501
+ const line = (
500
502
  String(p.pid).padEnd(pidW) +
501
503
  String(p.ppid).padEnd(ppidW) +
502
504
  truncatePlain(p.user, userW - 1).padEnd(userW) +
@@ -507,6 +509,7 @@ function processTableLines(
507
509
  rtStr +
508
510
  truncatePlain(p.command, cmdW)
509
511
  );
512
+ return isSelected ? chalk.bgCyan.black(line) : line;
510
513
  });
511
514
  return [head, borderAccent('─'.repeat(contentWidth)), ...rows];
512
515
  }
@@ -605,7 +608,7 @@ function diskTableLines(
605
608
 
606
609
  // --- main dashboard ------------------------------------------------------------
607
610
 
608
- function detailPanelTitle(panel: string): string {
611
+ function detailPanelTitle(panel: string, trackedPid?: number | null): string {
609
612
  const map: Record<string, string> = {
610
613
  cpu: 'CPU',
611
614
  mem: 'Memory',
@@ -617,7 +620,7 @@ function detailPanelTitle(panel: string): string {
617
620
  packets: 'Packets',
618
621
  tasks: 'Tasks',
619
622
  disk: 'Disk',
620
- process: 'Processes',
623
+ process: trackedPid !== undefined && trackedPid !== null ? `Processes (Following PID ${trackedPid})` : 'Processes',
621
624
  };
622
625
  return map[panel] || panel;
623
626
  }
@@ -681,11 +684,51 @@ function activeDetailLines(data: StatsData, activePanel: string, width: number,
681
684
  ? data.processes.filter(p => p.command.toLowerCase().includes(opts.filter!.toLowerCase()))
682
685
  : data.processes;
683
686
  const sorted = sortProcesses(filtered, opts.sortBy ?? 'cpu');
684
- const limited = opts.processLimit ? sorted.slice(0, opts.processLimit) : sorted;
687
+ let selectedIdx = opts.selectedProcessIndex ?? -1;
688
+ const isTracking = opts.trackedPid !== undefined && opts.trackedPid !== null;
689
+ if (isTracking) {
690
+ const trackedIdx = sorted.findIndex(p => p.pid === opts.trackedPid);
691
+ if (trackedIdx >= 0) {
692
+ selectedIdx = trackedIdx;
693
+ }
694
+ }
695
+
696
+ // Build tracking info header lines if we're following a process
697
+ const trackingLines: string[] = [];
698
+ if (isTracking) {
699
+ const tp = sorted.find(p => p.pid === opts.trackedPid);
700
+ if (tp) {
701
+ const cpuColor = tp.cpu > 50 ? chalk.red.bold : tp.cpu > 20 ? chalk.yellow : chalk.green;
702
+ const memColor = tp.mem > 50 ? chalk.red.bold : tp.mem > 20 ? chalk.yellow : chalk.green;
703
+ trackingLines.push(chalk.bgHex('#1a1a2e').hex('#e94560').bold(` ◉ FOLLOWING PID ${tp.pid} — ${truncatePlain(tp.command, Math.max(10, contentWidth - 30))} `));
704
+ trackingLines.push(
705
+ ` ${chalk.bold('CPU:')} ${cpuColor(tp.cpu.toFixed(1) + '%')} ` +
706
+ `${chalk.bold('MEM:')} ${memColor(tp.mem.toFixed(1) + '%')} ` +
707
+ `${chalk.bold('State:')} ${chalk.dim(tp.state)} ` +
708
+ `${chalk.bold('Threads:')} ${tp.threads} ` +
709
+ `${chalk.bold('Runtime:')} ${formatRuntime(tp.runtime)} ` +
710
+ `${chalk.bold('User:')} ${tp.user}`
711
+ );
712
+ trackingLines.push(chalk.dim(` Full: ${tp.command}`));
713
+ trackingLines.push(theme.border('─'.repeat(contentWidth)));
714
+ } else {
715
+ trackingLines.push(chalk.yellow(` ◉ PID ${opts.trackedPid} exited — no longer in process list`));
716
+ trackingLines.push(theme.border('─'.repeat(contentWidth)));
717
+ }
718
+ }
719
+
720
+ const availableRows = Math.max(1, (opts.processLimit || sorted.length) - trackingLines.length);
721
+ let offset = 0;
722
+ if (selectedIdx >= 0) {
723
+ if (selectedIdx >= availableRows) {
724
+ offset = selectedIdx - availableRows + 1;
725
+ }
726
+ }
727
+ const visible = sorted.slice(offset, offset + availableRows);
685
728
  if (opts.treeView) {
686
- return buildProcessTree(limited);
729
+ return [...trackingLines, ...buildProcessTree(visible)];
687
730
  }
688
- return processTableLines(limited, width - 4, theme.border);
731
+ return [...trackingLines, ...processTableLines(visible, width - 4, theme.border, selectedIdx - offset)];
689
732
  }
690
733
  case 'anomalies': {
691
734
  if (!opts.anomalyHistory || opts.anomalyHistory.length === 0) return ['No anomalies detected yet.'];
@@ -854,9 +897,48 @@ function btopProcBox(data: StatsData, width: number, targetHeight: number, theme
854
897
 
855
898
  const headerRowOverhead = 2; // top & bottom border
856
899
  const tableHeaderOverhead = 2; // header + separator line
857
- const availableRows = Math.max(1, targetHeight - headerRowOverhead - tableHeaderOverhead);
858
900
 
859
- const limited = sorted.slice(0, availableRows);
901
+ const isTracking = opts.trackedPid !== undefined && opts.trackedPid !== null;
902
+
903
+ // Build compact tracking info lines
904
+ const trackingLines: string[] = [];
905
+ if (isTracking) {
906
+ const tp = sorted.find(p => p.pid === opts.trackedPid);
907
+ if (tp) {
908
+ const cpuColor = tp.cpu > 50 ? chalk.red.bold : tp.cpu > 20 ? chalk.yellow : chalk.green;
909
+ const memColor = tp.mem > 50 ? chalk.red.bold : tp.mem > 20 ? chalk.yellow : chalk.green;
910
+ trackingLines.push(chalk.bgHex('#1a1a2e').hex('#e94560').bold(` ◉ PID ${tp.pid} ${truncatePlain(tp.command, Math.max(6, contentWidth - 20))} `));
911
+ trackingLines.push(
912
+ ` ${cpuColor('CPU ' + tp.cpu.toFixed(1) + '%')} ` +
913
+ `${memColor('MEM ' + tp.mem.toFixed(1) + '%')} ` +
914
+ `${chalk.dim(tp.state)} ` +
915
+ `THR:${tp.threads} ` +
916
+ `${formatRuntime(tp.runtime)}`
917
+ );
918
+ trackingLines.push(theme.border('─'.repeat(contentWidth)));
919
+ } else {
920
+ trackingLines.push(chalk.yellow(` ◉ PID ${opts.trackedPid} exited`));
921
+ trackingLines.push(theme.border('─'.repeat(contentWidth)));
922
+ }
923
+ }
924
+
925
+ const availableRows = Math.max(1, targetHeight - headerRowOverhead - tableHeaderOverhead - trackingLines.length);
926
+
927
+ let selectedIdx = opts.selectedProcessIndex ?? -1;
928
+ if (isTracking) {
929
+ const trackedIdx = sorted.findIndex(p => p.pid === opts.trackedPid);
930
+ if (trackedIdx >= 0) {
931
+ selectedIdx = trackedIdx;
932
+ }
933
+ }
934
+
935
+ let offset = 0;
936
+ if (selectedIdx >= 0) {
937
+ if (selectedIdx >= availableRows) {
938
+ offset = selectedIdx - availableRows + 1;
939
+ }
940
+ }
941
+ const visible = sorted.slice(offset, offset + availableRows);
860
942
 
861
943
  const pidW = 6, userW = 7, thrW = 3, memW = 6, cpuW = 6;
862
944
  const fixedW = pidW + userW + thrW + memW + cpuW + 5;
@@ -871,12 +953,14 @@ function btopProcBox(data: StatsData, width: number, targetHeight: number, theme
871
953
  chalk.bold('CPU%'.padStart(cpuW));
872
954
 
873
955
  if (opts.treeView) {
874
- const treeLines = buildProcessTree(limited);
875
- const rightTitle = opts.filter ? `filter: "${opts.filter}"` : `tree · sort: ${opts.sortBy || 'cpu'}`;
876
- return panel('P proc', [head, theme.border('─'.repeat(contentWidth)), ...treeLines], width, theme.process, theme.border, targetHeight - 2, rightTitle);
956
+ const treeLines = buildProcessTree(visible);
957
+ let rightTitle = opts.filter ? `filter: "${opts.filter}"` : `tree · sort: ${opts.sortBy || 'cpu'}`;
958
+ if (isTracking) rightTitle += ` · following PID ${opts.trackedPid}`;
959
+ return panel('P proc', [...trackingLines, head, theme.border('─'.repeat(contentWidth)), ...treeLines], width, theme.process, theme.border, targetHeight - 2, rightTitle);
877
960
  }
878
961
 
879
- const rows = limited.map(p => {
962
+ const rows = visible.map((p, idx) => {
963
+ const isSelected = (offset + idx) === selectedIdx;
880
964
  const cpuVal = p.cpu.toFixed(1) + '%';
881
965
  const cpuStr = p.cpu > 50 ? chalk.red.bold(cpuVal.padStart(cpuW)) : p.cpu > 20 ? chalk.yellow(cpuVal.padStart(cpuW)) : chalk.green(cpuVal.padStart(cpuW));
882
966
  const memStr = (p.mem.toFixed(1) + '%').padStart(memW);
@@ -885,15 +969,19 @@ function btopProcBox(data: StatsData, width: number, targetHeight: number, theme
885
969
  const pidStr = String(p.pid).padEnd(pidW);
886
970
  const cmdStr = truncatePlain(p.command, cmdW).padEnd(cmdW);
887
971
 
888
- return `${pidStr} ${cmdStr} ${userStr} ${thrStr} ${memStr} ${cpuStr}`;
972
+ const line = `${pidStr} ${cmdStr} ${userStr} ${thrStr} ${memStr} ${cpuStr}`;
973
+ return isSelected ? chalk.bgCyan.black(line) : line;
889
974
  });
890
975
 
891
- const lines = [head, theme.border('─'.repeat(contentWidth)), ...rows];
976
+ const lines = [...trackingLines, head, theme.border('─'.repeat(contentWidth)), ...rows];
892
977
  if (rows.length === 0) {
893
978
  lines.push(chalk.dim(`No processes match "${opts.filter || ''}"`));
894
979
  }
895
980
 
896
- const rightTitle = opts.filter ? `filter: "${opts.filter}"` : `sort: ${opts.sortBy || 'cpu'}`;
981
+ let rightTitle = opts.filter ? `filter: "${opts.filter}"` : `sort: ${opts.sortBy || 'cpu'}`;
982
+ if (isTracking) {
983
+ rightTitle += ` · following PID ${opts.trackedPid}`;
984
+ }
897
985
  return panel('P proc', lines, width, theme.process, theme.border, targetHeight - 2, rightTitle);
898
986
  }
899
987
 
@@ -920,33 +1008,34 @@ export function formatTable(data: StatsData, opts: TableOptions = {}): string {
920
1008
  if (activePanel !== 'grid') {
921
1009
  const lines = activeDetailLines(data, activePanel, width, opts);
922
1010
  if (lines) {
923
- out.push(...panel(detailPanelTitle(activePanel), lines, width, theme[activePanel as keyof ThemeColors] ?? theme.cpu, theme.border));
1011
+ out.push(...panel(detailPanelTitle(activePanel, opts.trackedPid), lines, width, theme[activePanel as keyof ThemeColors] ?? theme.cpu, theme.border));
924
1012
  }
925
- return out.join('\n');
926
- }
927
-
928
- // --- btop Dashboard Grid ---
929
- // 1. Top CPU & System Overview Box
930
- out.push(...btopCpuBox(data, width, theme, opts));
931
- out.push('');
1013
+ } else {
1014
+ // --- btop Dashboard Grid ---
1015
+ // 1. Top CPU & System Overview Box
1016
+ out.push(...btopCpuBox(data, width, theme, opts));
1017
+ out.push('');
932
1018
 
933
- // 2. Bottom Split Section
934
- if (width >= 85) {
935
- const leftColW = Math.max(32, Math.floor(width * 0.38));
936
- const rightColW = width - leftColW - 2;
1019
+ // 2. Bottom Split Section
1020
+ if (width >= 85) {
1021
+ const leftColW = Math.max(32, Math.floor(width * 0.38));
1022
+ const rightColW = width - leftColW - 2;
937
1023
 
938
- const leftLines = btopLeftColumn(data, leftColW, theme, opts);
939
- const procLines = btopProcBox(data, rightColW, leftLines.length, theme, opts);
1024
+ const leftLines = btopLeftColumn(data, leftColW, theme, opts);
1025
+ const procLines = btopProcBox(data, rightColW, leftLines.length, theme, opts);
940
1026
 
941
- out.push(...hstack([leftLines, procLines], 2, width));
942
- } else {
943
- const leftLines = btopLeftColumn(data, width, theme, opts);
944
- out.push(...leftLines);
945
- out.push('');
946
- const procLines = btopProcBox(data, width, 12, theme, opts);
947
- out.push(...procLines);
1027
+ out.push(...hstack([leftLines, procLines], 2, width));
1028
+ } else {
1029
+ const leftLines = btopLeftColumn(data, width, theme, opts);
1030
+ out.push(...leftLines);
1031
+ out.push('');
1032
+ const procLines = btopProcBox(data, width, 12, theme, opts);
1033
+ out.push(...procLines);
1034
+ }
948
1035
  }
949
1036
 
1037
+ // Inspect process modal has been replaced by inline tracking headers
1038
+
950
1039
  return out.join('\n');
951
1040
  }
952
1041
 
@@ -80,4 +80,10 @@ export interface TableOptions {
80
80
  graphMode?: 'spark' | 'bar';
81
81
  /** Panel layout order. */
82
82
  panelLayout?: string[];
83
+ /** Currently selected process index for highlight. */
84
+ selectedProcessIndex?: number;
85
+ /** Currently tracked PID. */
86
+ trackedPid?: number | null;
87
+ /** Currently inspected process details for modal overlay. */
88
+ inspectProcess?: any;
83
89
  }
package/src/live/input.ts CHANGED
@@ -10,7 +10,7 @@ import readline from 'node:readline';
10
10
  import chalk from 'chalk';
11
11
  import { collectAll, StatsData } from '../monitors/index.js';
12
12
  import { THEMES, type ThemeName, type VisibleItems, getTabHitboxes, TAB_BAR_ROW } from '../formatters/index.js';
13
- import { state, setStatus, getToggleKey, type SplashColorScheme, type SplashAnimation } from './state.js';
13
+ import { state, setStatus, getToggleKey, type SplashColorScheme, type SplashAnimation, MENU_OPTIONS } 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
16
  import type { LiveOptions, ExportFormat, InputMode, SortMode, GraphMode, ActivePanel } from './types.js';
@@ -115,6 +115,70 @@ function handleInputModeKey(str: string, key: readline.Key) {
115
115
  return;
116
116
  }
117
117
 
118
+ if (state.inputMode === 'menu') {
119
+ if (isEscKey(key, str)) {
120
+ state.inputMode = null;
121
+ render();
122
+ return;
123
+ }
124
+ if (isUpKey(key, str)) {
125
+ state.menuSelectionIndex = (state.menuSelectionIndex - 1 + MENU_OPTIONS.length) % MENU_OPTIONS.length;
126
+ render();
127
+ return;
128
+ }
129
+ if (isDownKey(key, str)) {
130
+ state.menuSelectionIndex = (state.menuSelectionIndex + 1) % MENU_OPTIONS.length;
131
+ render();
132
+ return;
133
+ }
134
+ if (isEnterKey(key, str) || str === ' ') {
135
+ const option = MENU_OPTIONS[state.menuSelectionIndex];
136
+ switch (option) {
137
+ case 'Resume Dashboard':
138
+ state.inputMode = null;
139
+ break;
140
+ case 'Settings (Customizer)':
141
+ state.inputMode = 'customizer';
142
+ state.customizerIndex = 0;
143
+ break;
144
+ case 'Readme':
145
+ state.inputMode = 'readme';
146
+ break;
147
+ case 'Credits':
148
+ state.inputMode = 'credits';
149
+ break;
150
+ case 'Quit pyre':
151
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
152
+ process.exit(0);
153
+ }
154
+ render();
155
+ return;
156
+ }
157
+ render();
158
+ return;
159
+ }
160
+
161
+ if (state.inputMode === 'readme' || state.inputMode === 'credits') {
162
+ if (isEscKey(key, str) || isEnterKey(key, str)) {
163
+ state.inputMode = 'menu';
164
+ render();
165
+ return;
166
+ }
167
+ if (state.inputMode === 'readme') {
168
+ if (isUpKey(key, str)) {
169
+ state.readmeScrollOffset = Math.max(0, state.readmeScrollOffset - 1);
170
+ render();
171
+ return;
172
+ }
173
+ if (isDownKey(key, str)) {
174
+ state.readmeScrollOffset += 1;
175
+ render();
176
+ return;
177
+ }
178
+ }
179
+ return;
180
+ }
181
+
118
182
  if (state.inputMode === 'signal') {
119
183
  if (isUpKey(key, str)) {
120
184
  const idx = state.SIGNAL_OPTIONS.indexOf(state.inputBuffer as typeof state.SIGNAL_OPTIONS[number]);
@@ -569,11 +633,72 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
569
633
  case 'tab':
570
634
  cycleTab(1);
571
635
  break;
636
+ case 'up':
637
+ if (state.lastData?.processes?.length) {
638
+ state.trackedPid = null;
639
+ state.processSelectionIndex = Math.max(0, state.processSelectionIndex - 1);
640
+ render();
641
+ }
642
+ break;
643
+ case 'down':
644
+ if (state.lastData?.processes?.length) {
645
+ state.trackedPid = null;
646
+ const count = state.lastData.processes.length;
647
+ state.processSelectionIndex = Math.min(count - 1, state.processSelectionIndex + 1);
648
+ render();
649
+ }
650
+ break;
651
+ case 'space':
652
+ case ' ':
653
+ if (state.lastData?.processes?.length && state.processSelectionIndex >= 0) {
654
+ const sorted = state.lastData.processes;
655
+ const proc = sorted[state.processSelectionIndex];
656
+ if (proc) {
657
+ if (state.trackedPid === proc.pid) {
658
+ state.trackedPid = null;
659
+ setStatus(`Stopped following PID ${proc.pid}`);
660
+ } else {
661
+ state.trackedPid = proc.pid;
662
+ setStatus(`Following PID ${proc.pid} (${proc.command})`);
663
+ }
664
+ render();
665
+ }
666
+ }
667
+ break;
668
+ case 'return':
669
+ case 'enter':
670
+ if (state.inspectingProcess) {
671
+ state.inspectingProcess = null;
672
+ render();
673
+ } else if (state.lastData?.processes?.length && state.processSelectionIndex >= 0) {
674
+ const sorted = state.lastData.processes;
675
+ const proc = sorted[state.processSelectionIndex];
676
+ if (proc) {
677
+ state.inspectingProcess = proc;
678
+ state.trackedPid = proc.pid;
679
+ setStatus(`Inspecting & following PID ${proc.pid}`);
680
+ render();
681
+ }
682
+ }
683
+ break;
572
684
  case 'escape':
573
685
  case 'esc':
574
- state.activePanel = 'grid';
575
- setStatus('Grid view');
576
- render();
686
+ if (state.inspectingProcess) {
687
+ state.inspectingProcess = null;
688
+ render();
689
+ } else if (state.trackedPid !== null) {
690
+ state.trackedPid = null;
691
+ setStatus('Stopped following process');
692
+ render();
693
+ } else if (state.activePanel !== 'grid') {
694
+ state.activePanel = 'grid';
695
+ setStatus('Grid view');
696
+ render();
697
+ } else {
698
+ state.inputMode = 'menu';
699
+ state.menuSelectionIndex = 0;
700
+ render();
701
+ }
577
702
  break;
578
703
  default:
579
704
  if (str && str.length === 1) {
@@ -592,7 +717,7 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
592
717
  }
593
718
 
594
719
  function tabKeyToId(str: string): string | null {
595
- const map: Record<string, string> = { '1': 'cpu', '2': 'mem', '3': 'gpu', '4': 'power', '5': 'battery', '6': 'thermal', '7': 'network', '8': 'packets', '9': 'tasks', '0': 'disk', 'p': 'process', 'r': 'p2p' };
720
+ const map: Record<string, string> = { '1': 'cpu', '2': 'mem', '3': 'gpu', '4': 'power', '5': 'battery', '6': 'thermal', '7': 'network', '8': 'packets', '9': 'tasks', '0': 'disk', 'p': 'process', 'r': 'p2p', 'A': 'anomalies' };
596
721
  return map[str] ?? null;
597
722
  }
598
723
 
@@ -612,6 +737,12 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
612
737
  const cy = seq.charCodeAt(5) - 32;
613
738
  if (cb === 0 || cb === 1) {
614
739
  handleMouseClick(cy, cx);
740
+ } else if (cb === 64) {
741
+ // scroll up
742
+ scrollProcessSelection(-1);
743
+ } else if (cb === 65) {
744
+ // scroll down
745
+ scrollProcessSelection(1);
615
746
  }
616
747
  }
617
748
 
@@ -623,6 +754,19 @@ function killProcess(pidStr: string, signal: string = 'SIGTERM') {
623
754
  const cy = parseInt(match[3]) - 1;
624
755
  if (button === 0 || button === 1) {
625
756
  handleMouseClick(cy, cx);
757
+ } else if (button === 64) {
758
+ scrollProcessSelection(-1);
759
+ } else if (button === 65) {
760
+ scrollProcessSelection(1);
761
+ }
762
+ }
763
+
764
+ function scrollProcessSelection(dir: number) {
765
+ if (state.lastData?.processes?.length) {
766
+ state.trackedPid = null;
767
+ const count = state.lastData.processes.length;
768
+ state.processSelectionIndex = Math.max(0, Math.min(count - 1, state.processSelectionIndex + dir));
769
+ render();
626
770
  }
627
771
  }
628
772
 
@@ -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,
@@ -197,13 +203,41 @@ function render() {
197
203
  writeFrame(out);
198
204
  return;
199
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
+ }
200
219
  const lines: string[] = [];
201
220
  const theme = THEMES[state.currentTheme] || THEMES.default;
202
221
 
203
222
  const params = tableCacheParams();
204
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
+ }
205
239
  const anomalyCacheKey = anomalies.map(a => `${a.metric}:${a.zScore.toFixed(2)}`).join(',');
206
- const cacheParams = params + '|' + anomalyCacheKey;
240
+ const cacheParams = params + '|' + anomalyCacheKey + '|' + state.anomalyHistory.length;
207
241
  const tableStr = _tableDataDirty || state.lastData !== _cachedTableData || cacheParams !== _cachedTableParams
208
242
  ? (() => {
209
243
  const out = formatTable(state.lastData, {
@@ -221,6 +255,9 @@ function render() {
221
255
  history: state.history,
222
256
  graphMode: state.graphMode,
223
257
  panelLayout: state.panelLayout,
258
+ selectedProcessIndex: state.processSelectionIndex,
259
+ trackedPid: state.trackedPid,
260
+ inspectProcess: state.inspectingProcess,
224
261
  });
225
262
  _cachedTableData = state.lastData;
226
263
  _cachedTableParams = cacheParams;
@@ -422,4 +459,109 @@ function checkAlerts(data: StatsData) {
422
459
  }
423
460
  }
424
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
+
425
567
  export { render, footerLine, renderCustomizerOverlay, checkAlerts, processRowBudget, invalidateTableCache, invalidateFrame };