@nredd/pi-session-kit 0.7.0 → 0.9.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/CHANGELOG.md +18 -0
- package/README.md +41 -8
- package/dist/pi-stats-export.mjs +22 -4
- package/docs/mon-dashboard.md +140 -0
- package/docs/stats-snapshots.md +15 -2
- package/package.json +10 -5
- package/src/config.ts +1 -1
- package/src/index.ts +4 -1
- package/src/mon/component.ts +604 -0
- package/src/mon/index.ts +105 -0
- package/src/mon/model.ts +197 -0
- package/src/mon/poll.ts +80 -0
- package/src/mon/source.ts +181 -0
- package/src/monitor/process.ts +2 -1
- package/src/monitor/validate.ts +1 -1
- package/src/stats/chart.ts +3 -1
- package/src/stats/cli.ts +24 -5
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
/** Responsive, cached /mon dashboard. Rendering and navigation never collect resources. */
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
type Component,
|
|
5
|
+
Key,
|
|
6
|
+
matchesKey,
|
|
7
|
+
truncateToWidth,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
wrapTextWithAnsi,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
import { fitCols, sanitize } from "../diff/view.ts";
|
|
12
|
+
import {
|
|
13
|
+
bytes,
|
|
14
|
+
type MonHistoryPoint,
|
|
15
|
+
type MonProcess,
|
|
16
|
+
type MonSnapshot,
|
|
17
|
+
type MonSort,
|
|
18
|
+
percent,
|
|
19
|
+
SORTS,
|
|
20
|
+
share,
|
|
21
|
+
sortProcesses,
|
|
22
|
+
timeHistory,
|
|
23
|
+
timeHistoryRows,
|
|
24
|
+
} from "./model.ts";
|
|
25
|
+
|
|
26
|
+
export interface MonViewOptions {
|
|
27
|
+
theme: Pick<Theme, "fg" | "bold" | "bg">;
|
|
28
|
+
rows: () => number;
|
|
29
|
+
requestRender: () => void;
|
|
30
|
+
onClose: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Color = Parameters<Theme["fg"]>[0];
|
|
34
|
+
|
|
35
|
+
/** Cards need this much room; below it a three-line summary keeps the process list usable. */
|
|
36
|
+
const CARD_MIN_WIDTH = 72;
|
|
37
|
+
const CARD_MIN_HEIGHT = 17;
|
|
38
|
+
const HISTORY_LIMIT = 120;
|
|
39
|
+
|
|
40
|
+
const HELP = [
|
|
41
|
+
"*Keys",
|
|
42
|
+
" j/k or arrows select process",
|
|
43
|
+
" PgUp/PgDn move one page",
|
|
44
|
+
" g/G first/last",
|
|
45
|
+
" c CPU m RSS descending",
|
|
46
|
+
" p PID ascending",
|
|
47
|
+
" s cycle sort",
|
|
48
|
+
" f toggle RSS / FOOT",
|
|
49
|
+
" ? back to dashboard",
|
|
50
|
+
" q/Esc close",
|
|
51
|
+
"",
|
|
52
|
+
"*Reading it",
|
|
53
|
+
" LIVE: the last collection succeeded;",
|
|
54
|
+
" updated HH:MM:SS is when (local).",
|
|
55
|
+
" CPU warming: first interval baseline,",
|
|
56
|
+
" not a stale or failed sample.",
|
|
57
|
+
" CPU is interval usage, not lifetime.",
|
|
58
|
+
" Process CPU can exceed 100% (one core).",
|
|
59
|
+
" -- is unavailable, never zero.",
|
|
60
|
+
" T-F / RAM total-free includes cache;",
|
|
61
|
+
" it is NOT Activity Monitor Memory Used.",
|
|
62
|
+
" macOS used (A+W+C) estimates resident",
|
|
63
|
+
" app + wired + compressed physical RAM.",
|
|
64
|
+
" Free is free pages, not available RAM.",
|
|
65
|
+
" Swap is used / allocated, not RAM.",
|
|
66
|
+
" MEM% is RSS over total RAM.",
|
|
67
|
+
" Load 1/5/15 min: not percent.",
|
|
68
|
+
" History: elapsed time, newest right;",
|
|
69
|
+
" dots are gaps, blank is no data yet.",
|
|
70
|
+
"",
|
|
71
|
+
"*Processes",
|
|
72
|
+
" Names are executable files, never",
|
|
73
|
+
" arguments; still untrusted text.",
|
|
74
|
+
" pi runs as node: executable, not title.",
|
|
75
|
+
" RSS excludes compressed pages.",
|
|
76
|
+
" Activity Monitor's Memory column:",
|
|
77
|
+
" footprint, not RSS, with compression.",
|
|
78
|
+
" f shows FOOT bytes; MEM% stays RSS.",
|
|
79
|
+
" Failed/unsupported footprint is --.",
|
|
80
|
+
" (unknown): no access, e.g. another",
|
|
81
|
+
" user's process on Linux.",
|
|
82
|
+
" macOS keeps 16 bytes of a name;",
|
|
83
|
+
" a trailing … marks a possible cut.",
|
|
84
|
+
" macOS CPU: native cumulative time;",
|
|
85
|
+
" Start ticks + usec reject PID reuse.",
|
|
86
|
+
" No native access/builtin FFI: --.",
|
|
87
|
+
" Node <26.1 lacks builtin FFI; Bun uses",
|
|
88
|
+
" bun:ffi. ps start is whole seconds.",
|
|
89
|
+
" Older sampler releases also lack CPU.",
|
|
90
|
+
" Same PID/parent/name/time is NOT proof.",
|
|
91
|
+
" ps %cpu is decaying, NOT interval CPU.",
|
|
92
|
+
" Lists can be partial; notices say why.",
|
|
93
|
+
" Selection follows identity, not row.",
|
|
94
|
+
" No kill, locks or scheduling.",
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Format local wall clock for display only.
|
|
99
|
+
* @param ms Unix epoch milliseconds.
|
|
100
|
+
* @returns Local HH:MM:SS time.
|
|
101
|
+
*/
|
|
102
|
+
function clock(ms: number): string {
|
|
103
|
+
const d = new Date(ms);
|
|
104
|
+
return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Single-interaction view; update is the sole ingress for sampler data. */
|
|
108
|
+
export class MonView implements Component {
|
|
109
|
+
private snapshot: MonSnapshot | undefined;
|
|
110
|
+
private history: MonHistoryPoint[] = [];
|
|
111
|
+
private lastSuccess: number | undefined;
|
|
112
|
+
private helpScroll = 0;
|
|
113
|
+
private helpPageSize = 1;
|
|
114
|
+
private helpLength = 0;
|
|
115
|
+
private processes: MonProcess[] = [];
|
|
116
|
+
private selected: string | undefined;
|
|
117
|
+
private sort: MonSort = "cpu";
|
|
118
|
+
private sortChosen = false;
|
|
119
|
+
private help = false;
|
|
120
|
+
private footprint = false;
|
|
121
|
+
private scroll = 0;
|
|
122
|
+
private pageSize = 1;
|
|
123
|
+
private cache: { width: number; height: number; lines: string[] } | undefined;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Initialize a single-interaction dashboard.
|
|
127
|
+
* @param options Theme, terminal dimensions and lifecycle callbacks.
|
|
128
|
+
*/
|
|
129
|
+
constructor(private readonly options: MonViewOptions) {}
|
|
130
|
+
|
|
131
|
+
/** @returns Readonly diagnostic selection/sort/history/scroll values. */
|
|
132
|
+
get state(): { selected: string | undefined; sort: MonSort; historyLength: number; scroll: number } {
|
|
133
|
+
return { selected: this.selected, sort: this.sort, historyLength: this.history.length, scroll: this.scroll };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Accept the sole sampler-data ingress and update selection/history.
|
|
138
|
+
* @param snapshot Current live/stale/waiting observation.
|
|
139
|
+
* @returns Nothing; requests a render.
|
|
140
|
+
*/
|
|
141
|
+
update(snapshot: MonSnapshot): void {
|
|
142
|
+
this.snapshot = snapshot;
|
|
143
|
+
// Collector success is independent of CPU: a warming sample still carries fresh memory/processes.
|
|
144
|
+
// A stale snapshot is not a new measurement, so it adds no history point.
|
|
145
|
+
if (snapshot.status === "live") {
|
|
146
|
+
this.lastSuccess = snapshot.capturedAt;
|
|
147
|
+
// Leading samples without a CPU value are "no data yet" (blank), not gaps.
|
|
148
|
+
if (snapshot.cpuPercent !== null || this.history.length > 0) {
|
|
149
|
+
this.history.push({ sampledAtMs: snapshot.sampledAtMs, value: snapshot.cpuPercent });
|
|
150
|
+
if (this.history.length > HISTORY_LIMIT) this.history.shift();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Never rank by a column that is unavailable for every row, unless the user asked for it.
|
|
154
|
+
if (!this.sortChosen) this.sort = snapshot.processCpu === "unavailable" ? "rss" : "cpu";
|
|
155
|
+
this.reorder();
|
|
156
|
+
this.refresh();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @returns Nothing; discard cached rendering without collecting data. */
|
|
160
|
+
invalidate(): void {
|
|
161
|
+
this.cache = undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** @returns Nothing; invalidate rendering and request a redraw. */
|
|
165
|
+
private refresh(): void {
|
|
166
|
+
this.invalidate();
|
|
167
|
+
this.options.requestRender();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** @returns Nothing; sort rows while retaining selection by identity when possible. */
|
|
171
|
+
private reorder(): void {
|
|
172
|
+
const oldIndex = Math.max(
|
|
173
|
+
0,
|
|
174
|
+
this.processes.findIndex((p) => p.identity === this.selected),
|
|
175
|
+
);
|
|
176
|
+
this.processes = sortProcesses(this.snapshot?.processes ?? [], this.sort);
|
|
177
|
+
if (!this.processes.some((p) => p.identity === this.selected)) {
|
|
178
|
+
this.selected = this.processes[Math.min(oldIndex, this.processes.length - 1)]?.identity;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Handle navigation, help, sorting, byte-column toggle and close keys.
|
|
184
|
+
* @param data Terminal key input.
|
|
185
|
+
* @returns Nothing; requests rendering for recognized view changes.
|
|
186
|
+
*/
|
|
187
|
+
handleInput(data: string): void {
|
|
188
|
+
if (data === "q" || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
189
|
+
this.options.onClose();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
let index = Math.max(
|
|
193
|
+
0,
|
|
194
|
+
this.processes.findIndex((p) => p.identity === this.selected),
|
|
195
|
+
);
|
|
196
|
+
if (data === "?") this.help = !this.help;
|
|
197
|
+
else if (this.help) {
|
|
198
|
+
if (data === "j" || matchesKey(data, Key.down)) this.helpScroll++;
|
|
199
|
+
else if (data === "k" || matchesKey(data, Key.up)) this.helpScroll--;
|
|
200
|
+
else if (matchesKey(data, Key.pageDown)) this.helpScroll += this.helpPageSize;
|
|
201
|
+
else if (matchesKey(data, Key.pageUp)) this.helpScroll -= this.helpPageSize;
|
|
202
|
+
else if (data === "g" || matchesKey(data, Key.home)) this.helpScroll = 0;
|
|
203
|
+
else if (data === "G" || matchesKey(data, Key.end)) this.helpScroll = this.helpLength;
|
|
204
|
+
else return;
|
|
205
|
+
this.helpScroll = Math.max(0, Math.min(this.helpScroll, this.helpLength - this.helpPageSize));
|
|
206
|
+
} else if (data === "f") {
|
|
207
|
+
this.footprint = !this.footprint;
|
|
208
|
+
} else if (data === "c" || data === "m" || data === "p" || data === "s") {
|
|
209
|
+
this.sortChosen = true;
|
|
210
|
+
this.sort =
|
|
211
|
+
data === "c"
|
|
212
|
+
? "cpu"
|
|
213
|
+
: data === "m"
|
|
214
|
+
? "rss"
|
|
215
|
+
: data === "p"
|
|
216
|
+
? "pid"
|
|
217
|
+
: (SORTS[(SORTS.indexOf(this.sort) + 1) % SORTS.length] ?? "cpu");
|
|
218
|
+
this.reorder();
|
|
219
|
+
} else {
|
|
220
|
+
if (data === "j" || matchesKey(data, Key.down)) index++;
|
|
221
|
+
else if (data === "k" || matchesKey(data, Key.up)) index--;
|
|
222
|
+
else if (matchesKey(data, Key.pageDown)) index += this.pageSize;
|
|
223
|
+
else if (matchesKey(data, Key.pageUp)) index -= this.pageSize;
|
|
224
|
+
else if (data === "g" || matchesKey(data, Key.home)) index = 0;
|
|
225
|
+
else if (data === "G" || matchesKey(data, Key.end)) index = this.processes.length - 1;
|
|
226
|
+
else return;
|
|
227
|
+
this.selected = this.processes[Math.max(0, Math.min(this.processes.length - 1, index))]?.identity;
|
|
228
|
+
}
|
|
229
|
+
this.refresh();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Dim unknown values consistently.
|
|
234
|
+
* @param text Preformatted metric.
|
|
235
|
+
* @param width Minimum cell width.
|
|
236
|
+
* @returns Left-padded, right-aligned text with unknown styling.
|
|
237
|
+
*/
|
|
238
|
+
private value(text: string, width: number): string {
|
|
239
|
+
const padded = text.padStart(width);
|
|
240
|
+
return text === "--" ? this.options.theme.fg("dim", padded) : padded;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Choose severity, always paired with a number or unknown marker.
|
|
245
|
+
* @param value Host CPU percentage or null.
|
|
246
|
+
* @returns Theme color for the CPU state.
|
|
247
|
+
*/
|
|
248
|
+
private cpuColor(value: number | null): Color {
|
|
249
|
+
return value === null ? "dim" : value >= 85 ? "error" : value >= 50 ? "warning" : "success";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Frame a dashboard card at fixed width.
|
|
254
|
+
* @param title Card heading.
|
|
255
|
+
* @param lines Already formatted body rows.
|
|
256
|
+
* @param width Outer card columns.
|
|
257
|
+
* @returns Framed rows fitted to the card.
|
|
258
|
+
*/
|
|
259
|
+
private panel(title: string, lines: string[], width: number): string[] {
|
|
260
|
+
const { theme } = this.options;
|
|
261
|
+
const inner = Math.max(0, width - 2);
|
|
262
|
+
const label = truncateToWidth(`─ ${title} `, inner, "…");
|
|
263
|
+
return [
|
|
264
|
+
theme.fg("border", "╭") +
|
|
265
|
+
theme.fg("accent", label) +
|
|
266
|
+
theme.fg("border", `${"─".repeat(Math.max(0, inner - visibleWidth(label)))}╮`),
|
|
267
|
+
...lines.map((line) => theme.fg("border", "│") + fitCols(line, inner) + theme.fg("border", "│")),
|
|
268
|
+
theme.fg("border", `╰${"─".repeat(inner)}╯`),
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Format CPU state; compact mode relies on the header's stale label.
|
|
274
|
+
* @param capacity Whether to include logical-core capacity and stale suffix.
|
|
275
|
+
* @returns Styled CPU summary.
|
|
276
|
+
*/
|
|
277
|
+
private cpuSummary(capacity = true): string {
|
|
278
|
+
const t = this.options.theme;
|
|
279
|
+
const s = this.snapshot;
|
|
280
|
+
const value = s?.cpuPercent ?? null;
|
|
281
|
+
const state = !capacity && s?.status === "stale" ? "" : this.cpuState();
|
|
282
|
+
const head =
|
|
283
|
+
value === null
|
|
284
|
+
? t.fg("dim", "--")
|
|
285
|
+
: s?.status === "stale"
|
|
286
|
+
? t.fg("muted", percent(value))
|
|
287
|
+
: t.bold(t.fg(this.cpuColor(value), percent(value)));
|
|
288
|
+
if (!capacity) return `${head}${state}`;
|
|
289
|
+
return `${head}${state} ${t.fg("muted", `${state ? "·" : "of"} ${s?.logicalCpus || "--"} logical CPUs`)}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** @returns Formatted 1/5/15-minute loads, or unknown markers. */
|
|
293
|
+
private loads(): string {
|
|
294
|
+
const load = this.snapshot?.load ?? [];
|
|
295
|
+
return load.length ? load.map((n) => (Number.isFinite(n) ? n.toFixed(2) : "--")).join(" ") : "--";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Render wide CPU and memory cards with explicit measurement basis.
|
|
300
|
+
* @param width Available dashboard columns.
|
|
301
|
+
* @returns Side-by-side card rows including free/swap and CPU history.
|
|
302
|
+
*/
|
|
303
|
+
private cards(width: number): string[] {
|
|
304
|
+
const s = this.snapshot;
|
|
305
|
+
const t = this.options.theme;
|
|
306
|
+
const leftWidth = Math.floor((width - 2) * 0.58);
|
|
307
|
+
const rightWidth = width - leftWidth - 2;
|
|
308
|
+
const chartWidth = leftWidth - 4;
|
|
309
|
+
const total = s?.memoryTotalBytes ?? null;
|
|
310
|
+
const used = s?.memoryUsedBytes ?? null;
|
|
311
|
+
const ratio = total && used !== null ? Math.max(0, Math.min(1, used / total)) : null;
|
|
312
|
+
const meterWidth = Math.max(1, Math.min(28, rightWidth - 9));
|
|
313
|
+
const full = ratio === null ? 0 : Math.round(ratio * meterWidth);
|
|
314
|
+
const load1 = s?.load[0];
|
|
315
|
+
const perCpu =
|
|
316
|
+
typeof load1 === "number" && Number.isFinite(load1) && s?.logicalCpus ? (load1 / s.logicalCpus).toFixed(2) : "--";
|
|
317
|
+
const cpu = this.panel(
|
|
318
|
+
"CPU / interval",
|
|
319
|
+
[
|
|
320
|
+
` ${this.cpuSummary()}`,
|
|
321
|
+
...timeHistoryRows(this.history, chartWidth, 2).map((row) => ` ${t.fg("accent", row)}`),
|
|
322
|
+
` ${t.fg("dim", this.historyLabel())}`,
|
|
323
|
+
"", // Match the memory panel's added free/swap row.
|
|
324
|
+
],
|
|
325
|
+
leftWidth,
|
|
326
|
+
);
|
|
327
|
+
const memory = this.panel(
|
|
328
|
+
"MEMORY / load",
|
|
329
|
+
[
|
|
330
|
+
` ${t.bold(bytes(used))} ${t.fg("muted", `/ ${bytes(total)} ${s?.memoryBasis === "app+wired+compressed" ? "used (A+W+C)" : "total-free"}`)}`,
|
|
331
|
+
` ${t.fg("accent", "━".repeat(full))}${t.fg("borderMuted", "─".repeat(meterWidth - full))} ${ratio === null ? t.fg("dim", "--") : `${Math.round(ratio * 100)}%`}`,
|
|
332
|
+
` ${t.fg("muted", "free")} ${bytes(s?.memoryFreeBytes ?? null)} ${t.fg("muted", "swap")} ${bytes(s?.swapUsedBytes ?? null)}/${bytes(s?.swapTotalBytes ?? null)}`,
|
|
333
|
+
` ${t.fg("muted", "load")} ${this.loads()}`,
|
|
334
|
+
` ${t.fg("muted", "1/5/15 min ·")} ${perCpu === "--" ? t.fg("dim", perCpu) : perCpu} ${t.fg("muted", "per CPU")}`,
|
|
335
|
+
],
|
|
336
|
+
rightWidth,
|
|
337
|
+
);
|
|
338
|
+
return cpu.map((line, i) => `${line} ${memory[i] ?? ""}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Compact three-line summary when cards do not fit. */
|
|
342
|
+
/**
|
|
343
|
+
* Render the compact metric/history summary.
|
|
344
|
+
* @param width Available dashboard columns.
|
|
345
|
+
* @returns Fitted summary rows with explicit RAM basis labels.
|
|
346
|
+
*/
|
|
347
|
+
private summary(width: number): string[] {
|
|
348
|
+
const s = this.snapshot;
|
|
349
|
+
const t = this.options.theme;
|
|
350
|
+
const total = s?.memoryTotalBytes ?? null;
|
|
351
|
+
const used = s?.memoryUsedBytes ?? null;
|
|
352
|
+
const ram = `${bytes(used)}/${bytes(total)}`;
|
|
353
|
+
const metrics = [
|
|
354
|
+
`${t.fg("muted", "CPU")} ${this.cpuSummary(false)}`,
|
|
355
|
+
`${t.fg("muted", s?.memoryBasis === "total-free" ? "T-F" : s?.memoryBasis === "app+wired+compressed" ? "A+W+C" : "RAM")} ${ram}`,
|
|
356
|
+
`${t.fg("muted", "load")} ${this.loads().split(" ")[0]}`,
|
|
357
|
+
];
|
|
358
|
+
const spaced = ` ${metrics.join(" ")}`;
|
|
359
|
+
return [
|
|
360
|
+
visibleWidth(spaced) <= width ? spaced : metrics.join(" "),
|
|
361
|
+
` ${t.fg("accent", timeHistory(this.history, width - 2))}`,
|
|
362
|
+
` ${t.fg("dim", this.historyLabel())}`,
|
|
363
|
+
];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** @returns Textual reason for unknown/stale host CPU, or an empty suffix. */
|
|
367
|
+
private cpuState(): string {
|
|
368
|
+
if (this.snapshot?.status === "stale") return ` ${this.options.theme.fg("muted", "last value")}`;
|
|
369
|
+
const state = this.snapshot?.cpuState;
|
|
370
|
+
return state === "warming" || state === "unavailable" ? ` ${this.options.theme.fg("warning", state)}` : "";
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** @returns Fixed CPU scale and actual monotonic history span label. */
|
|
374
|
+
private historyLabel(): string {
|
|
375
|
+
const span = Math.max(0, (this.history.at(-1)?.sampledAtMs ?? 0) - (this.history[0]?.sampledAtMs ?? 0));
|
|
376
|
+
return `0..100% · ${(span / 1000).toFixed(1)}s span · time →`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Format title, collector state and capture freshness.
|
|
381
|
+
* @param width Available dashboard columns.
|
|
382
|
+
* @returns Responsive styled header.
|
|
383
|
+
*/
|
|
384
|
+
private header(width: number): string {
|
|
385
|
+
const t = this.options.theme;
|
|
386
|
+
const status = this.snapshot?.status ?? "waiting";
|
|
387
|
+
const [mark, color]: [string, Color] =
|
|
388
|
+
status === "live" ? ["●", "success"] : status === "stale" ? ["▲", "warning"] : ["○", "warning"];
|
|
389
|
+
const left =
|
|
390
|
+
t.bold(t.fg("accent", " MON")) +
|
|
391
|
+
` ${t.fg(color, `${mark} ${status.toUpperCase()}`)}` +
|
|
392
|
+
// Narrow terminals keep freshness over the tagline.
|
|
393
|
+
(width >= 60 ? t.fg("dim", " local resources · read only") : "");
|
|
394
|
+
const scan = this.snapshot?.scanMs;
|
|
395
|
+
const parts: string[] = [];
|
|
396
|
+
if (this.lastSuccess !== undefined)
|
|
397
|
+
parts.push(
|
|
398
|
+
width >= 60
|
|
399
|
+
? `${status === "stale" ? "last ok" : "updated"} ${clock(this.lastSuccess)}`
|
|
400
|
+
: clock(this.lastSuccess),
|
|
401
|
+
);
|
|
402
|
+
if (width >= 80 && typeof scan === "number" && Number.isFinite(scan) && status === "live")
|
|
403
|
+
parts.push(`scan ${Math.round(scan)}ms`);
|
|
404
|
+
const right = parts.length ? t.fg("muted", `${parts.join(" · ")} `) : "";
|
|
405
|
+
const gap = width - visibleWidth(left) - visibleWidth(right);
|
|
406
|
+
return gap >= 2 ? `${left}${" ".repeat(gap)}${right}` : left;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Format reachable close/help/navigation keys.
|
|
411
|
+
* @param width Available dashboard columns.
|
|
412
|
+
* @returns Responsive footer text, fitted by the render caller.
|
|
413
|
+
*/
|
|
414
|
+
private footer(width: number): string {
|
|
415
|
+
const t = this.options.theme;
|
|
416
|
+
const keys: [string, string][] = this.help
|
|
417
|
+
? [
|
|
418
|
+
["q/Esc", "close"],
|
|
419
|
+
["?", "help"],
|
|
420
|
+
["j/k PgDn", "scroll"],
|
|
421
|
+
]
|
|
422
|
+
: width < 60
|
|
423
|
+
? [
|
|
424
|
+
["q/Esc", "close"],
|
|
425
|
+
["?", "help"],
|
|
426
|
+
["s", "sort"],
|
|
427
|
+
]
|
|
428
|
+
: [
|
|
429
|
+
["q/Esc", "close"],
|
|
430
|
+
["?", "help"],
|
|
431
|
+
["j/k", "move"],
|
|
432
|
+
["PgUp/PgDn", "page"],
|
|
433
|
+
["c/m/p", "sort"],
|
|
434
|
+
];
|
|
435
|
+
return ` ${keys.map(([key, label]) => `${t.fg("accent", key)} ${t.fg("muted", label)}`).join(" ")}`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Format a safe executable name with unknown/truncation markers.
|
|
440
|
+
* @param p Process row carrying sanitized name semantics.
|
|
441
|
+
* @returns Styled display name, never interpreted as terminal input.
|
|
442
|
+
*/
|
|
443
|
+
private nameCell(p: MonProcess): string {
|
|
444
|
+
const t = this.options.theme;
|
|
445
|
+
if (p.nameState === "unknown" || p.name === "") return t.fg("dim", "(unknown)");
|
|
446
|
+
const name = sanitize(p.name);
|
|
447
|
+
return p.nameState === "truncated" ? `${name}${t.fg("dim", "…")}` : name;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Render help/dashboard within terminal bounds without collecting data.
|
|
452
|
+
* @param width Available terminal columns.
|
|
453
|
+
* @returns Exactly the configured terminal-height rows, each fitted to width.
|
|
454
|
+
*/
|
|
455
|
+
render(width: number): string[] {
|
|
456
|
+
const height = Math.max(1, this.options.rows());
|
|
457
|
+
width = Math.max(1, width);
|
|
458
|
+
if (this.cache?.width === width && this.cache.height === height) return this.cache.lines;
|
|
459
|
+
const t = this.options.theme;
|
|
460
|
+
const status = this.snapshot?.status ?? "waiting";
|
|
461
|
+
const footer = this.footer(width);
|
|
462
|
+
if (height === 1) return [fitCols(footer, width)];
|
|
463
|
+
const lines = [this.header(width)];
|
|
464
|
+
if (this.help) {
|
|
465
|
+
const help = HELP.map((line) =>
|
|
466
|
+
line.startsWith("*") ? t.bold(t.fg("accent", ` ${line.slice(1)}`)) : line,
|
|
467
|
+
).flatMap((line) => wrapTextWithAnsi(line, width));
|
|
468
|
+
this.helpPageSize = Math.max(1, height - 3);
|
|
469
|
+
this.helpLength = help.length;
|
|
470
|
+
this.helpScroll = Math.max(0, Math.min(this.helpScroll, help.length - this.helpPageSize));
|
|
471
|
+
lines.push(
|
|
472
|
+
t.fg(
|
|
473
|
+
"accent",
|
|
474
|
+
` HELP ${this.helpScroll + 1}-${Math.min(help.length, this.helpScroll + this.helpPageSize)}/${help.length}`,
|
|
475
|
+
),
|
|
476
|
+
);
|
|
477
|
+
lines.push(...help.slice(this.helpScroll, this.helpScroll + this.helpPageSize));
|
|
478
|
+
} else {
|
|
479
|
+
const notice = this.snapshot?.notice;
|
|
480
|
+
const hint = this.snapshot?.hint;
|
|
481
|
+
const compactNotice = notice?.startsWith("Partial list:")
|
|
482
|
+
? `Partial: ${notice.match(/\(([^)]+)\)/)?.[1] ?? "list incomplete"}`
|
|
483
|
+
: notice?.startsWith("Sampling stopped")
|
|
484
|
+
? "Stopped: reopen to retry"
|
|
485
|
+
: notice;
|
|
486
|
+
const diagnosticRows = Number(status === "stale") + Number(Boolean(notice)) + Number(Boolean(hint));
|
|
487
|
+
if (height >= CARD_MIN_HEIGHT + diagnosticRows && width >= CARD_MIN_WIDTH) lines.push(...this.cards(width));
|
|
488
|
+
else if (height >= 8) {
|
|
489
|
+
// Reserve a process row and actionable warnings before optional history/platform hints.
|
|
490
|
+
const warnings = Number(status === "stale") + Number(Boolean(notice));
|
|
491
|
+
lines.push(...this.summary(width).slice(0, Math.max(1, height - 5 - warnings)));
|
|
492
|
+
}
|
|
493
|
+
if (height >= 8) {
|
|
494
|
+
if (status === "stale")
|
|
495
|
+
lines.push(
|
|
496
|
+
t.fg(
|
|
497
|
+
"warning",
|
|
498
|
+
` Last success: ${this.lastSuccess === undefined ? "none" : new Date(this.lastSuccess).toISOString()}`,
|
|
499
|
+
),
|
|
500
|
+
);
|
|
501
|
+
if (notice) lines.push(t.fg("warning", ` ▲ ${sanitize(width < 60 ? (compactNotice ?? notice) : notice)}`));
|
|
502
|
+
if (hint && lines.length < height - 5) lines.push(t.fg("muted", ` ${sanitize(hint)}`));
|
|
503
|
+
} else if (notice && height >= 6) {
|
|
504
|
+
// One short warning must survive even when there is no room for the metric summary.
|
|
505
|
+
lines.push(t.fg("warning", ` ▲ ${sanitize(compactNotice ?? notice)}`));
|
|
506
|
+
}
|
|
507
|
+
lines.push(
|
|
508
|
+
`${t.bold(t.fg("accent", " PROCESSES"))} ${t.fg("muted", `${this.processes.length} sampled`)} ${t.fg("accent", `${this.sort.toUpperCase()} ${this.sort === "pid" ? "↑" : "↓"}`)}`,
|
|
509
|
+
);
|
|
510
|
+
this.table(lines, width, height);
|
|
511
|
+
}
|
|
512
|
+
const clipped = lines.slice(0, height - 1);
|
|
513
|
+
while (clipped.length < height - 1) clipped.push("");
|
|
514
|
+
clipped.push(footer);
|
|
515
|
+
const result = clipped.map((line) => fitCols(line, width));
|
|
516
|
+
this.cache = { width, height, lines: result };
|
|
517
|
+
return result;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Append header, visible process page, scrollbar and position line.
|
|
522
|
+
* @param lines Mutable dashboard output prefix to extend.
|
|
523
|
+
* @param width Available terminal columns.
|
|
524
|
+
* @param height Available terminal rows including reserved footer.
|
|
525
|
+
* @returns Nothing; updates page/scroll state and appends formatted rows.
|
|
526
|
+
*/
|
|
527
|
+
private table(lines: string[], width: number, height: number): void {
|
|
528
|
+
const t = this.options.theme;
|
|
529
|
+
const compact = width < 60;
|
|
530
|
+
const pidWidth = compact ? 6 : 7;
|
|
531
|
+
const cpuWidth = compact ? 7 : 8;
|
|
532
|
+
const rssWidth = compact ? 6 : 8;
|
|
533
|
+
const memWidth = compact ? 0 : 6;
|
|
534
|
+
// Rows after the header: position line and footer stay reserved.
|
|
535
|
+
this.pageSize = Math.max(1, height - lines.length - 3);
|
|
536
|
+
const overflow = this.processes.length > this.pageSize;
|
|
537
|
+
const rowWidth = width - (overflow ? 1 : 0);
|
|
538
|
+
const nameWidth = Math.max(1, rowWidth - 1 - pidWidth - cpuWidth - rssWidth - memWidth - 2);
|
|
539
|
+
/**
|
|
540
|
+
* Fit preformatted table cells into one terminal row.
|
|
541
|
+
* @param mark Selection marker.
|
|
542
|
+
* @param pid Process identifier text.
|
|
543
|
+
* @param cpu CPU percentage text.
|
|
544
|
+
* @param rss Selected RSS/footprint bytes text.
|
|
545
|
+
* @param mem RSS share text, omitted in compact mode.
|
|
546
|
+
* @param name Safe executable display name.
|
|
547
|
+
* @returns Row fitted to the available table width.
|
|
548
|
+
*/
|
|
549
|
+
const row = (mark: string, pid: string, cpu: string, rss: string, mem: string, name: string) =>
|
|
550
|
+
fitCols(
|
|
551
|
+
`${mark}${fitCols(pid, pidWidth)}${cpu}${rss}${memWidth ? mem : ""} ${fitCols(name, nameWidth)}`,
|
|
552
|
+
rowWidth,
|
|
553
|
+
);
|
|
554
|
+
lines.push(
|
|
555
|
+
t.fg(
|
|
556
|
+
"muted",
|
|
557
|
+
row(
|
|
558
|
+
" ",
|
|
559
|
+
"PID",
|
|
560
|
+
"CPU%".padStart(cpuWidth),
|
|
561
|
+
(this.footprint ? "FOOT" : "RSS").padStart(rssWidth),
|
|
562
|
+
"MEM%".padStart(memWidth),
|
|
563
|
+
"NAME",
|
|
564
|
+
),
|
|
565
|
+
),
|
|
566
|
+
);
|
|
567
|
+
const index = Math.max(
|
|
568
|
+
0,
|
|
569
|
+
this.processes.findIndex((p) => p.identity === this.selected),
|
|
570
|
+
);
|
|
571
|
+
this.scroll = Math.min(this.scroll, Math.max(0, this.processes.length - this.pageSize));
|
|
572
|
+
if (index < this.scroll) this.scroll = index;
|
|
573
|
+
if (index >= this.scroll + this.pageSize) this.scroll = index - this.pageSize + 1;
|
|
574
|
+
const total = this.snapshot?.memoryTotalBytes ?? null;
|
|
575
|
+
const visible = this.processes.slice(this.scroll, this.scroll + this.pageSize);
|
|
576
|
+
const thumbSize = Math.max(1, Math.round((this.pageSize * this.pageSize) / Math.max(1, this.processes.length)));
|
|
577
|
+
const thumbAt = Math.round(
|
|
578
|
+
(this.scroll / Math.max(1, this.processes.length - this.pageSize)) * (this.pageSize - thumbSize),
|
|
579
|
+
);
|
|
580
|
+
visible.forEach((p, i) => {
|
|
581
|
+
const selected = p.identity === this.selected;
|
|
582
|
+
let text = row(
|
|
583
|
+
selected ? t.fg("accent", "▸") : " ",
|
|
584
|
+
String(p.pid),
|
|
585
|
+
this.value(percent(p.cpuPercent).replace("%", ""), cpuWidth),
|
|
586
|
+
this.value(bytes(this.footprint ? (p.footprintBytes ?? null) : p.rssBytes), rssWidth),
|
|
587
|
+
this.value(share(p.rssBytes, total), memWidth),
|
|
588
|
+
this.nameCell(p),
|
|
589
|
+
);
|
|
590
|
+
if (selected) text = t.bg("selectedBg", text);
|
|
591
|
+
if (overflow)
|
|
592
|
+
text += i >= thumbAt && i < thumbAt + thumbSize ? t.fg("scrollbarThumb", "┃") : t.fg("scrollbarTrack", "│");
|
|
593
|
+
lines.push(text);
|
|
594
|
+
});
|
|
595
|
+
if (this.processes.length === 0) lines.push(t.fg("dim", " Waiting for process sample..."));
|
|
596
|
+
if (lines.length < height - 1)
|
|
597
|
+
lines.push(
|
|
598
|
+
t.fg(
|
|
599
|
+
"dim",
|
|
600
|
+
` ${this.processes.length ? index + 1 : 0}/${this.processes.length} selected · CPU% 100 = one core${memWidth && total ? ` · MEM% of ${bytes(total)}` : ""}`,
|
|
601
|
+
),
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
}
|