@mohndoe/pi-atlas 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -19
- package/bunfig.toml +37 -0
- package/media/screenshot.png +0 -0
- package/package.json +4 -3
- package/src/__tests__/e2e.test.ts +3 -3
- package/src/{__tests__/cache.test.ts → cache.test.ts} +311 -10
- package/src/cache.ts +36 -3
- package/src/components/{__tests__/BarChart.test.ts → BarChart.test.ts} +9 -9
- package/src/components/{__tests__/Dashboard.test.ts → Dashboard.test.ts} +4 -4
- package/src/components/Dashboard.ts +2 -1
- package/src/components/{__tests__/KpiCards.test.ts → KpiCards.test.ts} +5 -5
- package/src/components/KpiCards.ts +1 -1
- package/src/components/LoadingView.test.ts +116 -0
- package/src/components/LoadingView.ts +87 -25
- package/src/components/{__tests__/MarqueeText.test.ts → MarqueeText.test.ts} +2 -2
- package/src/components/{__tests__/RangeSelector.test.ts → RangeSelector.test.ts} +2 -2
- package/src/components/{__tests__/RankedBarList.test.ts → RankedBarList.test.ts} +2 -2
- package/src/components/{__tests__/SortedTable.test.ts → SortedTable.test.ts} +3 -4
- package/src/components/{__tests__/TabBar.test.ts → TabBar.test.ts} +2 -2
- package/src/components/__tests__/SortedTable.integration.test.ts +5 -8
- package/src/components/{__tests__/cells.test.ts → cells.test.ts} +2 -2
- package/src/{__tests__ → components}/components.fixtures.ts +1 -1
- package/src/components/shared/Bar.ts +10 -2
- package/src/{__tests__/compute.fixtures.ts → compute.fixtures.ts} +6 -1
- package/src/{__tests__/compute.test.ts → compute.test.ts} +135 -3
- package/src/compute.ts +24 -4
- package/src/{__tests__/format.test.ts → format.test.ts} +173 -31
- package/src/format.ts +20 -7
- package/src/index.ts +23 -20
- package/src/{__tests__/parser.test.ts → parser.test.ts} +339 -109
- package/src/parser.ts +1 -1
- package/src/tabs/{__tests__/Languages.test.ts → Languages.test.ts} +3 -7
- package/src/tabs/Languages.ts +7 -3
- package/src/tabs/{__tests__/Models.test.ts → Models.test.ts} +3 -6
- package/src/tabs/Models.ts +2 -4
- package/src/tabs/{__tests__/Overview.test.ts → Overview.test.ts} +18 -15
- package/src/tabs/Overview.ts +50 -39
- package/src/tabs/{__tests__/Projects.test.ts → Projects.test.ts} +5 -8
- package/src/tabs/Projects.ts +9 -4
- package/src/tabs/{__tests__/Usage.test.ts → Usage.test.ts} +8 -18
- package/src/tabs/Usage.ts +7 -3
- package/src/types.ts +11 -0
- package/src/components/__tests__/LoadingView.test.ts +0 -26
- /package/src/components/{__tests__ → shared}/Bar.test.ts +0 -0
package/src/compute.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
LangStat,
|
|
7
7
|
ModelStat,
|
|
8
8
|
ProjectStat,
|
|
9
|
+
ProviderStat,
|
|
9
10
|
StatsSummary,
|
|
10
11
|
TimeRange,
|
|
11
12
|
ToolStat,
|
|
@@ -98,6 +99,10 @@ export function summarize(days: DayAgg[], range: TimeRange): StatsSummary {
|
|
|
98
99
|
const projectCost: Record<string, number> = {};
|
|
99
100
|
const projectSessions: Record<string, Set<string>> = {};
|
|
100
101
|
const toolCount: Record<string, number> = {};
|
|
102
|
+
let compactionCount = 0;
|
|
103
|
+
let compactedTokens = 0;
|
|
104
|
+
let modelChanges = 0;
|
|
105
|
+
const thinkingLevelCount: Record<string, number> = {};
|
|
101
106
|
|
|
102
107
|
let modelToProvider: Map<string, string> = new Map();
|
|
103
108
|
|
|
@@ -110,10 +115,9 @@ export function summarize(days: DayAgg[], range: TimeRange): StatsSummary {
|
|
|
110
115
|
totalCacheReadTokens += day.crTok;
|
|
111
116
|
totalCacheWriteTokens += day.cwTok;
|
|
112
117
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
]);
|
|
118
|
+
for (const [model, provider] of day.modelToProvider) {
|
|
119
|
+
modelToProvider.set(model, provider);
|
|
120
|
+
}
|
|
117
121
|
|
|
118
122
|
if (day.date === todayStr) todayCost += day.cost;
|
|
119
123
|
|
|
@@ -157,6 +161,13 @@ export function summarize(days: DayAgg[], range: TimeRange): StatsSummary {
|
|
|
157
161
|
for (const [tool, count] of Object.entries(day.toolCount)) {
|
|
158
162
|
toolCount[tool] = (toolCount[tool] ?? 0) + count;
|
|
159
163
|
}
|
|
164
|
+
|
|
165
|
+
compactionCount += day.compactionCount;
|
|
166
|
+
compactedTokens += day.compactedTokens;
|
|
167
|
+
modelChanges += day.modelChanges;
|
|
168
|
+
for (const [level, count] of Object.entries(day.thinkingLevelCount)) {
|
|
169
|
+
thinkingLevelCount[level] = (thinkingLevelCount[level] ?? 0) + count;
|
|
170
|
+
}
|
|
160
171
|
}
|
|
161
172
|
|
|
162
173
|
const daysActive = filtered.filter((d) => d.sessionIds.size > 0).length;
|
|
@@ -186,6 +197,10 @@ export function summarize(days: DayAgg[], range: TimeRange): StatsSummary {
|
|
|
186
197
|
.map(([tool, count]) => ({ name: tool, count }))
|
|
187
198
|
.sort((a, b) => b.count - a.count);
|
|
188
199
|
|
|
200
|
+
const providers: ProviderStat[] = Object.entries(providerCost)
|
|
201
|
+
.map(([provider, cost]) => ({ provider, cost, calls: providerCount[provider] ?? 0 }))
|
|
202
|
+
.sort((a, b) => b.cost - a.cost || b.calls - a.calls);
|
|
203
|
+
|
|
189
204
|
const hourlySpend = buildHourlySpend(filtered, range);
|
|
190
205
|
|
|
191
206
|
return {
|
|
@@ -204,6 +219,11 @@ export function summarize(days: DayAgg[], range: TimeRange): StatsSummary {
|
|
|
204
219
|
models,
|
|
205
220
|
projects,
|
|
206
221
|
tools,
|
|
222
|
+
providers,
|
|
223
|
+
compactionCount,
|
|
224
|
+
compactedTokens,
|
|
225
|
+
modelChanges,
|
|
226
|
+
thinkingLevelCount,
|
|
207
227
|
dailySpend: fillDailySpend(filtered, range),
|
|
208
228
|
hourlySpend,
|
|
209
229
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import {
|
|
3
|
+
EXT_TO_LANG,
|
|
3
4
|
MONTH_NAMES,
|
|
4
5
|
dateFromISOString,
|
|
5
6
|
formatCacheTimestamp,
|
|
@@ -8,7 +9,8 @@ import {
|
|
|
8
9
|
formatNumber,
|
|
9
10
|
langFromPath,
|
|
10
11
|
projectNameFromCwd,
|
|
11
|
-
|
|
12
|
+
stripAnsi,
|
|
13
|
+
} from "./format";
|
|
12
14
|
|
|
13
15
|
describe("formatModelName", () => {
|
|
14
16
|
it("handles standard model names", () => {
|
|
@@ -25,6 +27,18 @@ describe("formatModelName", () => {
|
|
|
25
27
|
it("strips YYYY-MM-DD date suffix", () => {
|
|
26
28
|
expect(formatModelName("some-model-2025-05-14")).toBe("Some Model");
|
|
27
29
|
});
|
|
30
|
+
|
|
31
|
+
it("handles underscore separators", () => {
|
|
32
|
+
expect(formatModelName("deepseek_v4_pro")).toBe("Deepseek V4 Pro");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("handles mixed separators", () => {
|
|
36
|
+
expect(formatModelName("claude-opus_4")).toBe("Claude Opus 4");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("handles empty string", () => {
|
|
40
|
+
expect(formatModelName("")).toBe("");
|
|
41
|
+
});
|
|
28
42
|
});
|
|
29
43
|
|
|
30
44
|
describe("langFromPath", () => {
|
|
@@ -68,6 +82,28 @@ describe("langFromPath", () => {
|
|
|
68
82
|
expect(langFromPath("/src/Foo.TS")).toBe("TypeScript");
|
|
69
83
|
expect(langFromPath("/src/Foo.PY")).toBe("Python");
|
|
70
84
|
});
|
|
85
|
+
|
|
86
|
+
it("handles multi-dot filenames", () => {
|
|
87
|
+
expect(langFromPath("/src/foo.min.js")).toBe("JavaScript");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("handles hidden files (dotfiles) as extensions", () => {
|
|
91
|
+
expect(langFromPath("/src/.gitignore")).toBe("Gitignore");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("handles Jinja compound extensions", () => {
|
|
95
|
+
expect(langFromPath("/templates/page.html.j2")).toBe("Jinja");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("handles file named only .extension", () => {
|
|
99
|
+
expect(langFromPath("/src/.ts")).toBe("TypeScript");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("maps all EXT_TO_LANG entries correctly", () => {
|
|
103
|
+
for (const [ext, expected] of Object.entries(EXT_TO_LANG)) {
|
|
104
|
+
expect(langFromPath(`/file.${ext}`)).toBe(expected);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
71
107
|
});
|
|
72
108
|
|
|
73
109
|
describe("projectNameFromCwd", () => {
|
|
@@ -82,6 +118,19 @@ describe("projectNameFromCwd", () => {
|
|
|
82
118
|
it("strips trailing slash like basename", () => {
|
|
83
119
|
expect(projectNameFromCwd("/home/doe/proj/")).toBe("proj");
|
|
84
120
|
});
|
|
121
|
+
|
|
122
|
+
it("handles root path", () => {
|
|
123
|
+
expect(projectNameFromCwd("/")).toBe("");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("handles empty string", () => {
|
|
127
|
+
expect(projectNameFromCwd("")).toBe("");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("handles relative path components", () => {
|
|
131
|
+
expect(projectNameFromCwd(".")).toBe(".");
|
|
132
|
+
expect(projectNameFromCwd("..")).toBe("..");
|
|
133
|
+
});
|
|
85
134
|
});
|
|
86
135
|
|
|
87
136
|
describe("dateFromISOString", () => {
|
|
@@ -92,6 +141,14 @@ describe("dateFromISOString", () => {
|
|
|
92
141
|
it("works on date-only", () => {
|
|
93
142
|
expect(dateFromISOString("2026-12-31")).toBe("2026-12-31");
|
|
94
143
|
});
|
|
144
|
+
|
|
145
|
+
it("handles empty string", () => {
|
|
146
|
+
expect(dateFromISOString("")).toBe("");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("handles short string", () => {
|
|
150
|
+
expect(dateFromISOString("2026")).toBe("2026");
|
|
151
|
+
});
|
|
95
152
|
});
|
|
96
153
|
|
|
97
154
|
describe("formatNumber", () => {
|
|
@@ -102,38 +159,111 @@ describe("formatNumber", () => {
|
|
|
102
159
|
});
|
|
103
160
|
|
|
104
161
|
it("formats thousands with k", () => {
|
|
105
|
-
expect(formatNumber(1000)).toBe("
|
|
162
|
+
expect(formatNumber(1000)).toBe("1k");
|
|
106
163
|
expect(formatNumber(1500)).toBe("1.5k");
|
|
107
|
-
expect(formatNumber(
|
|
164
|
+
expect(formatNumber(1510)).toBe("1.51k");
|
|
165
|
+
expect(formatNumber(1517)).toBe("1.52k");
|
|
166
|
+
expect(formatNumber(999999)).toBe("1,000k");
|
|
108
167
|
});
|
|
109
168
|
|
|
110
169
|
it("formats millions with M", () => {
|
|
111
|
-
expect(formatNumber(1_000_000)).toBe("
|
|
112
|
-
expect(formatNumber(2_500_000)).toBe("2.
|
|
170
|
+
expect(formatNumber(1_000_000)).toBe("1M");
|
|
171
|
+
expect(formatNumber(2_500_000)).toBe("2.5M");
|
|
113
172
|
});
|
|
114
173
|
|
|
115
174
|
it("formats billions with B", () => {
|
|
116
|
-
expect(formatNumber(1_000_000_000)).toBe("
|
|
117
|
-
expect(formatNumber(2_500_000_000)).toBe("2.
|
|
175
|
+
expect(formatNumber(1_000_000_000)).toBe("1B");
|
|
176
|
+
expect(formatNumber(2_500_000_000)).toBe("2.5B");
|
|
177
|
+
expect(formatNumber(12_530_000_000)).toBe("12.53B");
|
|
178
|
+
expect(formatNumber(12_533_000_000)).toBe("12.53B");
|
|
179
|
+
expect(formatNumber(12_538_000_000)).toBe("12.54B");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("handles negative numbers (no suffix — current behavior)", () => {
|
|
183
|
+
// Negative numbers fall through all n >= threshold checks
|
|
184
|
+
expect(formatNumber(-500)).toBe("-500");
|
|
185
|
+
expect(formatNumber(-1500)).toBe("-1500");
|
|
186
|
+
expect(formatNumber(-2_500_000)).toBe("-2500000");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("handles boundary values", () => {
|
|
190
|
+
expect(formatNumber(999)).toBe("999");
|
|
191
|
+
expect(formatNumber(1000)).toBe("1k");
|
|
192
|
+
expect(formatNumber(999_999)).toBe("1,000k");
|
|
193
|
+
expect(formatNumber(1_000_000)).toBe("1M");
|
|
194
|
+
expect(formatNumber(999_999_999)).toBe("1,000M");
|
|
195
|
+
expect(formatNumber(1_000_000_000)).toBe("1B");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("handles large numbers beyond billions", () => {
|
|
199
|
+
expect(formatNumber(1_000_000_000_000)).toBe("1,000B");
|
|
118
200
|
});
|
|
119
201
|
});
|
|
120
202
|
|
|
121
203
|
describe("formatCost", () => {
|
|
122
|
-
it("formats small costs with $ and
|
|
123
|
-
expect(formatCost(0)).toBe("$0
|
|
124
|
-
expect(formatCost(1.5)).toBe("$1.
|
|
204
|
+
it("formats small costs with $ and least decimals possible", () => {
|
|
205
|
+
expect(formatCost(0)).toBe("$0");
|
|
206
|
+
expect(formatCost(1.5)).toBe("$1.5");
|
|
125
207
|
expect(formatCost(999.99)).toBe("$999.99");
|
|
126
208
|
});
|
|
127
209
|
|
|
128
210
|
it("formats thousands with k", () => {
|
|
129
|
-
expect(formatCost(1000)).toBe("$
|
|
211
|
+
expect(formatCost(1000)).toBe("$1k");
|
|
130
212
|
expect(formatCost(1500)).toBe("$1.5k");
|
|
131
213
|
});
|
|
132
214
|
|
|
133
215
|
it("formats millions with M", () => {
|
|
134
|
-
expect(formatCost(1_000_000)).toBe("$
|
|
216
|
+
expect(formatCost(1_000_000)).toBe("$1M");
|
|
135
217
|
expect(formatCost(2_500_000)).toBe("$2.5M");
|
|
136
218
|
});
|
|
219
|
+
|
|
220
|
+
it("handles boundary values", () => {
|
|
221
|
+
expect(formatCost(999.99)).toBe("$999.99");
|
|
222
|
+
expect(formatCost(1000)).toBe("$1k");
|
|
223
|
+
expect(formatCost(999_999)).toBe("$1,000k");
|
|
224
|
+
expect(formatCost(1_000_000)).toBe("$1M");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("handles costs above billions", () => {
|
|
228
|
+
expect(formatCost(1_000_000_000)).toBe("$1,000M");
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("stripAnsi", () => {
|
|
233
|
+
it("passes through plain text unchanged", () => {
|
|
234
|
+
expect(stripAnsi("hello world")).toBe("hello world");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("strips ANSI color codes", () => {
|
|
238
|
+
expect(stripAnsi("\x1b[32mgreen\x1b[0m")).toBe("green");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("strips ANSI cursor and erase sequences", () => {
|
|
242
|
+
expect(stripAnsi("\x1b[2J\x1b[Hclear")).toBe("clear");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("strips ANSI underline codes", () => {
|
|
246
|
+
expect(stripAnsi("\x1b[4munderlined\x1b[24m")).toBe("underlined");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("strips control characters", () => {
|
|
250
|
+
expect(stripAnsi("line1\x00line2")).toBe("line1line2");
|
|
251
|
+
expect(stripAnsi("a\x08b")).toBe("ab");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("strips zero-width and formatting characters", () => {
|
|
255
|
+
expect(stripAnsi("a\u200Bb")).toBe("ab");
|
|
256
|
+
expect(stripAnsi("a\uFEFFb")).toBe("ab");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("strips OSC sequences (\x1b]...\x07)", () => {
|
|
260
|
+
const osc = "\x1b]0;My Title\x07content";
|
|
261
|
+
expect(stripAnsi(osc)).toBe("content");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("handles empty string", () => {
|
|
265
|
+
expect(stripAnsi("")).toBe("");
|
|
266
|
+
});
|
|
137
267
|
});
|
|
138
268
|
|
|
139
269
|
describe("formatCacheTimestamp", () => {
|
|
@@ -155,60 +285,72 @@ describe("formatCacheTimestamp", () => {
|
|
|
155
285
|
});
|
|
156
286
|
|
|
157
287
|
it("shows date for older dates this year", () => {
|
|
158
|
-
const
|
|
288
|
+
const year = new Date().getFullYear();
|
|
289
|
+
const old = new Date(year, 0, 15, 14, 30, 0);
|
|
159
290
|
const iso = old.toISOString();
|
|
160
291
|
const result = formatCacheTimestamp(iso);
|
|
161
|
-
|
|
292
|
+
const monthName = MONTH_NAMES[old.getMonth()];
|
|
293
|
+
const day = old.getDate();
|
|
294
|
+
expect(result).toMatch(new RegExp(`^${monthName} ${day},`));
|
|
295
|
+
expect(result).not.toContain(String(year));
|
|
162
296
|
});
|
|
163
297
|
|
|
164
298
|
it("shows date with year for previous year", () => {
|
|
165
|
-
const
|
|
299
|
+
const year = new Date().getFullYear() - 1;
|
|
300
|
+
const old = new Date(year, 5, 10, 9, 15, 0);
|
|
166
301
|
const iso = old.toISOString();
|
|
167
302
|
const result = formatCacheTimestamp(iso);
|
|
168
|
-
expect(result).toMatch(
|
|
303
|
+
expect(result).toMatch(new RegExp(String(year)));
|
|
169
304
|
});
|
|
170
305
|
|
|
171
306
|
// ---- Timezone-awareness tests ----
|
|
172
307
|
|
|
173
|
-
it("formats time in local timezone
|
|
308
|
+
it("formats time in local timezone", () => {
|
|
174
309
|
const d = new Date("2026-06-15T07:30:00Z");
|
|
175
310
|
const iso = d.toISOString();
|
|
176
311
|
const result = formatCacheTimestamp(iso);
|
|
177
312
|
|
|
313
|
+
// Compute expected local time using the same algorithm as the source
|
|
178
314
|
const localHr = d.getHours();
|
|
179
315
|
const localMin = d.getMinutes();
|
|
180
|
-
const utcHr = d.getUTCHours();
|
|
181
|
-
|
|
182
316
|
const h12 = localHr % 12 || 12;
|
|
183
317
|
const ampm = localHr >= 12 ? "PM" : "AM";
|
|
184
318
|
const expectedLocal = `${h12}:${String(localMin).padStart(2, "0")} ${ampm}`;
|
|
185
319
|
|
|
320
|
+
// Always asserts local time format is shown
|
|
186
321
|
expect(result).toContain(expectedLocal);
|
|
187
322
|
|
|
188
|
-
// When
|
|
189
|
-
|
|
323
|
+
// When the machine is not in UTC, also verify the UTC time is absent.
|
|
324
|
+
// In UTC (offset=0) this guard is skipped because local===UTC,
|
|
325
|
+
// so timezone correctness can only be fully validated on non-UTC CI.
|
|
326
|
+
if (d.getTimezoneOffset() !== 0) {
|
|
327
|
+
const utcHr = d.getUTCHours();
|
|
190
328
|
const utcH12 = utcHr % 12 || 12;
|
|
191
329
|
const utcAmpm = utcHr >= 12 ? "PM" : "AM";
|
|
192
330
|
expect(result).not.toContain(`${utcH12}:${String(localMin).padStart(2, "0")} ${utcAmpm}`);
|
|
193
331
|
}
|
|
194
332
|
});
|
|
195
333
|
|
|
196
|
-
it("uses local month/day
|
|
334
|
+
it("uses local month/day for older date display", () => {
|
|
197
335
|
const d = new Date("2026-01-15T12:00:00Z");
|
|
198
336
|
const iso = d.toISOString();
|
|
199
337
|
const result = formatCacheTimestamp(iso);
|
|
200
338
|
|
|
339
|
+
// Compute expected local date using the same algorithm as the source
|
|
201
340
|
const localMonth = d.getMonth();
|
|
202
341
|
const localDay = d.getDate();
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
342
|
+
const localStr = `${MONTH_NAMES[localMonth]} ${localDay},`;
|
|
343
|
+
|
|
344
|
+
expect(result).toMatch(new RegExp(localStr));
|
|
345
|
+
|
|
346
|
+
// When the machine is not in UTC, also verify UTC date is absent.
|
|
347
|
+
if (d.getTimezoneOffset() !== 0) {
|
|
348
|
+
const utcMonth = d.getUTCMonth();
|
|
349
|
+
const utcDay = d.getUTCDate();
|
|
350
|
+
const utcStr = `${MONTH_NAMES[utcMonth]} ${utcDay},`;
|
|
351
|
+
if (localStr !== utcStr) {
|
|
352
|
+
expect(result).not.toMatch(new RegExp(utcStr));
|
|
353
|
+
}
|
|
212
354
|
}
|
|
213
355
|
});
|
|
214
356
|
|
package/src/format.ts
CHANGED
|
@@ -137,19 +137,32 @@ export const MONTH_NAMES = [
|
|
|
137
137
|
"Dec",
|
|
138
138
|
];
|
|
139
139
|
|
|
140
|
+
const numberFormatter = new Intl.NumberFormat("en-US", {
|
|
141
|
+
style: "decimal",
|
|
142
|
+
minimumFractionDigits: 0,
|
|
143
|
+
maximumFractionDigits: 2,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const usdFormatter = new Intl.NumberFormat("en-US", {
|
|
147
|
+
style: "currency",
|
|
148
|
+
currency: "USD",
|
|
149
|
+
minimumFractionDigits: 0,
|
|
150
|
+
maximumFractionDigits: 2,
|
|
151
|
+
});
|
|
152
|
+
|
|
140
153
|
// ---- Number formatting ----
|
|
141
154
|
|
|
142
155
|
export function formatNumber(n: number): string {
|
|
143
|
-
if (n >= 1_000_000_000) return (n / 1_000_000_000)
|
|
144
|
-
if (n >= 1_000_000) return (n / 1_000_000)
|
|
145
|
-
if (n >= 1_000) return (n / 1_000)
|
|
156
|
+
if (n >= 1_000_000_000) return numberFormatter.format(n / 1_000_000_000) + "B";
|
|
157
|
+
if (n >= 1_000_000) return numberFormatter.format(n / 1_000_000) + "M";
|
|
158
|
+
if (n >= 1_000) return numberFormatter.format(n / 1_000) + "k";
|
|
146
159
|
return String(n);
|
|
147
160
|
}
|
|
148
161
|
|
|
149
162
|
export function formatCost(n: number): string {
|
|
150
|
-
if (n >= 1_000_000) return
|
|
151
|
-
if (n >= 1_000) return
|
|
152
|
-
return
|
|
163
|
+
if (n >= 1_000_000) return usdFormatter.format(n / 1_000_000) + "M";
|
|
164
|
+
if (n >= 1_000) return usdFormatter.format(n / 1_000) + "k";
|
|
165
|
+
return usdFormatter.format(n);
|
|
153
166
|
}
|
|
154
167
|
|
|
155
168
|
// ---- Timestamp formatting ----
|
|
@@ -204,7 +217,7 @@ export function formatCacheTimestamp(iso: string): string {
|
|
|
204
217
|
export function stripAnsi(text: string): string {
|
|
205
218
|
// First strip ANSI sequences
|
|
206
219
|
let clean = text.replace(
|
|
207
|
-
/[\u001B\u009B][[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]
|
|
220
|
+
/(?:\u001B\][\s\S]*?(?:\u0007|\u001B\\|\u009C))|[\u001B\u009B][[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]/g,
|
|
208
221
|
"",
|
|
209
222
|
);
|
|
210
223
|
// Then strip control characters that can break terminal rendering
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { summarize } from "./compute";
|
|
|
8
8
|
import { formatCacheTimestamp } from "./format";
|
|
9
9
|
import type { TimeRange } from "./types";
|
|
10
10
|
import { RangeSelector, type RangeOption } from "./components/RangeSelector";
|
|
11
|
+
import type { OverlayOptions } from "@earendil-works/pi-tui";
|
|
11
12
|
|
|
12
13
|
const SESSIONS_DIR = join(homedir(), ".pi", "agent", "sessions");
|
|
13
14
|
const CACHE_PATH = join(homedir(), ".pi", "pi-atlas-cache.json");
|
|
@@ -22,14 +23,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
const overlayOpts = {
|
|
25
|
-
overlay: true
|
|
26
|
+
overlay: true,
|
|
26
27
|
overlayOptions: {
|
|
27
28
|
minWidth: 100,
|
|
28
|
-
width: "50%"
|
|
29
|
-
maxHeight: "80%"
|
|
30
|
-
anchor: "center"
|
|
29
|
+
width: "50%",
|
|
30
|
+
maxHeight: "80%",
|
|
31
|
+
anchor: "top-center",
|
|
31
32
|
margin: 2,
|
|
32
|
-
},
|
|
33
|
+
} as OverlayOptions,
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
// Read last update timestamp before loading (cache may be rewritten)
|
|
@@ -37,28 +38,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
37
38
|
const updateLabel = lastUpdate ? `Last update : ${formatCacheTimestamp(lastUpdate)}` : null;
|
|
38
39
|
|
|
39
40
|
// Phase 1: Show loading, parse session logs
|
|
40
|
-
let days: Awaited<ReturnType<typeof loadAggregate
|
|
41
|
+
let days: Awaited<ReturnType<typeof loadAggregate> | undefined>;
|
|
41
42
|
try {
|
|
42
|
-
days = await ctx.ui.custom<
|
|
43
|
-
(
|
|
44
|
-
|
|
43
|
+
days = await ctx.ui.custom<typeof days>((tui, theme, _kb, done) => {
|
|
44
|
+
const loadingView = new LoadingView("Parsing session logs...", theme, () =>
|
|
45
|
+
done(undefined),
|
|
46
|
+
);
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
loadAggregate(CACHE_PATH, SESSIONS_DIR, false, (p) => {
|
|
49
|
+
loadingView.setProgress(p);
|
|
50
|
+
tui.requestRender();
|
|
51
|
+
})
|
|
52
|
+
.then((result) => done(result))
|
|
53
|
+
.catch(() => done([]));
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
);
|
|
57
|
-
} catch {
|
|
55
|
+
return loadingView;
|
|
56
|
+
}, overlayOpts);
|
|
57
|
+
} catch (e) {
|
|
58
58
|
ctx.ui.notify("Failed to parse session logs", "error");
|
|
59
|
+
ctx.ui.notify(e as string, "error");
|
|
59
60
|
return;
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
if (days === undefined) return;
|
|
64
|
+
|
|
62
65
|
// Phase 2: Show dashboard (handles empty state internally)
|
|
63
66
|
const rangesToSummarize: TimeRange[] = ["1d", "7d", "30d", "All"];
|
|
64
67
|
const summaries = new Map(rangesToSummarize.map((r) => [r, summarize(days, r)] as const));
|