@nredd/pi-session-kit 0.6.0 → 0.8.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 +28 -0
- package/README.md +35 -8
- package/docs/mon-dashboard.md +123 -0
- package/docs/stats-snapshots.md +19 -2
- package/package.json +8 -3
- package/src/config.ts +73 -2
- package/src/index.ts +4 -1
- package/src/mon/component.ts +486 -0
- package/src/mon/index.ts +105 -0
- package/src/mon/model.ts +153 -0
- package/src/mon/poll.ts +80 -0
- package/src/mon/source.ts +121 -0
- package/src/monitor/index.ts +16 -3
- package/src/monitor/manager.ts +5 -0
- package/src/monitor/types.ts +2 -0
- package/src/monitor/widget.ts +209 -26
- package/src/stats/component.ts +23 -5
- package/src/stats/freshness.ts +46 -0
- package/src/stats/index.ts +5 -2
- package/src/stats/snapshot.ts +24 -1
|
@@ -0,0 +1,486 @@
|
|
|
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
|
+
" ? back to dashboard",
|
|
49
|
+
" q/Esc close",
|
|
50
|
+
"",
|
|
51
|
+
"*Reading it",
|
|
52
|
+
" LIVE: the last collection succeeded;",
|
|
53
|
+
" updated HH:MM:SS is when (local).",
|
|
54
|
+
" CPU warming: first interval baseline,",
|
|
55
|
+
" not a stale or failed sample.",
|
|
56
|
+
" CPU is interval usage, not lifetime.",
|
|
57
|
+
" Process CPU can exceed 100% (one core).",
|
|
58
|
+
" -- is unavailable, never zero.",
|
|
59
|
+
" RAM is total minus free, not pressure.",
|
|
60
|
+
" MEM% is RSS over total RAM.",
|
|
61
|
+
" Load 1/5/15 min: not percent.",
|
|
62
|
+
" History: elapsed time, newest right;",
|
|
63
|
+
" dots are gaps, blank is no data yet.",
|
|
64
|
+
"",
|
|
65
|
+
"*Processes",
|
|
66
|
+
" Names are executable files, never",
|
|
67
|
+
" arguments; still untrusted text.",
|
|
68
|
+
" (unknown): no access, e.g. another",
|
|
69
|
+
" user's process on Linux.",
|
|
70
|
+
" macOS keeps 16 bytes of a name;",
|
|
71
|
+
" a trailing … marks a possible cut.",
|
|
72
|
+
" macOS: ps start time is whole seconds,",
|
|
73
|
+
" so process CPU stays -- (sort: RSS).",
|
|
74
|
+
" Lists can be partial; notices say why.",
|
|
75
|
+
" Selection follows identity, not row.",
|
|
76
|
+
" No kill, locks or scheduling.",
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
/** Local wall clock `HH:MM:SS`, display only. */
|
|
80
|
+
function clock(ms: number): string {
|
|
81
|
+
const d = new Date(ms);
|
|
82
|
+
return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Single-interaction view; update is the sole ingress for sampler data. */
|
|
86
|
+
export class MonView implements Component {
|
|
87
|
+
private snapshot: MonSnapshot | undefined;
|
|
88
|
+
private history: MonHistoryPoint[] = [];
|
|
89
|
+
private lastSuccess: number | undefined;
|
|
90
|
+
private helpScroll = 0;
|
|
91
|
+
private helpPageSize = 1;
|
|
92
|
+
private helpLength = 0;
|
|
93
|
+
private processes: MonProcess[] = [];
|
|
94
|
+
private selected: string | undefined;
|
|
95
|
+
private sort: MonSort = "cpu";
|
|
96
|
+
private sortChosen = false;
|
|
97
|
+
private help = false;
|
|
98
|
+
private scroll = 0;
|
|
99
|
+
private pageSize = 1;
|
|
100
|
+
private cache: { width: number; height: number; lines: string[] } | undefined;
|
|
101
|
+
|
|
102
|
+
constructor(private readonly options: MonViewOptions) {}
|
|
103
|
+
|
|
104
|
+
get state(): { selected: string | undefined; sort: MonSort; historyLength: number; scroll: number } {
|
|
105
|
+
return { selected: this.selected, sort: this.sort, historyLength: this.history.length, scroll: this.scroll };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
update(snapshot: MonSnapshot): void {
|
|
109
|
+
this.snapshot = snapshot;
|
|
110
|
+
// Collector success is independent of CPU: a warming sample still carries fresh memory/processes.
|
|
111
|
+
// A stale snapshot is not a new measurement, so it adds no history point.
|
|
112
|
+
if (snapshot.status === "live") {
|
|
113
|
+
this.lastSuccess = snapshot.capturedAt;
|
|
114
|
+
// Leading samples without a CPU value are "no data yet" (blank), not gaps.
|
|
115
|
+
if (snapshot.cpuPercent !== null || this.history.length > 0) {
|
|
116
|
+
this.history.push({ sampledAtMs: snapshot.sampledAtMs, value: snapshot.cpuPercent });
|
|
117
|
+
if (this.history.length > HISTORY_LIMIT) this.history.shift();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Never rank by a column that is unavailable for every row, unless the user asked for it.
|
|
121
|
+
if (!this.sortChosen) this.sort = snapshot.processCpu === "unavailable" ? "rss" : "cpu";
|
|
122
|
+
this.reorder();
|
|
123
|
+
this.refresh();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
invalidate(): void {
|
|
127
|
+
this.cache = undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private refresh(): void {
|
|
131
|
+
this.invalidate();
|
|
132
|
+
this.options.requestRender();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private reorder(): void {
|
|
136
|
+
const oldIndex = Math.max(
|
|
137
|
+
0,
|
|
138
|
+
this.processes.findIndex((p) => p.identity === this.selected),
|
|
139
|
+
);
|
|
140
|
+
this.processes = sortProcesses(this.snapshot?.processes ?? [], this.sort);
|
|
141
|
+
if (!this.processes.some((p) => p.identity === this.selected)) {
|
|
142
|
+
this.selected = this.processes[Math.min(oldIndex, this.processes.length - 1)]?.identity;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
handleInput(data: string): void {
|
|
147
|
+
if (data === "q" || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
148
|
+
this.options.onClose();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
let index = Math.max(
|
|
152
|
+
0,
|
|
153
|
+
this.processes.findIndex((p) => p.identity === this.selected),
|
|
154
|
+
);
|
|
155
|
+
if (data === "?") this.help = !this.help;
|
|
156
|
+
else if (this.help) {
|
|
157
|
+
if (data === "j" || matchesKey(data, Key.down)) this.helpScroll++;
|
|
158
|
+
else if (data === "k" || matchesKey(data, Key.up)) this.helpScroll--;
|
|
159
|
+
else if (matchesKey(data, Key.pageDown)) this.helpScroll += this.helpPageSize;
|
|
160
|
+
else if (matchesKey(data, Key.pageUp)) this.helpScroll -= this.helpPageSize;
|
|
161
|
+
else if (data === "g" || matchesKey(data, Key.home)) this.helpScroll = 0;
|
|
162
|
+
else if (data === "G" || matchesKey(data, Key.end)) this.helpScroll = this.helpLength;
|
|
163
|
+
else return;
|
|
164
|
+
this.helpScroll = Math.max(0, Math.min(this.helpScroll, this.helpLength - this.helpPageSize));
|
|
165
|
+
} else if (data === "c" || data === "m" || data === "p" || data === "s") {
|
|
166
|
+
this.sortChosen = true;
|
|
167
|
+
this.sort =
|
|
168
|
+
data === "c"
|
|
169
|
+
? "cpu"
|
|
170
|
+
: data === "m"
|
|
171
|
+
? "rss"
|
|
172
|
+
: data === "p"
|
|
173
|
+
? "pid"
|
|
174
|
+
: (SORTS[(SORTS.indexOf(this.sort) + 1) % SORTS.length] ?? "cpu");
|
|
175
|
+
this.reorder();
|
|
176
|
+
} else {
|
|
177
|
+
if (data === "j" || matchesKey(data, Key.down)) index++;
|
|
178
|
+
else if (data === "k" || matchesKey(data, Key.up)) index--;
|
|
179
|
+
else if (matchesKey(data, Key.pageDown)) index += this.pageSize;
|
|
180
|
+
else if (matchesKey(data, Key.pageUp)) index -= this.pageSize;
|
|
181
|
+
else if (data === "g" || matchesKey(data, Key.home)) index = 0;
|
|
182
|
+
else if (data === "G" || matchesKey(data, Key.end)) index = this.processes.length - 1;
|
|
183
|
+
else return;
|
|
184
|
+
this.selected = this.processes[Math.max(0, Math.min(this.processes.length - 1, index))]?.identity;
|
|
185
|
+
}
|
|
186
|
+
this.refresh();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** `--` reads as absent, not as a value: dim it everywhere. */
|
|
190
|
+
private value(text: string, width: number): string {
|
|
191
|
+
const padded = text.padStart(width);
|
|
192
|
+
return text === "--" ? this.options.theme.fg("dim", padded) : padded;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Host CPU severity, always paired with the number so meaning never depends on color. */
|
|
196
|
+
private cpuColor(value: number | null): Color {
|
|
197
|
+
return value === null ? "dim" : value >= 85 ? "error" : value >= 50 ? "warning" : "success";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private panel(title: string, lines: string[], width: number): string[] {
|
|
201
|
+
const { theme } = this.options;
|
|
202
|
+
const inner = Math.max(0, width - 2);
|
|
203
|
+
const label = truncateToWidth(`─ ${title} `, inner, "…");
|
|
204
|
+
return [
|
|
205
|
+
theme.fg("border", "╭") +
|
|
206
|
+
theme.fg("accent", label) +
|
|
207
|
+
theme.fg("border", `${"─".repeat(Math.max(0, inner - visibleWidth(label)))}╮`),
|
|
208
|
+
...lines.map((line) => theme.fg("border", "│") + fitCols(line, inner) + theme.fg("border", "│")),
|
|
209
|
+
theme.fg("border", `╰${"─".repeat(inner)}╯`),
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Compact mode omits capacity and the stale suffix; the header already labels every metric STALE. */
|
|
214
|
+
private cpuSummary(capacity = true): string {
|
|
215
|
+
const t = this.options.theme;
|
|
216
|
+
const s = this.snapshot;
|
|
217
|
+
const value = s?.cpuPercent ?? null;
|
|
218
|
+
const state = !capacity && s?.status === "stale" ? "" : this.cpuState();
|
|
219
|
+
const head =
|
|
220
|
+
value === null
|
|
221
|
+
? t.fg("dim", "--")
|
|
222
|
+
: s?.status === "stale"
|
|
223
|
+
? t.fg("muted", percent(value))
|
|
224
|
+
: t.bold(t.fg(this.cpuColor(value), percent(value)));
|
|
225
|
+
if (!capacity) return `${head}${state}`;
|
|
226
|
+
return `${head}${state} ${t.fg("muted", `${state ? "·" : "of"} ${s?.logicalCpus || "--"} logical CPUs`)}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private loads(): string {
|
|
230
|
+
const load = this.snapshot?.load ?? [];
|
|
231
|
+
return load.length ? load.map((n) => (Number.isFinite(n) ? n.toFixed(2) : "--")).join(" ") : "--";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private cards(width: number): string[] {
|
|
235
|
+
const s = this.snapshot;
|
|
236
|
+
const t = this.options.theme;
|
|
237
|
+
const leftWidth = Math.floor((width - 2) * 0.58);
|
|
238
|
+
const rightWidth = width - leftWidth - 2;
|
|
239
|
+
const chartWidth = leftWidth - 4;
|
|
240
|
+
const total = s?.memoryTotalBytes ?? null;
|
|
241
|
+
const used = s?.memoryUsedBytes ?? null;
|
|
242
|
+
const ratio = total && used !== null ? Math.max(0, Math.min(1, used / total)) : null;
|
|
243
|
+
const meterWidth = Math.max(1, Math.min(28, rightWidth - 9));
|
|
244
|
+
const full = ratio === null ? 0 : Math.round(ratio * meterWidth);
|
|
245
|
+
const load1 = s?.load[0];
|
|
246
|
+
const perCpu =
|
|
247
|
+
typeof load1 === "number" && Number.isFinite(load1) && s?.logicalCpus ? (load1 / s.logicalCpus).toFixed(2) : "--";
|
|
248
|
+
const cpu = this.panel(
|
|
249
|
+
"CPU / interval",
|
|
250
|
+
[
|
|
251
|
+
` ${this.cpuSummary()}`,
|
|
252
|
+
...timeHistoryRows(this.history, chartWidth, 2).map((row) => ` ${t.fg("accent", row)}`),
|
|
253
|
+
` ${t.fg("dim", this.historyLabel())}`,
|
|
254
|
+
],
|
|
255
|
+
leftWidth,
|
|
256
|
+
);
|
|
257
|
+
const memory = this.panel(
|
|
258
|
+
"MEMORY / load",
|
|
259
|
+
[
|
|
260
|
+
` ${t.bold(bytes(used))} ${t.fg("muted", `/ ${bytes(total)} non-free`)}`,
|
|
261
|
+
` ${t.fg("accent", "━".repeat(full))}${t.fg("borderMuted", "─".repeat(meterWidth - full))} ${ratio === null ? t.fg("dim", "--") : `${Math.round(ratio * 100)}%`}`,
|
|
262
|
+
` ${t.fg("muted", "load")} ${this.loads()}`,
|
|
263
|
+
` ${t.fg("muted", "1/5/15 min ·")} ${perCpu === "--" ? t.fg("dim", perCpu) : perCpu} ${t.fg("muted", "per CPU")}`,
|
|
264
|
+
],
|
|
265
|
+
rightWidth,
|
|
266
|
+
);
|
|
267
|
+
return cpu.map((line, i) => `${line} ${memory[i] ?? ""}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Compact three-line summary when cards do not fit. */
|
|
271
|
+
private summary(width: number): string[] {
|
|
272
|
+
const s = this.snapshot;
|
|
273
|
+
const t = this.options.theme;
|
|
274
|
+
const total = s?.memoryTotalBytes ?? null;
|
|
275
|
+
const used = s?.memoryUsedBytes ?? null;
|
|
276
|
+
const ram = `${bytes(used)}/${bytes(total)}`;
|
|
277
|
+
const metrics = [
|
|
278
|
+
`${t.fg("muted", "CPU")} ${this.cpuSummary(false)}`,
|
|
279
|
+
`${t.fg("muted", "RAM")} ${ram}`,
|
|
280
|
+
`${t.fg("muted", "load")} ${this.loads().split(" ")[0]}`,
|
|
281
|
+
];
|
|
282
|
+
const spaced = ` ${metrics.join(" ")}`;
|
|
283
|
+
return [
|
|
284
|
+
visibleWidth(spaced) <= width ? spaced : metrics.join(" "),
|
|
285
|
+
` ${t.fg("accent", timeHistory(this.history, width - 2))}`,
|
|
286
|
+
` ${t.fg("dim", this.historyLabel())}`,
|
|
287
|
+
];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Why host CPU reads `--`; textual, so meaning never depends on color. */
|
|
291
|
+
private cpuState(): string {
|
|
292
|
+
if (this.snapshot?.status === "stale") return ` ${this.options.theme.fg("muted", "last value")}`;
|
|
293
|
+
const state = this.snapshot?.cpuState;
|
|
294
|
+
return state === "warming" || state === "unavailable" ? ` ${this.options.theme.fg("warning", state)}` : "";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private historyLabel(): string {
|
|
298
|
+
const span = Math.max(0, (this.history.at(-1)?.sampledAtMs ?? 0) - (this.history[0]?.sampledAtMs ?? 0));
|
|
299
|
+
return `0..100% · ${(span / 1000).toFixed(1)}s span · time →`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Title, collector state, and when the shown data was collected. */
|
|
303
|
+
private header(width: number): string {
|
|
304
|
+
const t = this.options.theme;
|
|
305
|
+
const status = this.snapshot?.status ?? "waiting";
|
|
306
|
+
const [mark, color]: [string, Color] =
|
|
307
|
+
status === "live" ? ["●", "success"] : status === "stale" ? ["▲", "warning"] : ["○", "warning"];
|
|
308
|
+
const left =
|
|
309
|
+
t.bold(t.fg("accent", " MON")) +
|
|
310
|
+
` ${t.fg(color, `${mark} ${status.toUpperCase()}`)}` +
|
|
311
|
+
// Narrow terminals keep freshness over the tagline.
|
|
312
|
+
(width >= 60 ? t.fg("dim", " local resources · read only") : "");
|
|
313
|
+
const scan = this.snapshot?.scanMs;
|
|
314
|
+
const parts: string[] = [];
|
|
315
|
+
if (this.lastSuccess !== undefined)
|
|
316
|
+
parts.push(
|
|
317
|
+
width >= 60
|
|
318
|
+
? `${status === "stale" ? "last ok" : "updated"} ${clock(this.lastSuccess)}`
|
|
319
|
+
: clock(this.lastSuccess),
|
|
320
|
+
);
|
|
321
|
+
if (width >= 80 && typeof scan === "number" && Number.isFinite(scan) && status === "live")
|
|
322
|
+
parts.push(`scan ${Math.round(scan)}ms`);
|
|
323
|
+
const right = parts.length ? t.fg("muted", `${parts.join(" · ")} `) : "";
|
|
324
|
+
const gap = width - visibleWidth(left) - visibleWidth(right);
|
|
325
|
+
return gap >= 2 ? `${left}${" ".repeat(gap)}${right}` : left;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private footer(width: number): string {
|
|
329
|
+
const t = this.options.theme;
|
|
330
|
+
const keys: [string, string][] = this.help
|
|
331
|
+
? [
|
|
332
|
+
["q/Esc", "close"],
|
|
333
|
+
["?", "help"],
|
|
334
|
+
["j/k PgDn", "scroll"],
|
|
335
|
+
]
|
|
336
|
+
: width < 60
|
|
337
|
+
? [
|
|
338
|
+
["q/Esc", "close"],
|
|
339
|
+
["?", "help"],
|
|
340
|
+
["s", "sort"],
|
|
341
|
+
]
|
|
342
|
+
: [
|
|
343
|
+
["q/Esc", "close"],
|
|
344
|
+
["?", "help"],
|
|
345
|
+
["j/k", "move"],
|
|
346
|
+
["PgUp/PgDn", "page"],
|
|
347
|
+
["c/m/p", "sort"],
|
|
348
|
+
];
|
|
349
|
+
return ` ${keys.map(([key, label]) => `${t.fg("accent", key)} ${t.fg("muted", label)}`).join(" ")}`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Process name cell: dim `(unknown)` for no name, dim `…` where the OS may have cut it. */
|
|
353
|
+
private nameCell(p: MonProcess): string {
|
|
354
|
+
const t = this.options.theme;
|
|
355
|
+
if (p.nameState === "unknown" || p.name === "") return t.fg("dim", "(unknown)");
|
|
356
|
+
const name = sanitize(p.name);
|
|
357
|
+
return p.nameState === "truncated" ? `${name}${t.fg("dim", "…")}` : name;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
render(width: number): string[] {
|
|
361
|
+
const height = Math.max(1, this.options.rows());
|
|
362
|
+
width = Math.max(1, width);
|
|
363
|
+
if (this.cache?.width === width && this.cache.height === height) return this.cache.lines;
|
|
364
|
+
const t = this.options.theme;
|
|
365
|
+
const status = this.snapshot?.status ?? "waiting";
|
|
366
|
+
const footer = this.footer(width);
|
|
367
|
+
if (height === 1) return [fitCols(footer, width)];
|
|
368
|
+
const lines = [this.header(width)];
|
|
369
|
+
if (this.help) {
|
|
370
|
+
const help = HELP.map((line) =>
|
|
371
|
+
line.startsWith("*") ? t.bold(t.fg("accent", ` ${line.slice(1)}`)) : line,
|
|
372
|
+
).flatMap((line) => wrapTextWithAnsi(line, width));
|
|
373
|
+
this.helpPageSize = Math.max(1, height - 3);
|
|
374
|
+
this.helpLength = help.length;
|
|
375
|
+
this.helpScroll = Math.max(0, Math.min(this.helpScroll, help.length - this.helpPageSize));
|
|
376
|
+
lines.push(
|
|
377
|
+
t.fg(
|
|
378
|
+
"accent",
|
|
379
|
+
` HELP ${this.helpScroll + 1}-${Math.min(help.length, this.helpScroll + this.helpPageSize)}/${help.length}`,
|
|
380
|
+
),
|
|
381
|
+
);
|
|
382
|
+
lines.push(...help.slice(this.helpScroll, this.helpScroll + this.helpPageSize));
|
|
383
|
+
} else {
|
|
384
|
+
const notice = this.snapshot?.notice;
|
|
385
|
+
const hint = this.snapshot?.hint;
|
|
386
|
+
const compactNotice = notice?.startsWith("Partial list:")
|
|
387
|
+
? `Partial: ${notice.match(/\(([^)]+)\)/)?.[1] ?? "list incomplete"}`
|
|
388
|
+
: notice?.startsWith("Sampling stopped")
|
|
389
|
+
? "Stopped: reopen to retry"
|
|
390
|
+
: notice;
|
|
391
|
+
const diagnosticRows = Number(status === "stale") + Number(Boolean(notice)) + Number(Boolean(hint));
|
|
392
|
+
if (height >= CARD_MIN_HEIGHT + diagnosticRows && width >= CARD_MIN_WIDTH) lines.push(...this.cards(width));
|
|
393
|
+
else if (height >= 8) {
|
|
394
|
+
// Reserve a process row and actionable warnings before optional history/platform hints.
|
|
395
|
+
const warnings = Number(status === "stale") + Number(Boolean(notice));
|
|
396
|
+
lines.push(...this.summary(width).slice(0, Math.max(1, height - 5 - warnings)));
|
|
397
|
+
}
|
|
398
|
+
if (height >= 8) {
|
|
399
|
+
if (status === "stale")
|
|
400
|
+
lines.push(
|
|
401
|
+
t.fg(
|
|
402
|
+
"warning",
|
|
403
|
+
` Last success: ${this.lastSuccess === undefined ? "none" : new Date(this.lastSuccess).toISOString()}`,
|
|
404
|
+
),
|
|
405
|
+
);
|
|
406
|
+
if (notice) lines.push(t.fg("warning", ` ▲ ${sanitize(width < 60 ? (compactNotice ?? notice) : notice)}`));
|
|
407
|
+
if (hint && lines.length < height - 5) lines.push(t.fg("muted", ` ${sanitize(hint)}`));
|
|
408
|
+
} else if (notice && height >= 6) {
|
|
409
|
+
// One short warning must survive even when there is no room for the metric summary.
|
|
410
|
+
lines.push(t.fg("warning", ` ▲ ${sanitize(compactNotice ?? notice)}`));
|
|
411
|
+
}
|
|
412
|
+
lines.push(
|
|
413
|
+
`${t.bold(t.fg("accent", " PROCESSES"))} ${t.fg("muted", `${this.processes.length} sampled`)} ${t.fg("accent", `${this.sort.toUpperCase()} ${this.sort === "pid" ? "↑" : "↓"}`)}`,
|
|
414
|
+
);
|
|
415
|
+
this.table(lines, width, height);
|
|
416
|
+
}
|
|
417
|
+
const clipped = lines.slice(0, height - 1);
|
|
418
|
+
while (clipped.length < height - 1) clipped.push("");
|
|
419
|
+
clipped.push(footer);
|
|
420
|
+
const result = clipped.map((line) => fitCols(line, width));
|
|
421
|
+
this.cache = { width, height, lines: result };
|
|
422
|
+
return result;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Column header, the visible page of rows with a scrollbar when it overflows, and a position line. */
|
|
426
|
+
private table(lines: string[], width: number, height: number): void {
|
|
427
|
+
const t = this.options.theme;
|
|
428
|
+
const compact = width < 60;
|
|
429
|
+
const pidWidth = compact ? 6 : 7;
|
|
430
|
+
const cpuWidth = compact ? 7 : 8;
|
|
431
|
+
const rssWidth = compact ? 6 : 8;
|
|
432
|
+
const memWidth = compact ? 0 : 6;
|
|
433
|
+
// Rows after the header: position line and footer stay reserved.
|
|
434
|
+
this.pageSize = Math.max(1, height - lines.length - 3);
|
|
435
|
+
const overflow = this.processes.length > this.pageSize;
|
|
436
|
+
const rowWidth = width - (overflow ? 1 : 0);
|
|
437
|
+
const nameWidth = Math.max(1, rowWidth - 1 - pidWidth - cpuWidth - rssWidth - memWidth - 2);
|
|
438
|
+
const row = (mark: string, pid: string, cpu: string, rss: string, mem: string, name: string) =>
|
|
439
|
+
fitCols(
|
|
440
|
+
`${mark}${fitCols(pid, pidWidth)}${cpu}${rss}${memWidth ? mem : ""} ${fitCols(name, nameWidth)}`,
|
|
441
|
+
rowWidth,
|
|
442
|
+
);
|
|
443
|
+
lines.push(
|
|
444
|
+
t.fg(
|
|
445
|
+
"muted",
|
|
446
|
+
row(" ", "PID", "CPU%".padStart(cpuWidth), "RSS".padStart(rssWidth), "MEM%".padStart(memWidth), "NAME"),
|
|
447
|
+
),
|
|
448
|
+
);
|
|
449
|
+
const index = Math.max(
|
|
450
|
+
0,
|
|
451
|
+
this.processes.findIndex((p) => p.identity === this.selected),
|
|
452
|
+
);
|
|
453
|
+
this.scroll = Math.min(this.scroll, Math.max(0, this.processes.length - this.pageSize));
|
|
454
|
+
if (index < this.scroll) this.scroll = index;
|
|
455
|
+
if (index >= this.scroll + this.pageSize) this.scroll = index - this.pageSize + 1;
|
|
456
|
+
const total = this.snapshot?.memoryTotalBytes ?? null;
|
|
457
|
+
const visible = this.processes.slice(this.scroll, this.scroll + this.pageSize);
|
|
458
|
+
const thumbSize = Math.max(1, Math.round((this.pageSize * this.pageSize) / Math.max(1, this.processes.length)));
|
|
459
|
+
const thumbAt = Math.round(
|
|
460
|
+
(this.scroll / Math.max(1, this.processes.length - this.pageSize)) * (this.pageSize - thumbSize),
|
|
461
|
+
);
|
|
462
|
+
visible.forEach((p, i) => {
|
|
463
|
+
const selected = p.identity === this.selected;
|
|
464
|
+
let text = row(
|
|
465
|
+
selected ? t.fg("accent", "▸") : " ",
|
|
466
|
+
String(p.pid),
|
|
467
|
+
this.value(percent(p.cpuPercent).replace("%", ""), cpuWidth),
|
|
468
|
+
this.value(bytes(p.rssBytes), rssWidth),
|
|
469
|
+
this.value(share(p.rssBytes, total), memWidth),
|
|
470
|
+
this.nameCell(p),
|
|
471
|
+
);
|
|
472
|
+
if (selected) text = t.bg("selectedBg", text);
|
|
473
|
+
if (overflow)
|
|
474
|
+
text += i >= thumbAt && i < thumbAt + thumbSize ? t.fg("scrollbarThumb", "┃") : t.fg("scrollbarTrack", "│");
|
|
475
|
+
lines.push(text);
|
|
476
|
+
});
|
|
477
|
+
if (this.processes.length === 0) lines.push(t.fg("dim", " Waiting for process sample..."));
|
|
478
|
+
if (lines.length < height - 1)
|
|
479
|
+
lines.push(
|
|
480
|
+
t.fg(
|
|
481
|
+
"dim",
|
|
482
|
+
` ${this.processes.length ? index + 1 : 0}/${this.processes.length} selected · CPU% 100 = one core${memWidth && total ? ` · MEM% of ${bytes(total)}` : ""}`,
|
|
483
|
+
),
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
package/src/mon/index.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** Command/lifecycle shell. Production wiring passes `createSamplerSource` (`source.ts`). */
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { Clock } from "../clock.ts";
|
|
4
|
+
import { MonView } from "./component.ts";
|
|
5
|
+
import { MonPoll, type MonSource } from "./poll.ts";
|
|
6
|
+
|
|
7
|
+
export interface MonOptions {
|
|
8
|
+
/** Required: no fake or fallback production collector. Invoked only while the TUI opens. */
|
|
9
|
+
createSource: () => MonSource;
|
|
10
|
+
clock?: Clock;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** No persistence, model tools, notifications to model context, or collection at registration. */
|
|
14
|
+
export function registerMon(pi: ExtensionAPI, options: MonOptions): void {
|
|
15
|
+
let closeActive: (() => Promise<void>) | undefined;
|
|
16
|
+
let generation = 0;
|
|
17
|
+
let ownership = Promise.resolve();
|
|
18
|
+
const serialize = (action: () => Promise<void>): Promise<void> => {
|
|
19
|
+
const next = ownership.then(action);
|
|
20
|
+
ownership = next.catch(() => {});
|
|
21
|
+
return next;
|
|
22
|
+
};
|
|
23
|
+
const reset = (): Promise<void> => {
|
|
24
|
+
generation++;
|
|
25
|
+
return serialize(async () => {
|
|
26
|
+
await closeActive?.();
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
pi.on("session_shutdown", reset);
|
|
30
|
+
pi.on("session_start", reset);
|
|
31
|
+
pi.registerCommand("mon", {
|
|
32
|
+
description: "Live local CPU, memory and busy processes (read only)",
|
|
33
|
+
async handler(_args: string, ctx: ExtensionCommandContext) {
|
|
34
|
+
if (ctx.mode !== "tui") {
|
|
35
|
+
ctx.ui.notify("/mon requires an interactive terminal", "warning");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const requestedGeneration = generation;
|
|
39
|
+
let interaction: Promise<void> | undefined;
|
|
40
|
+
let poll: MonPoll | undefined;
|
|
41
|
+
let ownClose: (() => Promise<void>) | undefined;
|
|
42
|
+
try {
|
|
43
|
+
await serialize(async () => {
|
|
44
|
+
if (requestedGeneration !== generation) return;
|
|
45
|
+
await closeActive?.();
|
|
46
|
+
if (requestedGeneration !== generation) return;
|
|
47
|
+
let markReady!: () => void;
|
|
48
|
+
const ready = new Promise<void>((resolve) => {
|
|
49
|
+
markReady = resolve;
|
|
50
|
+
});
|
|
51
|
+
interaction = ctx.ui.custom<void>(
|
|
52
|
+
(tui, theme, _kb, done) => {
|
|
53
|
+
if (requestedGeneration !== generation) {
|
|
54
|
+
done();
|
|
55
|
+
return { render: () => [], invalidate: () => {} };
|
|
56
|
+
}
|
|
57
|
+
let closed = false;
|
|
58
|
+
const close = (): Promise<void> => {
|
|
59
|
+
const cleanup = poll?.dispose() ?? Promise.resolve();
|
|
60
|
+
if (!closed) {
|
|
61
|
+
closed = true;
|
|
62
|
+
done();
|
|
63
|
+
}
|
|
64
|
+
return cleanup;
|
|
65
|
+
};
|
|
66
|
+
ownClose = close;
|
|
67
|
+
closeActive = close;
|
|
68
|
+
const view = new MonView({
|
|
69
|
+
theme,
|
|
70
|
+
rows: () => tui.terminal.rows,
|
|
71
|
+
requestRender: () => tui.requestRender(),
|
|
72
|
+
onClose: () => {
|
|
73
|
+
void close();
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
poll = new MonPoll(options.createSource(), (sample) => view.update(sample), options.clock);
|
|
77
|
+
poll.start();
|
|
78
|
+
markReady();
|
|
79
|
+
return {
|
|
80
|
+
render: (width: number) => {
|
|
81
|
+
try {
|
|
82
|
+
return view.render(width);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
void close();
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
invalidate: () => view.invalidate(),
|
|
89
|
+
handleInput: (data: string) => view.handleInput(data),
|
|
90
|
+
dispose: () => poll?.dispose(),
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
{ overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 } },
|
|
94
|
+
);
|
|
95
|
+
// Hold ownership until even a deferred UI factory has installed its closer.
|
|
96
|
+
await Promise.race([ready, interaction]);
|
|
97
|
+
});
|
|
98
|
+
await interaction;
|
|
99
|
+
} finally {
|
|
100
|
+
await poll?.dispose();
|
|
101
|
+
if (closeActive === ownClose) closeActive = undefined;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|