@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.
- package/README.md +29 -26
- 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/{__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,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { makeTheme } from "
|
|
3
|
-
import { KpiCards } from "
|
|
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("
|
|
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.
|
|
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.
|
|
59
|
+
expect(lines.join("\n")).toContain("$5.43k");
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
it("formats very large costs with M notation", () => {
|
|
@@ -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 {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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:
|
|
57
|
+
setProgress(p: LoadingProgress): void {
|
|
16
58
|
this.progress = p;
|
|
17
59
|
this.invalidate();
|
|
18
60
|
}
|
|
19
61
|
|
|
20
|
-
render(width: number): string[] {
|
|
21
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
85
|
+
return super.render(width);
|
|
86
|
+
}
|
|
28
87
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
this.
|
|
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 "
|
|
3
|
-
import { MarqueeText } from "
|
|
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 "
|
|
3
|
-
import { type RangeOption, RangeSelector } from "
|
|
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 "
|
|
4
|
-
import { RankedBarList } from "
|
|
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 {
|
|
3
|
-
|
|
4
|
-
import {
|
|
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 "
|
|
3
|
-
import { TabBar } from "
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
import {
|
|
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 {
|
|
4
|
-
import {
|
|
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
|
|
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(
|
|
20
|
-
(emptyStyle !== "transparent"
|
|
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 "
|
|
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 "
|
|
3
|
-
import { dateFromISOString } from "
|
|
4
|
-
import { emptyDay, mergeDay } from "
|
|
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
|
-
|
|
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
|
};
|