@mohndoe/pi-atlas 0.1.2 → 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 (43) hide show
  1. package/README.md +29 -26
  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/{__tests__/KpiCards.test.ts → KpiCards.test.ts} +5 -5
  11. package/src/components/KpiCards.ts +1 -1
  12. package/src/components/LoadingView.test.ts +116 -0
  13. package/src/components/LoadingView.ts +87 -25
  14. package/src/components/{__tests__/MarqueeText.test.ts → MarqueeText.test.ts} +2 -2
  15. package/src/components/{__tests__/RangeSelector.test.ts → RangeSelector.test.ts} +2 -2
  16. package/src/components/{__tests__/RankedBarList.test.ts → RankedBarList.test.ts} +2 -2
  17. package/src/components/{__tests__/SortedTable.test.ts → SortedTable.test.ts} +3 -4
  18. package/src/components/{__tests__/TabBar.test.ts → TabBar.test.ts} +2 -2
  19. package/src/components/__tests__/SortedTable.integration.test.ts +5 -8
  20. package/src/components/{__tests__/cells.test.ts → cells.test.ts} +2 -2
  21. package/src/{__tests__ → components}/components.fixtures.ts +1 -1
  22. package/src/components/shared/Bar.ts +10 -2
  23. package/src/{__tests__/compute.fixtures.ts → compute.fixtures.ts} +6 -1
  24. package/src/{__tests__/compute.test.ts → compute.test.ts} +135 -3
  25. package/src/compute.ts +24 -4
  26. package/src/{__tests__/format.test.ts → format.test.ts} +173 -31
  27. package/src/format.ts +20 -7
  28. package/src/index.ts +23 -20
  29. package/src/{__tests__/parser.test.ts → parser.test.ts} +339 -109
  30. package/src/parser.ts +1 -1
  31. package/src/tabs/{__tests__/Languages.test.ts → Languages.test.ts} +3 -7
  32. package/src/tabs/Languages.ts +7 -3
  33. package/src/tabs/{__tests__/Models.test.ts → Models.test.ts} +3 -6
  34. package/src/tabs/Models.ts +2 -4
  35. package/src/tabs/{__tests__/Overview.test.ts → Overview.test.ts} +18 -15
  36. package/src/tabs/Overview.ts +50 -39
  37. package/src/tabs/{__tests__/Projects.test.ts → Projects.test.ts} +5 -8
  38. package/src/tabs/Projects.ts +9 -4
  39. package/src/tabs/{__tests__/Usage.test.ts → Usage.test.ts} +8 -18
  40. package/src/tabs/Usage.ts +7 -3
  41. package/src/types.ts +11 -0
  42. package/src/components/__tests__/LoadingView.test.ts +0 -26
  43. /package/src/components/{__tests__ → shared}/Bar.test.ts +0 -0
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { makeTheme } from "../../__tests__/components.fixtures";
3
- import { KpiCards } from "../KpiCards";
2
+ import { makeTheme } from "./components.fixtures";
3
+ import { KpiCards } from "./KpiCards";
4
4
 
