pyre-cli 3.3.0 → 3.5.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.
@@ -8,22 +8,67 @@
8
8
  */
9
9
  import chalk from 'chalk';
10
10
  import { formatTable, formatGraphs, gridColumns, THEMES, type ThemeName, panel } from '../formatters/index.js';
11
- import { state, setStatus, SIGNAL_OPTIONS } from './state.js';
11
+ import { state, setStatus, SIGNAL_OPTIONS, getToggleKey } from './state.js';
12
12
  import type { StatsData } from '../monitors/index.js';
13
13
 
14
+ // --- render caching -----------------------------------------------------------
15
+
16
+ let _cachedTable = '';
17
+ let _cachedTableData: StatsData | null = null;
18
+ let _cachedTableParams = '';
19
+ let _tableDataDirty = false;
20
+
21
+ function invalidateTableCache() {
22
+ _tableDataDirty = true;
23
+ }
24
+
25
+ let _cachedGraphs = '';
26
+ let _cachedGraphsVersion = -1;
27
+ let _cachedGraphsParams = '';
28
+
29
+ function tableCacheParams(): string {
30
+ const limit = processRowBudget();
31
+ return [
32
+ state.sortMode,
33
+ state.processFilter || '',
34
+ limit,
35
+ state.currentTheme,
36
+ state.activePanel,
37
+ state.treeView ? '1' : '0',
38
+ JSON.stringify(state.visiblePanels),
39
+ ].join('|');
40
+ }
41
+
42
+ function graphsCacheParams(): string {
43
+ return [
44
+ state.termWidth,
45
+ state.currentTheme,
46
+ state.graphMode,
47
+ ].join('|');
48
+ }
49
+
14
50
  /**
15
51
  * How many process rows we can realistically fit given
16
52
  * current terminal height. Computed from the number of
17
53
  * card columns and the reserved space for the header,
18
54
  * cards, graphs, and footer.
19
55
  */
20
- function processRowBudget(): number {
21
- const columns = gridColumns(state.termWidth);
22
- const visibleCards = Object.values(state.visiblePanels).filter(v => v !== false).length;
23
- const cardRows = Math.ceil(visibleCards / columns);
24
- const reserved = 2 + cardRows * 8 + 11 + (state.showGraphs ? 9 : 0) + 4;
25
- return Math.max(3, Math.min(40, state.termHeight - reserved));
26
- }
56
+ function processRowBudget(): number {
57
+ let overlayRows = 0;
58
+ if (state.inputMode === 'customizer') overlayRows = state.CUSTOMIZER_OPTIONS.length + 3;
59
+ else if (state.inputMode === 'signal') overlayRows = 6;
60
+ else if (state.inputMode === 'filter' || state.inputMode === 'kill' || state.inputMode === 'p2p') overlayRows = 3;
61
+
62
+ if (state.activePanel === 'process') {
63
+ return Math.max(10, state.termHeight - 12 - overlayRows);
64
+ }
65
+
66
+ const columns = gridColumns(state.termWidth);
67
+ const visibleCards = Object.values(state.visiblePanels).filter(v => v !== false).length;
68
+ const cardRows = Math.ceil(visibleCards / columns);
69
+ const reserved = 4 + cardRows * 8 + 8 + (state.showGraphs ? 11 : 0) + 5 + overlayRows;
70
+ return Math.max(5, state.termHeight - reserved);
71
+ }
27
72
 
28
73
  function p2pPanelLines(): { title: string; body: string[] } {
29
74
  const events = state.p2pServer ? state.p2pServer.peerEventHistory : state.p2pEvents;
@@ -66,106 +111,175 @@ function renderCustomizerOverlay(): string {
66
111
  lines.push(`${prefix}${opt}: ${chalk.bold.green(state.currentTheme)} [${themesList.join(', ')}]`);
67
112
  } else if (opt === 'Graph Mode') {
68
113
  lines.push(`${prefix}${opt}: ${chalk.bold.green(state.graphMode)} [spark, bar]`);
69
- } else if (opt === 'Toggle Tree View') {
70
-
71
- const status = state.treeView ? chalk.green('[VISIBLE]') : chalk.red('[HIDDEN]');
72
- lines.push(`${prefix}${opt}: ${status}`);
73
- } else {
74
- const key = opt.replace('Toggle ', '').toLowerCase();
75
- const isVisible = state.visiblePanels[key as keyof typeof state.visiblePanels] !== false;
76
- const status = isVisible ? chalk.green('[VISIBLE]') : chalk.red('[HIDDEN]');
77
- lines.push(`${prefix}${opt}: ${status}`);
78
- }
114
+ } else {
115
+ const toggleKey = getToggleKey(opt);
116
+ const isVisible = toggleKey ? (toggleKey === 'tree' ? state.treeView : state.visiblePanels[toggleKey] !== false) : true;
117
+ const status = isVisible ? chalk.green('[VISIBLE]') : chalk.red('[HIDDEN]');
118
+ lines.push(`${prefix}${opt}: ${status}`);
119
+ }
79
120
  });
