pyre-cli 1.3.0 → 2.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.
@@ -0,0 +1,659 @@
1
+ /**
2
+ * Dashboard rendering helpers.
3
+ *
4
+ * This module contains every function that turns a {@link StatsData}
5
+ * snapshot into styled terminal output: panel drawing, card layout,
6
+ * process/disk tables, and the main `formatTable` orchestrator.
7
+ *
8
+ * Low-level utilities (ANSI stripping, padding, truncation) live
9
+ * here alongside the higher-level card and table renderers so that
10
+ * all layout-related code is co-located.
11
+ */
12
+ import chalk from 'chalk';
13
+ import { sparkline } from '../sparkline.js';
14
+ import type { History } from '../history.js';
15
+ import type { StatsData, VisibleItems, TableOptions } from './types.js';
16
+ import { THEMES, type ThemeName } from './themes.js';
17
+
18
+ // --- low-level box drawing ---------------------------------------------------
19
+
20
+ function visLen(s: string): number {
21
+ // eslint-disable-next-line no-control-regex
22
+ return s.replace(/\x1b\[[0-9;]*m/g, '').length;
23
+ }
24
+
25
+ function padVisible(s: string, width: number): string {
26
+ const len = visLen(s);
27
+ if (len >= width) return s;
28
+ return s + ' '.repeat(width - len);
29
+ }
30
+
31
+ function truncatePlain(s: string, width: number): string {
32
+ if (s.length <= width) return s;
33
+ return width <= 1 ? s.slice(0, width) : s.slice(0, width - 1) + '…';
34
+ }
35
+
36
+ /** Draw a rounded panel of exactly `width` columns wide and `height` content rows tall. */
37
+ export function panel(
38
+ title: string,
39
+ lines: string[],
40
+ width: number,
41
+ accent: (s: string) => string,
42
+ borderAccent: (s: string) => string,
43
+ height?: number
44
+ ): string[] {
45
+ const h = height ?? lines.length;
46
+ const contentWidth = Math.max(1, width - 4);
47
+ const label = ` ${title} `;
48
+ const top = borderAccent('┌' + label + '─'.repeat(width - label.length - 2) + '┐');
49
+ const bottom = borderAccent('└' + '─'.repeat(width - 2) + '┘');
50
+ const body: string[] = [];
51
+ for (let i = 0; i < h; i++) {
52
+ const line = lines[i] ?? '';
53
+ body.push(`${borderAccent('│')} ${padVisible(line, contentWidth)} ${borderAccent('│')}`);
54
+ }
55
+ return [top, ...body, bottom];
56
+ }
57
+
58
+ /** Join equal-or-uneven-height panel blocks side by side with a gap. */
59
+ function hstack(blocks: string[][], gap = 2): string[] {
60
+ if (!blocks.length) return [];
61
+ const height = Math.max(...blocks.map(b => b.length));
62
+ const widths = blocks.map(b => visLen(b[0] ?? ''));
63
+ const padded = blocks.map((b, i) => {
64
+ const filler = ' '.repeat(widths[i]);
65
+ const arr = [...b];
66
+ while (arr.length < height) arr.push(filler);
67
+ return arr;
68
+ });
69
+ const out: string[] = [];
70
+ for (let row = 0; row < height; row++) {
71
+ out.push(padded.map(a => a[row]).join(' '.repeat(gap)));
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function statRow(label: string, value: string, width = 9): string {
77
+ const w = Math.max(width, label.length + 1);
78
+ return `${chalk.dim(label.padEnd(w))}${value}`;
79
+ }
80
+
81
+ function bar(percent: number, width = 20): string {
82
+ const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width);
83
+ const empty = width - filled;
84
+ const filledStr = '█'.repeat(Math.max(0, filled));
85
+ const emptyStr = '░'.repeat(Math.max(0, empty));
86
+ const color = percent > 90 ? chalk.red : percent > 70 ? chalk.yellow : chalk.green;
87
+ return color(filledStr + emptyStr);
88
+ }
89
+
90
+ function gaugeRow(label: string, percent: number, contentWidth: number): string {
91
+ const labelW = 9;
92
+ const pctText = `${Math.round(percent)}%`;
93
+ const barW = Math.max(6, contentWidth - labelW - pctText.length - 1);
94
+ const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * barW);
95
+ const empty = barW - filled;
96
+ const barStr = '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, empty));
97
+ const color = percent > 90 ? chalk.red : percent > 70 ? chalk.yellow : chalk.green;
98
+ return `${chalk.dim(label.padEnd(labelW))}${color(barStr)} ${pctText}`;
99
+ }
100
+
101
+ export function thermalColor(pressureLevel: number) {
102
+ if (pressureLevel >= 3) return chalk.red;
103
+ if (pressureLevel === 2) return chalk.yellow;
104
+ if (pressureLevel === 1) return chalk.yellowBright;
105
+ return chalk.green;
106
+ }
107
+
108
+ export function capacityColor(percent: number) {
109
+ if (percent < 80) return chalk.red;
110
+ if (percent < 90) return chalk.yellow;
111
+ return chalk.green;
112
+ }
113
+
114
+ export function formatBytes(bytes: number): string {
115
+ if (bytes < 1024) return `${bytes} B`;
116
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
117
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
118
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
119
+ }
120
+
121
+ export function celsiusToFahrenheit(c: number): number {
122
+ return c * (9 / 5) + 32;
123
+ }
124
+
125
+ /** Render a Celsius reading with its Fahrenheit equivalent alongside it, e.g. "62.0°C / 143.6°F". */
126
+ export function formatTemp(c: number): string {
127
+ return `${c.toFixed(1)}°C / ${celsiusToFahrenheit(c).toFixed(1)}°F`;
128
+ }
129
+
130
+ // --- cards -------------------------------------------------------------------
131
+
132
+ function cpuCard(data: StatsData, contentWidth: number): string[] {
133
+ const lines = [
134
+ statRow('Brand', truncatePlain(data.cpu.brand, Math.max(4, contentWidth - 8))),
135
+ statRow('Cores', `${data.cpu.physicalCores}/${data.cpu.cores} phys/log`),
136
+ statRow('Freq', `${data.cpu.frequency} MHz`),
137
+ gaugeRow('Usage', data.cpu.usage, contentWidth),
138
+ statRow('Load', data.cpu.loadAvg.map(l => l.toFixed(2)).join(' ')),
139
+ ];
140
+ if (data.cpu.temperature) lines.push(statRow('Temp', formatTemp(data.cpu.temperature)));
141
+ return lines;
142
+ }
143
+
144
+ function memCard(data: StatsData, contentWidth: number): string[] {
145
+ return [
146
+ statRow('Total', formatBytes(data.memory.total)),
147
+ statRow('Used', formatBytes(data.memory.used)),
148
+ statRow('Cached', formatBytes(data.memory.total - data.memory.free - data.memory.used)),
149
+ statRow('Free', formatBytes(data.memory.free)),
150
+ gaugeRow('Usage', data.memory.usagePercent, contentWidth),
151
+ statRow('Swap', `${formatBytes(data.memory.swapUsed)} / ${formatBytes(data.memory.swapTotal)}`),
152
+ ];
153
+ }
154
+
155
+ function gpuCard(data: StatsData, contentWidth: number): string[] | null {
156
+ if (!data.gpu) return null;
157
+ const lines: string[] = [];
158
+ lines.push(statRow('Model', truncatePlain(data.gpu.model, Math.max(4, contentWidth - 8))));
159
+ lines.push(statRow('Memory', formatBytes(data.gpu.memory)));
160
+ lines.push(statRow('Util', `${data.gpu.utilization}%`));
161
+ if (data.gpu.temperature) lines.push(statRow('Temp', formatTemp(data.gpu.temperature)));
162
+ lines.push(statRow('Procs', String(data.gpu.processes)));
163
+ return lines;
164
+ }
165
+
166
+ function powerCard(data: StatsData): string[] | null {
167
+ const lines: string[] = [];
168
+ if (data.power) {
169
+ if (data.power.cpuWatts !== undefined) lines.push(statRow('CPU', `${data.power.cpuWatts.toFixed(2)} W`));
170
+ if (data.power.gpuWatts !== undefined) lines.push(statRow('GPU', `${data.power.gpuWatts.toFixed(2)} W`));
171
+ if (data.power.combinedWatts !== undefined) lines.push(statRow('Total', `${data.power.combinedWatts.toFixed(2)} W`));
172
+ }
173
+ if (data.battery?.powerWatts !== undefined && data.battery.powerWatts > 0) {
174
+ lines.push(statRow('Battery', `${data.battery.powerWatts.toFixed(2)} W`));
175
+ }
176
+ return lines.length ? lines : null;
177
+ }
178
+
179
+ function batteryCard(data: StatsData, contentWidth: number): string[] | null {
180
+ if (!data.battery) return null;
181
+ const valW = Math.max(6, contentWidth - 10);
182
+ const lines = [
183
+ statRow('Level', `${data.battery.level}% (${data.battery.state})`),
184
+ statRow('Time', data.battery.timeRemaining),
185
+ statRow('Cycles', String(data.battery.cycles ?? 'N/A')),
186
+ statRow('Condition', data.battery.condition ?? 'N/A'),
187
+ ];
188
+ if (data.battery.maxCapacityPercent !== undefined) {
189
+ lines.push(statRow('Capacity', capacityColor(data.battery.maxCapacityPercent)(`${data.battery.maxCapacityPercent}%`)));
190
+ }
191
+ lines.push(statRow('Source', data.battery.powerSource));
192
+ if (data.battery.estimatedTimeToEmpty && data.battery.state !== 'charged') {
193
+ lines.push(statRow('Est. Empty', data.battery.estimatedTimeToEmpty));
194
+ }
195
+ if (data.battery.dischargeRatePerHour !== undefined && data.battery.state !== 'charged') {
196
+ lines.push(statRow('Disch. Rate', `${data.battery.dischargeRatePerHour}%/h`));
197
+ }
198
+ return lines;
199
+ }
200
+
201
+ function thermalCard(data: StatsData, contentWidth: number): string[] {
202
+ const valW = Math.max(6, contentWidth - 10);
203
+ const lines = [statRow('State', thermalColor(data.thermal.pressureLevel)(truncatePlain(data.thermal.state, valW)))];
204
+ if (data.thermal.temperatures) {
205
+ for (const [k, v] of Object.entries(data.thermal.temperatures)) {
206
+ lines.push(statRow(k.replace('_', ' '), v !== null && v !== undefined ? formatTemp(v) : 'N/A'));
207
+ }
208
+ }
209
+ if (data.thermal.error) lines.push(statRow('Error', truncatePlain(data.thermal.error, valW)));
210
+ return lines;
211
+ }
212
+
213
+ function networkCard(data: StatsData, contentWidth: number): string[] {
214
+ const valW = Math.max(6, contentWidth - 8);
215
+ return [
216
+ statRow('Iface', truncatePlain(`${data.network.interface} (${data.network.ip})`, valW)),
217
+ statRow('RX', `${formatBytes(data.network.rxBytes)} / ${data.network.rxPackets} pkt`),
218
+ statRow('TX', `${formatBytes(data.network.txBytes)} / ${data.network.txPackets} pkt`),
219
+ ];
220
+ }
221
+
222
+ function packetCard(data: StatsData, contentWidth: number): string[] | null {
223
+ if (!data.packets) return null;
224
+ const lines: string[] = [];
225
+ lines.push(statRow('Total', `${data.packets.totalPackets} pkt`));
226
+ lines.push(statRow('RX', `${data.packets.rxPackets} pkt`));
227
+ lines.push(statRow('TX', `${data.packets.txPackets} pkt`));
228
+ lines.push(statRow('Conns', `${data.packets.connections} TCP`));
229
+ if (data.packets.topProcesses && data.packets.topProcesses.length) {
230
+ lines.push(statRow('Top Proc', truncatePlain(data.packets.topProcesses[0].command, contentWidth - 10)));
231
+ }
232
+ if (data.packets.allProcesses && data.packets.allProcesses.length > 1) {
233
+ lines.push(statRow('Net Procs', `${data.packets.allProcesses.length} processes`));
234
+ }
235
+ if (data.packets.interfaces && data.packets.interfaces.length) {
236
+ lines.push(statRow('Ifaces', `${data.packets.interfaces.length} active`));
237
+ }
238
+ return lines;
239
+ }
240
+
241
+ function tasksCard(data: StatsData, contentWidth: number): string[] | null {
242
+ if (!data.tasks || !data.tasks.length) return null;
243
+ const lines: string[] = [];
244
+ for (const task of data.tasks.slice(0, 6)) {
245
+ const cmd = truncatePlain(task.command, Math.max(4, contentWidth - 28));
246
+ lines.push(statRow(`${task.pid}`, `${task.user.padEnd(8)} ${task.cpu.toFixed(1).padStart(5)}% ${task.mem.toFixed(1).padStart(5)}% ${task.state} ${cmd}`));
247
+ }
248
+ return lines;
249
+ }
250
+
251
+ // --- header / tabs -----------------------------------------------------------
252
+
253
+ function header(data: StatsData, width: number, badges: string[] = [], borderAccent = THEMES.default.border): string[] {
254
+ const now = new Date().toLocaleTimeString();
255
+ const left = `${chalk.hex('#ff5500').bold(' PYRE ')} ${chalk.bold(data.header.hostname)} ${chalk.dim(data.header.os)} ${chalk.dim('up ' + data.header.uptime)}`;
256
+ const right = `${chalk.dim(now)} ${badges.join(' ')}`;
257
+ const gap = Math.max(1, width - visLen(left) - visLen(right));
258
+ const line = left + ' '.repeat(gap) + right;
259
+ return [line, borderAccent('─'.repeat(width))];
260
+ }
261
+
262
+ /**
263
+ * Canonical tab list shared between the renderer (drawing the tab bar)
264
+ * and the live dashboard's mouse handler (hit-testing clicks against it).
265
+ * Keeping a single source of truth here avoids the two drifting apart.
266
+ */
267
+ export const TAB_DEFS: { id: string; label: string; key: string }[] = [
268
+ { id: 'cpu', label: 'CPU', key: '1' },
269
+ { id: 'mem', label: 'Memory', key: '2' },
270
+ { id: 'gpu', label: 'GPU', key: '3' },
271
+ { id: 'power', label: 'Power', key: '4' },
272
+ { id: 'battery', label: 'Battery', key: '5' },
273
+ { id: 'thermal', label: 'Thermal', key: '6' },
274
+ { id: 'network', label: 'Network', key: '7' },
275
+ { id: 'packets', label: 'Packets', key: '8' },
276
+ { id: 'tasks', label: 'Tasks', key: '9' },
277
+ { id: 'disk', label: 'Disk', key: '0' },
278
+ { id: 'process', label: 'Process', key: 'p' },
279
+ ];
280
+
281
+ /** The number of leading columns of indent before the tab bar's first character (see `tabBar`). */
282
+ const TAB_BAR_INDENT = 2;
283
+ /** Visible width of the ' │ ' separator drawn between tabs. */
284
+ const TAB_SEPARATOR_WIDTH = 3;
285
+
286
+ /**
287
+ * Compute the column range (inclusive start, exclusive end) each tab
288
+ * occupies when rendered by `tabBar`, so a click at a given column can be
289
+ * mapped back to a panel id. Row is always the same line the tab bar is
290
+ * printed on (see `formatTable`: header takes 2 lines, then a blank line,
291
+ * then the tab bar — i.e. row 4 in a 1-indexed terminal, row index 3 if
292
+ * 0-indexed from the top of the dashboard).
293
+ */
294
+ export function getTabHitboxes(): { id: string; start: number; end: number }[] {
295
+ const boxes: { id: string; start: number; end: number }[] = [];
296
+ let col = TAB_BAR_INDENT;
297
+ TAB_DEFS.forEach((t, i) => {
298
+ const text = `[${t.key}]${t.label}`;
299
+ const start = col;
300
+ const end = col + text.length;
301
+ boxes.push({ id: t.id, start, end });
302
+ col = end;
303
+ if (i < TAB_DEFS.length - 1) col += TAB_SEPARATOR_WIDTH;
304
+ });
305
+ return boxes;
306
+ }
307
+
308
+ /** Row (1-indexed, matching terminal mouse-report coordinates) the tab bar renders on. */
309
+ export const TAB_BAR_ROW = 4;
310
+
311
+ function tabBar(activePanel: string, width: number, theme: ThemeColors): string {
312
+ const rendered = TAB_DEFS.map(t => {
313
+ const isActive = activePanel === t.id;
314
+ const label = isActive ? chalk.bold(t.label) : chalk.dim(t.label);
315
+ const keyHint = chalk.dim(`[${t.key}]`);
316
+ return `${keyHint}${label}`;
317
+ });
318
+
319
+ const joined = rendered.join(chalk.dim(' │ '));
320
+ const padded = visLen(joined) < width ? joined + ' '.repeat(width - visLen(joined)) : joined;
321
+ return chalk.dim(' ') + padded;
322
+ }
323
+
324
+ // --- process / disk tables ---------------------------------------------------
325
+
326
+ function sortProcesses(
327
+ processes: { pid: number; ppid: number; user: string; cpu: number; mem: number; command: string; state: string; threads: number; runtime: number }[],
328
+ sortBy: 'cpu' | 'mem' | 'pid' | 'user' | 'command' | 'state' | 'threads' | 'runtime'
329
+ ) {
330
+ const copy = [...processes];
331
+ switch (sortBy) {
332
+ case 'pid': return copy.sort((a, b) => a.pid - b.pid);
333
+ case 'mem': return copy.sort((a, b) => b.mem - a.mem);
334
+ case 'user': return copy.sort((a, b) => a.user.localeCompare(b.user));
335
+ case 'command': return copy.sort((a, b) => a.command.localeCompare(b.command));
336
+ case 'state': return copy.sort((a, b) => a.state.localeCompare(b.state));
337
+ case 'threads': return copy.sort((a, b) => b.threads - a.threads);
338
+ case 'runtime': return copy.sort((a, b) => b.runtime - a.runtime);
339
+ default: return copy.sort((a, b) => b.cpu - a.cpu);
340
+ }
341
+ }
342
+
343
+ function pctColor(v: number): (s: string) => string {
344
+ return v > 80 ? chalk.red : v > 50 ? chalk.yellow : chalk.dim;
345
+ }
346
+
347
+ function processTableLines(
348
+ processes: { pid: number; ppid: number; user: string; cpu: number; mem: number; command: string; state: string; threads: number; runtime: number }[],
349
+ contentWidth: number,
350
+ borderAccent = THEMES.default.border
351
+ ): string[] {
352
+ const pidW = 8, ppidW = 8, userW = 12, cpuW = 8, memW = 8, stateW = 8, thrW = 6, rtW = 10;
353
+
354
+ const cmdW = Math.max(10, contentWidth - pidW - ppidW - userW - cpuW - memW - stateW - thrW - rtW - 8);
355
+ const head =
356
+ chalk.bold.hex('#f8f8f2').bgHex('#44475a')(' PID ') +
357
+ chalk.bold.hex('#f8f8f2').bgHex('#44475a')(' PPID ') +
358
+ chalk.bold.hex('#f8f8f2').bgHex('#44475a')(' USER ') +
359
+ chalk.bold.hex('#f8f8f2').bgHex('#44475a')(' CPU% ') +
360
+ chalk.bold.hex('#f8f8f2').bgHex('#44475a')(' MEM% ') +
361
+ chalk.bold.hex('#f8f8f2').bgHex('#44475a')(' STATE ') +
362
+ chalk.bold.hex('#f8f8f2').bgHex('#44475a')(' THREADS') +
363
+ chalk.bold.hex('#f8f8f2').bgHex('#44475a')(' RUNTIME ') +
364
+ chalk.bold.hex('#f8f8f2').bgHex('#44475a')(' COMMAND'.padEnd(cmdW + 1));
365
+
366
+ const rows = processes.map(p => {
367
+ const cpuStr = pctColor(p.cpu)(`${p.cpu.toFixed(1)}`.padEnd(cpuW));
368
+ const memStr = pctColor(p.mem)(`${p.mem.toFixed(1)}`.padEnd(memW));
369
+ const stateStr = chalk.dim(p.state.padEnd(stateW));
370
+ const thrStr = String(p.threads).padEnd(thrW);
371
+ const rtStr = formatRuntime(p.runtime).padEnd(rtW);
372
+ return (
373
+ String(p.pid).padEnd(pidW) +
374
+ String(p.ppid).padEnd(ppidW) +
375
+ truncatePlain(p.user, userW - 1).padEnd(userW) +
376
+ cpuStr +
377
+ memStr +
378
+ stateStr +
379
+ thrStr +
380
+ rtStr +
381
+ truncatePlain(p.command, cmdW)
382
+ );
383
+ });
384
+ return [head, borderAccent('─'.repeat(contentWidth)), ...rows];
385
+ }
386
+
387
+ function formatRuntime(seconds: number): string {
388
+ if (seconds < 60) return `${seconds}s`;
389
+ const mins = Math.floor(seconds / 60);
390
+ if (mins < 60) return `${mins}m ${seconds % 60}s`;
391
+ const hrs = Math.floor(mins / 60);
392
+ return `${hrs}h ${mins % 60}m`;
393
+ }
394
+
395
+ function buildProcessTree(
396
+ processes: { pid: number; ppid: number; user: string; cpu: number; mem: number; command: string; state: string; threads: number; runtime: number }[]
397
+ ): string[] {
398
+ const map = new Map<number, typeof processes[0]>();
399
+ const roots: typeof processes[0][] = [];
400
+ for (const p of processes) map.set(p.pid, p);
401
+ for (const p of processes) {
402
+ if (p.ppid === 0 || !map.has(p.ppid)) roots.push(p);
403
+ }
404
+ const lines: string[] = [];
405
+ const visited = new Set<number>();
406
+ function renderNode(proc: typeof processes[0], prefix: string, isLast: boolean) {
407
+ if (visited.has(proc.pid)) return;
408
+ visited.add(proc.pid);
409
+ const connector = isLast ? '└── ' : '├── ';
410
+ const pidStr = String(proc.pid).padEnd(8);
411
+ const cpuStr = pctColor(proc.cpu)(`${proc.cpu.toFixed(1)}%`.padEnd(8));
412
+ const memStr = pctColor(proc.mem)(`${proc.mem.toFixed(1)}%`.padEnd(8));
413
+ const stateStr = chalk.dim(proc.state.padEnd(8));
414
+ const thrStr = String(proc.threads).padEnd(6);
415
+
416
+ const cmd = truncatePlain(proc.command, Math.max(10, 100 - pidStr.length - cpuStr.length - memStr.length - stateStr.length - thrStr.length - 10));
417
+ lines.push(`${prefix}${connector}${pidStr}${cpuStr}${memStr}${stateStr}${thrStr} ${cmd}`);
418
+ const children = processes.filter(p => p.ppid === proc.pid && !visited.has(p.pid));
419
+ children.sort((a, b) => b.cpu - a.cpu);
420
+ children.forEach((child, i) => {
421
+ const nextPrefix = prefix + (isLast ? ' ' : '│ ');
422
+ renderNode(child, nextPrefix, i === children.length - 1);
423
+ });
424
+ }
425
+ roots.sort((a, b) => b.cpu - a.cpu);
426
+ roots.forEach((root, i) => renderNode(root, '', i === roots.length - 1));
427
+ return lines;
428
+ }
429
+
430
+ function diskTableLines(
431
+ disks: { filesystem: string; size: string; used: string; available: string; capacity: string; mountpoint: string }[],
432
+ contentWidth: number,
433
+ borderAccent = THEMES.default.border
434
+ ): string[] {
435
+ const fsW = 20, sizeW = 10, usedW = 10, availW = 10, capW = 8;
436
+
437
+ const mountW = Math.max(8, contentWidth - fsW - sizeW - usedW - availW - capW - 5);
438
+ const head =
439
+ chalk.bold.hex('#f8f8f2').bgHex('#44475a')(' FILESYSTEM ') +
440
+ chalk.bold.hex('#f8f8f2').bgHex('#44475a')(' SIZE ') +
441
+ chalk.bold.hex('#f8f8f2').bgHex('#44475a')(' USED ') +
442
+ chalk.bold.hex('#f8f8f2').bgHex('#44475a')(' AVAIL ') +
443
+ chalk.bold.hex('#f8f8f2').bgHex('#44475a')(' CAP ') +
444
+ chalk.bold.hex('#f8f8f2').bgHex('#44475a')(' MOUNT'.padEnd(mountW + 1));
445
+ const rows = disks.map(d => {
446
+
447
+ const capNum = parseInt(d.capacity, 10) || 0;
448
+ const capStr = pctColor(capNum)(d.capacity.padEnd(capW));
449
+ return (
450
+ truncatePlain(d.filesystem, fsW - 1).padEnd(fsW) +
451
+ d.size.padEnd(sizeW) +
452
+ d.used.padEnd(usedW) +
453
+ d.available.padEnd(availW) +
454
+ capStr +
455
+ truncatePlain(d.mountpoint, mountW)
456
+ );
457
+ });
458
+ return [head, borderAccent('─'.repeat(contentWidth)), ...rows];
459
+ }
460
+
461
+ // --- main dashboard ------------------------------------------------------------
462
+
463
+ function detailPanelTitle(panel: string): string {
464
+ const map: Record<string, string> = {
465
+ cpu: 'CPU',
466
+ mem: 'Memory',
467
+ gpu: 'GPU',
468
+ power: 'Power',
469
+ battery: 'Battery',
470
+ thermal: 'Thermal',
471
+ network: 'Network',
472
+ packets: 'Packets',
473
+ tasks: 'Tasks',
474
+ disk: 'Disk',
475
+ process: 'Processes',
476
+ };
477
+ return map[panel] || panel;
478
+ }
479
+
480
+ function activeDetailLines(data: StatsData, activePanel: string, width: number): string[] | null {
481
+ const contentWidth = width - 4;
482
+ switch (activePanel) {
483
+ case 'cpu':
484
+ return cpuCard(data, contentWidth);
485
+ case 'mem':
486
+ return memCard(data, contentWidth);
487
+ case 'gpu': {
488
+ const gpu = gpuCard(data, contentWidth);
489
+ return gpu ?? ['No GPU data available'];
490
+ }
491
+ case 'power': {
492
+ const power = powerCard(data);
493
+ if (!power && !data.battery) return ['No power data available'];
494
+ const lines: string[] = [];
495
+ if (power) lines.push(...power);
496
+ if (data.battery?.powerWatts !== undefined && data.battery.powerWatts > 0) {
497
+ lines.push(statRow('Battery', `${data.battery.powerWatts.toFixed(2)} W`));
498
+ }
499
+ return lines.length ? lines : ['No power data available'];
500
+ }
501
+ case 'battery':
502
+ return data.battery ? batteryCard(data, contentWidth) : ['No battery data available'];
503
+ case 'thermal':
504
+ return thermalCard(data, contentWidth);
505
+ case 'network':
506
+ return networkCard(data, contentWidth);
507
+ case 'packets': {
508
+ if (!data.packets) return ['No packet data available'];
509
+ const lines: string[] = [];
510
+ lines.push(statRow('Total', `${data.packets.totalPackets} pkt`));
511
+ lines.push(statRow('RX', `${data.packets.rxPackets} pkt`));
512
+ lines.push(statRow('TX', `${data.packets.txPackets} pkt`));
513
+ lines.push(statRow('Conns', `${data.packets.connections} TCP`));
514
+ if (data.packets.interfaces && data.packets.interfaces.length) {
515
+ lines.push(statRow('Ifaces', `${data.packets.interfaces.length} active`));
516
+ for (const iface of data.packets.interfaces.slice(0, 8)) {
517
+ lines.push(statRow(` ${iface.iface}`, `RX ${formatBytes(iface.rxBytes)} / TX ${formatBytes(iface.txBytes)}`));
518
+ }
519
+ }
520
+ if (data.packets.allProcesses && data.packets.allProcesses.length) {
521
+ lines.push(statRow('Net Procs', `${data.packets.allProcesses.length} total`));
522
+ for (const proc of data.packets.allProcesses.slice(0, 10)) {
523
+ lines.push(statRow(` ${proc.pid}`, `${truncatePlain(proc.command, 20)} TX ${formatBytes(proc.txBytes)}`));
524
+ }
525
+ }
526
+ return lines;
527
+ }
528
+ case 'tasks':
529
+ return data.tasks?.length ? tasksCard(data, contentWidth) : ['No task data available'];
530
+ case 'disk':
531
+ return data.disk.length ? diskTableLines(data.disk, width - 4, THEMES.default.border) : ['No disk data available'];
532
+ case 'process': {
533
+ const sorted = sortProcesses([...data.processes], 'cpu');
534
+ return processTableLines(sorted.slice(0, 50), width - 4, THEMES.default.border);
535
+ }
536
+ default:
537
+ return null;
538
+ }
539
+ }
540
+
541
+ export function formatTable(data: StatsData, opts: TableOptions = {}): string {
542
+ const width = clampWidth(opts.width);
543
+ const activePanel = opts.activePanel || 'grid';
544
+ const columns = gridColumns(width);
545
+ const gap = 2;
546
+ const cardWidth = Math.floor((width - gap * (columns - 1)) / columns);
547
+ const contentWidth = cardWidth - 4;
548
+
549
+ const themeName = opts.theme || 'default';
550
+ const theme = THEMES[themeName] || THEMES.default;
551
+ const vis = opts.visible ?? {};
552
+
553
+ const out: string[] = [];
554
+ out.push(...header(data, width, [], theme.border));
555
+ out.push('');
556
+ out.push(tabBar(activePanel, width, theme));
557
+ out.push('');
558
+
559
+ if (activePanel !== 'grid') {
560
+ const lines = activeDetailLines(data, activePanel, width);
561
+ if (lines) {
562
+ out.push(...panel(detailPanelTitle(activePanel), lines, width, theme[activePanel as keyof ThemeColors] ?? theme.cpu, theme.border));
563
+ }
564
+ return out.join('\n');
565
+ }
566
+
567
+ type Card = { title: string; accent: (s: string) => string; lines: string[] };
568
+ const cards: Card[] = [];
569
+
570
+ if (vis.cpu !== false) {
571
+ cards.push({ title: 'CPU', accent: theme.cpu, lines: cpuCard(data, contentWidth) });
572
+ }
573
+ if (vis.mem !== false) {
574
+ cards.push({ title: 'Memory', accent: theme.mem, lines: memCard(data, contentWidth) });
575
+ }
576
+ if (vis.gpu !== false) {
577
+ const gpu = gpuCard(data, contentWidth);
578
+ if (gpu) cards.push({ title: 'GPU', accent: theme.gpu, lines: gpu });
579
+ }
580
+ if (vis.power !== false) {
581
+ const power = powerCard(data);
582
+ if (power) cards.push({ title: 'Power', accent: theme.power, lines: power });
583
+ }
584
+ if (vis.battery !== false) {
585
+ const battery = batteryCard(data, contentWidth);
586
+ if (battery) cards.push({ title: 'Battery', accent: theme.battery, lines: battery });
587
+ }
588
+ if (vis.thermal !== false) {
589
+ cards.push({ title: 'Thermal', accent: theme.thermal, lines: thermalCard(data, contentWidth) });
590
+ }
591
+ if (vis.network !== false) {
592
+ cards.push({ title: 'Network', accent: theme.network, lines: networkCard(data, contentWidth) });
593
+ }
594
+ if (vis.packets !== false) {
595
+ const packets = packetCard(data, contentWidth);
596
+ if (packets) cards.push({ title: 'Packets', accent: theme.network, lines: packets });
597
+ }
598
+ if (vis.tasks !== false) {
599
+ const tasks = tasksCard(data, contentWidth);
600
+ if (tasks) cards.push({ title: 'Tasks', accent: theme.process, lines: tasks });
601
+ }
602
+
603
+ for (let i = 0; i < cards.length; i += columns) {
604
+ const row = cards.slice(i, i + columns);
605
+ const rowHeight = Math.max(...row.map(c => c.lines.length));
606
+ const blocks = row.map(c => panel(c.title, c.lines, cardWidth, c.accent, theme.border, rowHeight));
607
+ out.push(...hstack(blocks, gap));
608
+ out.push('');
609
+ }
610
+
611
+ if (vis.disk !== false && data.disk.length) {
612
+ const dw = width - 4;
613
+ out.push(...panel('Disk', diskTableLines(data.disk, dw, theme.border), width, theme.disk, theme.border));
614
+ out.push('');
615
+ }
616
+
617
+ if (vis.process !== false) {
618
+ const filteredProcesses = opts.filter
619
+ ? data.processes.filter(p => p.command.toLowerCase().includes(opts.filter!.toLowerCase()))
620
+ : data.processes;
621
+ const sortedProcesses = sortProcesses(filteredProcesses, opts.sortBy ?? 'cpu');
622
+ const limited = opts.processLimit ? sortedProcesses.slice(0, opts.processLimit) : sortedProcesses;
623
+
624
+ const procTitle = opts.filter
625
+ ? `Processes · filter "${opts.filter}" · sort ${opts.sortBy ?? 'cpu'}`
626
+ : `Processes · sort ${opts.sortBy ?? 'cpu'}`;
627
+
628
+ if (opts.treeView) {
629
+ const treeLines = buildProcessTree(limited);
630
+ if (treeLines.length) {
631
+ out.push(...panel(procTitle + ' · tree', treeLines, width, theme.process, theme.border));
632
+ } else if (opts.filter) {
633
+ out.push(...panel(procTitle, [chalk.dim(`No processes match "${opts.filter}"`)], width, theme.process, theme.border));
634
+ }
635
+ } else if (limited.length) {
636
+ out.push(...panel(procTitle, processTableLines(limited, width - 4, theme.border), width, theme.process, theme.border));
637
+ } else if (opts.filter) {
638
+ out.push(...panel(procTitle, [chalk.dim(`No processes match "${opts.filter}"`)], width, theme.process, theme.border));
639
+ }
640
+ }
641
+
642
+ return out.join('\n');
643
+ }
644
+
645
+ /** Clamp/normalize a terminal width to a sane range, defaulting to 80 cols. */
646
+ export function clampWidth(width?: number): number {
647
+ if (!width || Number.isNaN(width)) return 80;
648
+ return Math.max(60, Math.min(width, 240));
649
+ }
650
+
651
+ /**
652
+ * How many stat cards fit per row at a given terminal width.
653
+ * Shared with the live dashboard for layout budgeting.
654
+ */
655
+ export function gridColumns(width: number): number {
656
+ if (width >= 150) return 3;
657
+ if (width >= 96) return 2;
658
+ return 1;
659
+ }