@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.
Files changed (44) hide show
  1. package/README.md +96 -19
  2. package/bunfig.toml +37 -0
  3. package/media/screenshot.png +0 -0
  4. package/package.json +4 -3
  5. package/src/__tests__/e2e.test.ts +3 -3
  6. package/src/{__tests__/cache.test.ts → cache.test.ts} +311 -10
  7. package/src/cache.ts +36 -3
  8. package/src/components/{__tests__/BarChart.test.ts → BarChart.test.ts} +9 -9
  9. package/src/components/{__tests__/Dashboard.test.ts → Dashboard.test.ts} +4 -4
  10. package/src/components/Dashboard.ts +2 -1
  11. package/src/components/{__tests__/KpiCards.test.ts → KpiCards.test.ts} +5 -5
  12. package/src/components/KpiCards.ts +1 -1
  13. package/src/components/LoadingView.test.ts +116 -0
  14. package/src/components/LoadingView.ts +87 -25
  15. package/src/components/{__tests__/MarqueeText.test.ts → MarqueeText.test.ts} +2 -2
  16. package/src/components/{__tests__/RangeSelector.test.ts → RangeSelector.test.ts} +2 -2
  17. package/src/components/{__tests__/RankedBarList.test.ts → RankedBarList.test.ts} +2 -2
  18. package/src/components/{__tests__/SortedTable.test.ts → SortedTable.test.ts} +3 -4
  19. package/src/components/{__tests__/TabBar.test.ts → TabBar.test.ts} +2 -2
  20. package/src/components/__tests__/SortedTable.integration.test.ts +5 -8
  21. package/src/components/{__tests__/cells.test.ts → cells.test.ts} +2 -2
  22. package/src/{__tests__ → components}/components.fixtures.ts +1 -1
  23. package/src/components/shared/Bar.ts +10 -2
  24. package/src/{__tests__/compute.fixtures.ts → compute.fixtures.ts} +6 -1
  25. package/src/{__tests__/compute.test.ts → compute.test.ts} +135 -3
  26. package/src/compute.ts +24 -4
  27. package/src/{__tests__/format.test.ts → format.test.ts} +173 -31
  28. package/src/format.ts +20 -7
  29. package/src/index.ts +23 -20
  30. package/src/{__tests__/parser.test.ts → parser.test.ts} +339 -109
  31. package/src/parser.ts +1 -1
  32. package/src/tabs/{__tests__/Languages.test.ts → Languages.test.ts} +3 -7
  33. package/src/tabs/Languages.ts +7 -3
  34. package/src/tabs/{__tests__/Models.test.ts → Models.test.ts} +3 -6
  35. package/src/tabs/Models.ts +2 -4
  36. package/src/tabs/{__tests__/Overview.test.ts → Overview.test.ts} +18 -15
  37. package/src/tabs/Overview.ts +50 -39
  38. package/src/tabs/{__tests__/Projects.test.ts → Projects.test.ts} +5 -8
  39. package/src/tabs/Projects.ts +9 -4
  40. package/src/tabs/{__tests__/Usage.test.ts → Usage.test.ts} +8 -18
  41. package/src/tabs/Usage.ts +7 -3
  42. package/src/types.ts +11 -0
  43. package/src/components/__tests__/LoadingView.test.ts +0 -26
  44. /package/src/components/{__tests__ → shared}/Bar.test.ts +0 -0
@@ -1,14 +1,26 @@
1
- import { mkdir, rm, writeFile } from "node:fs/promises";
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 "../parser";
20
- import type { DayAgg } from "../types";
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 missing usage gracefully", () => {
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/Work/dev/my-cool-project",
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
- assert(dayAgg.projectSessions["my-cool-project"]);
610
- expect(dayAgg.projectSessions["my-cool-project"].has("s1")).toBe(true);
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 missing usage gracefully", () => {
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("handles assistant message with model but no cost", () => {
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-\x08\x0A-\x1F\x7F\u200B-\u200F\u2028-\u2029\uFEFF]/g, "");
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, testPalette, makeTheme } from "../../__tests__/components.fixtures";
3
- import { Languages } from "../Languages";
4
- import { type LangStat } from "../../types";
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
 
@@ -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
- const barPct = maxLines > 0 ? (l.lines / maxLines) * 100 : 0;
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("dim", formatNumber(this.languages.length)), align: "right" },
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