80
121
 
81
122
  return lines.map(l => ` ${l}`).join('\n');
82
123
  }
83
124
 
125
+ // --- frame diffing -------------------------------------------------------
126
+ // Instead of erasing the whole screen (`\x1b[2J`) and repainting everything
127
+ // on every keypress/tick, we keep the previous frame's lines around and
128
+ // only move the cursor + rewrite the lines that actually changed. A full
129
+ // erase forces a scrollback/display invalidation in many terminals
130
+ // (iTerm2, Windows Terminal, tmux, various SSH clients), which is what
131
+ // made menu navigation and tab switches feel like they took a second or
132
+ // two even though the underlying data was already cached.
133
+ let _prevFrameLines: string[] = [];
134
+ let _frameDirty = true; // force full paint on first render / resize
135
+
136
+ function invalidateFrame() {
137
+ _frameDirty = true;
138
+ }
139
+
140
+ function writeFrame(lines: string[]) {
141
+ if (_frameDirty || _prevFrameLines.length === 0 || lines.length !== _prevFrameLines.length) {
142
+ process.stdout.write('\x1b[?25l\x1b[H' + lines.map(l => `\x1b[2K${l}`).join('\r\n') + '\x1b[0J');
143
+ _prevFrameLines = lines;
144
+ _frameDirty = false;
145
+ return;
146
+ }
147
+
148
+ // Incremental repaint: move to each changed line and rewrite only
149
+ // that line, clearing to end-of-line first so shorter replacement
150
+ // text doesn't leave stale characters behind.
151
+ let out = '';
152
+ for (let i = 0; i < lines.length; i++) {
153
+ if (lines[i] !== _prevFrameLines[i]) {
154
+ out += `\x1b[${i + 1};1H\x1b[2K${lines[i]}`;
155
+ }
156
+ }
157
+ if (out) process.stdout.write(out);
158
+ _prevFrameLines = lines;
159
+ }
160
+
84
161
  function render() {
85
162
  if (!state.lastData) return;
86
163
  const lines: string[] = [];
87
164
  const theme = THEMES[state.currentTheme] || THEMES.default;
88
165
 
166
+ const params = tableCacheParams();
167
+ const tableStr = _tableDataDirty || state.lastData !== _cachedTableData || params !== _cachedTableParams
168
+ ? (() => {
169
+ const out = formatTable(state.lastData, {
170
+ width: state.termWidth,
171
+ sortBy: state.sortMode,
172
+ filter: state.processFilter || undefined,
173
+ processLimit: processRowBudget(),
174
+ theme: state.currentTheme,
175
+ visible: state.visiblePanels,
176
+ treeView: state.treeView,
177
+ activePanel: state.activePanel,
178
+ });
179
+ _cachedTableData = state.lastData;
180
+ _cachedTableParams = params;
181
+ _cachedTable = out;
182
+ _tableDataDirty = false;
183
+ return out;
184
+ })()
185
+ : _cachedTable;
186
+
187
+ const bodyLines: string[] = [];
89
188
  if (state.activePanel === 'p2p') {
90
189
  const panelLines = p2pPanelLines();
91
- lines.push(...panel(panelLines.title, panelLines.body, state.termWidth, theme.process, theme.border));
190
+ bodyLines.push(...panel(panelLines.title, panelLines.body, state.termWidth, theme.process, theme.border));
92
191
  } else {
93
- lines.push(
94
- formatTable(state.lastData, {
95
- width: state.termWidth,
96
- sortBy: state.sortMode,
97
- filter: state.processFilter || undefined,
98
- processLimit: processRowBudget(),
99
- theme: state.currentTheme,
100
- visible: state.visiblePanels,
101
- treeView: state.treeView,
102
- activePanel: state.activePanel,
103
- })
104
- );
192
+ bodyLines.push(...tableStr.split('\n'));
105
193
  }
106
194
 
195
+ const graphsParams = graphsCacheParams();
196
+ const graphsStr =
197
+ state.history.version !== _cachedGraphsVersion || graphsParams !== _cachedGraphsParams
198
+ ? (() => {
199
+ const out = formatGraphs(state.history, state.termWidth, state.currentTheme, state.graphMode);
200
+ _cachedGraphsVersion = state.history.version;
201
+ _cachedGraphsParams = graphsParams;
202
+ _cachedGraphs = out;
203
+ return out;
204
+ })()
205
+ : _cachedGraphs;
206
+
107
207
  if (state.showGraphs && state.activePanel !== 'p2p') {
108
- lines.push('');
109
- lines.push(formatGraphs(state.history, state.termWidth, state.currentTheme, state.graphMode));
208
+ bodyLines.push('');
209
+ bodyLines.push(...graphsStr.split('\n'));
110
210
  }
111
211
 
112
- lines.push('');
113
- lines.push(footerLine());
212
+ const footerLines: string[] = [];
213
+ footerLines.push('');
214
+ footerLines.push(...footerLine().split('\n'));
114
215
 
115
216
  if (state.inputMode === 'customizer') {
116
- lines.push(renderCustomizerOverlay());
217
+ footerLines.push(...renderCustomizerOverlay().split('\n'));
117
218
  } else if (state.inputMode === 'filter') {
118
- lines.push(chalk.cyan(` Filter processes: ${state.inputBuffer}_`));
219
+ footerLines.push(chalk.cyan(` Filter processes: ${state.inputBuffer}_`));
119
220
  } else if (state.inputMode === 'kill') {
120
- lines.push(chalk.cyan(` Kill PID: ${state.inputBuffer}_ (enter to confirm, esc to cancel)`));
221
+ footerLines.push(chalk.cyan(` Kill PID: ${state.inputBuffer}_ (enter to confirm, esc to cancel)`));
121
222
  } else if (state.inputMode === 'signal') {
122
223
  const sigIdx = state.SIGNAL_OPTIONS.indexOf(state.inputBuffer as typeof state.SIGNAL_OPTIONS[number]);
123
224
  const sigList = state.SIGNAL_OPTIONS.map((s, i) => i === sigIdx ? chalk.yellow(s) : chalk.dim(s)).join(' ');
124
- lines.push(chalk.cyan(` Signal: ${state.inputBuffer}_`));
125
- lines.push(chalk.dim(` ${sigList}`));
126
- lines.push(chalk.dim(' ↑/↓ to select, Enter to confirm, Esc to cancel'));
225
+ footerLines.push(chalk.cyan(` Signal: ${state.inputBuffer}_`));
226
+ footerLines.push(chalk.dim(` ${sigList}`));
227
+ footerLines.push(chalk.dim(' ↑/↓ to select, Enter to confirm, Esc to cancel'));
127
228
  } else if (state.inputMode === 'p2p') {
128
- lines.push(chalk.cyan(` P2P password (${state.p2pPort}): ${state.inputBuffer}_ (enter to start, esc to cancel)`));
229
+ footerLines.push(chalk.cyan(` P2P password (${state.p2pPort}): ${state.inputBuffer}_ (enter to start, esc to cancel)`));
129
230
  } else if (state.statusMessage) {
130
- lines.push(` ${state.statusMessage}`);
131
- }
231
+ footerLines.push(` ${state.statusMessage}`);
232
+ } else {
233
+ footerLines.push('');
234
+ }
235
+
236
+ const maxBodyLines = Math.max(1, state.termHeight - footerLines.length);
237
+ if (bodyLines.length > maxBodyLines) {
238
+ bodyLines.length = maxBodyLines;
239
+ }
132
240
 
