@pi-unipi/unipi 2.1.2 → 2.1.3

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.
Files changed (41) hide show
  1. package/package.json +22 -22
  2. package/packages/ask-user/package.json +3 -3
  3. package/packages/autocomplete/package.json +1 -1
  4. package/packages/btw/package.json +2 -2
  5. package/packages/cocoindex/package.json +2 -2
  6. package/packages/compactor/package.json +4 -4
  7. package/packages/core/package.json +1 -1
  8. package/packages/footer/package.json +3 -3
  9. package/packages/info-screen/package.json +2 -2
  10. package/packages/input-shortcuts/package.json +3 -3
  11. package/packages/kanboard/package.json +2 -2
  12. package/packages/mcp/package.json +2 -2
  13. package/packages/memory/package.json +3 -3
  14. package/packages/milestone/package.json +2 -2
  15. package/packages/notify/README.md +2 -1
  16. package/packages/notify/events.ts +119 -81
  17. package/packages/notify/package.json +2 -2
  18. package/packages/notify/settings.ts +1 -0
  19. package/packages/notify/skills/configure-notify/SKILL.md +3 -1
  20. package/packages/notify/src/__tests__/event-bus.test.ts +3 -2
  21. package/packages/ralph/package.json +3 -3
  22. package/packages/subagents/package.json +5 -5
  23. package/packages/updater/package.json +3 -3
  24. package/packages/utility/package.json +2 -2
  25. package/packages/web-api/package.json +2 -2
  26. package/packages/workflow/package.json +2 -2
  27. package/packages/info-screen/tui/info-overlay.ts +0 -504
  28. package/packages/kanboard/tui/kanboard-overlay.ts +0 -300
  29. package/packages/memory/tui/settings-tui.ts +0 -281
  30. package/packages/notify/platforms/focus-win.ts +0 -123
  31. package/packages/notify/platforms/focus.ts +0 -33
  32. package/packages/notify/platforms/gotify.ts +0 -36
  33. package/packages/notify/platforms/native.ts +0 -79
  34. package/packages/notify/platforms/node-notifier.d.ts +0 -20
  35. package/packages/notify/platforms/ntfy.ts +0 -48
  36. package/packages/notify/platforms/telegram.ts +0 -77
  37. package/packages/notify/tui/gotify-setup.ts +0 -528
  38. package/packages/notify/tui/ntfy-setup.ts +0 -652
  39. package/packages/notify/tui/recap-model-selector.ts +0 -301
  40. package/packages/notify/tui/settings-overlay.ts +0 -352
  41. package/packages/notify/tui/telegram-setup.ts +0 -301
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ralph",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "Long-running iterative development loops for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -27,8 +27,8 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@pi-unipi/core": "2.1.2",
31
- "@pi-unipi/info-screen": "2.1.2"
30
+ "@pi-unipi/core": "2.1.3",
31
+ "@pi-unipi/info-screen": "2.1.3"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@earendil-works/pi-ai": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/subagents",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "Subagents for UniPi — parallel execution, file locking, workflow integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,12 +14,12 @@
14
14
  "scripts": {
15
15
  "build": "tsc",
16
16
  "dev": "tsc --watch",
17
- "test": "node --test src/__tests__/*.test.ts"
17
+ "test": "npx tsx --test src/__tests__/*.test.ts"
18
18
  },
19
19
  "dependencies": {
20
- "@pi-unipi/core": "2.1.2",
21
- "@pi-unipi/workflow": "2.1.2",
22
- "@pi-unipi/info-screen": "2.1.2",
20
+ "@pi-unipi/core": "2.1.3",
21
+ "@pi-unipi/workflow": "2.1.3",
22
+ "@pi-unipi/info-screen": "2.1.3",
23
23
  "@earendil-works/pi-agent-core": "^0.80.0"
24
24
  },
25
25
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/updater",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "Auto-updater, changelog browser, and readme browser for Unipi — checks npm registry, renders CHANGELOG.md and README.md files in TUI overlays",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -27,13 +27,13 @@
27
27
  "README.md"
28
28
  ],
