@pi-unipi/info-screen 2.2.1 → 2.4.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/README.md +4 -4
- package/config.ts +28 -8
- package/core-groups.ts +5 -39
- package/index.ts +25 -10
- package/package.json +2 -2
- package/settings/settings-tui.ts +86 -31
- package/tui/info-overlay.ts +114 -38
- package/types.ts +20 -5
- package/usage-parser.ts +318 -128
package/README.md
CHANGED
|
@@ -76,8 +76,8 @@ Settings in pi `settings.json`:
|
|
|
76
76
|
{
|
|
77
77
|
"unipi": {
|
|
78
78
|
"infoScreen": {
|
|
79
|
-
"
|
|
80
|
-
"bootTimeoutMs":
|
|
79
|
+
"bootMode": "auto-close",
|
|
80
|
+
"bootTimeoutMs": 2000,
|
|
81
81
|
"groups": {
|
|
82
82
|
"modules": { "show": true },
|
|
83
83
|
"ralph": { "show": true },
|
|
@@ -91,8 +91,8 @@ Settings in pi `settings.json`:
|
|
|
91
91
|
|
|
92
92
|
| Setting | Default | What It Does |
|
|
93
93
|
|---------|---------|--------------|
|
|
94
|
-
| `
|
|
95
|
-
| `bootTimeoutMs` |
|
|
94
|
+
| `bootMode` | `"auto-close"` | `"on"` keeps the dashboard up until dismissed, `"auto-close"` closes it after `bootTimeoutMs`, `"off"` never shows it |
|
|
95
|
+
| `bootTimeoutMs` | 2000 | Auto-close delay, in ms. Any keypress cancels it. Ignored unless `bootMode` is `"auto-close"` |
|
|
96
96
|
| `groups.{id}.show` | true | Toggle group visibility |
|
|
97
97
|
| `groupOrder` | priority sort | Custom group ordering |
|
|
98
98
|
|
package/config.ts
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
* under the "unipi.info" key.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
9
|
-
import { join } from "node:path";
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { join, dirname } from "node:path";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
|
-
import type { InfoScreenSettings, GroupSettings } from "./types.js";
|
|
12
|
-
import { DEFAULT_SETTINGS } from "./types.js";
|
|
11
|
+
import type { InfoScreenSettings, GroupSettings, BootMode } from "./types.js";
|
|
12
|
+
import { DEFAULT_SETTINGS, BOOT_MODES } from "./types.js";
|
|
13
13
|
|
|
14
14
|
/** Settings path */
|
|
15
15
|
const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
|
|
@@ -44,9 +44,11 @@ function readSettingsFile(): Record<string, unknown> {
|
|
|
44
44
|
* Write the full settings file.
|
|
45
45
|
*/
|
|
46
46
|
function writeSettingsFile(data: Record<string, unknown>): void {
|
|
47
|
-
|
|
47
|
+
// These were require() calls in an ESM module, which throws under Node's
|
|
48
|
+
// module-format detection as soon as the directory is missing.
|
|
49
|
+
const dir = dirname(SETTINGS_PATH);
|
|
48
50
|
if (!existsSync(dir)) {
|
|
49
|
-
|
|
51
|
+
mkdirSync(dir, { recursive: true });
|
|
50
52
|
}
|
|
51
53
|
writeFileSync(SETTINGS_PATH, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
52
54
|
}
|
|
@@ -68,7 +70,7 @@ export function getInfoSettings(): InfoScreenSettings {
|
|
|
68
70
|
const info = unipi.info as Record<string, unknown>;
|
|
69
71
|
|
|
70
72
|
cachedSettings = {
|
|
71
|
-
|
|
73
|
+
bootMode: parseBootMode(info),
|
|
72
74
|
bootTimeoutMs: typeof info.bootTimeoutMs === "number" ? info.bootTimeoutMs : DEFAULT_SETTINGS.bootTimeoutMs,
|
|
73
75
|
groups: isRecord(info.groups) ? parseGroupSettings(info.groups) : {},
|
|
74
76
|
};
|
|
@@ -76,6 +78,24 @@ export function getInfoSettings(): InfoScreenSettings {
|
|
|
76
78
|
return cachedSettings;
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the boot mode, migrating the legacy `showOnBoot` boolean.
|
|
83
|
+
*
|
|
84
|
+
* `showOnBoot: false` maps to "off". `showOnBoot: true` maps to "on" rather
|
|
85
|
+
* than the new "auto-close" default, so an existing config keeps behaving the
|
|
86
|
+
* way its owner configured it.
|
|
87
|
+
*/
|
|
88
|
+
function parseBootMode(info: Record<string, unknown>): BootMode {
|
|
89
|
+
const raw = info.bootMode;
|
|
90
|
+
if (typeof raw === "string" && (BOOT_MODES as string[]).includes(raw)) {
|
|
91
|
+
return raw as BootMode;
|
|
92
|
+
}
|
|
93
|
+
if (typeof info.showOnBoot === "boolean") {
|
|
94
|
+
return info.showOnBoot ? "on" : "off";
|
|
95
|
+
}
|
|
96
|
+
return DEFAULT_SETTINGS.bootMode;
|
|
97
|
+
}
|
|
98
|
+
|
|
79
99
|
/**
|
|
80
100
|
* Parse group settings from raw object.
|
|
81
101
|
*/
|
|
@@ -118,7 +138,7 @@ export function saveInfoSettings(settings: InfoScreenSettings): void {
|
|
|
118
138
|
}
|
|
119
139
|
|
|
120
140
|
(file[SETTINGS_KEY] as Record<string, unknown>).info = {
|
|
121
|
-
|
|
141
|
+
bootMode: settings.bootMode,
|
|
122
142
|
bootTimeoutMs: settings.bootTimeoutMs,
|
|
123
143
|
groups: settings.groups,
|
|
124
144
|
};
|
package/core-groups.ts
CHANGED
|
@@ -9,8 +9,9 @@ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
|
9
9
|
import { join, basename } from "node:path";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { getPiVersion } from "@pi-unipi/core";
|
|
12
13
|
import { infoRegistry } from "./registry.js";
|
|
13
|
-
import {
|
|
14
|
+
import { parseUsageStatsAsync, formatTokens, formatCost } from "./usage-parser.js";
|
|
14
15
|
import type { InfoGroup } from "./types.js";
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -27,43 +28,6 @@ function getPackageVersion(packageDir: string): string {
|
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
/**
|
|
31
|
-
* Get pi version from its package.json.
|
|
32
|
-
*/
|
|
33
|
-
function getPiVersion(): string {
|
|
34
|
-
// Try to find pi's package.json in various locations
|
|
35
|
-
const possiblePaths = [
|
|
36
|
-
// Global npm install
|
|
37
|
-
join(homedir(), ".local", "share", "mise", "installs", "node", "24.14.1", "lib", "node_modules", "@earendil-works", "pi-coding-agent", "package.json"),
|
|
38
|
-
// Alternative locations
|
|
39
|
-
join(homedir(), ".local", "share", "mise", "installs", "node", "lib", "node_modules", "@earendil-works", "pi-coding-agent", "package.json"),
|
|
40
|
-
];
|
|
41
|
-
|
|
42
|
-
for (const pkgPath of possiblePaths) {
|
|
43
|
-
try {
|
|
44
|
-
if (existsSync(pkgPath)) {
|
|
45
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
46
|
-
return pkg?.version ?? "unknown";
|
|
47
|
-
}
|
|
48
|
-
} catch {
|
|
49
|
-
// Continue to next path
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Fallback: try to run pi --version
|
|
54
|
-
try {
|
|
55
|
-
const { execSync } = require("node:child_process");
|
|
56
|
-
const version = execSync("pi --version 2>/dev/null", { encoding: "utf-8" }).trim();
|
|
57
|
-
// Extract version number from output like "pi v0.42.4"
|
|
58
|
-
const match = version.match(/v([\d.]+)/);
|
|
59
|
-
if (match) return match[1];
|
|
60
|
-
} catch {
|
|
61
|
-
// Ignore
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return "unknown";
|
|
65
|
-
}
|
|
66
|
-
|
|
67
31
|
/**
|
|
68
32
|
* Discover loaded extensions by scanning filesystem.
|
|
69
33
|
*/
|
|
@@ -433,7 +397,9 @@ export function registerCoreGroups(): void {
|
|
|
433
397
|
],
|
|
434
398
|
},
|
|
435
399
|
dataProvider: async () => {
|
|
436
|
-
|
|
400
|
+
// Async variant yields to the event loop between files, so a cold parse
|
|
401
|
+
// cannot block keystrokes or the initial paint.
|
|
402
|
+
const stats = await parseUsageStatsAsync();
|
|
437
403
|
|
|
438
404
|
// Find top model for each period
|
|
439
405
|
const findTopModel = (modelStats: Record<string, { tokens: number; cost: number; sessions: number }> | undefined) => {
|
package/index.ts
CHANGED
|
@@ -34,6 +34,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
34
34
|
// Start load tracking
|
|
35
35
|
startLoadTracking();
|
|
36
36
|
|
|
37
|
+
// Whether an info overlay is currently on screen. Module announcements only
|
|
38
|
+
// trigger a re-fetch when something is actually displaying the result.
|
|
39
|
+
let overlayVisible = false;
|
|
40
|
+
|
|
37
41
|
// Debounced MODULE_READY handling — batch module announcements
|
|
38
42
|
// to prevent layout shift from rapid per-module cache invalidation.
|
|
39
43
|
let moduleReadyBatch: Array<{ name: string; version: string; tools?: string[]; loadTimeMs?: number }> = [];
|
|
@@ -60,14 +64,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
// Single cache invalidation for all modules
|
|
67
|
+
// Single cache invalidation for all modules.
|
|
68
|
+
//
|
|
69
|
+
// Only re-fetch while an overlay is actually on screen. Otherwise this
|
|
70
|
+
// ran every module announcement even with the dashboard disabled,
|
|
71
|
+
// doing work nobody would see.
|
|
64
72
|
infoRegistry.invalidateCache("overview");
|
|
65
|
-
infoRegistry.
|
|
73
|
+
if (hasTools) infoRegistry.invalidateCache("tools");
|
|
66
74
|
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
if (!overlayVisible) return;
|
|
76
|
+
|
|
77
|
+
infoRegistry.getGroupData("overview");
|
|
78
|
+
if (hasTools) infoRegistry.getGroupData("tools");
|
|
71
79
|
}
|
|
72
80
|
|
|
73
81
|
// Listen for module announcements — track and trigger reactive updates
|
|
@@ -107,16 +115,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
107
115
|
* Cache-first: opens with whatever data is cached (even empty).
|
|
108
116
|
* Background: each group fetches independently, overlay re-renders reactively.
|
|
109
117
|
*/
|
|
110
|
-
function showOverlay(ctx: ExtensionContext): void {
|
|
118
|
+
function showOverlay(ctx: ExtensionContext, autoCloseMs?: number): void {
|
|
111
119
|
ctx.ui.custom<void>(
|
|
112
120
|
(tui, theme, _keybindings, done) => {
|
|
113
121
|
const overlay = new InfoOverlay();
|
|
114
122
|
overlay.setTheme(theme);
|
|
123
|
+
overlayVisible = true;
|
|
115
124
|
overlay.onClose = () => {
|
|
125
|
+
overlayVisible = false;
|
|
116
126
|
overlay.destroy();
|
|
117
127
|
done();
|
|
118
128
|
};
|
|
119
129
|
overlay.requestRender = () => tui.requestRender();
|
|
130
|
+
// Boot dashboard dismisses itself; any keypress cancels the timer.
|
|
131
|
+
if (autoCloseMs && autoCloseMs > 0) {
|
|
132
|
+
overlay.startBootTimer(autoCloseMs);
|
|
133
|
+
}
|
|
120
134
|
return {
|
|
121
135
|
render: (w: number) => overlay.render(w),
|
|
122
136
|
invalidate: () => overlay.invalidate(),
|
|
@@ -142,9 +156,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
142
156
|
pi.on("session_start", async (event, ctx) => {
|
|
143
157
|
const settings = getInfoSettings();
|
|
144
158
|
|
|
145
|
-
if (settings.
|
|
146
|
-
// Open immediately — cache-first, no waiting
|
|
147
|
-
|
|
159
|
+
if (settings.bootMode !== "off" && event.reason === "startup") {
|
|
160
|
+
// Open immediately — cache-first, no waiting. In "auto-close" mode the
|
|
161
|
+
// overlay dismisses itself after bootTimeoutMs; any keypress cancels it.
|
|
162
|
+
showOverlay(ctx, settings.bootMode === "auto-close" ? settings.bootTimeoutMs : 0);
|
|
148
163
|
}
|
|
149
164
|
|
|
150
165
|
finishLoadTracking();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/info-screen",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Dashboard and module registry for Unipi — configurable info overlay with tabbed groups",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@pi-unipi/core": "2.
|
|
36
|
+
"@pi-unipi/core": "2.4.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@earendil-works/pi-coding-agent": "^0.80.0",
|
package/settings/settings-tui.ts
CHANGED
|
@@ -9,7 +9,8 @@ import type { Component } from "@earendil-works/pi-tui";
|
|
|
9
9
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
10
|
import { infoRegistry } from "../registry.js";
|
|
11
11
|
import { getInfoSettings, saveInfoSettings, getGroupSettings, setGroupSettings } from "../config.js";
|
|
12
|
-
import type { InfoScreenSettings, GroupSettings } from "../types.js";
|
|
12
|
+
import type { InfoScreenSettings, GroupSettings, BootMode } from "../types.js";
|
|
13
|
+
import { BOOT_MODES } from "../types.js";
|
|
13
14
|
|
|
14
15
|
/** ANSI escape codes */
|
|
15
16
|
const ansi = {
|
|
@@ -28,6 +29,13 @@ const ansi = {
|
|
|
28
29
|
const TOGGLE_ON = `${ansi.green}●${ansi.reset}`;
|
|
29
30
|
const TOGGLE_OFF = `${ansi.dim}○${ansi.reset}`;
|
|
30
31
|
|
|
32
|
+
/** How each boot mode is presented in the settings list. */
|
|
33
|
+
const BOOT_MODE_LABELS: Record<BootMode, string> = {
|
|
34
|
+
"on": `${ansi.green}● On${ansi.reset}`,
|
|
35
|
+
"auto-close": `${ansi.cyan}◐ Auto-close${ansi.reset}`,
|
|
36
|
+
"off": `${ansi.dim}○ Off${ansi.reset}`,
|
|
37
|
+
};
|
|
38
|
+
|
|
31
39
|
/**
|
|
32
40
|
* Settings overlay component.
|
|
33
41
|
*/
|
|
@@ -36,7 +44,11 @@ export class SettingsOverlay implements Component {
|
|
|
36
44
|
private groups: Array<{ id: string; name: string; icon: string }>;
|
|
37
45
|
private selectedIndex = 0;
|
|
38
46
|
/** Index of the "Show on boot" toggle row (above the group list). */
|
|
39
|
-
|
|
47
|
+
/** Row 0 cycles the boot mode; row 1 edits the auto-close delay. */
|
|
48
|
+
private static readonly BOOT_MODE_INDEX = 0;
|
|
49
|
+
private static readonly BOOT_TIMEOUT_INDEX = 1;
|
|
50
|
+
/** Number of settings rows rendered above the group list. */
|
|
51
|
+
private static readonly HEADER_ROWS = 2;
|
|
40
52
|
private savedGroupIndex = 0; // Saved position before entering stats mode
|
|
41
53
|
private mode: "groups" | "stats" = "groups";
|
|
42
54
|
private selectedGroupId: string | null = null;
|
|
@@ -83,39 +95,54 @@ export class SettingsOverlay implements Component {
|
|
|
83
95
|
* Handle input in groups mode.
|
|
84
96
|
*/
|
|
85
97
|
private handleGroupsInput(data: string): void {
|
|
86
|
-
const
|
|
98
|
+
const H = SettingsOverlay.HEADER_ROWS;
|
|
99
|
+
const rowCount = this.groups.length + H; // header rows, then groups
|
|
100
|
+
const onBootMode = this.selectedIndex === SettingsOverlay.BOOT_MODE_INDEX;
|
|
101
|
+
const onBootTimeout = this.selectedIndex === SettingsOverlay.BOOT_TIMEOUT_INDEX;
|
|
87
102
|
switch (data) {
|
|
88
103
|
case "\x1b[A": // Up
|
|
89
104
|
case "k":
|
|
90
|
-
this.selectedIndex = (this.selectedIndex - 1 +
|
|
105
|
+
this.selectedIndex = (this.selectedIndex - 1 + rowCount) % rowCount;
|
|
91
106
|
break;
|
|
92
107
|
case "\x1b[B": // Down
|
|
93
108
|
case "j":
|
|
94
|
-
this.selectedIndex = (this.selectedIndex + 1) %
|
|
109
|
+
this.selectedIndex = (this.selectedIndex + 1) % rowCount;
|
|
95
110
|
break;
|
|
96
|
-
case " ": // Space - toggle
|
|
97
|
-
if (
|
|
98
|
-
this.
|
|
111
|
+
case " ": // Space - toggle / cycle
|
|
112
|
+
if (onBootMode) {
|
|
113
|
+
this.cycleBootMode();
|
|
114
|
+
} else if (onBootTimeout) {
|
|
115
|
+
this.adjustBootTimeout(500);
|
|
99
116
|
} else {
|
|
100
|
-
this.toggleGroupVisibility(this.groups[this.selectedIndex -
|
|
117
|
+
this.toggleGroupVisibility(this.groups[this.selectedIndex - H].id);
|
|
101
118
|
}
|
|
102
119
|
break;
|
|
103
120
|
case "\r": // Enter - enter stats mode
|
|
104
121
|
case "\x1b[C": // Right - enter stats mode
|
|
105
122
|
case "l":
|
|
106
|
-
if (
|
|
107
|
-
this.
|
|
123
|
+
if (onBootMode) {
|
|
124
|
+
this.cycleBootMode();
|
|
125
|
+
} else if (onBootTimeout) {
|
|
126
|
+
this.adjustBootTimeout(500);
|
|
108
127
|
} else {
|
|
109
|
-
this.enterStatsMode(this.groups[this.selectedIndex -
|
|
128
|
+
this.enterStatsMode(this.groups[this.selectedIndex - H].id);
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
case "\x1b[D": // Left - cycle back / decrease
|
|
132
|
+
case "h":
|
|
133
|
+
if (onBootMode) {
|
|
134
|
+
this.cycleBootMode(-1);
|
|
135
|
+
} else if (onBootTimeout) {
|
|
136
|
+
this.adjustBootTimeout(-500);
|
|
110
137
|
}
|
|
111
138
|
break;
|
|
112
139
|
case "J": // Shift+J - move group down
|
|
113
|
-
if (this.selectedIndex
|
|
140
|
+
if (this.selectedIndex >= H) {
|
|
114
141
|
this.moveGroupDown();
|
|
115
142
|
}
|
|
116
143
|
break;
|
|
117
144
|
case "K": // Shift+K - move group up
|
|
118
|
-
if (this.selectedIndex
|
|
145
|
+
if (this.selectedIndex >= H) {
|
|
119
146
|
this.moveGroupUp();
|
|
120
147
|
}
|
|
121
148
|
break;
|
|
@@ -195,11 +222,23 @@ export class SettingsOverlay implements Component {
|
|
|
195
222
|
this.settings.groups[groupId] = groupSettings;
|
|
196
223
|
}
|
|
197
224
|
|
|
225
|
+
/** Cycle the boot mode: on → auto-close → off → on. */
|
|
226
|
+
private cycleBootMode(step = 1): void {
|
|
227
|
+
const current = BOOT_MODES.indexOf(this.settings.bootMode);
|
|
228
|
+
const next = (current + step + BOOT_MODES.length) % BOOT_MODES.length;
|
|
229
|
+
this.settings.bootMode = BOOT_MODES[next]!;
|
|
230
|
+
saveInfoSettings(this.settings);
|
|
231
|
+
}
|
|
232
|
+
|
|
198
233
|
/**
|
|
199
|
-
*
|
|
234
|
+
* Adjust the auto-close delay in 500ms steps.
|
|
235
|
+
*
|
|
236
|
+
* Clamped to a sane range: below 500ms the dashboard would vanish before it
|
|
237
|
+
* could be read, and beyond 30s "auto-close" stops meaning anything.
|
|
200
238
|
*/
|
|
201
|
-
private
|
|
202
|
-
|
|
239
|
+
private adjustBootTimeout(deltaMs: number): void {
|
|
240
|
+
const next = this.settings.bootTimeoutMs + deltaMs;
|
|
241
|
+
this.settings.bootTimeoutMs = Math.min(30_000, Math.max(500, next));
|
|
203
242
|
saveInfoSettings(this.settings);
|
|
204
243
|
}
|
|
205
244
|
|
|
@@ -217,8 +256,9 @@ export class SettingsOverlay implements Component {
|
|
|
217
256
|
* Move selected group up in order.
|
|
218
257
|
*/
|
|
219
258
|
private moveGroupUp(): void {
|
|
220
|
-
|
|
221
|
-
|
|
259
|
+
// Already the first group; nothing above it but the settings rows.
|
|
260
|
+
if (this.selectedIndex <= SettingsOverlay.HEADER_ROWS) return;
|
|
261
|
+
const i = this.selectedIndex - SettingsOverlay.HEADER_ROWS;
|
|
222
262
|
// Swap with previous
|
|
223
263
|
const temp = this.groups[i]!;
|
|
224
264
|
this.groups[i] = this.groups[i - 1]!;
|
|
@@ -231,8 +271,9 @@ export class SettingsOverlay implements Component {
|
|
|
231
271
|
* Move selected group down in order.
|
|
232
272
|
*/
|
|
233
273
|
private moveGroupDown(): void {
|
|
234
|
-
|
|
235
|
-
|
|
274
|
+
// Header rows occupy 0..HEADER_ROWS-1; groups follow.
|
|
275
|
+
if (this.selectedIndex >= this.groups.length + SettingsOverlay.HEADER_ROWS - 1) return;
|
|
276
|
+
const i = this.selectedIndex - SettingsOverlay.HEADER_ROWS;
|
|
236
277
|
// Swap with next
|
|
237
278
|
const temp = this.groups[i]!;
|
|
238
279
|
this.groups[i] = this.groups[i + 1]!;
|
|
@@ -283,16 +324,30 @@ export class SettingsOverlay implements Component {
|
|
|
283
324
|
lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderCentered(`${ansi.bold}⚙️ Info Screen Settings${ansi.reset}`, innerWidth), innerWidth)}${ansi.dim}│${ansi.reset}`);
|
|
284
325
|
lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
|
|
285
326
|
|
|
286
|
-
//
|
|
287
|
-
// Boot toggle row (index 0)
|
|
327
|
+
// Boot mode row (index 0)
|
|
288
328
|
{
|
|
289
|
-
const isSelected = SettingsOverlay.
|
|
290
|
-
const isEnabled = this.settings.showOnBoot;
|
|
291
|
-
const toggle = isEnabled ? TOGGLE_ON : TOGGLE_OFF;
|
|
329
|
+
const isSelected = SettingsOverlay.BOOT_MODE_INDEX === this.selectedIndex;
|
|
292
330
|
const indicator = isSelected ? `${ansi.cyan}▸${ansi.reset}` : " ";
|
|
293
|
-
|
|
331
|
+
const label = BOOT_MODE_LABELS[this.settings.bootMode];
|
|
332
|
+
let line = ` ${indicator} 🚀 Show on boot ${ansi.bold}${label}${ansi.reset}`;
|
|
294
333
|
if (isSelected) {
|
|
295
|
-
line += ` ${ansi.dim}
|
|
334
|
+
line += ` ${ansi.dim}←/→ change${ansi.reset}`;
|
|
335
|
+
}
|
|
336
|
+
lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(line, innerWidth)}${ansi.dim}│${ansi.reset}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Auto-close delay row (index 1) — only meaningful in auto-close mode.
|
|
340
|
+
{
|
|
341
|
+
const isSelected = SettingsOverlay.BOOT_TIMEOUT_INDEX === this.selectedIndex;
|
|
342
|
+
const active = this.settings.bootMode === "auto-close";
|
|
343
|
+
const indicator = isSelected ? `${ansi.cyan}▸${ansi.reset}` : " ";
|
|
344
|
+
const seconds = `${(this.settings.bootTimeoutMs / 1000).toFixed(1)}s`;
|
|
345
|
+
const value = active ? `${ansi.bold}${seconds}${ansi.reset}` : `${ansi.dim}${seconds}${ansi.reset}`;
|
|
346
|
+
let line = active
|
|
347
|
+
? ` ${indicator} ⏱️ Auto-close after ${value}`
|
|
348
|
+
: ` ${indicator} ${ansi.dim}⏱️ Auto-close after${ansi.reset} ${value}`;
|
|
349
|
+
if (isSelected && active) {
|
|
350
|
+
line += ` ${ansi.dim}←/→ ±0.5s${ansi.reset}`;
|
|
296
351
|
}
|
|
297
352
|
lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(line, innerWidth)}${ansi.dim}│${ansi.reset}`);
|
|
298
353
|
}
|
|
@@ -300,8 +355,8 @@ export class SettingsOverlay implements Component {
|
|
|
300
355
|
|
|
301
356
|
for (let i = 0; i < this.groups.length; i++) {
|
|
302
357
|
const group = this.groups[i];
|
|
303
|
-
//
|
|
304
|
-
const rowIndex = i +
|
|
358
|
+
// Header rows occupy 0..HEADER_ROWS-1; groups follow.
|
|
359
|
+
const rowIndex = i + SettingsOverlay.HEADER_ROWS;
|
|
305
360
|
const isSelected = rowIndex === this.selectedIndex;
|
|
306
361
|
const groupSettings = getGroupSettings(group.id);
|
|
307
362
|
const isEnabled = groupSettings.show;
|
|
@@ -324,7 +379,7 @@ export class SettingsOverlay implements Component {
|
|
|
324
379
|
|
|
325
380
|
// Footer
|
|
326
381
|
lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
|
|
327
|
-
lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderCentered(`${ansi.dim}↑↓ select Space toggle Enter
|
|
382
|
+
lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderCentered(`${ansi.dim}↑↓ select ←→ change Space toggle Enter stats J/K reorder q close${ansi.reset}`, innerWidth), innerWidth)}${ansi.dim}│${ansi.reset}`);
|
|
328
383
|
lines.push(`${ansi.dim}╰${"─".repeat(innerWidth)}╯${ansi.reset}`);
|
|
329
384
|
|
|
330
385
|
return lines;
|
package/tui/info-overlay.ts
CHANGED
|
@@ -15,6 +15,14 @@ import { getInfoSettings } from "../config.js";
|
|
|
15
15
|
import type { InfoGroup, GroupData } from "../types.js";
|
|
16
16
|
import { boxInnerWidth } from "@pi-unipi/core";
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* How long to wait before warming the non-visible tabs.
|
|
20
|
+
*
|
|
21
|
+
* Long enough that startup and the first paint finish first, short enough that
|
|
22
|
+
* a tab switch a second later is already warm.
|
|
23
|
+
*/
|
|
24
|
+
const PREFETCH_DELAY_MS = 1500;
|
|
25
|
+
|
|
18
26
|
/** Tab color palette */
|
|
19
27
|
const TAB_FG: Array<"accent" | "success" | "warning" | "error"> = [
|
|
20
28
|
"accent",
|
|
@@ -48,6 +56,10 @@ export class InfoOverlay implements Component {
|
|
|
48
56
|
private lastGlobalUpdate = 0;
|
|
49
57
|
private unsubscribers: Array<() => void> = [];
|
|
50
58
|
private _destroyed = false;
|
|
59
|
+
/** Groups whose fetch has already been kicked off (lazy-load bookkeeping). */
|
|
60
|
+
private fetched = new Set<string>();
|
|
61
|
+
private prefetchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
62
|
+
private bootTimer: ReturnType<typeof setTimeout> | null = null;
|
|
51
63
|
|
|
52
64
|
onClose?: () => void;
|
|
53
65
|
requestRender?: () => void;
|
|
@@ -89,31 +101,55 @@ export class InfoOverlay implements Component {
|
|
|
89
101
|
})
|
|
90
102
|
);
|
|
91
103
|
|
|
92
|
-
//
|
|
93
|
-
this.
|
|
104
|
+
// Fetch the visible tab now; everything else waits for idle.
|
|
105
|
+
this.fetchActiveGroup();
|
|
106
|
+
this.schedulePrefetch();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Fetch one group, tracking its loading state. Safe to call repeatedly. */
|
|
110
|
+
private fetchGroup(groupId: string): void {
|
|
111
|
+
if (this._destroyed) return;
|
|
112
|
+
if (this.fetched.has(groupId)) return;
|
|
113
|
+
this.fetched.add(groupId);
|
|
114
|
+
infoRegistry.getGroupData(groupId).then(() => {
|
|
115
|
+
this.groupLoading.set(groupId, false);
|
|
116
|
+
}).catch(() => {
|
|
117
|
+
this.groupLoading.set(groupId, false);
|
|
118
|
+
});
|
|
94
119
|
}
|
|
95
120
|
|
|
96
121
|
/**
|
|
97
|
-
* Fetch
|
|
122
|
+
* Fetch the currently visible group.
|
|
98
123
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* heavy providers (usage stats parse 1GB+ of session files, memory scans)
|
|
103
|
-
* blocked the session_start handler for seconds.
|
|
124
|
+
* Deferred to a macrotask because an async dataProvider still runs
|
|
125
|
+
* synchronously up to its first `await`; calling it inline would put that
|
|
126
|
+
* work back on the constructor's caller (session_start).
|
|
104
127
|
*/
|
|
105
|
-
private
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
128
|
+
private fetchActiveGroup(): void {
|
|
129
|
+
const group = this.groups[this.activeTabIndex];
|
|
130
|
+
if (!group) return;
|
|
131
|
+
setTimeout(() => this.fetchGroup(group.id), 0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Warm the remaining tabs once the app is idle.
|
|
136
|
+
*
|
|
137
|
+
* Fetching every group up front cost seconds of startup for panels the user
|
|
138
|
+
* may never open. Prefetching after a delay keeps tab switches instant
|
|
139
|
+
* without paying for them before the first prompt is ready.
|
|
140
|
+
*/
|
|
141
|
+
private schedulePrefetch(): void {
|
|
142
|
+
if (this.prefetchTimer) return;
|
|
143
|
+
this.prefetchTimer = setTimeout(() => {
|
|
144
|
+
this.prefetchTimer = null;
|
|
145
|
+
if (this._destroyed) return;
|
|
146
|
+
for (const group of this.groups) {
|
|
147
|
+
if (group.id === this.groups[this.activeTabIndex]?.id) continue;
|
|
148
|
+
this.fetchGroup(group.id);
|
|
149
|
+
}
|
|
150
|
+
}, PREFETCH_DELAY_MS);
|
|
151
|
+
// Never hold the process open just to warm a panel.
|
|
152
|
+
this.prefetchTimer.unref?.();
|
|
117
153
|
}
|
|
118
154
|
|
|
119
155
|
/**
|
|
@@ -127,29 +163,30 @@ export class InfoOverlay implements Component {
|
|
|
127
163
|
this.applyOrder();
|
|
128
164
|
}
|
|
129
165
|
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
166
|
+
// Adopt any data the registry already has. Registration notifications
|
|
167
|
+
// inject `{}` to trigger a re-sync; that is not real data and must not be
|
|
168
|
+
// treated as fetched, or the stats render as "—".
|
|
169
|
+
//
|
|
170
|
+
// Groups are NOT fetched here: doing so would defeat lazy loading, since
|
|
171
|
+
// syncGroups() runs on every render. Fetches are driven by tab visibility
|
|
172
|
+
// (fetchActiveGroup) and the idle prefetch instead.
|
|
133
173
|
for (const group of this.groups) {
|
|
134
174
|
const existing = this.groupData.get(group.id);
|
|
135
175
|
const hasRealData = existing && Object.keys(existing).length > 0;
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
this.groupLoading.set(group.id, true);
|
|
142
|
-
infoRegistry.getGroupData(group.id).then((data) => {
|
|
143
|
-
this.groupData.set(group.id, data);
|
|
144
|
-
this.groupLoading.set(group.id, false);
|
|
145
|
-
this.lastGlobalUpdate = Date.now();
|
|
146
|
-
this.requestRender?.();
|
|
147
|
-
}).catch(() => {
|
|
148
|
-
this.groupLoading.set(group.id, false);
|
|
149
|
-
});
|
|
150
|
-
}
|
|
176
|
+
if (hasRealData) continue;
|
|
177
|
+
|
|
178
|
+
const cached = infoRegistry.getCachedData(group.id);
|
|
179
|
+
if (cached && Object.keys(cached).length > 0) {
|
|
180
|
+
this.groupData.set(group.id, cached);
|
|
151
181
|
}
|
|
152
182
|
}
|
|
183
|
+
|
|
184
|
+
// A late-arriving group may now be the visible one, and the prefetch pass
|
|
185
|
+
// may have already run — make sure the active tab still gets its data.
|
|
186
|
+
if (hadNewGroups) {
|
|
187
|
+
this.fetchActiveGroup();
|
|
188
|
+
this.schedulePrefetch();
|
|
189
|
+
}
|
|
153
190
|
}
|
|
154
191
|
|
|
155
192
|
private applyOrder(): void {
|
|
@@ -169,23 +206,59 @@ export class InfoOverlay implements Component {
|
|
|
169
206
|
*/
|
|
170
207
|
destroy(): void {
|
|
171
208
|
this._destroyed = true;
|
|
209
|
+
this.cancelBootTimer();
|
|
210
|
+
if (this.prefetchTimer) {
|
|
211
|
+
clearTimeout(this.prefetchTimer);
|
|
212
|
+
this.prefetchTimer = null;
|
|
213
|
+
}
|
|
172
214
|
for (const unsub of this.unsubscribers) {
|
|
173
215
|
unsub();
|
|
174
216
|
}
|
|
175
217
|
this.unsubscribers = [];
|
|
176
218
|
}
|
|
177
219
|
|
|
220
|
+
/** Stop the boot auto-close timer, if one is pending. */
|
|
221
|
+
private cancelBootTimer(): void {
|
|
222
|
+
if (this.bootTimer) {
|
|
223
|
+
clearTimeout(this.bootTimer);
|
|
224
|
+
this.bootTimer = null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Auto-close the overlay after `ms`, unless the user interacts first.
|
|
230
|
+
*
|
|
231
|
+
* Used when the overlay is shown on boot: the dashboard is informational, so
|
|
232
|
+
* it should get out of the way on its own rather than requiring a keypress.
|
|
233
|
+
*/
|
|
234
|
+
startBootTimer(ms: number): void {
|
|
235
|
+
this.cancelBootTimer();
|
|
236
|
+
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
237
|
+
this.bootTimer = setTimeout(() => {
|
|
238
|
+
this.bootTimer = null;
|
|
239
|
+
if (this._destroyed) return;
|
|
240
|
+
this.destroy();
|
|
241
|
+
this.onClose?.();
|
|
242
|
+
}, ms);
|
|
243
|
+
this.bootTimer.unref?.();
|
|
244
|
+
}
|
|
245
|
+
|
|
178
246
|
invalidate(): void {
|
|
179
247
|
this.syncGroups();
|
|
180
248
|
}
|
|
181
249
|
|
|
182
250
|
handleInput(data: string): void {
|
|
251
|
+
// Any keypress means the user is driving; stop the boot auto-close.
|
|
252
|
+
this.cancelBootTimer();
|
|
253
|
+
|
|
183
254
|
if (data === "\x1b[C" || data === "l") {
|
|
184
255
|
this.activeTabIndex = (this.activeTabIndex + 1) % this.groups.length;
|
|
185
256
|
this.scrollOffset = 0;
|
|
257
|
+
this.fetchActiveGroup();
|
|
186
258
|
} else if (data === "\x1b[D" || data === "h") {
|
|
187
259
|
this.activeTabIndex = (this.activeTabIndex - 1 + this.groups.length) % this.groups.length;
|
|
188
260
|
this.scrollOffset = 0;
|
|
261
|
+
this.fetchActiveGroup();
|
|
189
262
|
} else if (data === "\x1b[B" || data === "j") {
|
|
190
263
|
this.scrollOffset++;
|
|
191
264
|
} else if (data === "\x1b[A" || data === "k") {
|
|
@@ -211,12 +284,15 @@ export class InfoOverlay implements Component {
|
|
|
211
284
|
if (!group) return;
|
|
212
285
|
this.groupLoading.set(group.id, true);
|
|
213
286
|
this.requestRender?.();
|
|
287
|
+
// Explicit refresh must bypass the lazy-load guard.
|
|
288
|
+
this.fetched.add(group.id);
|
|
214
289
|
infoRegistry.refreshGroup(group.id);
|
|
215
290
|
}
|
|
216
291
|
|
|
217
292
|
private refreshAll(): void {
|
|
218
293
|
for (const group of this.groups) {
|
|
219
294
|
this.groupLoading.set(group.id, true);
|
|
295
|
+
this.fetched.add(group.id);
|
|
220
296
|
}
|
|
221
297
|
this.requestRender?.();
|
|
222
298
|
infoRegistry.refreshAll();
|
package/types.ts
CHANGED
|
@@ -47,11 +47,26 @@ export interface InfoGroup {
|
|
|
47
47
|
dataProvider: () => Promise<GroupData>;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** How the dashboard behaves at startup. */
|
|
51
|
+
export type BootMode = "on" | "off" | "auto-close";
|
|
52
|
+
|
|
53
|
+
/** All valid boot modes, in the order the settings UI cycles them. */
|
|
54
|
+
export const BOOT_MODES: BootMode[] = ["on", "auto-close", "off"];
|
|
55
|
+
|
|
50
56
|
/** Settings for info-screen in settings.json */
|
|
51
57
|
export interface InfoScreenSettings {
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
/**
|
|
59
|
+
* What the dashboard does at startup:
|
|
60
|
+
* - "on": show it and leave it up until dismissed (q/Esc)
|
|
61
|
+
* - "off": do not show it at all (no data is fetched)
|
|
62
|
+
* - "auto-close": show it, then close after `bootTimeoutMs`
|
|
63
|
+
*/
|
|
64
|
+
bootMode: BootMode;
|
|
65
|
+
/**
|
|
66
|
+
* How long the boot dashboard stays up in "auto-close" mode, in ms.
|
|
67
|
+
* Any keypress cancels the timer and keeps the overlay open.
|
|
68
|
+
* Does not apply to the overlay opened via /unipi:info.
|
|
69
|
+
*/
|
|
55
70
|
bootTimeoutMs: number;
|
|
56
71
|
/** Per-group settings */
|
|
57
72
|
groups: Record<string, GroupSettings>;
|
|
@@ -69,8 +84,8 @@ export interface GroupSettings {
|
|
|
69
84
|
|
|
70
85
|
/** Default settings */
|
|
71
86
|
export const DEFAULT_SETTINGS: InfoScreenSettings = {
|
|
72
|
-
|
|
73
|
-
bootTimeoutMs:
|
|
87
|
+
bootMode: "auto-close",
|
|
88
|
+
bootTimeoutMs: 2000,
|
|
74
89
|
groups: {},
|
|
75
90
|
groupOrder: [],
|
|
76
91
|
};
|
package/usage-parser.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* Reference: tmustier/pi-extensions/usage-extension
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
9
|
-
import { join, basename } from "node:path";
|
|
8
|
+
import { readdirSync, readFileSync, statSync, existsSync, mkdirSync, writeFileSync, renameSync, openSync, readSync, closeSync } from "node:fs";
|
|
9
|
+
import { join, basename, dirname } from "node:path";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
|
|
12
12
|
/** Usage data for a single message */
|
|
@@ -54,6 +54,75 @@ interface PeriodBounds {
|
|
|
54
54
|
end: Date;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* A single usage record, stored in the cache in compact tuple form.
|
|
59
|
+
*
|
|
60
|
+
* [timestamp, hashTokens, countedTokens, cost, modelIndex, counted]
|
|
61
|
+
*
|
|
62
|
+
* `hashTokens` is input+output+cacheRead+cacheWrite and exists ONLY to rebuild
|
|
63
|
+
* the dedup key. `countedTokens` is input+output+cacheWrite, which is what the
|
|
64
|
+
* totals actually sum (cacheRead is deliberately excluded).
|
|
65
|
+
*
|
|
66
|
+
* `counted` (1/0) mirrors the original `input > 0 || output > 0 || cost > 0`
|
|
67
|
+
* check. It must be stored separately because the original claims the dedup
|
|
68
|
+
* hash BEFORE applying that filter — so a zero-usage message still suppresses
|
|
69
|
+
* a later duplicate. Collapsing the two would change the totals.
|
|
70
|
+
*/
|
|
71
|
+
type UsageRecord = [number, number, number, number, number, number];
|
|
72
|
+
|
|
73
|
+
/** Per-file cache entry, invalidated on mtime or size change. */
|
|
74
|
+
interface CachedFile {
|
|
75
|
+
mtimeMs: number;
|
|
76
|
+
size: number;
|
|
77
|
+
records: UsageRecord[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface UsageCacheFile {
|
|
81
|
+
version: number;
|
|
82
|
+
/** Interned model names; records store an index into this array. */
|
|
83
|
+
models: string[];
|
|
84
|
+
files: Record<string, CachedFile>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Bump when the record layout or parsing semantics change, so stale caches
|
|
89
|
+
* from an older build are discarded rather than silently reused.
|
|
90
|
+
*/
|
|
91
|
+
const CACHE_VERSION = 1;
|
|
92
|
+
|
|
93
|
+
function getCachePath(): string {
|
|
94
|
+
const base = process.env.UNIPI_DIR || join(homedir(), ".unipi");
|
|
95
|
+
return join(base, "cache", "usage-stats.json");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readCache(): UsageCacheFile {
|
|
99
|
+
const empty: UsageCacheFile = { version: CACHE_VERSION, models: [], files: {} };
|
|
100
|
+
try {
|
|
101
|
+
const path = getCachePath();
|
|
102
|
+
if (!existsSync(path)) return empty;
|
|
103
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as UsageCacheFile;
|
|
104
|
+
if (!parsed || parsed.version !== CACHE_VERSION) return empty;
|
|
105
|
+
if (!Array.isArray(parsed.models) || typeof parsed.files !== "object") return empty;
|
|
106
|
+
return parsed;
|
|
107
|
+
} catch {
|
|
108
|
+
// Corrupt or unreadable cache: rebuild from scratch.
|
|
109
|
+
return empty;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function writeCache(cache: UsageCacheFile): void {
|
|
114
|
+
try {
|
|
115
|
+
const path = getCachePath();
|
|
116
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
117
|
+
// Write-then-rename so a crash mid-write cannot leave a torn cache behind.
|
|
118
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
119
|
+
writeFileSync(tmp, JSON.stringify(cache), "utf-8");
|
|
120
|
+
renameSync(tmp, path);
|
|
121
|
+
} catch {
|
|
122
|
+
// A cache we cannot persist is a performance loss, not a correctness one.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
57
126
|
/**
|
|
58
127
|
* Get the sessions directory path.
|
|
59
128
|
*/
|
|
@@ -95,71 +164,95 @@ function getPeriodBounds(): { today: PeriodBounds; week: PeriodBounds; month: Pe
|
|
|
95
164
|
|
|
96
165
|
|
|
97
166
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
167
|
+
* Read a file line-by-line without materializing it in memory.
|
|
168
|
+
*
|
|
169
|
+
* Session files reach 200MB+, so readFileSync would allocate the whole file
|
|
170
|
+
* (and its split() array) just to scan it once. Reads in 1MB chunks and keeps
|
|
171
|
+
* only the trailing partial line between chunks.
|
|
100
172
|
*/
|
|
101
|
-
function
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
): Array<{ usage: MessageUsage; model: string; timestamp: number }> {
|
|
105
|
-
const results: Array<{ usage: MessageUsage; model: string; timestamp: number }> = [];
|
|
106
|
-
|
|
173
|
+
function forEachLine(filePath: string, onLine: (line: string) => void): void {
|
|
174
|
+
const CHUNK = 1024 * 1024;
|
|
175
|
+
let fd: number | undefined;
|
|
107
176
|
try {
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
for (
|
|
112
|
-
const
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const cacheRead = msg.usage.cacheRead || 0;
|
|
125
|
-
const cacheWrite = msg.usage.cacheWrite || 0;
|
|
126
|
-
const cost = msg.usage.cost?.total || 0;
|
|
127
|
-
|
|
128
|
-
// Get timestamp
|
|
129
|
-
const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
130
|
-
const timestamp = msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
|
|
131
|
-
|
|
132
|
-
// Deduplicate copied history across branched session files
|
|
133
|
-
const totalTokens = input + output + cacheRead + cacheWrite;
|
|
134
|
-
const hash = `${timestamp}:${totalTokens}`;
|
|
135
|
-
if (seenHashes.has(hash)) continue;
|
|
136
|
-
seenHashes.add(hash);
|
|
137
|
-
|
|
138
|
-
// Only include if we have valid data
|
|
139
|
-
if (input > 0 || output > 0 || cost > 0) {
|
|
140
|
-
results.push({
|
|
141
|
-
usage: {
|
|
142
|
-
input,
|
|
143
|
-
output,
|
|
144
|
-
cacheRead,
|
|
145
|
-
cacheWrite,
|
|
146
|
-
cost: { total: cost },
|
|
147
|
-
},
|
|
148
|
-
model: msg.model,
|
|
149
|
-
timestamp,
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
} catch {
|
|
155
|
-
// Skip malformed lines
|
|
177
|
+
fd = openSync(filePath, "r");
|
|
178
|
+
const buf = Buffer.allocUnsafe(CHUNK);
|
|
179
|
+
let carry = "";
|
|
180
|
+
for (;;) {
|
|
181
|
+
const bytes = readSync(fd, buf, 0, CHUNK, null);
|
|
182
|
+
if (bytes <= 0) break;
|
|
183
|
+
// latin1 would corrupt multi-byte UTF-8 split across a chunk boundary;
|
|
184
|
+
// toString("utf8") on a Buffer slice handles the common case, and any
|
|
185
|
+
// partial sequence lands in `carry` and is completed by the next chunk.
|
|
186
|
+
const text = carry + buf.toString("utf8", 0, bytes);
|
|
187
|
+
let start = 0;
|
|
188
|
+
for (;;) {
|
|
189
|
+
const nl = text.indexOf("\n", start);
|
|
190
|
+
if (nl === -1) break;
|
|
191
|
+
onLine(text.slice(start, nl));
|
|
192
|
+
start = nl + 1;
|
|
156
193
|
}
|
|
194
|
+
carry = text.slice(start);
|
|
157
195
|
}
|
|
196
|
+
if (carry.length > 0) onLine(carry);
|
|
158
197
|
} catch {
|
|
159
198
|
// Skip unreadable files
|
|
199
|
+
} finally {
|
|
200
|
+
if (fd !== undefined) {
|
|
201
|
+
try { closeSync(fd); } catch { /* already closed */ }
|
|
202
|
+
}
|
|
160
203
|
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Extract the compact usage records from one session file.
|
|
208
|
+
*
|
|
209
|
+
* Deliberately does NOT deduplicate: dedup is cross-file and order-dependent,
|
|
210
|
+
* so it must happen at aggregation time. Caching raw per-file records keeps
|
|
211
|
+
* each entry independent and lets any single file be re-parsed in isolation.
|
|
212
|
+
*/
|
|
213
|
+
function extractRecords(filePath: string, modelIndex: Map<string, number>, models: string[]): UsageRecord[] {
|
|
214
|
+
const records: UsageRecord[] = [];
|
|
215
|
+
|
|
216
|
+
forEachLine(filePath, (line) => {
|
|
217
|
+
if (!line || !line.trim()) return;
|
|
218
|
+
let entry: any;
|
|
219
|
+
try {
|
|
220
|
+
entry = JSON.parse(line);
|
|
221
|
+
} catch {
|
|
222
|
+
return; // Skip malformed lines
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (entry.type !== "message" || entry.message?.role !== "assistant") return;
|
|
226
|
+
const msg = entry.message;
|
|
227
|
+
if (!msg.usage || !msg.provider || !msg.model) return;
|
|
161
228
|
|
|
162
|
-
|
|
229
|
+
const input = msg.usage.input || 0;
|
|
230
|
+
const output = msg.usage.output || 0;
|
|
231
|
+
const cacheRead = msg.usage.cacheRead || 0;
|
|
232
|
+
const cacheWrite = msg.usage.cacheWrite || 0;
|
|
233
|
+
const cost = msg.usage.cost?.total || 0;
|
|
234
|
+
|
|
235
|
+
const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
236
|
+
const timestamp = msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
|
|
237
|
+
|
|
238
|
+
let idx = modelIndex.get(msg.model);
|
|
239
|
+
if (idx === undefined) {
|
|
240
|
+
idx = models.length;
|
|
241
|
+
models.push(msg.model);
|
|
242
|
+
modelIndex.set(msg.model, idx);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
records.push([
|
|
246
|
+
timestamp,
|
|
247
|
+
input + output + cacheRead + cacheWrite, // dedup key component
|
|
248
|
+
input + output + cacheWrite, // counted tokens (excludes cacheRead)
|
|
249
|
+
cost,
|
|
250
|
+
idx,
|
|
251
|
+
input > 0 || output > 0 || cost > 0 ? 1 : 0,
|
|
252
|
+
]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return records;
|
|
163
256
|
}
|
|
164
257
|
|
|
165
258
|
/**
|
|
@@ -186,8 +279,30 @@ function collectSessionFiles(dir: string, files: string[]): void {
|
|
|
186
279
|
* Matches tmustier's parsing logic.
|
|
187
280
|
*/
|
|
188
281
|
export function parseUsageStats(): UsageStats {
|
|
189
|
-
const
|
|
190
|
-
|
|
282
|
+
const { stats } = collectStats(null);
|
|
283
|
+
return stats;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Async variant that yields to the event loop while parsing.
|
|
288
|
+
*
|
|
289
|
+
* A cold parse is several seconds of pure CPU. Running it synchronously starves
|
|
290
|
+
* the event loop, so keystrokes queue up and the UI cannot repaint. Deferring
|
|
291
|
+
* the *start* (setTimeout) does not help — the block must be broken up.
|
|
292
|
+
* `yieldEvery` files, control returns to the loop.
|
|
293
|
+
*/
|
|
294
|
+
export async function parseUsageStatsAsync(): Promise<UsageStats> {
|
|
295
|
+
const yielder = async (): Promise<void> => {
|
|
296
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
297
|
+
};
|
|
298
|
+
const { stats, pending } = collectStats(yielder);
|
|
299
|
+
if (pending) await pending;
|
|
300
|
+
return stats;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Empty stats accumulator. */
|
|
304
|
+
function emptyStats(): UsageStats {
|
|
305
|
+
return {
|
|
191
306
|
tokens: { today: 0, week: 0, month: 0, allTime: 0 },
|
|
192
307
|
cost: { today: 0, week: 0, month: 0, allTime: 0 },
|
|
193
308
|
byModel: {},
|
|
@@ -197,93 +312,168 @@ export function parseUsageStats(): UsageStats {
|
|
|
197
312
|
sessionCount: 0,
|
|
198
313
|
messageCount: 0,
|
|
199
314
|
};
|
|
315
|
+
}
|
|
200
316
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
317
|
+
/**
|
|
318
|
+
* Shared implementation for the sync and async entry points.
|
|
319
|
+
*
|
|
320
|
+
* When `yielder` is null the whole scan runs synchronously and `stats` is fully
|
|
321
|
+
* populated on return. When provided, the returned `stats` object is filled in
|
|
322
|
+
* as `pending` progresses, and the caller must await it.
|
|
323
|
+
*/
|
|
324
|
+
function collectStats(
|
|
325
|
+
yielder: (() => Promise<void>) | null,
|
|
326
|
+
): { stats: UsageStats; pending: Promise<void> | null } {
|
|
327
|
+
const stats = emptyStats();
|
|
328
|
+
const sessionsDir = getSessionsDir();
|
|
329
|
+
if (!existsSync(sessionsDir)) return { stats, pending: null };
|
|
205
330
|
|
|
206
|
-
// Collect all session files recursively
|
|
207
331
|
const sessionFiles: string[] = [];
|
|
208
332
|
collectSessionFiles(sessionsDir, sessionFiles);
|
|
209
333
|
sessionFiles.sort();
|
|
210
334
|
|
|
211
|
-
|
|
212
|
-
|
|
335
|
+
const cache = readCache();
|
|
336
|
+
const models = cache.models.slice();
|
|
337
|
+
const modelIndex = new Map<string, number>();
|
|
338
|
+
models.forEach((name, i) => modelIndex.set(name, i));
|
|
213
339
|
|
|
214
|
-
|
|
340
|
+
const nextFiles: Record<string, CachedFile> = {};
|
|
341
|
+
let cacheDirty = false;
|
|
215
342
|
|
|
216
|
-
|
|
217
|
-
|
|
343
|
+
// Statting 600 files costs ~1ms, so the mtime+size gate is essentially free
|
|
344
|
+
// compared to re-reading gigabytes of immutable history.
|
|
345
|
+
const work: Array<{ path: string; cached: CachedFile | null }> = [];
|
|
346
|
+
for (const filePath of sessionFiles) {
|
|
347
|
+
let mtimeMs = 0;
|
|
348
|
+
let size = 0;
|
|
349
|
+
try {
|
|
350
|
+
const st = statSync(filePath);
|
|
351
|
+
mtimeMs = st.mtimeMs;
|
|
352
|
+
size = st.size;
|
|
353
|
+
} catch {
|
|
354
|
+
continue; // Vanished between listing and statting.
|
|
355
|
+
}
|
|
218
356
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
357
|
+
const hit = cache.files[filePath];
|
|
358
|
+
if (hit && hit.mtimeMs === mtimeMs && hit.size === size) {
|
|
359
|
+
nextFiles[filePath] = hit;
|
|
360
|
+
work.push({ path: filePath, cached: hit });
|
|
361
|
+
} else {
|
|
362
|
+
cacheDirty = true;
|
|
363
|
+
work.push({ path: filePath, cached: null });
|
|
364
|
+
nextFiles[filePath] = { mtimeMs, size, records: [] };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
222
367
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
stats.cost.allTime += msg.usage.cost.total;
|
|
368
|
+
// A file disappearing means the old cache had entries we must drop.
|
|
369
|
+
if (Object.keys(nextFiles).length !== Object.keys(cache.files).length) cacheDirty = true;
|
|
226
370
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
371
|
+
const seenHashes = new Set<string>();
|
|
372
|
+
const periods = getPeriodBounds();
|
|
373
|
+
const todayStart = periods.today.start.getTime();
|
|
374
|
+
const weekStart = periods.week.start.getTime();
|
|
375
|
+
const monthStart = periods.month.start.getTime();
|
|
376
|
+
|
|
377
|
+
const bump = (
|
|
378
|
+
bucket: Record<string, { tokens: number; cost: number; sessions: number }>,
|
|
379
|
+
model: string,
|
|
380
|
+
tokens: number,
|
|
381
|
+
cost: number,
|
|
382
|
+
) => {
|
|
383
|
+
let entry = bucket[model];
|
|
384
|
+
if (!entry) {
|
|
385
|
+
entry = { tokens: 0, cost: 0, sessions: 0 };
|
|
386
|
+
bucket[model] = entry;
|
|
387
|
+
}
|
|
388
|
+
entry.tokens += tokens;
|
|
389
|
+
entry.cost += cost;
|
|
390
|
+
entry.sessions++;
|
|
391
|
+
};
|
|
232
392
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
393
|
+
const aggregate = (records: UsageRecord[]): void => {
|
|
394
|
+
let counted = 0;
|
|
395
|
+
for (const rec of records) {
|
|
396
|
+
const [timestamp, hashTokens, countedTokens, cost, modelIdx, isCounted] = rec;
|
|
397
|
+
|
|
398
|
+
// Dedup key is claimed even for records that are not counted, matching
|
|
399
|
+
// the original ordering (hash added before the validity check).
|
|
400
|
+
const hash = `${timestamp}:${hashTokens}`;
|
|
401
|
+
if (seenHashes.has(hash)) continue;
|
|
402
|
+
seenHashes.add(hash);
|
|
403
|
+
if (!isCounted) continue;
|
|
404
|
+
|
|
405
|
+
counted++;
|
|
406
|
+
const model = models[modelIdx] ?? "unknown";
|
|
407
|
+
|
|
408
|
+
stats.tokens.allTime += countedTokens;
|
|
409
|
+
stats.cost.allTime += cost;
|
|
410
|
+
bump(stats.byModel, model, countedTokens, cost);
|
|
411
|
+
|
|
412
|
+
if (timestamp >= todayStart) {
|
|
413
|
+
stats.tokens.today += countedTokens;
|
|
414
|
+
stats.cost.today += cost;
|
|
415
|
+
bump(stats.byModelToday, model, countedTokens, cost);
|
|
237
416
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
stats.
|
|
242
|
-
stats.cost.month += msg.usage.cost.total;
|
|
417
|
+
if (timestamp >= weekStart) {
|
|
418
|
+
stats.tokens.week += countedTokens;
|
|
419
|
+
stats.cost.week += cost;
|
|
420
|
+
bump(stats.byModelWeek, model, countedTokens, cost);
|
|
243
421
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
stats.byModel[model] = { tokens: 0, cost: 0, sessions: 0 };
|
|
249
|
-
}
|
|
250
|
-
stats.byModel[model].tokens += totalTokens;
|
|
251
|
-
stats.byModel[model].cost += msg.usage.cost.total;
|
|
252
|
-
stats.byModel[model].sessions++;
|
|
253
|
-
|
|
254
|
-
// By model (today)
|
|
255
|
-
if (msg.timestamp >= periods.today.start.getTime()) {
|
|
256
|
-
if (!stats.byModelToday[model]) {
|
|
257
|
-
stats.byModelToday[model] = { tokens: 0, cost: 0, sessions: 0 };
|
|
258
|
-
}
|
|
259
|
-
stats.byModelToday[model].tokens += totalTokens;
|
|
260
|
-
stats.byModelToday[model].cost += msg.usage.cost.total;
|
|
261
|
-
stats.byModelToday[model].sessions++;
|
|
422
|
+
if (timestamp >= monthStart) {
|
|
423
|
+
stats.tokens.month += countedTokens;
|
|
424
|
+
stats.cost.month += cost;
|
|
425
|
+
bump(stats.byModelMonth, model, countedTokens, cost);
|
|
262
426
|
}
|
|
427
|
+
}
|
|
263
428
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
stats.byModelWeek[model].tokens += totalTokens;
|
|
270
|
-
stats.byModelWeek[model].cost += msg.usage.cost.total;
|
|
271
|
-
stats.byModelWeek[model].sessions++;
|
|
272
|
-
}
|
|
429
|
+
if (counted > 0) {
|
|
430
|
+
stats.sessionCount++;
|
|
431
|
+
stats.messageCount += counted;
|
|
432
|
+
}
|
|
433
|
+
};
|
|
273
434
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
435
|
+
const finish = (): void => {
|
|
436
|
+
if (cacheDirty) {
|
|
437
|
+
writeCache({ version: CACHE_VERSION, models, files: nextFiles });
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const YIELD_EVERY = 25;
|
|
442
|
+
|
|
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);
|
|
283
449
|
}
|
|
450
|
+
finish();
|
|
451
|
+
return { stats, pending: null };
|
|
284
452
|
}
|
|
285
453
|
|
|
286
|
-
|
|
454
|
+
const pending = (async () => {
|
|
455
|
+
let sinceYield = 0;
|
|
456
|
+
for (const item of work) {
|
|
457
|
+
let records: UsageRecord[];
|
|
458
|
+
if (item.cached) {
|
|
459
|
+
records = item.cached.records;
|
|
460
|
+
} else {
|
|
461
|
+
records = extractRecords(item.path, modelIndex, models);
|
|
462
|
+
nextFiles[item.path].records = records;
|
|
463
|
+
// Only re-parsed files are expensive; cache hits are near-free, so
|
|
464
|
+
// yielding is gated on real work to avoid pointless loop turns.
|
|
465
|
+
sinceYield++;
|
|
466
|
+
}
|
|
467
|
+
aggregate(records);
|
|
468
|
+
if (sinceYield >= YIELD_EVERY) {
|
|
469
|
+
sinceYield = 0;
|
|
470
|
+
await yielder();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
finish();
|
|
474
|
+
})();
|
|
475
|
+
|
|
476
|
+
return { stats, pending };
|
|
287
477
|
}
|
|
288
478
|
|
|
289
479
|
/**
|