133
- process.stdout.write('\x1b[2J\x1b[H');
134
- process.stdout.write(lines.join('\n'));
241
+ const frameLines = [...bodyLines, ...footerLines];
242
+ if (frameLines.length > state.termHeight) {
243
+ frameLines.length = state.termHeight;
244
+ }
245
+ writeFrame(frameLines);
135
246
  }
136
247
 
137
248
  function footerLine(): string {
138
- const keys: [string, string][] = [
249
+ const row1: [string, string][] = [
139
250
  ['q', 'quit'],
140
251
  ['c', 'customize UI'],
252
+ ['←/→', 'tab'],
253
+ ['1-9', 'panels'],
254
+ ['g', state.showGraphs ? 'hide graphs' : 'show graphs'],
255
+ ['b', `graph:${state.graphMode}`],
256
+ ['t', state.treeView ? 'flat' : 'tree'],
257
+ ['d', state.detailed ? 'basic' : 'detailed'],
258
+ ['p', state.paused ? 'resume' : 'pause'],
259
+ ];
260
+ const row2: [string, string][] = [
261
+ ['s', `sort:${state.sortMode}`],
262
+ ['/', 'filter'],
263
+ ['k', 'kill'],
264
+ ['S', 'signal'],
265
+ ['e', 'export'],
266
+ ['l', state.logging ? 'stop log' : 'log'],
267
+ ['f', state.exportFormat],
268
+ ['+/-', `${state.interval}s`],
141
269
  ['r', state.p2pServerRunning ? 'stop p2p' : 'start p2p'],
142
- ['p', state.paused ? 'resume' : 'pause'],
143
- ['g', state.showGraphs ? 'hide graphs' : 'show graphs'],
144
- ['b', `graph:${state.graphMode}`],
145
- ['d', state.detailed ? 'basic' : 'detailed'],
146
- ['←/→', 'tab'],
147
- ['1-9', 'panels'],
148
- ['s', `sort:${state.sortMode}`],
149
- ['/', 'filter'],
150
- ['k', 'kill'],
151
- ['S', 'signal'],
152
- ['t', state.treeView ? 'flat' : 'tree'],
153
- ['e', 'export'],
154
- ['l', state.logging ? 'stop log' : 'log'],
155
- ['f', state.exportFormat],
156
- ['+/-', `${state.interval}s`],
157
- ];
158
- const keyStr = keys.map(([k, label]) => `${chalk.hex('#50fa7b').bold(k)} ${chalk.dim(label)}`).join(chalk.dim(' | '));
159
-
270
+ ];
271
+ const fmt = (list: [string, string][]) => list.map(([k, label]) => `${chalk.hex('#50fa7b').bold(k)} ${chalk.dim(label)}`).join(chalk.dim(' | '));
272
+ const str1 = fmt(row1);
273
+ const str2 = fmt(row2);
160
274
 
161
275
  const badges: string[] = [];
162
276
  if (state.paused) badges.push(chalk.yellow.bold('⏸ PAUSED'));
163
277
  if (state.logging) badges.push(chalk.red.bold('● REC'));
164
278
  if (state.activePanel !== 'grid') badges.push(chalk.cyan.bold(`◉ ${state.activePanel.toUpperCase()}`));
165
- if (state.p2pServerRunning) badges.push(chalk.green.bold(`P2P ${state.p2pBind || '0.0.0.0'}:${state.p2pPort}`));
279
+ if (state.p2pServerRunning) badges.push(chalk.green.bold(`P2P ${state.p2pBind || '0.0.0.0'}:${state.p2pPort}`));
166
280
  const badgeStr = badges.join(' ');
167
281
 
168
- return badgeStr ? `${keyStr} ${badgeStr}` : keyStr;
282
+ return badgeStr ? `${str1}\n${str2} ${badgeStr}` : `${str1}\n${str2}`;
169
283
  }
