@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
|
@@ -1,14 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type {
|
|
2
|
+
AssistantMessage as PiAssistantMessage,
|
|
3
|
+
ToolResultMessage as PiToolResultMessage,
|
|
4
|
+
ToolCall,
|
|
5
|
+
} from "@earendil-works/pi-ai";
|
|
6
|
+
import type {
|
|
7
|
+
CompactionEntry,
|
|
8
|
+
ModelChangeEntry,
|
|
9
|
+
SessionHeader,
|
|
10
|
+
SessionMessageEntry,
|
|
11
|
+
ThinkingLevelChangeEntry,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
14
|
+
import { mkdir, rm, unlink, writeFile } from "node:fs/promises";
|
|
2
15
|
import { tmpdir } from "node:os";
|
|
3
16
|
import { join } from "node:path";
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
5
17
|
import {
|
|
6
|
-
parseLanguageUsage,
|
|
7
18
|
emptyDay,
|
|
8
19
|
mergeDay,
|
|
9
20
|
parseAssistantMessage,
|
|
10
21
|
parseCompactionEntry,
|
|
11
22
|
parseFile,
|
|
23
|
+
parseLanguageUsage,
|
|
12
24
|
parseModelChangeEntry,
|
|
13
25
|
parseSessionHeader,
|
|
14
26
|
parseSessionLogEntry,
|
|
@@ -16,21 +28,8 @@ import {
|
|
|
16
28
|
parseToolResultMessage,
|
|
17
29
|
parseUserMessage,
|
|
18
30
|
sessionProjectMap,
|
|
19
|
-
} from "
|
|
20
|
-
import type { DayAgg } from "
|
|
21
|
-
import type {
|
|
22
|
-
AssistantMessage as PiAssistantMessage,
|
|
23
|
-
ToolResultMessage as PiToolResultMessage,
|
|
24
|
-
ToolCall,
|
|
25
|
-
} from "@earendil-works/pi-ai";
|
|
26
|
-
import type {
|
|
27
|
-
CompactionEntry,
|
|
28
|
-
ModelChangeEntry,
|
|
29
|
-
SessionHeader,
|
|
30
|
-
SessionMessageEntry,
|
|
31
|
-
ThinkingLevelChangeEntry,
|
|
32
|
-
} from "@earendil-works/pi-coding-agent";
|
|
33
|
-
import assert from "node:assert";
|
|
31
|
+
} from "./parser";
|
|
32
|
+
import type { DayAgg } from "./types";
|
|
34
33
|
|
|
35
34
|
// Helper: minimal AssistantMessage with required fields
|
|
36
35
|
function mkAsst(msg: {
|
|
@@ -109,9 +108,8 @@ describe("emptyDay", () => {
|
|
|
109
108
|
it("returns a new empty object each call", () => {
|
|
110
109
|
const a = emptyDay("2026-06-09");
|
|
111
110
|
const b = emptyDay("2026-06-09");
|
|
111
|
+
expect(a).toEqual(b);
|
|
112
112
|
expect(a).not.toBe(b);
|
|
113
|
-
expect(a.langLines).not.toBe(b.langLines);
|
|
114
|
-
expect(a.toolCount).not.toBe(b.toolCount);
|
|
115
113
|
});
|
|
116
114
|
});
|
|
117
115
|
|
|
@@ -146,6 +144,12 @@ describe("parseToolResultMessage", () => {
|
|
|
146
144
|
expect(Object.keys(day.toolCount)[0]).toBe("ls -la agent/</parameter");
|
|
147
145
|
expect(day.toolCount["ls -la agent/</parameter"]).toBe(1);
|
|
148
146
|
});
|
|
147
|
+
|
|
148
|
+
it("strips various control characters from toolName", () => {
|
|
149
|
+
const msg = mkToolResult({ toolName: "test\r\t\0\u200Btool" });
|
|
150
|
+
const day = parseToolResultMessage(msg);
|
|
151
|
+
expect(day.toolCount["testtool"]).toBe(1);
|
|
152
|
+
});
|
|
149
153
|
});
|
|
150
154
|
|
|
151
155
|
describe("parseLanguageUsage", () => {
|
|
@@ -179,7 +183,7 @@ describe("parseLanguageUsage", () => {
|
|
|
179
183
|
expect(day.langEdits["TypeScript"]).toBe(1);
|
|
180
184
|
});
|
|
181
185
|
|
|
182
|
-
it("handles non-array edits gracefully", () => {
|
|
186
|
+
it("handles non-array (string) edits gracefully", () => {
|
|
183
187
|
const day = parseLanguageUsage("edit", {
|
|
184
188
|
path: "/src/foo.ts",
|
|
185
189
|
edits: "not-an-array",
|
|
@@ -188,6 +192,24 @@ describe("parseLanguageUsage", () => {
|
|
|
188
192
|
expect(day.langEdits["TypeScript"]).toBe(1);
|
|
189
193
|
});
|
|
190
194
|
|
|
195
|
+
it("handles edits set undefined gracefully", () => {
|
|
196
|
+
const day = parseLanguageUsage("edit", {
|
|
197
|
+
path: "/src/foo.ts",
|
|
198
|
+
edits: undefined,
|
|
199
|
+
});
|
|
200
|
+
expect(day.langLines["TypeScript"]).toBe(1);
|
|
201
|
+
expect(day.langEdits["TypeScript"]).toBe(1);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("handles edits set to null gracefully", () => {
|
|
205
|
+
const day = parseLanguageUsage("edit", {
|
|
206
|
+
path: "/src/foo.ts",
|
|
207
|
+
edits: null,
|
|
208
|
+
});
|
|
209
|
+
expect(day.langLines["TypeScript"]).toBe(1);
|
|
210
|
+
expect(day.langEdits["TypeScript"]).toBe(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
191
213
|
it("handles missing content in write gracefully", () => {
|
|
192
214
|
const day = parseLanguageUsage("write", { path: "/src/foo.py" });
|
|
193
215
|
expect(day.langLines["Python"]).toBe(1);
|
|
@@ -204,6 +226,29 @@ describe("parseLanguageUsage", () => {
|
|
|
204
226
|
expect(day.langLines).toEqual({});
|
|
205
227
|
expect(day.langEdits).toEqual({});
|
|
206
228
|
});
|
|
229
|
+
|
|
230
|
+
it("parses unknown file extensions as 'Other'", () => {
|
|
231
|
+
const day = parseSessionLogEntry({
|
|
232
|
+
type: "message",
|
|
233
|
+
id: "m1",
|
|
234
|
+
parentId: "p",
|
|
235
|
+
timestamp: "2026-06-08T10:01:00.000Z",
|
|
236
|
+
message: mkAsst({
|
|
237
|
+
content: [tc("write", { path: "/x/config.xyz", content: "abc" })],
|
|
238
|
+
model: "m",
|
|
239
|
+
}),
|
|
240
|
+
})!;
|
|
241
|
+
|
|
242
|
+
expect(day.langLines["Other"]).toBe(1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("counts multiline write content correctly", () => {
|
|
246
|
+
const day = parseLanguageUsage("write", {
|
|
247
|
+
path: "/src/foo.py",
|
|
248
|
+
content: "line1\nline2\nline3",
|
|
249
|
+
});
|
|
250
|
+
expect(day.langLines["Python"]).toBe(3);
|
|
251
|
+
});
|
|
207
252
|
});
|
|
208
253
|
|
|
209
254
|
describe("parseAssistantMessage", () => {
|
|
@@ -318,7 +363,7 @@ describe("parseAssistantMessage", () => {
|
|
|
318
363
|
expect(day.projectCost["beta"]).toBe(0.05);
|
|
319
364
|
});
|
|
320
365
|
|
|
321
|
-
it("handles
|
|
366
|
+
it("handles zero-cost usage gracefully", () => {
|
|
322
367
|
const msg = mkAsst({
|
|
323
368
|
content: [{ type: "text", text: "hi" }],
|
|
324
369
|
usage: {
|
|
@@ -336,6 +381,16 @@ describe("parseAssistantMessage", () => {
|
|
|
336
381
|
expect(day.cost).toBe(0);
|
|
337
382
|
});
|
|
338
383
|
|
|
384
|
+
it("handles missing usage gracefully", () => {
|
|
385
|
+
const msg = mkAsst({
|
|
386
|
+
content: [{ type: "text", text: "hi" }],
|
|
387
|
+
});
|
|
388
|
+
const day = parseAssistantMessage(msg);
|
|
389
|
+
expect(day.asstMsgs).toBe(1);
|
|
390
|
+
expect(day.inTok).toBe(0);
|
|
391
|
+
expect(day.cost).toBe(0);
|
|
392
|
+
});
|
|
393
|
+
|
|
339
394
|
it("handles missing content gracefully", () => {
|
|
340
395
|
const msg = mkAsst({
|
|
341
396
|
usage: {
|
|
@@ -351,6 +406,81 @@ describe("parseAssistantMessage", () => {
|
|
|
351
406
|
expect(day.asstMsgs).toBe(1);
|
|
352
407
|
expect(day.toolCount).toEqual({});
|
|
353
408
|
});
|
|
409
|
+
|
|
410
|
+
it("parses JSON-string toolCall arguments", () => {
|
|
411
|
+
const msg = mkAsst({
|
|
412
|
+
content: [
|
|
413
|
+
{
|
|
414
|
+
type: "toolCall" as const,
|
|
415
|
+
id: "c1",
|
|
416
|
+
name: "edit",
|
|
417
|
+
//@ts-expect-error
|
|
418
|
+
arguments: JSON.stringify({ path: "/src/foo.ts", edits: [{ newText: "abc" }] }),
|
|
419
|
+
},
|
|
420
|
+
],
|
|
421
|
+
usage: {
|
|
422
|
+
input: 0,
|
|
423
|
+
output: 0,
|
|
424
|
+
cacheRead: 0,
|
|
425
|
+
cacheWrite: 0,
|
|
426
|
+
totalTokens: 0,
|
|
427
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
const day = parseAssistantMessage(msg);
|
|
431
|
+
expect(day.toolCount["edit"]).toBe(1);
|
|
432
|
+
expect(day.langLines["TypeScript"]).toBe(1);
|
|
433
|
+
expect(day.langEdits["TypeScript"]).toBe(1);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("handles toolCall with undefined arguments", () => {
|
|
437
|
+
const msg = mkAsst({
|
|
438
|
+
//@ts-expect-error
|
|
439
|
+
content: [{ type: "toolCall" as const, id: "c1", name: "read" }],
|
|
440
|
+
});
|
|
441
|
+
const day = parseAssistantMessage(msg);
|
|
442
|
+
expect(day.toolCount["read"]).toBe(1);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("records model count but not provider when provider is empty", () => {
|
|
446
|
+
const msg = mkAsst({
|
|
447
|
+
model: "gpt-5",
|
|
448
|
+
provider: "",
|
|
449
|
+
usage: {
|
|
450
|
+
input: 10,
|
|
451
|
+
output: 5,
|
|
452
|
+
cacheRead: 0,
|
|
453
|
+
cacheWrite: 0,
|
|
454
|
+
totalTokens: 15,
|
|
455
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.003 },
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
const day = parseAssistantMessage(msg);
|
|
459
|
+
expect(day.modelCost["gpt-5"]).toBe(0.003);
|
|
460
|
+
expect(day.modelCount["gpt-5"]).toBe(1);
|
|
461
|
+
expect(day.modelToProvider.has("gpt-5")).toBe(false);
|
|
462
|
+
expect(day.providerCost).toEqual({});
|
|
463
|
+
expect(day.providerCount).toEqual({});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("records provider cost, count, and model mapping", () => {
|
|
467
|
+
const msg = mkAsst({
|
|
468
|
+
model: "deepseek-v4-pro",
|
|
469
|
+
provider: "deepseek",
|
|
470
|
+
usage: {
|
|
471
|
+
input: 10,
|
|
472
|
+
output: 5,
|
|
473
|
+
cacheRead: 0,
|
|
474
|
+
cacheWrite: 0,
|
|
475
|
+
totalTokens: 15,
|
|
476
|
+
cost: { input: 0.001, output: 0.002, cacheRead: 0, cacheWrite: 0, total: 0.003 },
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
const day = parseAssistantMessage(msg);
|
|
480
|
+
expect(day.providerCost["deepseek"]).toBe(0.003);
|
|
481
|
+
expect(day.providerCount["deepseek"]).toBe(1);
|
|
482
|
+
expect(day.modelToProvider.get("deepseek-v4-pro")).toBe("deepseek");
|
|
483
|
+
});
|
|
354
484
|
});
|
|
355
485
|
|
|
356
486
|
describe("parseSessionHeader", () => {
|
|
@@ -481,9 +611,8 @@ describe("parseSessionLogEntry", () => {
|
|
|
481
611
|
cwd: "/home/doe/dev/pi-atlas",
|
|
482
612
|
};
|
|
483
613
|
|
|
484
|
-
const dayAgg = parseSessionLogEntry(entry)
|
|
614
|
+
const dayAgg = parseSessionLogEntry(entry)!;
|
|
485
615
|
|
|
486
|
-
assert(dayAgg);
|
|
487
616
|
expect(dayAgg.date).toBe("2026-06-08");
|
|
488
617
|
expect(dayAgg.sessionIds.has("abc-123")).toBe(true);
|
|
489
618
|
});
|
|
@@ -518,9 +647,8 @@ describe("parseSessionLogEntry", () => {
|
|
|
518
647
|
}),
|
|
519
648
|
};
|
|
520
649
|
|
|
521
|
-
const dayAgg = parseSessionLogEntry(msgEntry)
|
|
650
|
+
const dayAgg = parseSessionLogEntry(msgEntry)!;
|
|
522
651
|
|
|
523
|
-
assert(dayAgg);
|
|
524
652
|
expect(dayAgg.cost).toBe(0.00141);
|
|
525
653
|
expect(dayAgg.inTok).toBe(1000);
|
|
526
654
|
expect(dayAgg.outTok).toBe(200);
|
|
@@ -545,7 +673,6 @@ describe("parseSessionLogEntry", () => {
|
|
|
545
673
|
message: { role: "user" as const, content: "hi", timestamp: 1700000000000 },
|
|
546
674
|
})!;
|
|
547
675
|
|
|
548
|
-
assert(dayAgg);
|
|
549
676
|
expect(dayAgg.userMsgs).toBe(1);
|
|
550
677
|
expect(dayAgg.date).toBe("2026-06-08");
|
|
551
678
|
// No cost => hourCost not incremented
|
|
@@ -601,13 +728,13 @@ describe("parseSessionLogEntry", () => {
|
|
|
601
728
|
version: 3,
|
|
602
729
|
id: "s1",
|
|
603
730
|
timestamp: "2026-06-08T10:00:00.000Z",
|
|
604
|
-
cwd: "/home/doe/
|
|
731
|
+
cwd: "/home/doe/dev/my-cool-project",
|
|
605
732
|
})!;
|
|
606
733
|
|
|
607
734
|
expect(dayAgg.projectCost["my-cool-project"]).toBe(0);
|
|
608
735
|
|
|
609
|
-
|
|
610
|
-
expect(dayAgg.projectSessions["my-cool-project"]
|
|
736
|
+
expect(dayAgg.projectSessions["my-cool-project"]).toBeDefined();
|
|
737
|
+
expect(dayAgg.projectSessions["my-cool-project"]!.has("s1")).toBe(true);
|
|
611
738
|
});
|
|
612
739
|
|
|
613
740
|
it("accumulates project costs", () => {
|
|
@@ -663,41 +790,7 @@ describe("parseSessionLogEntry", () => {
|
|
|
663
790
|
expect(dayAgg.toolCount["read"]).toBe(2);
|
|
664
791
|
});
|
|
665
792
|
|
|
666
|
-
it("handles
|
|
667
|
-
const session = parseSessionLogEntry({
|
|
668
|
-
type: "session",
|
|
669
|
-
version: 3,
|
|
670
|
-
id: "s1",
|
|
671
|
-
timestamp: "2026-06-08T10:00:00.000Z",
|
|
672
|
-
cwd: "/home/doe/proj",
|
|
673
|
-
})!;
|
|
674
|
-
|
|
675
|
-
const day = parseSessionLogEntry({
|
|
676
|
-
type: "message",
|
|
677
|
-
id: "m1",
|
|
678
|
-
parentId: "p",
|
|
679
|
-
timestamp: "2026-06-08T10:01:00.000Z",
|
|
680
|
-
message: mkAsst({
|
|
681
|
-
content: [{ type: "text", text: "hi" }],
|
|
682
|
-
model: "m",
|
|
683
|
-
usage: {
|
|
684
|
-
input: 0,
|
|
685
|
-
output: 0,
|
|
686
|
-
cacheRead: 0,
|
|
687
|
-
cacheWrite: 0,
|
|
688
|
-
totalTokens: 0,
|
|
689
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
690
|
-
},
|
|
691
|
-
}),
|
|
692
|
-
})!;
|
|
693
|
-
|
|
694
|
-
mergeDay(session, day);
|
|
695
|
-
expect(session.asstMsgs).toBe(1);
|
|
696
|
-
expect(session.cost).toBe(0);
|
|
697
|
-
expect(session.inTok).toBe(0);
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
it("handles session entry without cwd", () => {
|
|
793
|
+
it("handles session entry with empty cwd", () => {
|
|
701
794
|
const day = parseSessionLogEntry({
|
|
702
795
|
type: "session",
|
|
703
796
|
version: 3,
|
|
@@ -712,7 +805,7 @@ describe("parseSessionLogEntry", () => {
|
|
|
712
805
|
expect(Object.keys(day.projectSessions).length).toBe(0);
|
|
713
806
|
});
|
|
714
807
|
|
|
715
|
-
it("
|
|
808
|
+
it("counts model usage whith zero-cost usage", () => {
|
|
716
809
|
const day = parseSessionLogEntry({
|
|
717
810
|
type: "message",
|
|
718
811
|
id: "m1",
|
|
@@ -733,37 +826,10 @@ describe("parseSessionLogEntry", () => {
|
|
|
733
826
|
})!;
|
|
734
827
|
|
|
735
828
|
expect(day.asstMsgs).toBe(1);
|
|
736
|
-
expect(day.inTok).toBe(100);
|
|
737
|
-
expect(day.outTok).toBe(50);
|
|
738
|
-
expect(day.cost).toBe(0);
|
|
739
829
|
expect(day.modelCost).toEqual({ "deepseek-v4": 0 });
|
|
740
830
|
expect(day.modelCount).toEqual({ "deepseek-v4": 1 });
|
|
741
831
|
});
|
|
742
832
|
|
|
743
|
-
it("parses unknown file extensions as 'Other'", () => {
|
|
744
|
-
const session = parseSessionLogEntry({
|
|
745
|
-
type: "session",
|
|
746
|
-
version: 3,
|
|
747
|
-
id: "s1",
|
|
748
|
-
timestamp: "2026-06-08T10:00:00.000Z",
|
|
749
|
-
cwd: "/home/doe/proj",
|
|
750
|
-
})!;
|
|
751
|
-
|
|
752
|
-
const day = parseSessionLogEntry({
|
|
753
|
-
type: "message",
|
|
754
|
-
id: "m1",
|
|
755
|
-
parentId: "p",
|
|
756
|
-
timestamp: "2026-06-08T10:01:00.000Z",
|
|
757
|
-
message: mkAsst({
|
|
758
|
-
content: [tc("write", { path: "/x/config.xyz", content: "abc" })],
|
|
759
|
-
model: "m",
|
|
760
|
-
}),
|
|
761
|
-
})!;
|
|
762
|
-
|
|
763
|
-
mergeDay(session, day);
|
|
764
|
-
expect(session.langLines["Other"]).toBe(1);
|
|
765
|
-
});
|
|
766
|
-
|
|
767
833
|
it("returns null for unknown entry types", () => {
|
|
768
834
|
expect(
|
|
769
835
|
parseSessionLogEntry({
|
|
@@ -809,7 +875,6 @@ describe("parseSessionLogEntry", () => {
|
|
|
809
875
|
tokensBefore: 42000,
|
|
810
876
|
})!;
|
|
811
877
|
|
|
812
|
-
assert(day);
|
|
813
878
|
expect(day.compactionCount).toBe(1);
|
|
814
879
|
expect(day.compactedTokens).toBe(42000);
|
|
815
880
|
});
|
|
@@ -824,7 +889,6 @@ describe("parseSessionLogEntry", () => {
|
|
|
824
889
|
modelId: "gpt-5",
|
|
825
890
|
})!;
|
|
826
891
|
|
|
827
|
-
assert(day);
|
|
828
892
|
expect(day.modelChanges).toBe(1);
|
|
829
893
|
});
|
|
830
894
|
|
|
@@ -837,9 +901,44 @@ describe("parseSessionLogEntry", () => {
|
|
|
837
901
|
thinkingLevel: "xhigh",
|
|
838
902
|
})!;
|
|
839
903
|
|
|
840
|
-
assert(day);
|
|
841
904
|
expect(day.thinkingLevelCount).toEqual({ xhigh: 1 });
|
|
842
905
|
});
|
|
906
|
+
|
|
907
|
+
it("returns null for custom_message type", () => {
|
|
908
|
+
expect(
|
|
909
|
+
parseSessionLogEntry({
|
|
910
|
+
type: "custom_message",
|
|
911
|
+
id: "cm1",
|
|
912
|
+
parentId: "p",
|
|
913
|
+
timestamp: "2026-06-08T10:00:00.000Z",
|
|
914
|
+
//@ts-expect-error
|
|
915
|
+
contentType: "my-type",
|
|
916
|
+
message: "hi",
|
|
917
|
+
}),
|
|
918
|
+
).toBeNull();
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it("returns null for session_info type", () => {
|
|
922
|
+
expect(
|
|
923
|
+
parseSessionLogEntry({
|
|
924
|
+
type: "session_info",
|
|
925
|
+
id: "si1",
|
|
926
|
+
parentId: "p",
|
|
927
|
+
timestamp: "2026-06-08T10:00:00.000Z",
|
|
928
|
+
//@ts-expect-error
|
|
929
|
+
totalTokens: 100,
|
|
930
|
+
}),
|
|
931
|
+
).toBeNull();
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it("returns null for non-object entry types", () => {
|
|
935
|
+
// @ts-expect-error: testing runtime resilience
|
|
936
|
+
expect(parseSessionLogEntry("corrupt")).toBeNull();
|
|
937
|
+
// @ts-expect-error: testing runtime resilience
|
|
938
|
+
expect(parseSessionLogEntry(42)).toBeNull();
|
|
939
|
+
// @ts-expect-error: testing runtime resilience
|
|
940
|
+
expect(parseSessionLogEntry(true)).toBeNull();
|
|
941
|
+
});
|
|
843
942
|
});
|
|
844
943
|
|
|
845
944
|
describe("mergeDay", () => {
|
|
@@ -848,6 +947,8 @@ describe("mergeDay", () => {
|
|
|
848
947
|
const b: DayAgg = {
|
|
849
948
|
...emptyDay("2026-06-08"),
|
|
850
949
|
cost: 1,
|
|
950
|
+
crTok: 100,
|
|
951
|
+
cwTok: 200,
|
|
851
952
|
inTok: 100,
|
|
852
953
|
outTok: 50,
|
|
853
954
|
userMsgs: 2,
|
|
@@ -857,6 +958,8 @@ describe("mergeDay", () => {
|
|
|
857
958
|
|
|
858
959
|
mergeDay(a, b);
|
|
859
960
|
expect(a.cost).toBe(1);
|
|
961
|
+
expect(a.crTok).toBe(100);
|
|
962
|
+
expect(a.cwTok).toBe(200);
|
|
860
963
|
expect(a.inTok).toBe(100);
|
|
861
964
|
expect(a.outTok).toBe(50);
|
|
862
965
|
expect(a.userMsgs).toBe(2);
|
|
@@ -946,19 +1049,6 @@ describe("mergeDay", () => {
|
|
|
946
1049
|
expect(a.projectSessions["proj1"]?.has("s2")).toBe(true);
|
|
947
1050
|
});
|
|
948
1051
|
|
|
949
|
-
it("sums crTok and cwTok", () => {
|
|
950
|
-
const a = emptyDay("2026-06-08");
|
|
951
|
-
const b: DayAgg = {
|
|
952
|
-
...emptyDay("2026-06-08"),
|
|
953
|
-
crTok: 100,
|
|
954
|
-
cwTok: 200,
|
|
955
|
-
};
|
|
956
|
-
|
|
957
|
-
mergeDay(a, b);
|
|
958
|
-
expect(a.crTok).toBe(100);
|
|
959
|
-
expect(a.cwTok).toBe(200);
|
|
960
|
-
});
|
|
961
|
-
|
|
962
1052
|
it("merges model cost and count records", () => {
|
|
963
1053
|
const a = emptyDay("2026-06-08");
|
|
964
1054
|
const b: DayAgg = {
|
|
@@ -1103,6 +1193,14 @@ describe("mergeDay", () => {
|
|
|
1103
1193
|
expect(a.modelChanges).toBe(3);
|
|
1104
1194
|
expect(a.thinkingLevelCount).toEqual({ low: 3, high: 1, xhigh: 1 });
|
|
1105
1195
|
});
|
|
1196
|
+
|
|
1197
|
+
it("preserves base modelToProvider when update has empty map", () => {
|
|
1198
|
+
const a = emptyDay("2026-06-08");
|
|
1199
|
+
a.modelToProvider.set("gpt-5", "openai");
|
|
1200
|
+
const b = emptyDay("2026-06-08");
|
|
1201
|
+
mergeDay(a, b);
|
|
1202
|
+
expect(a.modelToProvider.get("gpt-5")).toBe("openai");
|
|
1203
|
+
});
|
|
1106
1204
|
});
|
|
1107
1205
|
|
|
1108
1206
|
describe("parseFile", () => {
|
|
@@ -1393,4 +1491,136 @@ describe("parseFile", () => {
|
|
|
1393
1491
|
expect(map.get("2026-06-08")?.userMsgs).toBe(1);
|
|
1394
1492
|
expect(warnings).toBe(0);
|
|
1395
1493
|
});
|
|
1494
|
+
|
|
1495
|
+
it("end-to-end: parses and aggregates a realistic session file", async () => {
|
|
1496
|
+
const filePath = join(tmpDir, "session.jsonl");
|
|
1497
|
+
const lines = [
|
|
1498
|
+
// Session header — establishes project "pi-tui-extras"
|
|
1499
|
+
JSON.stringify({
|
|
1500
|
+
type: "session",
|
|
1501
|
+
version: 3,
|
|
1502
|
+
id: "s-main",
|
|
1503
|
+
timestamp: "2026-06-10T09:00:00.000Z",
|
|
1504
|
+
cwd: "/home/doe/dev/pi-tui-extras",
|
|
1505
|
+
}),
|
|
1506
|
+
// User message
|
|
1507
|
+
JSON.stringify({
|
|
1508
|
+
type: "message",
|
|
1509
|
+
id: "m1",
|
|
1510
|
+
parentId: "s-main",
|
|
1511
|
+
timestamp: "2026-06-10T09:01:00.000Z",
|
|
1512
|
+
message: { role: "user", content: "add logging", timestamp: 1700000000000 },
|
|
1513
|
+
}),
|
|
1514
|
+
// Assistant message with cost + edit tool call
|
|
1515
|
+
JSON.stringify({
|
|
1516
|
+
type: "message",
|
|
1517
|
+
id: "m2",
|
|
1518
|
+
parentId: "m1",
|
|
1519
|
+
timestamp: "2026-06-10T09:02:00.000Z",
|
|
1520
|
+
message: mkAsst({
|
|
1521
|
+
content: [
|
|
1522
|
+
{ type: "text", text: "sure" },
|
|
1523
|
+
tc("edit", { path: "/src/lib.ts", edits: [{ newText: "console.log(1)\n" }] }),
|
|
1524
|
+
{ ...tc("write", { path: "/src/log.rs", content: "fn log() {}" }), id: "c2" },
|
|
1525
|
+
{ ...tc("read", { path: "/src/main.ts" }), id: "c3" },
|
|
1526
|
+
],
|
|
1527
|
+
model: "sonnet-v3",
|
|
1528
|
+
provider: "anthropic",
|
|
1529
|
+
usage: {
|
|
1530
|
+
input: 500,
|
|
1531
|
+
output: 200,
|
|
1532
|
+
cacheRead: 50,
|
|
1533
|
+
cacheWrite: 10,
|
|
1534
|
+
totalTokens: 760,
|
|
1535
|
+
cost: {
|
|
1536
|
+
input: 0.002,
|
|
1537
|
+
output: 0.003,
|
|
1538
|
+
cacheRead: 0.0001,
|
|
1539
|
+
cacheWrite: 0.0002,
|
|
1540
|
+
total: 0.0053,
|
|
1541
|
+
},
|
|
1542
|
+
},
|
|
1543
|
+
}),
|
|
1544
|
+
}),
|
|
1545
|
+
// Tool result
|
|
1546
|
+
JSON.stringify({
|
|
1547
|
+
type: "message",
|
|
1548
|
+
id: "m3",
|
|
1549
|
+
parentId: "m2",
|
|
1550
|
+
timestamp: "2026-06-10T09:02:30.000Z",
|
|
1551
|
+
message: mkToolResult({ toolName: "edit" }),
|
|
1552
|
+
}),
|
|
1553
|
+
// Model change
|
|
1554
|
+
JSON.stringify({
|
|
1555
|
+
type: "model_change",
|
|
1556
|
+
id: "mc1",
|
|
1557
|
+
parentId: "m3",
|
|
1558
|
+
timestamp: "2026-06-10T09:05:00.000Z",
|
|
1559
|
+
provider: "deepseek",
|
|
1560
|
+
modelId: "deepseek-v4",
|
|
1561
|
+
}),
|
|
1562
|
+
// Compaction
|
|
1563
|
+
JSON.stringify({
|
|
1564
|
+
type: "compaction",
|
|
1565
|
+
id: "c1",
|
|
1566
|
+
parentId: "mc1",
|
|
1567
|
+
timestamp: "2026-06-10T09:10:00.000Z",
|
|
1568
|
+
summary: "mid-session compact",
|
|
1569
|
+
firstKeptEntryId: "m1",
|
|
1570
|
+
tokensBefore: 30000,
|
|
1571
|
+
}),
|
|
1572
|
+
];
|
|
1573
|
+
await writeFile(filePath, lines.join("\n"));
|
|
1574
|
+
|
|
1575
|
+
const map = parseFile(filePath);
|
|
1576
|
+
|
|
1577
|
+
expect(map.size).toBe(1);
|
|
1578
|
+
const day = map.get("2026-06-10")!;
|
|
1579
|
+
|
|
1580
|
+
// delete temp file
|
|
1581
|
+
await unlink(filePath);
|
|
1582
|
+
|
|
1583
|
+
// Session tracking
|
|
1584
|
+
expect(day.sessionIds.has("s-main")).toBe(true);
|
|
1585
|
+
expect(day.projectCost["pi-tui-extras"]).toBe(0.0053);
|
|
1586
|
+
expect(day.projectSessions["pi-tui-extras"]?.has("s-main")).toBe(true);
|
|
1587
|
+
|
|
1588
|
+
// Message counts
|
|
1589
|
+
expect(day.userMsgs).toBe(1);
|
|
1590
|
+
expect(day.asstMsgs).toBe(1);
|
|
1591
|
+
expect(day.toolResults).toBe(1);
|
|
1592
|
+
|
|
1593
|
+
// Token usage
|
|
1594
|
+
expect(day.inTok).toBe(500);
|
|
1595
|
+
expect(day.outTok).toBe(200);
|
|
1596
|
+
expect(day.crTok).toBe(50);
|
|
1597
|
+
expect(day.cwTok).toBe(10);
|
|
1598
|
+
|
|
1599
|
+
// Cost
|
|
1600
|
+
expect(day.cost).toBe(0.0053);
|
|
1601
|
+
const hour = new Date("2026-06-10T09:02:00.000Z").getHours();
|
|
1602
|
+
expect(day.hourCost[hour]).toBe(0.0053);
|
|
1603
|
+
|
|
1604
|
+
// Model and provider
|
|
1605
|
+
expect(day.modelCost["sonnet-v3"]).toBe(0.0053);
|
|
1606
|
+
expect(day.modelCount["sonnet-v3"]).toBe(1);
|
|
1607
|
+
expect(day.providerCost["anthropic"]).toBe(0.0053);
|
|
1608
|
+
expect(day.providerCount["anthropic"]).toBe(1);
|
|
1609
|
+
expect(day.modelToProvider.get("sonnet-v3")).toBe("anthropic");
|
|
1610
|
+
|
|
1611
|
+
// Tool counts
|
|
1612
|
+
expect(day.toolCount["edit"]).toBe(2); // 1 tool call + 1 tool result
|
|
1613
|
+
expect(day.toolCount["write"]).toBe(1);
|
|
1614
|
+
expect(day.toolCount["read"]).toBe(1);
|
|
1615
|
+
|
|
1616
|
+
// Language attribution
|
|
1617
|
+
expect(day.langLines["TypeScript"]).toBe(2); // edit (1) + read doesn't count
|
|
1618
|
+
expect(day.langEdits["TypeScript"]).toBe(1);
|
|
1619
|
+
expect(day.langLines["Rust"]).toBe(1);
|
|
1620
|
+
|
|
1621
|
+
// Non-cost-relevant entry types
|
|
1622
|
+
expect(day.modelChanges).toBe(1);
|
|
1623
|
+
expect(day.compactionCount).toBe(1);
|
|
1624
|
+
expect(day.compactedTokens).toBe(30000);
|
|
1625
|
+
});
|
|
1396
1626
|
});
|
package/src/parser.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { DayAgg } from "./types";
|
|
|
17
17
|
function sanitizeToolName(name: string): string {
|
|
18
18
|
// Remove any character below 0x20 (control chars) except 0x09 (\t) which
|
|
19
19
|
// we also strip, plus 0x7F (DEL) and Unicode general category Cc/Cf.
|
|
20
|
-
return name.replace(/[\x00-\
|
|
20
|
+
return name.replace(/[\x00-\x09\x0A-\x1F\x7F\u200B-\u200F\u2028-\u2029\uFEFF]/g, "");
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// Tracks session ID → project name for cost attribution
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
2
|
-
import { makeMockTUI,
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { makeMockTUI, makeTheme, testPalette } from "../components/components.fixtures";
|
|
3
|
+
import { type LangStat } from "../types";
|
|
4
|
+
import { Languages } from "./Languages";
|
|
5
5
|
|
|
6
6
|
describe("Languages", () => {
|
|
7
7
|
const mockTui = makeMockTUI();
|
|
@@ -18,7 +18,6 @@ describe("Languages", () => {
|
|
|
18
18
|
const text = lines.join("\n");
|
|
19
19
|
|
|
20
20
|
expect(lines[0]).toContain("Languages");
|
|
21
|
-
expect(lines[0]).toContain(languages.length.toString());
|
|
22
21
|
|
|
23
22
|
// Headers
|
|
24
23
|
expect(text).toContain("Name");
|
|
@@ -49,9 +48,6 @@ describe("Languages", () => {
|
|
|
49
48
|
const text = lines.join("\n");
|
|
50
49
|
|
|
51
50
|
expect(lines[0]).toContain("Languages");
|
|
52
|
-
// don't display 0 counter
|
|
53
|
-
expect(lines[0]).not.toContain("0");
|
|
54
|
-
|
|
55
51
|
expect(text).toContain("No language data for this time range");
|
|
56
52
|
});
|
|
57
53
|
|
package/src/tabs/Languages.ts
CHANGED
|
@@ -32,9 +32,13 @@ export class Languages extends Container {
|
|
|
32
32
|
* a new Languages is created whenever Dashboard.buildTabs() runs (range switch). */
|
|
33
33
|
private buildRows(): void {
|
|
34
34
|
if (this.isEmpty) return;
|
|
35
|
+
const totalLines = this.languages.reduce((sum, item) => sum + item.lines, 0);
|
|
35
36
|
const maxLines = Math.max(...this.languages.map((l) => l.lines), 0);
|
|
36
37
|
this.rows = this.languages.map((l) => {
|
|
37
|
-
|
|
38
|
+
let barPct = 0;
|
|
39
|
+
if (totalLines > 0) {
|
|
40
|
+
barPct = maxLines > 0 ? (l.lines / maxLines) * 100 : 0;
|
|
41
|
+
}
|
|
38
42
|
return [
|
|
39
43
|
cell.marquee(l.language, this.tui),
|
|
40
44
|
cell.bar(barPct, this.palette.getColor(l.language), "transparent"),
|
|
@@ -56,8 +60,8 @@ export class Languages extends Container {
|
|
|
56
60
|
const bb = new BorderBox({
|
|
57
61
|
...baseBorderBoxOptions,
|
|
58
62
|
titles: [
|
|
59
|
-
{ text: "Languages", align: "left" },
|
|
60
|
-
{ text: this.theme.fg("
|
|
63
|
+
{ text: this.theme.bold("Languages"), align: "left" },
|
|
64
|
+
{ text: this.theme.fg("muted", "by lines written"), align: "right" },
|
|
61
65
|
],
|
|
62
66
|
});
|
|
63
67
|
|