29
29
  "scripts": {
30
- "test": "node --experimental-strip-types --test tests/**/*.test.ts"
30
+ "test": "npx tsx --test tests/**/*.test.ts"
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "2.1.2"
36
+ "@pi-unipi/core": "2.1.3"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/utility",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "Utility commands and tools for Pi coding agent — lifecycle, diagnostics, cache, analytics, display, batch execution",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "2.1.2",
37
+ "@pi-unipi/core": "2.1.3",
38
38
  "diff": "^7.0.0",
39
39
  "@shikijs/cli": "^4.0.2"
40
40
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/web-api",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "Web search, read, and summarize tools with provider-based backend selection for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -31,7 +31,7 @@
31
31
  "README.md"
32
32
  ],
33
33
  "dependencies": {
34
- "@pi-unipi/core": "2.1.2",
34
+ "@pi-unipi/core": "2.1.3",
35
35
  "defuddle": "^0.18.1",
36
36
  "linkedom": "^0.18.12",
37
37
  "lodash": "^4.17.21",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/workflow",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "Structured development workflow commands for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -26,7 +26,7 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@pi-unipi/core": "2.1.2"
29
+ "@pi-unipi/core": "2.1.3"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "@earendil-works/pi-ai": "^0.80.0",
@@ -1,504 +0,0 @@
1
- /**
2
- * @pi-unipi/info-screen — TUI Overlay Component (Cache-First Reactive)
3
- *
4
- * Opens immediately with cached data.
5
- * Each group loads independently in the background.
6
- * Reactive: re-renders as data arrives.
7
- * Shows humanized "last updated" timestamps.
8
- */
9
-
10
- import type { Component } from "@earendil-works/pi-tui";
11
- import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
12
- import type { Theme } from "@earendil-works/pi-coding-agent";
13
- import { infoRegistry } from "../registry.js";
14
- import { getInfoSettings } from "../config.js";
15
- import type { InfoGroup, GroupData } from "../types.js";
16
-
17
- /** Tab color palette */
18
- const TAB_FG: Array<"accent" | "success" | "warning" | "error"> = [
19
- "accent",
20
- "success",
21
- "warning",
22
- "error",
23
- ];
24
-
25
- /** Humanize a duration in ms to a short string */
26
- function humanizeAge(ms: number): string {
27
- if (ms <= 0) return "never";
28
- const seconds = Math.floor(ms / 1000);
29
- if (seconds < 5) return "just now";
30
- if (seconds < 60) return `${seconds}s ago`;
31
- const minutes = Math.floor(seconds / 60);
32
- if (minutes < 60) return `${minutes}m ago`;
33
- const hours = Math.floor(minutes / 60);
34
- return `${hours}h ago`;
35
- }
36
-
37
- /**
38
- * Info overlay component with cache-first reactive model.
39
- */
40
- export class InfoOverlay implements Component {
41
- private groups: InfoGroup[] = [];
42
- private activeTabIndex = 0;
43
- private groupData = new Map<string, GroupData>();
44
- private groupLoading = new Map<string, boolean>();
45
- private scrollOffset = 0;
46
- private tabScrollOffset = 0;
47
- private lastGlobalUpdate = 0;
48
- private unsubscribers: Array<() => void> = [];
49
- private _destroyed = false;
50
-
51
- onClose?: () => void;
52
- requestRender?: () => void;
53
-
54
- private theme: Theme | null = null;
55
-
56
- setTheme(theme: Theme): void {
57
- this.theme = theme;
58
- }
59
-
60
- constructor() {
61
- // Load groups synchronously (they're already registered)
62
- this.groups = infoRegistry.getAllGroups();
63
- this.applyOrder();
64
-
65
- // Seed cache with any existing data (instant display)
66
- for (const group of this.groups) {
67
- const cached = infoRegistry.getCachedData(group.id);
68
- if (cached) {
69
- this.groupData.set(group.id, cached);
70
- }
71
- this.groupLoading.set(group.id, true);
72
- }
73
-
74
- // Subscribe to per-group updates for reactive rendering
75
- this.unsubscribers.push(
76
- infoRegistry.subscribeAll((groupId, data) => {
77
- if (this._destroyed) return;
78
- // Skip empty data from registration notifications — syncGroups()
79
- // will trigger the real fetch.
80
- if (Object.keys(data).length === 0) {
81
- this.requestRender?.();
82
- return;
83
- }
84
- this.groupData.set(groupId, data);
85
- this.groupLoading.set(groupId, false);
86
- this.lastGlobalUpdate = Date.now();
87
- this.requestRender?.();
88
- })
89
- );
90
-
91
- // Start background fetch for all groups (non-blocking)
92
- this.fetchAllBackground();
93
- }
94
-
95
- /**
96
- * Fetch all groups in background. Each resolves independently.
97
- *
98
- * Each fetch is deferred to a macrotask (setTimeout 0) so the constructor
99
- * returns immediately. Without this, getGroupData() runs each group's
100
- * dataProvider synchronously up to its first `await` before yielding —
101
- * heavy providers (usage stats parse 1GB+ of session files, memory scans)
102
- * blocked the session_start handler for seconds.
103
- */
104
- private fetchAllBackground(): void {
105
- for (const group of this.groups) {
106
- // Defer each fetch to a macrotask so the overlay constructs instantly.
107
- setTimeout(() => {
108
- if (this._destroyed) return;
109
- infoRegistry.getGroupData(group.id).then(() => {
110
- this.groupLoading.set(group.id, false);
111
- }).catch(() => {
112
- this.groupLoading.set(group.id, false);
113
- });
114
- }, 0);
115
- }
116
- }
117
-
118
- /**
119
- * Handle late-arriving groups (e.g., subagents announces after boot).
120
- */
121
- private syncGroups(): void {
122
- const allGroups = infoRegistry.getAllGroups();
123
- const hadNewGroups = allGroups.length !== this.groups.length;
124
- if (hadNewGroups) {
125
- this.groups = allGroups;
126
- this.applyOrder();
127
- }
128
-
129
- // Ensure every group has real (non-empty) data.
130
- // Registration notifications inject `{}` to trigger re-sync; we must
131
- // not treat that as fetched data or the stats render as "—".
132
- for (const group of this.groups) {
133
- const existing = this.groupData.get(group.id);
134
- const hasRealData = existing && Object.keys(existing).length > 0;
135
- if (!hasRealData) {
136
- const cached = infoRegistry.getCachedData(group.id);
137
- if (cached && Object.keys(cached).length > 0) {
138
- this.groupData.set(group.id, cached);
139
- } else if (!this.groupLoading.get(group.id)) {
140
- this.groupLoading.set(group.id, true);
141
- infoRegistry.getGroupData(group.id).then((data) => {
142
- this.groupData.set(group.id, data);
143
- this.groupLoading.set(group.id, false);
144
- this.lastGlobalUpdate = Date.now();
145
- this.requestRender?.();
146
- }).catch(() => {
147
- this.groupLoading.set(group.id, false);
148
- });
149
- }
150
- }
151
- }
152
- }
153
-
154
- private applyOrder(): void {
155
- const settings = getInfoSettings();
156
- if (settings.groupOrder && settings.groupOrder.length > 0) {
157
- const order = settings.groupOrder;
158
- this.groups.sort((a, b) => {
159
- const ai = order.indexOf(a.id);
160
- const bi = order.indexOf(b.id);
161
- return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
162
- });
163
- }
164
- }
165
-
166
- /**
167
- * Cleanup subscriptions.
168
- */
169
- destroy(): void {
170
- this._destroyed = true;
171
- for (const unsub of this.unsubscribers) {
172
- unsub();
173
- }
174
- this.unsubscribers = [];
175
- }
176
-
177
- invalidate(): void {
178
- this.syncGroups();
179
- }
180
-
181
- handleInput(data: string): void {
182
- if (data === "\x1b[C" || data === "l") {
183
- this.activeTabIndex = (this.activeTabIndex + 1) % this.groups.length;
184
- this.scrollOffset = 0;
185
- } else if (data === "\x1b[D" || data === "h") {
186
- this.activeTabIndex = (this.activeTabIndex - 1 + this.groups.length) % this.groups.length;
187
- this.scrollOffset = 0;
188
- } else if (data === "\x1b[B" || data === "j") {
189
- this.scrollOffset++;
190
- } else if (data === "\x1b[A" || data === "k") {
191
- this.scrollOffset = Math.max(0, this.scrollOffset - 1);
192
- } else if (data === "g") {
193
- this.scrollOffset = 0;
194
- } else if (data === "G") {
195
- this.scrollOffset = Infinity;
196
- } else if (data === "r") {
197
- // Manual refresh
198
- this.refreshActiveGroup();
199
- } else if (data === "R") {
200
- // Refresh all
201
- this.refreshAll();
202
- } else if (data === "q" || data === "\x1b") {
203
- this.destroy();
204
- this.onClose?.();
205
- }
206
- }
207
-
208
- private refreshActiveGroup(): void {
209
- const group = this.groups[this.activeTabIndex];
210
- if (!group) return;
211
- this.groupLoading.set(group.id, true);
212
- this.requestRender?.();
213
- infoRegistry.refreshGroup(group.id);
214
- }
215
-
216
- private refreshAll(): void {
217
- for (const group of this.groups) {
218
- this.groupLoading.set(group.id, true);
219
- }
220
- this.requestRender?.();
221
- infoRegistry.refreshAll();
222
- }
223
-
224
- render(width: number): string[] {
225
- // Sync groups in case late arrivals
226
- this.syncGroups();
227
-
228
- if (this.groups.length === 0) {
229
- return this.renderEmpty(width);
230
- }
231
-
232
- return this.renderDashboard(width);
233
- }
234
-
235
- // ─── Theme helpers ───────────────────────────────────────────────────
236
-
237
- private fg(color: string, text: string): string {
238
- if (this.theme) return this.theme.fg(color as any, text);
239
- const c: Record<string, string> = {
240
- accent: "\x1b[36m", success: "\x1b[32m", warning: "\x1b[33m",
241
- error: "\x1b[31m", dim: "\x1b[2m", borderMuted: "\x1b[90m",
242
- };
243
- return `${c[color] ?? ""}${text}\x1b[0m`;
244
- }
245
-
246
- private bold(text: string): string {
247
- return this.theme ? this.theme.bold(text) : `\x1b[1m${text}\x1b[0m`;
248
- }
249
-
250
- private bg(color: string, text: string): string {
251
- return this.theme ? this.theme.bg(color as any, text) : text;
252
- }
253
-
254
- private frameLine(content: string, innerWidth: number): string {
255
- const truncated = truncateToWidth(content, innerWidth, "");
256
- const padding = Math.max(0, innerWidth - visibleWidth(truncated));
257
- return `${this.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.fg("borderMuted", "│")}`;
258
- }
259
-
260
- private ruleLine(innerWidth: number): string {
261
- return this.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`);
262
- }
263
-
264
- private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
265
- const left = edge === "top" ? "┌" : "└";
266
- const right = edge === "top" ? "┐" : "┘";
267
- return this.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`);
268
- }
269
-
270
- private getDialogHeight(): number {
271
- const terminalRows = process.stdout.rows ?? 30;
272
- return Math.max(18, Math.min(32, Math.floor(terminalRows * 0.78)));
273
- }
274
-
275
- // ─── State views ─────────────────────────────────────────────────────
276
-
277
- private renderEmpty(width: number): string[] {
278
- const innerWidth = Math.max(22, width - 2);
279
- const lines: string[] = [];
280
- lines.push(this.borderLine(innerWidth, "top"));
281
- lines.push(this.frameLine(this.fg("accent", this.bold("📊 UniPi Info Screen")), innerWidth));
282
- lines.push(this.ruleLine(innerWidth));
283
- lines.push(this.frameLine(this.fg("dim", "No groups registered."), innerWidth));
284
- lines.push(this.frameLine(this.fg("dim", "Modules will register groups on startup."), innerWidth));
285
- for (let i = 0; i < 4; i++) lines.push(this.frameLine("", innerWidth));
286
- lines.push(this.ruleLine(innerWidth));
287
- lines.push(this.frameLine(this.fg("dim", "q/Esc close · r refresh"), innerWidth));
288
- lines.push(this.borderLine(innerWidth, "bottom"));
289
- return lines;
290
- }
291
-
292
- // ─── Dashboard ───────────────────────────────────────────────────────
293
-
294
- private renderDashboard(width: number): string[] {
295
- const innerWidth = Math.max(22, width - 2);
296
- const group = this.groups[this.activeTabIndex];
297
- const data = this.groupData.get(group.id) ?? {};
298
- const isLoading = this.groupLoading.get(group.id) ?? false;
299
-
300
- const CONTENT_HEIGHT = 12;
301
- const lines: string[] = [];
302
-
303
- lines.push(this.borderLine(innerWidth, "top"));
304
-
305
- // Header: group name + loading indicator
306
- const loadingDot = isLoading
307
- ? ` ${this.fg("warning", "●")}`
308
- : ` ${this.fg("success", "●")}`;
309
- const headerText = this.fg("accent", this.bold(` ${group.icon} ${group.name} `)) + loadingDot;
310
- lines.push(this.frameLine(headerText, innerWidth));
311
- lines.push(this.ruleLine(innerWidth));
312
-
313
- // Tab bar
314
- lines.push(this.frameLine(this.renderTabBar(innerWidth), innerWidth));
315
- lines.push(this.ruleLine(innerWidth));
316
-
317
- // Content with scrolling
318
- const contentLines = this.renderGroupContent(innerWidth, group, data);
319
- const wrapped = this.wrapLines(contentLines, innerWidth);
320
- const maxScroll = Math.max(0, wrapped.length - CONTENT_HEIGHT);
321
- this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
322
-
323
- const visible = wrapped.slice(this.scrollOffset, this.scrollOffset + CONTENT_HEIGHT);
324
- for (let i = 0; i < CONTENT_HEIGHT; i++) {
325
- lines.push(this.frameLine(visible[i] ?? "", innerWidth));
326
- }
327
-
328
- // Footer
329
- lines.push(this.ruleLine(innerWidth));
330
- lines.push(this.frameLine(this.renderFooter(innerWidth, wrapped.length, CONTENT_HEIGHT), innerWidth));
331
- lines.push(this.borderLine(innerWidth, "bottom"));
332
-
333
- return lines;
334
- }
335
-
336
- private renderTabBar(width: number): string {
337
- if (this.groups.length === 0) return "";
338
-
339
- const tabWidths = this.groups.map(g => visibleWidth(` ${g.icon} ${g.name} `));
340
- const sepW = visibleWidth(this.fg("borderMuted", "│"));
341
- const indicatorSpace = 3;
342
- let maxTabs = 0;
343
- let totalW = 0;
344
- for (let i = 0; i < this.groups.length; i++) {
345
- const add = (i > 0 ? sepW : 0) + tabWidths[i]!;
346
- if (totalW + add > width - 2 - indicatorSpace) break;
347
- totalW += add;
348
- maxTabs = i + 1;
349
- }
350
-
351
- if (maxTabs >= this.groups.length) {
352
- return this.renderAllTabs();
353
- }
354
-
355
- if (this.activeTabIndex < this.tabScrollOffset) {
356
- this.tabScrollOffset = this.activeTabIndex;
357
- } else if (this.activeTabIndex >= this.tabScrollOffset + maxTabs) {
358
- this.tabScrollOffset = this.activeTabIndex - maxTabs + 1;
359
- }
360
- this.tabScrollOffset = Math.max(0, Math.min(this.tabScrollOffset, this.groups.length - maxTabs));
361
-
362
- const tabs: string[] = [];
363
- for (let i = this.tabScrollOffset; i < this.tabScrollOffset + maxTabs && i < this.groups.length; i++) {
364
- const g = this.groups[i]!;
365
- const isActive = i === this.activeTabIndex;
366
- const color = TAB_FG[i % TAB_FG.length]!;
367
- // Per-tab loading indicator
368
- const isLoading = this.groupLoading.get(g.id) ?? false;
369
- const dot = isLoading ? this.fg("warning", "●") : "";
370
-
371
- if (isActive) {
372
- tabs.push(this.fg(color, this.bold(` ${g.icon} ${g.name} ${dot}`)));
373
- } else {
374
- tabs.push(this.fg("dim", ` ${g.icon} ${g.name} ${dot}`));
375
- }
376
- }
377
-
378
- const tabStr = tabs.join(this.fg("borderMuted", "│"));
379
- if (this.tabScrollOffset > 0) return `${this.fg("dim", "◀")} ${tabStr}`;
380
- if (this.tabScrollOffset + maxTabs < this.groups.length) return `${tabStr} ${this.fg("dim", "▶")}`;
381
- return tabStr;
382
- }
383
-
384
- private renderAllTabs(): string {
385
- const tabs: string[] = [];
386
- for (let i = 0; i < this.groups.length; i++) {
387
- const g = this.groups[i]!;
388
- const isActive = i === this.activeTabIndex;
389
- const color = TAB_FG[i % TAB_FG.length]!;
390
- const isLoading = this.groupLoading.get(g.id) ?? false;
391
- const dot = isLoading ? this.fg("warning", "●") : "";
392
-
393
- if (isActive) {
394
- tabs.push(this.fg(color, this.bold(` ${g.icon} ${g.name} ${dot}`)));
395
- } else {
396
- tabs.push(this.fg("dim", ` ${g.icon} ${g.name} ${dot}`));
397
- }
398
- }
399
- return tabs.join(this.fg("borderMuted", "│"));
400
- }
401
-
402
- private renderGroupContent(width: number, group: InfoGroup, data: GroupData): string[] {
403
- const lines: string[] = [];
404
- const isLoading = this.groupLoading.get(group.id) ?? false;
405
- const visibleStats = infoRegistry.getVisibleStats(group.id);
406
-
407
- if (visibleStats.length === 0) {
408
- lines.push(` ${this.fg("dim", "No stats configured for this group.")}`);
409
- return lines;
410
- }
411
-
412
- // If no data yet and loading, show placeholder per stat
413
- if (Object.keys(data).length === 0 && isLoading) {
414
- for (const stat of visibleStats) {
415
- lines.push(` ${this.fg("dim", `${stat.label}:`)} ${this.fg("warning", "···")}`);
416
- }
417
- return lines;
418
- }
419
-
420
- const maxLabelLen = Math.max(...visibleStats.map((s) => s.label.length));
421
-
422
- for (const stat of visibleStats) {
423
- const statData = data[stat.id];
424
- const value = statData?.value ?? "—";
425
- const detail = statData?.detail;
426
-
427
- const label = `${stat.label}:`.padEnd(maxLabelLen + 1);
428
- let line = ` ${this.fg("dim", label)} ${this.bold(value)}`;
429
-
430
- if (detail) {
431
- const detailLines = detail.split("\n");
432
- if (detailLines.length === 1) {
433
- line += ` ${this.fg("dim", `(${detail})`)}`;
434
- } else {
435
- lines.push(line);
436
- for (const dLine of detailLines) {
437
- const indent = " ".repeat(maxLabelLen + 4);
438
- let detailLine = `${indent}${dLine}`;
439
- if (visibleWidth(detailLine) > width - 2) {
440
- detailLine = truncateToWidth(detailLine, width - 2);
441
- }
442
- lines.push(detailLine);
443
- }
444
- continue;
445
- }
446
- }
447
-
448
- if (visibleWidth(line) > width - 2) {
449
- line = truncateToWidth(line, width - 2);
450
- }
451
-
452
- lines.push(line);
453
- }
454
-
455
- return lines;
456
- }
457
-
458
- private renderFooter(width: number, totalLines: number, visibleHeight: number): string {
459
- const hasScroll = totalLines > visibleHeight;
460
- let scrollStr = "";
461
- if (hasScroll) {
462
- scrollStr = this.fg("dim", `${this.scrollOffset + 1}-${Math.min(this.scrollOffset + visibleHeight, totalLines)}/${totalLines}`);
463
- }
464
-
465
- // Last updated for active group
466
- const group = this.groups[this.activeTabIndex];
467
- const lastUp = infoRegistry.getLastUpdated(group?.id ?? "");
468
- const age = lastUp > 0 ? humanizeAge(Date.now() - lastUp) : "loading…";
469
-
470
- const hints = [
471
- `${this.fg("accent", "←/→")} tabs`,
472
- `${this.fg("success", "↑/↓")} scroll`,
473
- `${this.fg("warning", "r")} refresh`,
474
- `${this.fg("error", "q/Esc")} close`,
475
- ];
476
-
477
- const hintStr = hints.join(` ${this.fg("borderMuted", "•")} `);
478
-
479
- // Build right side: age + hints
480
- const ageStr = this.fg("dim", `⏱ ${age}`);
481
- const rightStr = `${ageStr} ${this.fg("borderMuted", "│")} ${hintStr}`;
482
-
483
- const scrollW = visibleWidth(scrollStr);
484
- const rightW = visibleWidth(rightStr);
485
- const gap = 4;
486
- const totalW = scrollW + gap + rightW;
487
-
488
- if (totalW >= width - 2) {
489
- return truncateToWidth(rightStr, width - 2);
490
- }
491
-
492
- const padding = " ".repeat(Math.max(0, width - 2 - totalW));
493
- return scrollStr + padding + rightStr;
494
- }
495
-
496
- private wrapLines(lines: string[], innerWidth: number): string[] {
497
- const wrapped: string[] = [];
498
- for (const line of lines) {
499
- if (!line) { wrapped.push(""); continue; }
500
- wrapped.push(...wrapTextWithAnsi(line, Math.max(1, innerWidth)));
501
- }
502
- return wrapped;
503
- }
504
- }