@pi-unipi/info-screen 2.6.1 → 2.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/config.ts +0 -16
- package/core-groups.ts +0 -10
- package/index.ts +22 -13
- package/package.json +8 -6
- package/registry.ts +1 -72
- package/settings/settings-tui.ts +48 -86
- package/tui/info-overlay.ts +85 -100
- package/usage-parser.ts +12 -47
package/config.ts
CHANGED
|
@@ -164,15 +164,6 @@ export function setGroupSettings(groupId: string, groupSettings: GroupSettings):
|
|
|
164
164
|
saveInfoSettings(settings);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
/**
|
|
168
|
-
* Check if a group is enabled.
|
|
169
|
-
*/
|
|
170
|
-
export function isGroupEnabled(groupId: string): boolean {
|
|
171
|
-
const settings = getInfoSettings();
|
|
172
|
-
if (!(groupId in settings.groups)) return true; // Default to enabled
|
|
173
|
-
return settings.groups[groupId].show;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
167
|
/**
|
|
177
168
|
* Check if a stat within a group is enabled.
|
|
178
169
|
*/
|
|
@@ -182,10 +173,3 @@ export function isStatEnabled(groupId: string, statId: string): boolean {
|
|
|
182
173
|
if (!(statId in groupSettings.stats)) return true;
|
|
183
174
|
return groupSettings.stats[statId];
|
|
184
175
|
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Clear cached settings (for testing or reload).
|
|
188
|
-
*/
|
|
189
|
-
export function clearSettingsCache(): void {
|
|
190
|
-
cachedSettings = null;
|
|
191
|
-
}
|
package/core-groups.ts
CHANGED
|
@@ -141,11 +141,6 @@ export function startLoadTracking(): void {
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
/** Record when a module starts loading */
|
|
145
|
-
export function recordModuleStart(name: string): void {
|
|
146
|
-
moduleStartTimes.set(name, Date.now());
|
|
147
|
-
}
|
|
148
|
-
|
|
149
144
|
/** Record a load time */
|
|
150
145
|
export function recordLoadTime(name: string, type: string, ms?: number): void {
|
|
151
146
|
// If no ms provided, calculate from start time
|
|
@@ -172,11 +167,6 @@ export function finishLoadTracking(): void {
|
|
|
172
167
|
}
|
|
173
168
|
}
|
|
174
169
|
|
|
175
|
-
/** Get load times */
|
|
176
|
-
export function getLoadTimes(): Array<{ name: string; type: string; ms: number }> {
|
|
177
|
-
return [...loadTimes];
|
|
178
|
-
}
|
|
179
|
-
|
|
180
170
|
/** Get total load time */
|
|
181
171
|
export function getTotalLoadTime(): number {
|
|
182
172
|
return totalLoadTimeMs > 0 ? totalLoadTimeMs : (loadTrackingStarted ? Date.now() - loadTrackingStartMs : 0);
|
package/index.ts
CHANGED
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
import { dirname } from "node:path";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import { UNIPI_EVENTS, MODULES, UNIPI_PREFIX, emitEvent, getPackageVersion, type UnipiModuleEvent
|
|
15
|
+
import { UNIPI_EVENTS, MODULES, UNIPI_PREFIX, emitEvent, getPackageVersion, type UnipiModuleEvent } from "@pi-unipi/core";
|
|
16
16
|
import { infoRegistry } from "./registry.js";
|
|
17
|
-
import { registerCoreGroups, trackModule, trackTool, setPiApi, registerSkillDir, startLoadTracking, recordLoadTime, finishLoadTracking
|
|
17
|
+
import { registerCoreGroups, trackModule, trackTool, setPiApi, registerSkillDir, startLoadTracking, recordLoadTime, finishLoadTracking } from "./core-groups.js";
|
|
18
18
|
|
|
19
19
|
/** Re-export for external use */
|
|
20
|
-
export { infoRegistry, registerSkillDir, startLoadTracking, recordLoadTime, finishLoadTracking
|
|
20
|
+
export { infoRegistry, registerSkillDir, startLoadTracking, recordLoadTime, finishLoadTracking };
|
|
21
21
|
import { getInfoSettings } from "./config.js";
|
|
22
22
|
import { InfoOverlay } from "./tui/info-overlay.js";
|
|
23
23
|
import { SettingsOverlay } from "./settings/settings-tui.js";
|
|
@@ -95,10 +95,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
95
95
|
}
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
-
pi.events.on(UNIPI_EVENTS.INFO_GROUP_REGISTERED, (_data) => {
|
|
99
|
-
// Group already registered via globalThis in registerGroup()
|
|
100
|
-
});
|
|
101
|
-
|
|
102
98
|
// Track built-in tools
|
|
103
99
|
const trackedBuiltinTools = new Set<string>();
|
|
104
100
|
pi.on("tool_call", async (event, _ctx) => {
|
|
@@ -116,9 +112,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
116
112
|
* Background: each group fetches independently, overlay re-renders reactively.
|
|
117
113
|
*/
|
|
118
114
|
function showOverlay(ctx: ExtensionContext, autoCloseMs?: number): void {
|
|
115
|
+
let overlay: InfoOverlay;
|
|
119
116
|
ctx.ui.custom<void>(
|
|
120
117
|
(tui, theme, _keybindings, done) => {
|
|
121
|
-
|
|
118
|
+
overlay = new InfoOverlay();
|
|
122
119
|
overlay.setTheme(theme);
|
|
123
120
|
overlayVisible = true;
|
|
124
121
|
overlay.onClose = () => {
|
|
@@ -127,11 +124,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
127
124
|
done();
|
|
128
125
|
};
|
|
129
126
|
overlay.requestRender = () => tui.requestRender();
|
|
130
|
-
|
|
131
|
-
if (autoCloseMs && autoCloseMs > 0) {
|
|
132
|
-
overlay.startBootTimer(autoCloseMs);
|
|
133
|
-
}
|
|
134
|
-
return {
|
|
127
|
+
const component = {
|
|
135
128
|
render: (w: number) => overlay.render(w),
|
|
136
129
|
invalidate: () => overlay.invalidate(),
|
|
137
130
|
handleInput: (data: string) => {
|
|
@@ -139,6 +132,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
139
132
|
tui.requestRender();
|
|
140
133
|
},
|
|
141
134
|
};
|
|
135
|
+
// Boot dashboard dismisses itself; any keypress cancels the timer.
|
|
136
|
+
if (autoCloseMs && autoCloseMs > 0) {
|
|
137
|
+
overlay.startBootTimer(autoCloseMs);
|
|
138
|
+
}
|
|
139
|
+
return component;
|
|
142
140
|
},
|
|
143
141
|
{
|
|
144
142
|
overlay: true,
|
|
@@ -148,6 +146,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
148
146
|
anchor: "center" as const,
|
|
149
147
|
margin: 2,
|
|
150
148
|
},
|
|
149
|
+
// `done()` (the extension UI's close callback) pops the *topmost* overlay
|
|
150
|
+
// in the TUI stack, not this one specifically. When another overlay (e.g.
|
|
151
|
+
// the updater's "Update Available" prompt) is stacked on top, the boot
|
|
152
|
+
// auto-close timer must not fire `done()` — that would pop the covering
|
|
153
|
+
// overlay and strand this dashboard with a spent one-shot close the user
|
|
154
|
+
// can no longer dismiss. `isTopmostOverlay` lets the boot timer defer
|
|
155
|
+
// until we are the focused (topmost) entry; the user can still press
|
|
156
|
+
// q/Esc to close once the covering overlay is gone.
|
|
157
|
+
onHandle: (handle) => {
|
|
158
|
+
overlay.isTopmostOverlay = () => handle.isFocused();
|
|
159
|
+
},
|
|
151
160
|
}
|
|
152
161
|
);
|
|
153
162
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/info-screen",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Dashboard and module registry for Unipi
|
|
3
|
+
"version": "2.9.0",
|
|
4
|
+
"description": "Dashboard and module registry for Unipi \u2014 configurable info overlay with tabbed groups",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
7
7
|
"license": "MIT",
|
|
@@ -33,18 +33,20 @@
|
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@pi-unipi/core": "2.
|
|
36
|
+
"@pi-unipi/core": "2.9.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
40
|
-
"@earendil-works/pi-tui": "^0.
|
|
39
|
+
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
40
|
+
"@earendil-works/pi-tui": "^0.84.0",
|
|
41
41
|
"typebox": "^1.1.38"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/node": "^25.6.0"
|
|
45
45
|
},
|
|
46
46
|
"pi": {
|
|
47
|
-
"extensions": [
|
|
47
|
+
"extensions": [
|
|
48
|
+
"./index.ts"
|
|
49
|
+
],
|
|
48
50
|
"skills": [],
|
|
49
51
|
"prompts": [],
|
|
50
52
|
"themes": []
|
package/registry.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { InfoGroup, GroupData } from "./types.js";
|
|
9
|
-
import {
|
|
9
|
+
import { isStatEnabled } from "./config.js";
|
|
10
10
|
|
|
11
11
|
/** Callback for reactive updates */
|
|
12
12
|
type GroupUpdateCallback = (groupId: string, data: GroupData) => void;
|
|
@@ -24,9 +24,6 @@ class InfoRegistry {
|
|
|
24
24
|
/** Cache TTL in ms */
|
|
25
25
|
private cacheTtlMs = 5000;
|
|
26
26
|
|
|
27
|
-
/** Subscribers per group */
|
|
28
|
-
private subscribers = new Map<string, Set<GroupUpdateCallback>>();
|
|
29
|
-
|
|
30
27
|
/** Global subscribers (any group update) */
|
|
31
28
|
private globalSubscribers = new Set<GroupUpdateCallback>();
|
|
32
29
|
|
|
@@ -43,33 +40,6 @@ class InfoRegistry {
|
|
|
43
40
|
this.notifyGroupRegistered(group.id);
|
|
44
41
|
}
|
|
45
42
|
|
|
46
|
-
/**
|
|
47
|
-
* Unregister an info group.
|
|
48
|
-
*/
|
|
49
|
-
unregisterGroup(groupId: string): void {
|
|
50
|
-
this.groups.delete(groupId);
|
|
51
|
-
this.dataCache.delete(groupId);
|
|
52
|
-
this.lastUpdated.delete(groupId);
|
|
53
|
-
this.subscribers.delete(groupId);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Get all registered groups, sorted by priority.
|
|
58
|
-
*/
|
|
59
|
-
getGroups(): InfoGroup[] {
|
|
60
|
-
const settings = getInfoSettings();
|
|
61
|
-
const allGroups = Array.from(this.groups.values());
|
|
62
|
-
|
|
63
|
-
return allGroups
|
|
64
|
-
.filter((group) => {
|
|
65
|
-
const groupSettings = settings.groups[group.id];
|
|
66
|
-
if (groupSettings && !groupSettings.show) return false;
|
|
67
|
-
if (!groupSettings && !group.config.showByDefault) return false;
|
|
68
|
-
return true;
|
|
69
|
-
})
|
|
70
|
-
.sort((a, b) => a.priority - b.priority);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
43
|
/**
|
|
74
44
|
* Get all registered groups (including hidden ones).
|
|
75
45
|
*/
|
|
@@ -100,13 +70,6 @@ class InfoRegistry {
|
|
|
100
70
|
return this.lastUpdated.get(groupId) ?? 0;
|
|
101
71
|
}
|
|
102
72
|
|
|
103
|
-
/**
|
|
104
|
-
* Synchronous: check if a group is currently fetching.
|
|
105
|
-
*/
|
|
106
|
-
isFetching(groupId: string): boolean {
|
|
107
|
-
return this.inflight.has(groupId);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
73
|
/**
|
|
111
74
|
* Get data for a group, using cache if fresh.
|
|
112
75
|
* Returns immediately from cache if fresh, otherwise fetches in background
|
|
@@ -176,21 +139,6 @@ class InfoRegistry {
|
|
|
176
139
|
}
|
|
177
140
|
}
|
|
178
141
|
|
|
179
|
-
/**
|
|
180
|
-
* Subscribe to updates for a specific group.
|
|
181
|
-
* Returns unsubscribe function.
|
|
182
|
-
*/
|
|
183
|
-
subscribe(groupId: string, callback: GroupUpdateCallback): () => void {
|
|
184
|
-
if (!this.subscribers.has(groupId)) {
|
|
185
|
-
this.subscribers.set(groupId, new Set());
|
|
186
|
-
}
|
|
187
|
-
this.subscribers.get(groupId)!.add(callback);
|
|
188
|
-
|
|
189
|
-
return () => {
|
|
190
|
-
this.subscribers.get(groupId)?.delete(callback);
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
142
|
/**
|
|
195
143
|
* Subscribe to all group updates.
|
|
196
144
|
* Returns unsubscribe function.
|
|
@@ -203,14 +151,6 @@ class InfoRegistry {
|
|
|
203
151
|
}
|
|
204
152
|
|
|
205
153
|
private notifySubscribers(groupId: string, data: GroupData): void {
|
|
206
|
-
// Per-group subscribers
|
|
207
|
-
const groupSubs = this.subscribers.get(groupId);
|
|
208
|
-
if (groupSubs) {
|
|
209
|
-
for (const cb of groupSubs) {
|
|
210
|
-
try { cb(groupId, data); } catch { /* ignore */ }
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
154
|
// Global subscribers
|
|
215
155
|
for (const cb of this.globalSubscribers) {
|
|
216
156
|
try { cb(groupId, data); } catch { /* ignore */ }
|
|
@@ -239,14 +179,6 @@ class InfoRegistry {
|
|
|
239
179
|
this.lastUpdated.delete(groupId);
|
|
240
180
|
}
|
|
241
181
|
|
|
242
|
-
/**
|
|
243
|
-
* Invalidate all caches.
|
|
244
|
-
*/
|
|
245
|
-
invalidateAllCaches(): void {
|
|
246
|
-
this.dataCache.clear();
|
|
247
|
-
this.lastUpdated.clear();
|
|
248
|
-
}
|
|
249
|
-
|
|
250
182
|
/**
|
|
251
183
|
* Notify that a new group was registered.
|
|
252
184
|
* Subscribers can use this to sync group lists.
|
|
@@ -266,6 +198,3 @@ export const infoRegistry = new InfoRegistry();
|
|
|
266
198
|
if (!globalThis.__unipi_info_registry) {
|
|
267
199
|
globalThis.__unipi_info_registry = infoRegistry;
|
|
268
200
|
}
|
|
269
|
-
export const getGlobalRegistry = (): InfoRegistry => {
|
|
270
|
-
return (globalThis.__unipi_info_registry as InfoRegistry | undefined) ?? infoRegistry;
|
|
271
|
-
};
|
package/settings/settings-tui.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ansi, TOGGLE_ON, TOGGLE_OFF } from "@pi-unipi/core";
|
|
1
2
|
/**
|
|
2
3
|
* @pi-unipi/info-screen — Settings TUI Component
|
|
3
4
|
*
|
|
@@ -6,28 +7,15 @@
|
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { Component } from "@earendil-works/pi-tui";
|
|
9
|
-
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
11
|
import { infoRegistry } from "../registry.js";
|
|
11
12
|
import { getInfoSettings, saveInfoSettings, getGroupSettings, setGroupSettings } from "../config.js";
|
|
12
13
|
import type { InfoScreenSettings, GroupSettings, BootMode } from "../types.js";
|
|
13
14
|
import { BOOT_MODES } from "../types.js";
|
|
14
15
|
|
|
15
16
|
/** ANSI escape codes */
|
|
16
|
-
const ansi = {
|
|
17
|
-
reset: "\x1b[0m",
|
|
18
|
-
bold: "\x1b[1m",
|
|
19
|
-
dim: "\x1b[2m",
|
|
20
|
-
// Colors
|
|
21
|
-
cyan: "\x1b[36m",
|
|
22
|
-
green: "\x1b[32m",
|
|
23
|
-
yellow: "\x1b[33m",
|
|
24
|
-
red: "\x1b[31m",
|
|
25
|
-
gray: "\x1b[90m",
|
|
26
|
-
};
|
|
27
17
|
|
|
28
18
|
/** Toggle symbols */
|
|
29
|
-
const TOGGLE_ON = `${ansi.green}●${ansi.reset}`;
|
|
30
|
-
const TOGGLE_OFF = `${ansi.dim}○${ansi.reset}`;
|
|
31
19
|
|
|
32
20
|
/** How each boot mode is presented in the settings list. */
|
|
33
21
|
const BOOT_MODE_LABELS: Record<BootMode, string> = {
|
|
@@ -99,57 +87,42 @@ export class SettingsOverlay implements Component {
|
|
|
99
87
|
const rowCount = this.groups.length + H; // header rows, then groups
|
|
100
88
|
const onBootMode = this.selectedIndex === SettingsOverlay.BOOT_MODE_INDEX;
|
|
101
89
|
const onBootTimeout = this.selectedIndex === SettingsOverlay.BOOT_TIMEOUT_INDEX;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
break;
|
|
139
|
-
case "J": // Shift+J - move group down
|
|
140
|
-
if (this.selectedIndex >= H) {
|
|
141
|
-
this.moveGroupDown();
|
|
142
|
-
}
|
|
143
|
-
break;
|
|
144
|
-
case "K": // Shift+K - move group up
|
|
145
|
-
if (this.selectedIndex >= H) {
|
|
146
|
-
this.moveGroupUp();
|
|
147
|
-
}
|
|
148
|
-
break;
|
|
149
|
-
case "q": // Quit
|
|
150
|
-
case "\x1b": // Escape
|
|
151
|
-
this.onClose?.();
|
|
152
|
-
break;
|
|
90
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
91
|
+
this.selectedIndex = (this.selectedIndex - 1 + rowCount) % rowCount;
|
|
92
|
+
} else if (matchesKey(data, Key.down) || data === "j") {
|
|
93
|
+
this.selectedIndex = (this.selectedIndex + 1) % rowCount;
|
|
94
|
+
} else if (data === " ") { // Space - toggle / cycle
|
|
95
|
+
if (onBootMode) {
|
|
96
|
+
this.cycleBootMode();
|
|
97
|
+
} else if (onBootTimeout) {
|
|
98
|
+
this.adjustBootTimeout(500);
|
|
99
|
+
} else {
|
|
100
|
+
this.toggleGroupVisibility(this.groups[this.selectedIndex - H].id);
|
|
101
|
+
}
|
|
102
|
+
} else if (data === "\r" || matchesKey(data, Key.right) || data === "l") { // Enter / Right - enter stats mode
|
|
103
|
+
if (onBootMode) {
|
|
104
|
+
this.cycleBootMode();
|
|
105
|
+
} else if (onBootTimeout) {
|
|
106
|
+
this.adjustBootTimeout(500);
|
|
107
|
+
} else {
|
|
108
|
+
this.enterStatsMode(this.groups[this.selectedIndex - H].id);
|
|
109
|
+
}
|
|
110
|
+
} else if (matchesKey(data, Key.left) || data === "h") { // Left - cycle back / decrease
|
|
111
|
+
if (onBootMode) {
|
|
112
|
+
this.cycleBootMode(-1);
|
|
113
|
+
} else if (onBootTimeout) {
|
|
114
|
+
this.adjustBootTimeout(-500);
|
|
115
|
+
}
|
|
116
|
+
} else if (data === "J") { // Shift+J - move group down
|
|
117
|
+
if (this.selectedIndex >= H) {
|
|
118
|
+
this.moveGroupDown();
|
|
119
|
+
}
|
|
120
|
+
} else if (data === "K") { // Shift+K - move group up
|
|
121
|
+
if (this.selectedIndex >= H) {
|
|
122
|
+
this.moveGroupUp();
|
|
123
|
+
}
|
|
124
|
+
} else if (data === "q" || matchesKey(data, Key.escape)) { // Quit
|
|
125
|
+
this.onClose?.();
|
|
153
126
|
}
|
|
154
127
|
}
|
|
155
128
|
|
|
@@ -162,27 +135,16 @@ export class SettingsOverlay implements Component {
|
|
|
162
135
|
const group = infoRegistry.getGroup(this.selectedGroupId);
|
|
163
136
|
if (!group) return;
|
|
164
137
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
this.toggleStatVisibility(this.selectedGroupId, group.config.stats[this.selectedIndex].id);
|
|
176
|
-
break;
|
|
177
|
-
case "\x1b[D": // Left - back to groups
|
|
178
|
-
case "h":
|
|
179
|
-
case "\r": // Enter - also go back
|
|
180
|
-
this.backToGroups();
|
|
181
|
-
break;
|
|
182
|
-
case "q": // Quit from stats mode
|
|
183
|
-
case "\x1b":
|
|
184
|
-
this.onClose?.();
|
|
185
|
-
break;
|
|
138
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
139
|
+
this.selectedIndex = (this.selectedIndex - 1 + group.config.stats.length) % group.config.stats.length;
|
|
140
|
+
} else if (matchesKey(data, Key.down) || data === "j") {
|
|
141
|
+
this.selectedIndex = (this.selectedIndex + 1) % group.config.stats.length;
|
|
142
|
+
} else if (data === " ") { // Space - toggle stat
|
|
143
|
+
this.toggleStatVisibility(this.selectedGroupId, group.config.stats[this.selectedIndex].id);
|
|
144
|
+
} else if (matchesKey(data, Key.left) || data === "h" || data === "\r") { // Left/Enter - back to groups
|
|
145
|
+
this.backToGroups();
|
|
146
|
+
} else if (data === "q" || matchesKey(data, Key.escape)) { // Quit from stats mode
|
|
147
|
+
this.onClose?.();
|
|
186
148
|
}
|
|
187
149
|
}
|
|
188
150
|
|
package/tui/info-overlay.ts
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { Component } from "@earendil-works/pi-tui";
|
|
11
|
-
import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
11
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
12
12
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { infoRegistry } from "../registry.js";
|
|
14
14
|
import { getInfoSettings } from "../config.js";
|
|
15
15
|
import type { InfoGroup, GroupData } from "../types.js";
|
|
16
|
-
import { boxInnerWidth } from "@pi-unipi/core";
|
|
16
|
+
import { boxInnerWidth, OverlayTheme } from "@pi-unipi/core";
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* How long to wait before warming the non-visible tabs.
|
|
@@ -63,11 +63,17 @@ export class InfoOverlay implements Component {
|
|
|
63
63
|
|
|
64
64
|
onClose?: () => void;
|
|
65
65
|
requestRender?: () => void;
|
|
66
|
+
/**
|
|
67
|
+
* Whether this overlay is the focused (topmost) entry in the TUI overlay
|
|
68
|
+
* stack. The boot auto-close timer only fires while this is true — see
|
|
69
|
+
* `startBootTimer` for why.
|
|
70
|
+
*/
|
|
71
|
+
isTopmostOverlay?: () => boolean;
|
|
66
72
|
|
|
67
|
-
private
|
|
73
|
+
private overlay = new OverlayTheme();
|
|
68
74
|
|
|
69
75
|
setTheme(theme: Theme): void {
|
|
70
|
-
this.theme
|
|
76
|
+
this.overlay.setTheme(theme);
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
constructor() {
|
|
@@ -230,17 +236,36 @@ export class InfoOverlay implements Component {
|
|
|
230
236
|
*
|
|
231
237
|
* Used when the overlay is shown on boot: the dashboard is informational, so
|
|
232
238
|
* it should get out of the way on its own rather than requiring a keypress.
|
|
239
|
+
*
|
|
240
|
+
* The close callback (`onClose` → `done`) pops the *topmost* overlay in the
|
|
241
|
+
* TUI stack, not this one specifically. When another overlay (e.g. the
|
|
242
|
+
* updater's "Update Available" prompt) is stacked on top, firing `done()`
|
|
243
|
+
* here would remove the covering overlay and strand this dashboard with a
|
|
244
|
+
* spent one-shot close the user can no longer trigger — leaving the starting
|
|
245
|
+
* screen stuck. So we defer the auto-close until we are actually the
|
|
246
|
+
* focused/topmost overlay; the user can still press q/Esc to dismiss it once
|
|
247
|
+
* the covering overlay is gone.
|
|
233
248
|
*/
|
|
234
249
|
startBootTimer(ms: number): void {
|
|
235
250
|
this.cancelBootTimer();
|
|
236
251
|
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
237
|
-
|
|
238
|
-
this.bootTimer =
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
252
|
+
const arm = (): void => {
|
|
253
|
+
this.bootTimer = setTimeout(() => {
|
|
254
|
+
this.bootTimer = null;
|
|
255
|
+
if (this._destroyed) return;
|
|
256
|
+
if (this.isTopmostOverlay && !this.isTopmostOverlay()) {
|
|
257
|
+
// Something is stacked on top of us — closing now would pop it
|
|
258
|
+
// instead of this dashboard. Retry shortly; once the covering
|
|
259
|
+
// overlay closes we'll be topmost and can auto-close safely.
|
|
260
|
+
arm();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
this.destroy();
|
|
264
|
+
this.onClose?.();
|
|
265
|
+
}, ms);
|
|
266
|
+
this.bootTimer.unref?.();
|
|
267
|
+
};
|
|
268
|
+
arm();
|
|
244
269
|
}
|
|
245
270
|
|
|
246
271
|
invalidate(): void {
|
|
@@ -251,17 +276,17 @@ export class InfoOverlay implements Component {
|
|
|
251
276
|
// Any keypress means the user is driving; stop the boot auto-close.
|
|
252
277
|
this.cancelBootTimer();
|
|
253
278
|
|
|
254
|
-
if (data
|
|
279
|
+
if (matchesKey(data, Key.right) || data === "l") {
|
|
255
280
|
this.activeTabIndex = (this.activeTabIndex + 1) % this.groups.length;
|
|
256
281
|
this.scrollOffset = 0;
|
|
257
282
|
this.fetchActiveGroup();
|
|
258
|
-
} else if (data
|
|
283
|
+
} else if (matchesKey(data, Key.left) || data === "h") {
|
|
259
284
|
this.activeTabIndex = (this.activeTabIndex - 1 + this.groups.length) % this.groups.length;
|
|
260
285
|
this.scrollOffset = 0;
|
|
261
286
|
this.fetchActiveGroup();
|
|
262
|
-
} else if (data
|
|
287
|
+
} else if (matchesKey(data, Key.down) || data === "j") {
|
|
263
288
|
this.scrollOffset++;
|
|
264
|
-
} else if (data
|
|
289
|
+
} else if (matchesKey(data, Key.up) || data === "k") {
|
|
265
290
|
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
266
291
|
} else if (data === "g") {
|
|
267
292
|
this.scrollOffset = 0;
|
|
@@ -273,7 +298,7 @@ export class InfoOverlay implements Component {
|
|
|
273
298
|
} else if (data === "R") {
|
|
274
299
|
// Refresh all
|
|
275
300
|
this.refreshAll();
|
|
276
|
-
} else if (data === "q" || matchesKey(data,
|
|
301
|
+
} else if (data === "q" || matchesKey(data, Key.escape)) {
|
|
277
302
|
this.destroy();
|
|
278
303
|
this.onClose?.();
|
|
279
304
|
}
|
|
@@ -309,60 +334,20 @@ export class InfoOverlay implements Component {
|
|
|
309
334
|
return this.renderDashboard(width);
|
|
310
335
|
}
|
|
311
336
|
|
|
312
|
-
// ─── Theme helpers ───────────────────────────────────────────────────
|
|
313
|
-
|
|
314
|
-
private fg(color: string, text: string): string {
|
|
315
|
-
if (this.theme) return this.theme.fg(color as any, text);
|
|
316
|
-
const c: Record<string, string> = {
|
|
317
|
-
accent: "\x1b[36m", success: "\x1b[32m", warning: "\x1b[33m",
|
|
318
|
-
error: "\x1b[31m", dim: "\x1b[2m", borderMuted: "\x1b[90m",
|
|
319
|
-
};
|
|
320
|
-
return `${c[color] ?? ""}${text}\x1b[0m`;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
private bold(text: string): string {
|
|
324
|
-
return this.theme ? this.theme.bold(text) : `\x1b[1m${text}\x1b[0m`;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
private bg(color: string, text: string): string {
|
|
328
|
-
return this.theme ? this.theme.bg(color as any, text) : text;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
private frameLine(content: string, innerWidth: number): string {
|
|
332
|
-
const truncated = truncateToWidth(content, innerWidth, "");
|
|
333
|
-
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
334
|
-
return `${this.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.fg("borderMuted", "│")}`;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
private ruleLine(innerWidth: number): string {
|
|
338
|
-
return this.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
|
|
342
|
-
const left = edge === "top" ? "┌" : "└";
|
|
343
|
-
const right = edge === "top" ? "┐" : "┘";
|
|
344
|
-
return this.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
private getDialogHeight(): number {
|
|
348
|
-
const terminalRows = process.stdout.rows ?? 30;
|
|
349
|
-
return Math.max(18, Math.min(32, Math.floor(terminalRows * 0.78)));
|
|
350
|
-
}
|
|
351
|
-
|
|
352
337
|
// ─── State views ─────────────────────────────────────────────────────
|
|
353
338
|
|
|
354
339
|
private renderEmpty(width: number): string[] {
|
|
355
340
|
const innerWidth = boxInnerWidth(width);
|
|
356
341
|
const lines: string[] = [];
|
|
357
|
-
lines.push(this.borderLine(innerWidth, "top"));
|
|
358
|
-
lines.push(this.frameLine(this.fg("accent", this.bold("📊 UniPi Info Screen")), innerWidth));
|
|
359
|
-
lines.push(this.ruleLine(innerWidth));
|
|
360
|
-
lines.push(this.frameLine(this.fg("dim", "No groups registered."), innerWidth));
|
|
361
|
-
lines.push(this.frameLine(this.fg("dim", "Modules will register groups on startup."), innerWidth));
|
|
362
|
-
for (let i = 0; i < 4; i++) lines.push(this.frameLine("", innerWidth));
|
|
363
|
-
lines.push(this.ruleLine(innerWidth));
|
|
364
|
-
lines.push(this.frameLine(this.fg("dim", "q/Esc close · r refresh"), innerWidth));
|
|
365
|
-
lines.push(this.borderLine(innerWidth, "bottom"));
|
|
342
|
+
lines.push(this.overlay.borderLine(innerWidth, "top"));
|
|
343
|
+
lines.push(this.overlay.frameLine(this.overlay.fg("accent", this.overlay.bold("📊 UniPi Info Screen")), innerWidth));
|
|
344
|
+
lines.push(this.overlay.ruleLine(innerWidth));
|
|
345
|
+
lines.push(this.overlay.frameLine(this.overlay.fg("dim", "No groups registered."), innerWidth));
|
|
346
|
+
lines.push(this.overlay.frameLine(this.overlay.fg("dim", "Modules will register groups on startup."), innerWidth));
|
|
347
|
+
for (let i = 0; i < 4; i++) lines.push(this.overlay.frameLine("", innerWidth));
|
|
348
|
+
lines.push(this.overlay.ruleLine(innerWidth));
|
|
349
|
+
lines.push(this.overlay.frameLine(this.overlay.fg("dim", "q/Esc close · r refresh"), innerWidth));
|
|
350
|
+
lines.push(this.overlay.borderLine(innerWidth, "bottom"));
|
|
366
351
|
return lines;
|
|
367
352
|
}
|
|
368
353
|
|
|
@@ -377,19 +362,19 @@ export class InfoOverlay implements Component {
|
|
|
377
362
|
const CONTENT_HEIGHT = 12;
|
|
378
363
|
const lines: string[] = [];
|
|
379
364
|
|
|
380
|
-
lines.push(this.borderLine(innerWidth, "top"));
|
|
365
|
+
lines.push(this.overlay.borderLine(innerWidth, "top"));
|
|
381
366
|
|
|
382
367
|
// Header: group name + loading indicator
|
|
383
368
|
const loadingDot = isLoading
|
|
384
|
-
? ` ${this.fg("warning", "●")}`
|
|
385
|
-
: ` ${this.fg("success", "●")}`;
|
|
386
|
-
const headerText = this.fg("accent", this.bold(` ${group.icon} ${group.name} `)) + loadingDot;
|
|
387
|
-
lines.push(this.frameLine(headerText, innerWidth));
|
|
388
|
-
lines.push(this.ruleLine(innerWidth));
|
|
369
|
+
? ` ${this.overlay.fg("warning", "●")}`
|
|
370
|
+
: ` ${this.overlay.fg("success", "●")}`;
|
|
371
|
+
const headerText = this.overlay.fg("accent", this.overlay.bold(` ${group.icon} ${group.name} `)) + loadingDot;
|
|
372
|
+
lines.push(this.overlay.frameLine(headerText, innerWidth));
|
|
373
|
+
lines.push(this.overlay.ruleLine(innerWidth));
|
|
389
374
|
|
|
390
375
|
// Tab bar
|
|
391
|
-
lines.push(this.frameLine(this.renderTabBar(innerWidth), innerWidth));
|
|
392
|
-
lines.push(this.ruleLine(innerWidth));
|
|
376
|
+
lines.push(this.overlay.frameLine(this.renderTabBar(innerWidth), innerWidth));
|
|
377
|
+
lines.push(this.overlay.ruleLine(innerWidth));
|
|
393
378
|
|
|
394
379
|
// Content with scrolling
|
|
395
380
|
const contentLines = this.renderGroupContent(innerWidth, group, data);
|
|
@@ -399,13 +384,13 @@ export class InfoOverlay implements Component {
|
|
|
399
384
|
|
|
400
385
|
const visible = wrapped.slice(this.scrollOffset, this.scrollOffset + CONTENT_HEIGHT);
|
|
401
386
|
for (let i = 0; i < CONTENT_HEIGHT; i++) {
|
|
402
|
-
lines.push(this.frameLine(visible[i] ?? "", innerWidth));
|
|
387
|
+
lines.push(this.overlay.frameLine(visible[i] ?? "", innerWidth));
|
|
403
388
|
}
|
|
404
389
|
|
|
405
390
|
// Footer
|
|
406
|
-
lines.push(this.ruleLine(innerWidth));
|
|
407
|
-
lines.push(this.frameLine(this.renderFooter(innerWidth, wrapped.length, CONTENT_HEIGHT), innerWidth));
|
|
408
|
-
lines.push(this.borderLine(innerWidth, "bottom"));
|
|
391
|
+
lines.push(this.overlay.ruleLine(innerWidth));
|
|
392
|
+
lines.push(this.overlay.frameLine(this.renderFooter(innerWidth, wrapped.length, CONTENT_HEIGHT), innerWidth));
|
|
393
|
+
lines.push(this.overlay.borderLine(innerWidth, "bottom"));
|
|
409
394
|
|
|
410
395
|
return lines;
|
|
411
396
|
}
|
|
@@ -414,7 +399,7 @@ export class InfoOverlay implements Component {
|
|
|
414
399
|
if (this.groups.length === 0) return "";
|
|
415
400
|
|
|
416
401
|
const tabWidths = this.groups.map(g => visibleWidth(` ${g.icon} ${g.name} `));
|
|
417
|
-
const sepW = visibleWidth(this.fg("borderMuted", "│"));
|
|
402
|
+
const sepW = visibleWidth(this.overlay.fg("borderMuted", "│"));
|
|
418
403
|
const indicatorSpace = 3;
|
|
419
404
|
let maxTabs = 0;
|
|
420
405
|
let totalW = 0;
|
|
@@ -443,18 +428,18 @@ export class InfoOverlay implements Component {
|
|
|
443
428
|
const color = TAB_FG[i % TAB_FG.length]!;
|
|
444
429
|
// Per-tab loading indicator
|
|
445
430
|
const isLoading = this.groupLoading.get(g.id) ?? false;
|
|
446
|
-
const dot = isLoading ? this.fg("warning", "●") : "";
|
|
431
|
+
const dot = isLoading ? this.overlay.fg("warning", "●") : "";
|
|
447
432
|
|
|
448
433
|
if (isActive) {
|
|
449
|
-
tabs.push(this.fg(color, this.bold(` ${g.icon} ${g.name} ${dot}`)));
|
|
434
|
+
tabs.push(this.overlay.fg(color, this.overlay.bold(` ${g.icon} ${g.name} ${dot}`)));
|
|
450
435
|
} else {
|
|
451
|
-
tabs.push(this.fg("dim", ` ${g.icon} ${g.name} ${dot}`));
|
|
436
|
+
tabs.push(this.overlay.fg("dim", ` ${g.icon} ${g.name} ${dot}`));
|
|
452
437
|
}
|
|
453
438
|
}
|
|
454
439
|
|
|
455
|
-
const tabStr = tabs.join(this.fg("borderMuted", "│"));
|
|
456
|
-
if (this.tabScrollOffset > 0) return `${this.fg("dim", "◀")} ${tabStr}`;
|
|
457
|
-
if (this.tabScrollOffset + maxTabs < this.groups.length) return `${tabStr} ${this.fg("dim", "▶")}`;
|
|
440
|
+
const tabStr = tabs.join(this.overlay.fg("borderMuted", "│"));
|
|
441
|
+
if (this.tabScrollOffset > 0) return `${this.overlay.fg("dim", "◀")} ${tabStr}`;
|
|
442
|
+
if (this.tabScrollOffset + maxTabs < this.groups.length) return `${tabStr} ${this.overlay.fg("dim", "▶")}`;
|
|
458
443
|
return tabStr;
|
|
459
444
|
}
|
|
460
445
|
|
|
@@ -465,15 +450,15 @@ export class InfoOverlay implements Component {
|
|
|
465
450
|
const isActive = i === this.activeTabIndex;
|
|
466
451
|
const color = TAB_FG[i % TAB_FG.length]!;
|
|
467
452
|
const isLoading = this.groupLoading.get(g.id) ?? false;
|
|
468
|
-
const dot = isLoading ? this.fg("warning", "●") : "";
|
|
453
|
+
const dot = isLoading ? this.overlay.fg("warning", "●") : "";
|
|
469
454
|
|
|
470
455
|
if (isActive) {
|
|
471
|
-
tabs.push(this.fg(color, this.bold(` ${g.icon} ${g.name} ${dot}`)));
|
|
456
|
+
tabs.push(this.overlay.fg(color, this.overlay.bold(` ${g.icon} ${g.name} ${dot}`)));
|
|
472
457
|
} else {
|
|
473
|
-
tabs.push(this.fg("dim", ` ${g.icon} ${g.name} ${dot}`));
|
|
458
|
+
tabs.push(this.overlay.fg("dim", ` ${g.icon} ${g.name} ${dot}`));
|
|
474
459
|
}
|
|
475
460
|
}
|
|
476
|
-
return tabs.join(this.fg("borderMuted", "│"));
|
|
461
|
+
return tabs.join(this.overlay.fg("borderMuted", "│"));
|
|
477
462
|
}
|
|
478
463
|
|
|
479
464
|
private renderGroupContent(width: number, group: InfoGroup, data: GroupData): string[] {
|
|
@@ -482,14 +467,14 @@ export class InfoOverlay implements Component {
|
|
|
482
467
|
const visibleStats = infoRegistry.getVisibleStats(group.id);
|
|
483
468
|
|
|
484
469
|
if (visibleStats.length === 0) {
|
|
485
|
-
lines.push(` ${this.fg("dim", "No stats configured for this group.")}`);
|
|
470
|
+
lines.push(` ${this.overlay.fg("dim", "No stats configured for this group.")}`);
|
|
486
471
|
return lines;
|
|
487
472
|
}
|
|
488
473
|
|
|
489
474
|
// If no data yet and loading, show placeholder per stat
|
|
490
475
|
if (Object.keys(data).length === 0 && isLoading) {
|
|
491
476
|
for (const stat of visibleStats) {
|
|
492
|
-
lines.push(` ${this.fg("dim", `${stat.label}:`)} ${this.fg("warning", "···")}`);
|
|
477
|
+
lines.push(` ${this.overlay.fg("dim", `${stat.label}:`)} ${this.overlay.fg("warning", "···")}`);
|
|
493
478
|
}
|
|
494
479
|
return lines;
|
|
495
480
|
}
|
|
@@ -502,12 +487,12 @@ export class InfoOverlay implements Component {
|
|
|
502
487
|
const detail = statData?.detail;
|
|
503
488
|
|
|
504
489
|
const label = `${stat.label}:`.padEnd(maxLabelLen + 1);
|
|
505
|
-
let line = ` ${this.fg("dim", label)} ${this.bold(value)}`;
|
|
490
|
+
let line = ` ${this.overlay.fg("dim", label)} ${this.overlay.bold(value)}`;
|
|
506
491
|
|
|
507
492
|
if (detail) {
|
|
508
493
|
const detailLines = detail.split("\n");
|
|
509
494
|
if (detailLines.length === 1) {
|
|
510
|
-
line += ` ${this.fg("dim", `(${detail})`)}`;
|
|
495
|
+
line += ` ${this.overlay.fg("dim", `(${detail})`)}`;
|
|
511
496
|
} else {
|
|
512
497
|
lines.push(line);
|
|
513
498
|
for (const dLine of detailLines) {
|
|
@@ -536,7 +521,7 @@ export class InfoOverlay implements Component {
|
|
|
536
521
|
const hasScroll = totalLines > visibleHeight;
|
|
537
522
|
let scrollStr = "";
|
|
538
523
|
if (hasScroll) {
|
|
539
|
-
scrollStr = this.fg("dim", `${this.scrollOffset + 1}-${Math.min(this.scrollOffset + visibleHeight, totalLines)}/${totalLines}`);
|
|
524
|
+
scrollStr = this.overlay.fg("dim", `${this.scrollOffset + 1}-${Math.min(this.scrollOffset + visibleHeight, totalLines)}/${totalLines}`);
|
|
540
525
|
}
|
|
541
526
|
|
|
542
527
|
// Last updated for active group
|
|
@@ -545,17 +530,17 @@ export class InfoOverlay implements Component {
|
|
|
545
530
|
const age = lastUp > 0 ? humanizeAge(Date.now() - lastUp) : "loading…";
|
|
546
531
|
|
|
547
532
|
const hints = [
|
|
548
|
-
`${this.fg("accent", "←/→")} tabs`,
|
|
549
|
-
`${this.fg("success", "↑/↓")} scroll`,
|
|
550
|
-
`${this.fg("warning", "r")} refresh`,
|
|
551
|
-
`${this.fg("error", "q/Esc")} close`,
|
|
533
|
+
`${this.overlay.fg("accent", "←/→")} tabs`,
|
|
534
|
+
`${this.overlay.fg("success", "↑/↓")} scroll`,
|
|
535
|
+
`${this.overlay.fg("warning", "r")} refresh`,
|
|
536
|
+
`${this.overlay.fg("error", "q/Esc")} close`,
|
|
552
537
|
];
|
|
553
538
|
|
|
554
|
-
const hintStr = hints.join(` ${this.fg("borderMuted", "•")} `);
|
|
539
|
+
const hintStr = hints.join(` ${this.overlay.fg("borderMuted", "•")} `);
|
|
555
540
|
|
|
556
541
|
// Build right side: age + hints
|
|
557
|
-
const ageStr = this.fg("dim", `⏱ ${age}`);
|
|
558
|
-
const rightStr = `${ageStr} ${this.fg("borderMuted", "│")} ${hintStr}`;
|
|
542
|
+
const ageStr = this.overlay.fg("dim", `⏱ ${age}`);
|
|
543
|
+
const rightStr = `${ageStr} ${this.overlay.fg("borderMuted", "│")} ${hintStr}`;
|
|
559
544
|
|
|
560
545
|
const scrollW = visibleWidth(scrollStr);
|
|
561
546
|
const rightW = visibleWidth(rightStr);
|
package/usage-parser.ts
CHANGED
|
@@ -25,13 +25,10 @@ export interface UsageStats {
|
|
|
25
25
|
today: number;
|
|
26
26
|
week: number;
|
|
27
27
|
month: number;
|
|
28
|
-
allTime: number;
|
|
29
28
|
};
|
|
30
29
|
/** Total cost by period (USD) */
|
|
31
30
|
cost: {
|
|
32
31
|
today: number;
|
|
33
|
-
week: number;
|
|
34
|
-
month: number;
|
|
35
32
|
allTime: number;
|
|
36
33
|
};
|
|
37
34
|
/** Token counts by model (all time) */
|
|
@@ -44,8 +41,6 @@ export interface UsageStats {
|
|
|
44
41
|
byModelMonth: Record<string, { tokens: number; cost: number; sessions: number }>;
|
|
45
42
|
/** Total sessions */
|
|
46
43
|
sessionCount: number;
|
|
47
|
-
/** Total messages */
|
|
48
|
-
messageCount: number;
|
|
49
44
|
}
|
|
50
45
|
|
|
51
46
|
/** Time period boundaries */
|
|
@@ -275,16 +270,8 @@ function collectSessionFiles(dir: string, files: string[]): void {
|
|
|
275
270
|
}
|
|
276
271
|
|
|
277
272
|
/**
|
|
278
|
-
* Parse all session files and aggregate usage stats
|
|
279
|
-
*
|
|
280
|
-
*/
|
|
281
|
-
export function parseUsageStats(): UsageStats {
|
|
282
|
-
const { stats } = collectStats(null);
|
|
283
|
-
return stats;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Async variant that yields to the event loop while parsing.
|
|
273
|
+
* Parse all session files and aggregate usage stats, yielding to the event
|
|
274
|
+
* loop while parsing.
|
|
288
275
|
*
|
|
289
276
|
* A cold parse is several seconds of pure CPU. Running it synchronously starves
|
|
290
277
|
* the event loop, so keystrokes queue up and the UI cannot repaint. Deferring
|
|
@@ -292,41 +279,34 @@ export function parseUsageStats(): UsageStats {
|
|
|
292
279
|
* `yieldEvery` files, control returns to the loop.
|
|
293
280
|
*/
|
|
294
281
|
export async function parseUsageStatsAsync(): Promise<UsageStats> {
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
};
|
|
298
|
-
const { stats, pending } = collectStats(yielder);
|
|
299
|
-
if (pending) await pending;
|
|
282
|
+
const { stats, pending } = collectStats();
|
|
283
|
+
await pending;
|
|
300
284
|
return stats;
|
|
301
285
|
}
|
|
302
286
|
|
|
303
287
|
/** Empty stats accumulator. */
|
|
304
288
|
function emptyStats(): UsageStats {
|
|
305
289
|
return {
|
|
306
|
-
tokens: { today: 0, week: 0, month: 0
|
|
307
|
-
cost: { today: 0,
|
|
290
|
+
tokens: { today: 0, week: 0, month: 0 },
|
|
291
|
+
cost: { today: 0, allTime: 0 },
|
|
308
292
|
byModel: {},
|
|
309
293
|
byModelToday: {},
|
|
310
294
|
byModelWeek: {},
|
|
311
295
|
byModelMonth: {},
|
|
312
296
|
sessionCount: 0,
|
|
313
|
-
messageCount: 0,
|
|
314
297
|
};
|
|
315
298
|
}
|
|
316
299
|
|
|
317
300
|
/**
|
|
318
|
-
*
|
|
301
|
+
* Scan all session files and aggregate usage stats.
|
|
319
302
|
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
* as `pending` progresses, and the caller must await it.
|
|
303
|
+
* The returned `stats` object is filled in as `pending` progresses; the caller
|
|
304
|
+
* must await it.
|
|
323
305
|
*/
|
|
324
|
-
function collectStats(
|
|
325
|
-
yielder: (() => Promise<void>) | null,
|
|
326
|
-
): { stats: UsageStats; pending: Promise<void> | null } {
|
|
306
|
+
function collectStats(): { stats: UsageStats; pending: Promise<void> } {
|
|
327
307
|
const stats = emptyStats();
|
|
328
308
|
const sessionsDir = getSessionsDir();
|
|
329
|
-
if (!existsSync(sessionsDir)) return { stats, pending:
|
|
309
|
+
if (!existsSync(sessionsDir)) return { stats, pending: Promise.resolve() };
|
|
330
310
|
|
|
331
311
|
const sessionFiles: string[] = [];
|
|
332
312
|
collectSessionFiles(sessionsDir, sessionFiles);
|
|
@@ -405,7 +385,6 @@ function collectStats(
|
|
|
405
385
|
counted++;
|
|
406
386
|
const model = models[modelIdx] ?? "unknown";
|
|
407
387
|
|
|
408
|
-
stats.tokens.allTime += countedTokens;
|
|
409
388
|
stats.cost.allTime += cost;
|
|
410
389
|
bump(stats.byModel, model, countedTokens, cost);
|
|
411
390
|
|
|
@@ -416,19 +395,16 @@ function collectStats(
|
|
|
416
395
|
}
|
|
417
396
|
if (timestamp >= weekStart) {
|
|
418
397
|
stats.tokens.week += countedTokens;
|
|
419
|
-
stats.cost.week += cost;
|
|
420
398
|
bump(stats.byModelWeek, model, countedTokens, cost);
|
|
421
399
|
}
|
|
422
400
|
if (timestamp >= monthStart) {
|
|
423
401
|
stats.tokens.month += countedTokens;
|
|
424
|
-
stats.cost.month += cost;
|
|
425
402
|
bump(stats.byModelMonth, model, countedTokens, cost);
|
|
426
403
|
}
|
|
427
404
|
}
|
|
428
405
|
|
|
429
406
|
if (counted > 0) {
|
|
430
407
|
stats.sessionCount++;
|
|
431
|
-
stats.messageCount += counted;
|
|
432
408
|
}
|
|
433
409
|
};
|
|
434
410
|
|
|
@@ -440,17 +416,6 @@ function collectStats(
|
|
|
440
416
|
|
|
441
417
|
const YIELD_EVERY = 25;
|
|
442
418
|
|
|
443
|
-
if (!yielder) {
|
|
444
|
-
for (const item of work) {
|
|
445
|
-
const records = item.cached
|
|
446
|
-
? item.cached.records
|
|
447
|
-
: (nextFiles[item.path].records = extractRecords(item.path, modelIndex, models));
|
|
448
|
-
aggregate(records);
|
|
449
|
-
}
|
|
450
|
-
finish();
|
|
451
|
-
return { stats, pending: null };
|
|
452
|
-
}
|
|
453
|
-
|
|
454
419
|
const pending = (async () => {
|
|
455
420
|
let sinceYield = 0;
|
|
456
421
|
for (const item of work) {
|
|
@@ -467,7 +432,7 @@ function collectStats(
|
|
|
467
432
|
aggregate(records);
|
|
468
433
|
if (sinceYield >= YIELD_EVERY) {
|
|
469
434
|
sinceYield = 0;
|
|
470
|
-
await
|
|
435
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
471
436
|
}
|
|
472
437
|
}
|
|
473
438
|
finish();
|