5
5
  describe("KpiCards", () => {
6
6
  const kpis = {
@@ -22,7 +22,7 @@ describe("KpiCards", () => {
22
22
  expect(text).toContain("12.34");
23
23
  expect(text).toContain("42");
24
24
  expect(text).toContain("1.5k");
25
- expect(text).toContain("250.0k");
25
+ expect(text).toContain("250k");
26
26
  expect(text).toContain("7");
27
27
  expect(text).toContain("1.76");
28
28
  });
@@ -50,13 +50,13 @@ describe("KpiCards", () => {
50
50
  it("formats large token numbers", () => {
51
51
  const cards = new KpiCards({ ...kpis, totalTokens: 1500000 }, makeTheme());
52
52
  const lines = cards.render(80);
53
- expect(lines.join("\n")).toContain("1.50M");
53
+ expect(lines.join("\n")).toContain("1.5M");
54
54
  });
55
55
 
56
56
  it("formats large costs with compact notation", () => {
57
57
  const cards = new KpiCards({ ...kpis, totalCost: 5432.1 }, makeTheme());
58
58
  const lines = cards.render(80);
59
- expect(lines.join("\n")).toContain("$5.4k");
59
+ expect(lines.join("\n")).toContain("$5.43k");
60
60
  });
61
61
 
62
62
  it("formats very large costs with M notation", () => {
@@ -20,7 +20,7 @@ export class KpiCards implements Component {
20
20
  constructor(kpis: KpiData, theme: Theme) {
21
21
  this.theme = theme;
22
22
 
23
- const colPcts = [16, 16, 16, 16, 16, 17];
23
+ const colPcts = [17, 17, 17, 17, 17, 15];
24
24
 
25
25
  const totalCostCard = new StatCard(
26
26
  {
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { makeTheme } from "./components.fixtures";
3
+ import { LoadingView } from "./LoadingView";
4
+
5
+ const theme = makeTheme();
6
+
7
+ describe("LoadingView", () => {
8
+ it("renders with 0% progress", () => {
9
+ const lv = new LoadingView("Parsing session logs...", theme);
10
+ lv.setProgress({ total: 100, done: 0, pct: 0 });
11
+ const lines = lv.render(80);
12
+ expect(lines.join("\n")).toContain("Parsing session logs...");
13
+ expect(lines.join("\n")).toContain("0/100");
14
+ });
15
+
16
+ it("updates progress", () => {
17
+ const lv = new LoadingView("Parsing session logs...", theme);
18
+ lv.setProgress({ total: 20, done: 10, pct: 50 });
19
+ const lines = lv.render(80);
20
+ expect(lines.join("\n")).toContain("10/20");
21
+ });
22
+
23
+ it("renders progress bar with block chars", () => {
24
+ const lv = new LoadingView("Parsing session logs...", theme);
25
+ lv.setProgress({ total: 20, done: 15, pct: 75 });
26
+ const lines = lv.render(80);
27
+ expect(lines.join("\n")).toContain("15/20");
28
+ expect(lines.join("\n")).toContain("█");
29
+ });
30
+
31
+ it("does not show remaining time when not provided", () => {
32
+ const lv = new LoadingView("Parsing session logs...", theme);
33
+ lv.setProgress({ total: 20, done: 0, pct: 0 });
34
+ const lines = lv.render(80);
35
+ // Progress line is the second content line (index 2); title also contains · so check specific line
36
+ expect(lines[2]).toContain("0/20");
37
+ expect(lines[2]).not.toContain("·");
38
+ });
39
+
40
+ it("shows remaining time in seconds", () => {
41
+ const lv = new LoadingView("Parsing session logs...", theme);
42
+ lv.setProgress({ total: 20, done: 5, pct: 25, remainingTimeMs: 5210 });
43
+ const lines = lv.render(80);
44
+ expect(lines[2]).toContain("~5.21s remaining · 5/20");
45
+ });
46
+
47
+ it("shows remaining time in minutes and seconds", () => {
48
+ const lv = new LoadingView("Parsing session logs...", theme);
49
+ lv.setProgress({ total: 20, done: 5, pct: 25, remainingTimeMs: 90000 });
50
+ const lines = lv.render(80);
51
+ expect(lines[2]).toContain("~1m 30s remaining · 5/20");
52
+ });
53
+
54
+ it("shows remaining time in ms when under 1s", () => {
55
+ const lv = new LoadingView("Parsing session logs...", theme);
56
+ lv.setProgress({ total: 20, done: 19, pct: 95, remainingTimeMs: 500 });
57
+ const lines = lv.render(80);
58
+ expect(lines[2]).toContain("~0.5s remaining · 19/20");
59
+ });
60
+
61
+ it("shows 0ms remaining when done", () => {
62
+ const lv = new LoadingView("Parsing session logs...", theme);
63
+ lv.setProgress({ total: 20, done: 20, pct: 100, remainingTimeMs: 0 });
64
+ const lines = lv.render(80);
65
+ expect(lines[2]).toContain("~0s remaining · 20/20");
66
+ });
67
+
68
+ describe("handleInput", () => {
69
+ it("calls onClose on Escape key", () => {
70
+ let called = false;
71
+ const lv = new LoadingView("test", theme, () => {
72
+ called = true;
73
+ });
74
+ lv.handleInput("\x1b");
75
+ expect(called).toBe(true);
76
+ });
77
+
78
+ it("calls onClose on 'q'", () => {
79
+ let called = false;
80
+ const lv = new LoadingView("test", theme, () => {
81
+ called = true;
82
+ });
83
+ lv.handleInput("q");
84
+ expect(called).toBe(true);
85
+ });
86
+
87
+ it("calls onClose on 'Q'", () => {
88
+ let called = false;
89
+ const lv = new LoadingView("test", theme, () => {
90
+ called = true;
91
+ });
92
+ lv.handleInput("Q");
93
+ expect(called).toBe(true);
94
+ });
95
+
96
+ it("does not call onClose on other keys", () => {
97
+ let called = false;
98
+ const lv = new LoadingView("test", theme, () => {
99
+ called = true;
100
+ });
101
+ lv.handleInput("a");
102
+ lv.handleInput("Enter");
103
+ lv.handleInput("\x1b[C"); // right arrow
104
+ expect(called).toBe(false);
105
+ });
106
+
107
+ it("does not throw when onClose is null", () => {
108
+ const lv = new LoadingView("test", theme, null);
109
+ expect(() => {
110
+ lv.handleInput("\x1b");
111
+ lv.handleInput("q");
112
+ lv.handleInput("Q");
113
+ }).not.toThrow();
114
+ });
115
+ });
116
+ });
@@ -1,38 +1,100 @@
1
- import { type Component } from "@earendil-works/pi-tui";
2
-
3
- export class LoadingView implements Component {
4
- private progress = 0;
5
- private message: string;
6
- private cachedLines: string[] | null = null;
7
- private cachedWidth = -1;
8
- private tui: { requestRender: () => void } | null;
9
-
10
- constructor(message = "Parsing session logs...", tui?: { requestRender: () => void }) {
11
- this.message = message;
12
- this.tui = tui ?? null;
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey, Spacer, Text } from "@earendil-works/pi-tui";
3
+ import { BorderBox } from "@mohndoe/pi-tui-extras";
4
+ import { alignInWidthLR } from "@mohndoe/pi-tui-extras/src/core/align";
5
+ import pkg from "../../package.json" with { type: "json" };
6
+ import type { LoadingProgress } from "../cache";
7
+ import { renderBar } from "./shared/Bar";
8
+
9
+ function formatRemainingTime(ms: number): string {
10
+ if (ms < 60000)
11
+ return `~${new Intl.NumberFormat("en-US", {
12
+ minimumFractionDigits: 0,
13
+ maximumFractionDigits: 2,
14
+ style: "decimal",
15
+ }).format(ms / 1000)}s`;
16
+ const m = Math.floor(ms / 60000);
17
+ const s = Math.round((ms % 60000) / 1000);
18
+ return `~${m}m ${s}s`;
19
+ }
20
+
21
+ export class LoadingView extends BorderBox {
22
+ private progress: LoadingProgress = {
23
+ pct: 0,
24
+ total: 0,
25
+ done: 0,
26
+ };
27
+ private bar: Text;
28
+ private loadingText: Text;
29
+
30
+ constructor(
31
+ private message = "Parsing session logs...",
32
+ private theme: Theme,
33
+ private onClose: (() => void) | null = null,
34
+ ) {
35
+ super({
36
+ titles: [
37
+ { text: theme.bold("Pi Atlas") + theme.fg("dim", ` · v${pkg.version}`), align: "left" },
38
+ ],
39
+ padding: {
40
+ top: 1,
41
+ bottom: 0,
42
+ left: 1,
43
+ right: 1,
44
+ },
45
+ });
46
+
47
+ this.bar = new Text("", 0, 0);
48
+ this.loadingText = new Text(this.theme.fg("text", message), 0, 0);
49
+
50
+ this.addChild(this.loadingText);
51
+ this.addChild(this.bar);
52
+
53
+ this.addChild(new Spacer(1));
54
+ this.addChild(new Text(this.theme.fg("dim", "Esc/q to cancel and close"), 0, 0));
13
55
  }
14
56
 
15
- setProgress(p: number): void {
57
+ setProgress(p: LoadingProgress): void {
16
58
  this.progress = p;
17
59
  this.invalidate();
18
60
  }
19
61
 
20
- render(width: number): string[] {
21
- if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
62
+ override render(width: number): string[] {
63
+ const innerWidth = width - 2 - 2;
64
+ this.bar.setText(
65
+ renderBar(
66
+ innerWidth,
67
+ this.progress.pct,
68
+ (s) => this.theme.bold(this.theme.fg("success", s)),
69
+ (s) => this.theme.fg("muted", s),
70
+ "█",
71
+ "░",
72
+ ),
73
+ );
22
74
 
23
- const barW = Math.min(40, width - 10);
24
- const filled = Math.round((this.progress / 100) * barW);
25
- const bar = "".repeat(filled) + "░".repeat(barW - filled);
75
+ const remainingStr =
76
+ this.progress.remainingTimeMs != null
77
+ ? this.theme.fg("dim", formatRemainingTime(this.progress.remainingTimeMs) + " remaining · ")
78
+ : "";
79
+ const loadingRightText =
80
+ remainingStr + this.progress.done + this.theme.fg("dim", "/" + this.progress.total);
81
+ this.loadingText.setText(
82
+ alignInWidthLR(this.theme.fg("text", this.message), loadingRightText, innerWidth),
83
+ );
26
84
 
27
- const lines = ["", ` ${this.message}`, ` [${bar}] ${this.progress}%`, ""];
85
+ return super.render(width);
86
+ }
28
87
 
29
- this.cachedLines = lines;
30
- this.cachedWidth = width;
31
- return lines;
88
+ override handleInput(data: string): void {
89
+ if (matchesKey(data, "escape") || data === "q" || data === "Q") {
90
+ this.onClose?.();
91
+ return;
92
+ }
32
93
  }
33
94
 
34
- invalidate(): void {
35
- this.cachedLines = null;
36
- this.cachedWidth = -1;
95
+ override invalidate(): void {
96
+ super.invalidate();
97
+ this.bar.invalidate();
98
+ this.loadingText.invalidate();
37
99
  }
38
100
  }
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
2
- import { makeMockTUI } from "../../__tests__/components.fixtures";
3
- import { MarqueeText } from "../MarqueeText";
2
+ import { makeMockTUI } from "./components.fixtures";
3
+ import { MarqueeText } from "./MarqueeText";
4
4
 
5
5
  describe("MarqueeText", () => {
6
6
  let tui: ReturnType<typeof makeMockTUI>;
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { makeTheme } from "../../__tests__/components.fixtures";
3
- import { type RangeOption, RangeSelector } from "../RangeSelector";
2
+ import { makeTheme } from "./components.fixtures";
3
+ import { type RangeOption, RangeSelector } from "./RangeSelector";
4
4
 
5
5
  describe("RangeSelector", () => {
6
6
  const ranges: RangeOption[] = [
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import chalk from "chalk";
3
- import { makeTheme } from "../../__tests__/components.fixtures";
4
- import { RankedBarList } from "../RankedBarList";
3
+ import { makeTheme } from "./components.fixtures";
4
+ import { RankedBarList } from "./RankedBarList";
5
5
 
6
6
  describe("RankedBarList", () => {
7
7
  it("renders a single item with 100% bar width", () => {
@@ -1,8 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
2
- import { makeMockTUI, makeTheme } from "../../__tests__/components.fixtures";
3
-
4
- import { cell } from "../cells";
5
- import { type ColumnDef, SortedTable } from "../SortedTable";
2
+ import { cell } from "./cells";
3
+ import { makeMockTUI, makeTheme } from "./components.fixtures";
4
+ import { type ColumnDef, SortedTable } from "./SortedTable";
6
5
 
7
6
  const CURSOR = SortedTable.DEFAULT_CURSOR_CHAR;
8
7
  const mockTui = makeMockTUI();
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { makeTheme } from "../../__tests__/components.fixtures";
3
- import { TabBar } from "../TabBar";
2
+ import { makeTheme } from "./components.fixtures";
3
+ import { TabBar } from "./TabBar";
4
4
 
5
5
  describe("TabBar", () => {
6
6
  const tabs = ["Overview", "Languages", "Models", "Projects + Tools"];
@@ -1,13 +1,10 @@
1
- /**
2
- * Integration test: Dashboard → Models → SortedTable keyboard interaction.
3
- */
4
- import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
5
- import { makeMockTUI, makeRangeSelector, makeTheme } from "../../__tests__/components.fixtures";
6
- import { makeSummary } from "../../__tests__/compute.fixtures";
1
+ import { describe, expect, it } from "bun:test";
2
+ import { makeSummary } from "../../compute.fixtures";
3
+ import type { StatsSummary, TimeRange } from "../../types";
4
+ import { makeMockTUI, makeRangeSelector, makeTheme } from "../components.fixtures";
7
5
  import { Dashboard } from "../Dashboard";
6
+ import { allRanges, mapAllSummaries } from "../Dashboard.test";
8
7
  import { SortedTable } from "../SortedTable";
9
- import { allRanges, mapAllSummaries } from "./Dashboard.test";
10
- import type { StatsSummary, TimeRange } from "../../types";
11
8
 
12
9
  const CURSOR = SortedTable.DEFAULT_CURSOR_CHAR;
13
10
  const mockTui = makeMockTUI();
@@ -1,7 +1,7 @@
1
1
  import { truncateToWidth } from "@earendil-works/pi-tui";
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
3
- import { makeMockTUI } from "../../__tests__/components.fixtures";
4
- import { cell } from "../cells";
3
+ import { cell } from "./cells";
4
+ import { makeMockTUI } from "./components.fixtures";
5
5
 
6
6
  describe("cell.text", () => {
7
7
  it("renders content truncated to width", () => {
@@ -1,7 +1,7 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import type { TUI } from "@earendil-works/pi-tui";
3
3
  import { ColorPalette } from "../colorPalette";
4
- import { type RangeOption, RangeSelector } from "../components/RangeSelector";
4
+ import { RangeSelector, type RangeOption } from "./RangeSelector";
5
5
 
6
6
  /**
7
7
  * Pass-through mock theme for tests. All styling methods return text unchanged.
@@ -1,3 +1,5 @@
1
+ export const BAR_DEFAULT_FILLED_CHAR = "■";
2
+ export const BAR_DEFAULT_EMPTY_CHAR = "■";
1
3
  /**
2
4
  * Render a horizontal bar using ■ characters.
3
5
  *
@@ -5,18 +7,24 @@
5
7
  * @param fillPct Fill percentage (0–100, clamped)
6
8
  * @param filledStyle Styling function for the filled portion (e.g. chalk.green)
7
9
  * @param emptyStyle Styling function for the empty/unfilled portion (e.g. s => theme.fg("dim", s))
10
+ * @param filledChar Character to repeat for the part that's filled
11
+ * @param emptyChar Character to repeat for the part that's empty
8
12
  */
9
13
  export function renderBar(
10
14
  width: number,
11
15
  fillPct: number,
12
16
  filledStyle: (text: string) => string,
13
17
  emptyStyle: "transparent" | ((text: string) => string),
18
+ filledChar: string = BAR_DEFAULT_FILLED_CHAR,
19
+ emptyChar: string = BAR_DEFAULT_EMPTY_CHAR,
14
20
  ): string {
15
21
  const clamped = Math.max(0, Math.min(100, fillPct));
16
22
  const filledCount = Math.round((clamped / 100) * Math.max(0, width));
17
23
  const emptyCount = Math.max(0, width - filledCount);
18
24
  return (
19
- filledStyle("■".repeat(filledCount)) +
20
- (emptyStyle !== "transparent" ? emptyStyle("■".repeat(emptyCount)) : " ".repeat(emptyCount))
25
+ filledStyle(filledChar.repeat(filledCount)) +
26
+ (emptyStyle !== "transparent"
27
+ ? emptyStyle(emptyChar.repeat(emptyCount))
28
+ : " ".repeat(emptyCount))
21
29
  );
22
30
  }
@@ -1,4 +1,4 @@
1
- import { type StatsSummary } from "../types";
1
+ import { type StatsSummary } from "./types";
2
2
 
3
3
  export const makeSummary = (): StatsSummary => ({
4
4
  totalCost: 5.0,
@@ -40,6 +40,11 @@ export const makeSummary = (): StatsSummary => ({
40
40
  name: "bash",
41
41
  },
42
42
  ],
43
+ providers: [],
44
+ compactionCount: 0,
45
+ compactedTokens: 0,
46
+ modelChanges: 0,
47
+ thinkingLevelCount: {},
43
48
  dailySpend: [
44
49
  { date: "2026-06-06", cost: 1.0 },
45
50
  { date: "2026-06-07", cost: 2.0 },
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { summarize } from "../compute";
3
- import { dateFromISOString } from "../format";
4
- import { emptyDay, mergeDay } from "../parser";
2
+ import { summarize } from "./compute";
3
+ import { dateFromISOString } from "./format";
4
+ import { emptyDay, mergeDay } from "./parser";
5
5
 
6
6
  describe("summarize", () => {
7
7
  it("returns zeros for empty day list", () => {
@@ -323,6 +323,127 @@ describe("summarize", () => {
323
323
  expect(s.hourlySpend[23]!.cost).toBe(0);
324
324
  });
325
325
 
326
+ it("returns providers sorted by cost descending", () => {
327
+ const d = emptyDay("2026-06-08");
328
+ mergeDay(d, {
329
+ ...emptyDay(""),
330
+ providerCost: { anthropic: 5.0, openai: 1.0, free: 0 },
331
+ providerCount: { anthropic: 15, openai: 5, free: 100 },
332
+ });
333
+ const days = [d];
334
+
335
+ const s = summarize(days, "All");
336
+ expect(s.providers).toEqual([
337
+ { provider: "anthropic", cost: 5.0, calls: 15 },
338
+ { provider: "openai", cost: 1.0, calls: 5 },
339
+ { provider: "free", cost: 0, calls: 100 },
340
+ ]);
341
+ });
342
+
343
+ it("surfaces entry-type fields (compaction, modelChanges, thinkingLevel)", () => {
344
+ const d = emptyDay("2026-06-08");
345
+ mergeDay(d, {
346
+ ...emptyDay(""),
347
+ compactionCount: 2,
348
+ compactedTokens: 15000,
349
+ modelChanges: 3,
350
+ thinkingLevelCount: { low: 1, high: 2 },
351
+ });
352
+ const days = [d];
353
+
354
+ const s = summarize(days, "All");
355
+ expect(s.compactionCount).toBe(2);
356
+ expect(s.compactedTokens).toBe(15000);
357
+ expect(s.modelChanges).toBe(3);
358
+ expect(s.thinkingLevelCount).toEqual({ low: 1, high: 2 });
359
+ });
360
+
361
+ it("attaches provider to model stats from modelToProvider", () => {
362
+ const d = emptyDay("2026-06-08");
363
+ mergeDay(d, {
364
+ ...emptyDay(""),
365
+ modelCost: { sonnet: 2.0, haiku: 0.5 },
366
+ modelCount: { sonnet: 5, haiku: 2 },
367
+ modelToProvider: new Map([
368
+ ["sonnet", "anthropic"],
369
+ ["haiku", "anthropic"],
370
+ ]),
371
+ });
372
+ const days = [d];
373
+
374
+ const s = summarize(days, "All");
375
+ expect(s.models).toHaveLength(2);
376
+ expect(s.models.find((m) => m.model === "sonnet")?.provider).toBe("anthropic");
377
+ expect(s.models.find((m) => m.model === "haiku")?.provider).toBe("anthropic");
378
+ });
379
+
380
+ it("deduplicates session IDs across days", () => {
381
+ const d1 = emptyDay("2026-06-01");
382
+ d1.cost = 1;
383
+ d1.sessionIds = new Set(["shared-session"]);
384
+ const d2 = emptyDay("2026-06-02");
385
+ d2.cost = 2;
386
+ d2.sessionIds = new Set(["shared-session"]);
387
+ const d3 = emptyDay("2026-06-03");
388
+ d3.cost = 3;
389
+ d3.sessionIds = new Set(["unique-session"]);
390
+ const days = [d1, d2, d3];
391
+
392
+ const s = summarize(days, "All");
393
+ expect(s.sessionCount).toBe(2);
394
+ expect(s.totalCost).toBe(6);
395
+ expect(s.daysActive).toBe(3);
396
+ });
397
+
398
+ it("accumulates project stats across days", () => {
399
+ const d1 = emptyDay("2026-06-01");
400
+ d1.cost = 10;
401
+ d1.sessionIds = new Set(["s1"]);
402
+ d1.projectCost = { pi: 10 };
403
+ d1.projectSessions = { pi: new Set(["s1"]) };
404
+
405
+ const d2 = emptyDay("2026-06-02");
406
+ d2.cost = 5;
407
+ d2.sessionIds = new Set(["s2"]);
408
+ d2.projectCost = { pi: 5, other: 5 };
409
+ d2.projectSessions = { pi: new Set(["s2"]), other: new Set(["s2"]) };
410
+
411
+ const s = summarize([d1, d2], "All");
412
+ expect(s.projects).toHaveLength(2);
413
+ // pi: cost=15, sessions=2; other: cost=5, sessions=1
414
+ // sorted by cost desc
415
+ expect(s.projects[0]).toEqual({ project: "pi", cost: 15, sessions: 2 });
416
+ expect(s.projects[1]).toEqual({ project: "other", cost: 5, sessions: 1 });
417
+ });
418
+
419
+ it("excludes days with zero sessions from daysActive", () => {
420
+ const d1 = emptyDay("2026-06-01");
421
+ d1.cost = 10;
422
+ d1.sessionIds = new Set(["s1"]);
423
+ const d2 = emptyDay("2026-06-02");
424
+ d2.cost = 5;
425
+ d2.sessionIds = new Set(["s2"]);
426
+ const d3 = emptyDay("2026-06-03");
427
+ d3.cost = 20;
428
+ d3.sessionIds = new Set(); // no sessions
429
+
430
+ const s = summarize([d1, d2, d3], "All");
431
+ expect(s.daysActive).toBe(2);
432
+ expect(s.totalCost).toBe(35); // d3 still counts toward total
433
+ expect(s.avgCostPerDay).toBeCloseTo(17.5); // 35 / 2
434
+ });
435
+
436
+ it("fillDailySpend returns single entry for single day in bounded range", () => {
437
+ const today = dateFromISOString(new Date().toISOString());
438
+ const d = emptyDay(today);
439
+ d.cost = 5;
440
+ d.sessionIds = new Set(["s1"]);
441
+
442
+ const s = summarize([d], "1d");
443
+ expect(s.dailySpend).toHaveLength(1);
444
+ expect(s.dailySpend[0]).toEqual({ date: today, cost: 5 });
445
+ });
446
+
326
447
  it("hourlySpend is empty for 7d, 30d, All ranges", () => {
327
448
  const d = emptyDay("2026-06-01");
328
449
  d.cost = 5;
@@ -333,4 +454,15 @@ describe("summarize", () => {
333
454
  expect(summarize(days, "30d").hourlySpend).toEqual([]);
334
455
  expect(summarize(days, "All").hourlySpend).toEqual([]);
335
456
  });
457
+
458
+ it("hourlySpend is empty when 1d range has no matching days", () => {
459
+ // Date is in the past, not matching today
460
+ const d = emptyDay("2026-06-01");
461
+ d.cost = 5;
462
+ d.sessionIds = new Set(["s1"]);
463
+
464
+ const s = summarize([d], "1d");
465
+ expect(s.hourlySpend).toEqual([]);
466
+ expect(s.dailySpend).toEqual([]);
467
+ });
336
468
  });
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
- modelToProvider = new Map([
114
- ...(modelToProvider.size > 0 ? modelToProvider.entries() : []),
115
- ...(day.modelToProvider.size > 0 ? day.modelToProvider.entries() : []),
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
  };