170
284
 
171
285
  function checkAlerts(data: StatsData) {
@@ -181,4 +295,4 @@ function checkAlerts(data: StatsData) {
181
295
  }
182
296
  }
183
297
 
184
- export { render, footerLine, renderCustomizerOverlay, checkAlerts, processRowBudget };
298
+ export { render, footerLine, renderCustomizerOverlay, checkAlerts, processRowBudget, invalidateTableCache, invalidateFrame };
package/src/live/state.ts CHANGED
@@ -16,6 +16,7 @@ export const SIGNAL_OPTIONS = ['SIGTERM', 'SIGKILL', 'SIGINT', 'SIGHUP', 'SIGSTO
16
16
 
17
17
  const state = {
18
18
  intervalHandle: null as NodeJS.Timeout | null,
19
+ uiIntervalHandle: null as NodeJS.Timeout | null,
19
20
  running: false,
20
21
  paused: false,
21
22
  detailed: false,
@@ -53,6 +54,8 @@ const state = {
53
54
  processFilter: '',
54
55
  inputMode: null as InputMode,
55
56
  inputBuffer: '',
57
+ targetPid: '',
58
+ selectedSignal: 'SIGTERM' as typeof SIGNAL_OPTIONS[number],
56
59
  treeView: false,
57
60
  SIGNAL_OPTIONS,
58
61
  customizerIndex: 0,
@@ -107,4 +110,22 @@ function setStatus(msg: string, ms = 3000) {
107
110
  }, ms);
108
111
  }
109
112
 
110
- export { state, setStatus };
113
+ function getToggleKey(opt: string): keyof VisibleItems | null {
114
+ const map: Record<string, keyof VisibleItems> = {
115
+ 'Toggle CPU': 'cpu',
116
+ 'Toggle Memory': 'mem',
117
+ 'Toggle GPU': 'gpu',
118
+ 'Toggle Power': 'power',
119
+ 'Toggle Battery': 'battery',
120
+ 'Toggle Thermal': 'thermal',
121
+ 'Toggle Network': 'network',
122
+ 'Toggle Packets': 'packets',
123
+ 'Toggle Tasks': 'tasks',
124
+ 'Toggle Disk': 'disk',
125
+ 'Toggle Processes': 'process',
126
+ 'Toggle Tree View': 'tree',
127
+ };
128
+ return map[opt] ?? null;
129
+ }
130
+
131
+ export { state, setStatus, getToggleKey };
package/src/live/types.ts CHANGED
@@ -10,6 +10,7 @@ export interface LiveOptions {
10
10
  detailed?: boolean;
11
11
  exportDir?: string;
12
12
  autoLog?: boolean;
13
+ theme?: ThemeName;
13
14
  }
14
15
 
15
16
  export type ExportFormat = 'json' | 'csv' | 'tsv';
@@ -16,6 +16,52 @@ import { run } from './run.js';
16
16
  import { getSmcMetrics, parseSuffix } from './smc.js';
17
17
  import type { StatsData, CpuData, MemoryData, ThermalData, BatteryData, PowerData, DiskData, NetworkData, ProcessData, GpuData, PacketData, NetworkProcess } from './types.js';
18
18
 
19
+ const SP_TTL_MS = 10_000;
20
+ const NETSTAT_TTL_MS = 1000;
21
+ const ROUTE_TTL_MS = 5_000;
22
+ const SYSCTL_TTL_MS = 60_000;
23
+
24
+ let spGpuCache: { raw: string; ts: number } | null = null;
25
+ let spBatteryCache: { raw: string; ts: number } | null = null;
26
+ let netstatIbCache: { raw: string; ts: number } | null = null;
27
+ let routeCache: { iface: string; ip: string; ts: number } | null = null;
28
+ const sysctlCache = new Map<string, { value: string; ts: number }>();
29
+
30
+ async function cachedSysctl(key: string, fallback: string): Promise<string> {
31
+ const now = Date.now();
32
+ const hit = sysctlCache.get(key);
33
+ if (hit && now - hit.ts < SYSCTL_TTL_MS) return hit.value;
34
+ const value = await run(`sysctl -n ${key}`, fallback);
35
+ sysctlCache.set(key, { value, ts: now });
36
+ return value;
37
+ }
38
+
39
+ async function cachedNetstatIb(): Promise<string> {
40
+ const now = Date.now();
41
+ if (netstatIbCache && now - netstatIbCache.ts < NETSTAT_TTL_MS) return netstatIbCache.raw;
42
+ const raw = await run('netstat -ib');
43
+ netstatIbCache = { raw, ts: now };
44
+ return raw;
45
+ }
46
+
47
+ async function cachedRouteIface(): Promise<{ iface: string; ip: string }> {
48
+ const now = Date.now();
49
+ if (routeCache && now - routeCache.ts < ROUTE_TTL_MS) return routeCache;
50
+ const primary = (await run('route -n get default')).trim();
51
+ const ifaceMatch = primary.match(/interface:\s+(\S+)/);
52
+ const iface = ifaceMatch?.[1] || 'en0';
53
+ let ip = '0.0.0.0';
54
+ try {
55
+ const ifconfig = (await run(`ifconfig ${iface}`)).trim();
56
+ const ipMatch = ifconfig.match(/inet\s+([\d.]+)\s+netmask/);
57
+ if (ipMatch) ip = ipMatch[1];
58
+ } catch {
59
+ // ignore
60
+ }
61
+ routeCache = { iface, ip, ts: now };
62
+ return routeCache;
63
+ }
64
+
19
65
  // Retain samples over a *time* window rather than a fixed tick count. At the
20
66
  // default 2s refresh interval the old 10-sample window only spanned ~20s,
21
67
  // but battery level only moves in whole-percent steps that typically take
@@ -178,58 +224,61 @@ export async function collectPower(): Promise<PowerData | null> {
178
224
  * Returns null when GPU data is unavailable (e.g. on machines
179
225
  * without a discrete GPU or when powermetrics is inaccessible).
180
226
  */
181
- export async function collectGpu(detailed?: boolean): Promise<GpuData | null> {
182
- try {
183
- const spRaw = (await run('system_profiler SPDisplaysDataType 2>/dev/null')).trim();
184
- if (!spRaw) return null;
185
-
186
- const modelMatch = spRaw.match(/Chipset Model:\s*(.+)/i);
187
- const model = modelMatch ? modelMatch[1].trim() : 'Unknown';
188
-
189
- const memMatch = spRaw.match(/VRAM \(Total\):\s*(.+)/i);
190
- let memory = 0;
191
- if (memMatch) {
192
- const memStr = memMatch[1].trim();
193
- const memNum = parseFloat(memStr);
194
- if (memStr.includes('GB')) memory = memNum * 1024 * 1024 * 1024;
195
- else if (memStr.includes('MB')) memory = memNum * 1024 * 1024;
196
- }
227
+ export async function collectGpu(detailed?: boolean): Promise<GpuData | null> {
228
+ let spRaw = '';
229
+ const now = Date.now();
230
+ if (spGpuCache && now - spGpuCache.ts < SP_TTL_MS) {
231
+ spRaw = spGpuCache.raw;
232
+ } else {
233
+ spRaw = (await run('system_profiler SPDisplaysDataType 2>/dev/null')).trim();
234
+ spGpuCache = { raw: spRaw, ts: now };
235
+ }
236
+ if (!spRaw) return null;
237
+
238
+ const modelMatch = spRaw.match(/Chipset Model:\s*(.+)/i);
239
+ const model = modelMatch ? modelMatch[1].trim() : 'Unknown';
240
+
241
+ const memMatch = spRaw.match(/VRAM \(Total\):\s*(.+)/i);
242
+ let memory = 0;
243
+ if (memMatch) {
244
+ const memStr = memMatch[1].trim();
245
+ const memNum = parseFloat(memStr);
246
+ if (memStr.includes('GB')) memory = memNum * 1024 * 1024 * 1024;
247
+ else if (memStr.includes('MB')) memory = memNum * 1024 * 1024;
248
+ }
197
249
 
198
- let utilization = 0;
199
- let temperature: number | undefined;
200
- let gpuProcesses = 0;
250
+ let utilization = 0;
251
+ let temperature: number | undefined;
252
+ let gpuProcesses = 0;
201
253
 
202
- if (detailed) {
203
- try {
204
- const pm = (await run('sudo -n powermetrics --samplers gpu_power -n 1 --format text 2>/dev/null', '')).trim();
205
- const gpuPowerMatch = pm.match(/GPU Power:\s*([\d.]+)\s*mW/i);
206
- if (gpuPowerMatch) {
207
- utilization = Math.min(100, Math.round(parseFloat(gpuPowerMatch[1]) / 10));
208
- }
209
- } catch {
210
- // ignore
211
- }
254
+ if (detailed) {
255
+ try {
256
+ const pm = (await run('sudo -n powermetrics --samplers gpu_power -n 1 --format text 2>/dev/null', '')).trim();
257
+ const gpuPowerMatch = pm.match(/GPU Power:\s*([\d.]+)\s*mW/i);
258
+ if (gpuPowerMatch) {
259
+ utilization = Math.min(100, Math.round(parseFloat(gpuPowerMatch[1]) / 10));
260
+ }
261
+ } catch {
262
+ // ignore
263
+ }
212
264
 
213
- try {
214
- const { temps } = await getSmcMetrics();
215
- if (temps.gpu_die !== undefined) temperature = temps.gpu_die;
216
- } catch {
217
- // ignore
218
- }
265
+ try {
266
+ const { temps } = await getSmcMetrics();
267
+ if (temps.gpu_die !== undefined) temperature = temps.gpu_die;
268
+ } catch {
269
+ // ignore
270
+ }
219
271
 
220
- try {
221
- const psRaw = (await run('ps aux | grep -i "[g]pu" | wc -l')).trim();
222
- gpuProcesses = parseInt(psRaw) || 0;
223
- } catch {
224
- // ignore
225
- }
226
- }
272
+ try {
273
+ const psRaw = (await run('ps aux | grep -i "[g]pu" | wc -l')).trim();
274
+ gpuProcesses = parseInt(psRaw) || 0;
275
+ } catch {
276
+ // ignore
277
+ }
278
+ }
227
279
 
228
- return { model, memory, utilization, temperature, processes: gpuProcesses };
229
- } catch {
230
- return null;
231
- }
232
- }
280
+ return { model, memory, utilization, temperature, processes: gpuProcesses };
281
+ }
233
282
 
234
283
  async function collectSystem(): Promise<{ hostname: string; os: string; uptime: string }> {
235
284
  const hostname = (await run('hostname', 'Mac')).trim();
@@ -244,28 +293,21 @@ async function collectSystem(): Promise<{ hostname: string; os: string; uptime:
244
293
  }
245
294
 
246
295
  export async function collectCpu(): Promise<CpuData> {
247
- const brand = (await run('sysctl -n machdep.cpu.brand_string', 'Unknown CPU')).trim();
248
- const cores = parseInt((await run('sysctl -n hw.ncpu')).trim()) || 1;
249
- const physicalCores = parseInt((await run('sysctl -n hw.physicalcpu')).trim()) || cores;
250
- const frequencyHz = parseInt((await run('sysctl -n hw.cpufrequency')).trim());
296
+ const brand = (await cachedSysctl('machdep.cpu.brand_string', 'Unknown CPU')).trim();
297
+ const cores = parseInt(await cachedSysctl('hw.ncpu', '1')) || 1;
298
+ const physicalCores = parseInt(await cachedSysctl('hw.physicalcpu', String(cores))) || cores;
299
+ const frequencyHz = parseInt(await cachedSysctl('hw.cpufrequency', '0'));
251
300
  let frequency = frequencyHz > 0 ? Math.round(frequencyHz / 1_000_000) : 0;
252
301
 
253
302
  if (frequency === 0) {
254
- // hw.cpufrequency is an Intel-era sysctl. On Apple Silicon it's either
255
- // absent or reads back 0, so frequency was silently stuck at 0 MHz on
256
- // every M-series Mac. powermetrics reports the actual active clock speed
257
- // per core cluster (E/P-cluster) instead — reuse the same cached call
258
- // already used for temperature/power rather than shelling out again.
259
303
  try {
260
304
  const { freq } = await getSmcMetrics();
261
305
  const values = Object.values(freq);
262
306
  if (values.length) {
263
- // powermetrics reports in Hz, system profiler reports in MHz.
264
- // We need MHz for the display.
265
307
  frequency = Math.round(Math.max(...values) / 1_000_000);
266
308
  }
267
309
  } catch {
268
- // ignore — frequency stays 0, meaning "unavailable" rather than wrong
310
+ // ignore — frequency stays 0
269
311
  }
270
312
  }
271
313
 
@@ -300,8 +342,8 @@ export async function collectCpu(): Promise<CpuData> {
300
342
  }
301
343
 
302
344
  export async function collectMemory(): Promise<MemoryData> {
303
- const totalBytes = parseInt((await run('sysctl -n hw.memsize')).trim()) || 0;
304
- const pageSize = parseInt((await run('sysctl -n hw.pagesize', '4096')).trim()) || 4096;
345
+ const totalBytes = parseInt(await cachedSysctl('hw.memsize', '0')) || 0;
346
+ const pageSize = parseInt(await cachedSysctl('hw.pagesize', '4096')) || 4096;
305
347
 
306
348
  const vmStat = (await run('vm_stat')).trim();
307
349
  const getPageCount = (label: string) => {
@@ -390,7 +432,14 @@ export async function collectBattery(): Promise<BatteryData | null> {
390
432
  let maxCapacityPercent: number | undefined;
391
433
 
392
434
  try {
393
- const battInfo = (await run('system_profiler SPPowerDataType 2>/dev/null')).trim();
435
+ const now = Date.now();
436
+ let battInfo: string;
437
+ if (spBatteryCache && now - spBatteryCache.ts < SP_TTL_MS) {
438
+ battInfo = spBatteryCache.raw;
439
+ } else {
440
+ battInfo = (await run('system_profiler SPPowerDataType 2>/dev/null')).trim();
441
+ spBatteryCache = { raw: battInfo, ts: now };
442
+ }
394
443
 
395
444
  const cycleMatch = battInfo.match(/Cycle Count:\s+(\d+)/);
396
445
  if (cycleMatch) cycles = parseInt(cycleMatch[1]);
@@ -530,20 +579,8 @@ export async function collectThermal(detailed?: boolean): Promise<ThermalData> {
530
579
 
531
580
  export async function collectNetwork(): Promise<NetworkData> {
532
581
  try {
533
- const primary = (await run('route -n get default')).trim();
534
- const ifaceMatch = primary.match(/interface:\s+(\S+)/);
535
- const iface = ifaceMatch?.[1] || 'en0';
536
-
537
- let ip = '0.0.0.0';
538
- try {
539
- const ifconfig = (await run(`ifconfig ${iface}`)).trim();
540
- const ipMatch = ifconfig.match(/inet\s+([\d.]+)\s+netmask/);
541
- if (ipMatch) ip = ipMatch[1];
542
- } catch {
543
- // ignore
544
- }
545
-
546
- const netStat = (await run('netstat -ib')).trim();
582
+ const { iface, ip } = await cachedRouteIface();
583
+ const netStat = await cachedNetstatIb();
547
584
  let rxBytes = 0;
548
585
  let txBytes = 0;
549
586
  let rxPackets = 0;
@@ -590,7 +627,7 @@ export async function collectProcesses(limit?: number): Promise<ProcessData[]> {
590
627
 
591
628
  export async function collectPackets(): Promise<PacketData | null> {
592
629
  try {
593
- const netStat = (await run('netstat -ib')).trim();
630
+ const netStat = await cachedNetstatIb();
594
631
  const lines = netStat.split('\n').slice(1);
595
632
  let totalRxPackets = 0;
596
633
  let totalTxPackets = 0;
@@ -626,26 +663,18 @@ export async function collectPackets(): Promise<PacketData | null> {
626
663
 
627
664
  let topProcesses: { pid: number; command: string; rxBytes: number; txBytes: number }[] = [];
628
665
  let allProcesses: { pid: number; command: string; rxBytes: number; txBytes: number; rxPackets?: number; txPackets?: number }[] = [];
629
- try {
630
- const lsof = (await run('lsof -i -n -P 2>/dev/null | tail -n +2 | awk \'{print $1, $2}\' | sort | uniq -c | sort -rn', '')).trim();
631
- const lsofLines = lsof.split('\n');
632
- topProcesses = lsofLines.map(line => {
633
- const parts = line.trim().split(/\s+/);
634
- if (parts.length >= 3) {
635
- return { pid: parseInt(parts[2]) || 0, command: parts[1], rxBytes: 0, txBytes: parseInt(parts[0]) || 0 };
636
- }
637
- return { pid: 0, command: '', rxBytes: 0, txBytes: 0 };
638
- }).filter(p => p.pid > 0);
639
-
640
- const allLsof = (await run('lsof -i -n -P 2>/dev/null | tail -n +2 | awk \'{print $1, $2}\' | sort | uniq -c | sort -rn', '')).trim();
641
- const allLsofLines = allLsof.split('\n');
642
- allProcesses = allLsofLines.map(line => {
643
- const parts = line.trim().split(/\s+/);
644
- if (parts.length >= 3) {
645
- return { pid: parseInt(parts[2]) || 0, command: parts[1], rxBytes: 0, txBytes: parseInt(parts[0]) || 0 };
646
- }
647
- return { pid: 0, command: '', rxBytes: 0, txBytes: 0 };
648
- }).filter(p => p.pid > 0);
666
+ try {
667
+ const lsof = (await run('lsof -i -n -P 2>/dev/null | tail -n +2 | awk \'{print $1, $2}\' | sort | uniq -c | sort -rn', '')).trim();
668
+ const lsofLines = lsof.split('\n');
669
+ topProcesses = lsofLines.map(line => {
670
+ const parts = line.trim().split(/\s+/);
671
+ if (parts.length >= 3) {
672
+ return { pid: parseInt(parts[2]) || 0, command: parts[1], rxBytes: 0, txBytes: parseInt(parts[0]) || 0 };
673
+ }
674
+ return { pid: 0, command: '', rxBytes: 0, txBytes: 0 };
675
+ }).filter(p => p.pid > 0);
676
+
677
+ allProcesses = topProcesses;
649
678
  } catch {
650
679
  topProcesses = [];
651
680
  allProcesses = [];