@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,7 +1,7 @@
|
|
|
1
|
-
import { describe,
|
|
2
|
-
import { makeTheme } from "
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { makeTheme } from "./components.fixtures";
|
|
3
|
+
import type { HourSpend } from "../types";
|
|
4
|
+
import { BarChart } from "./BarChart";
|
|
5
5
|
|
|
6
6
|
describe("BarChart", () => {
|
|
7
7
|
const dailySpend = [
|
|
@@ -104,8 +104,8 @@ describe("BarChart", () => {
|
|
|
104
104
|
const lines = chart.render(80);
|
|
105
105
|
const text = lines.join("\n");
|
|
106
106
|
// Y-axis has $ labels
|
|
107
|
-
expect(text).toContain("$0
|
|
108
|
-
expect(text).toContain("$3
|
|
107
|
+
expect(text).toContain("$0");
|
|
108
|
+
expect(text).toContain("$3");
|
|
109
109
|
// Y-axis separator present
|
|
110
110
|
expect(text).toContain("│");
|
|
111
111
|
});
|
|
@@ -126,9 +126,9 @@ describe("BarChart", () => {
|
|
|
126
126
|
const chart = new BarChart(dailySpend, "7d", 12, makeTheme());
|
|
127
127
|
const lines = chart.render(80);
|
|
128
128
|
const barLines = lines.slice(1, -2); // exclude granularity + x-axis label
|
|
129
|
-
// At max cost $3
|
|
129
|
+
// At max cost $3, row 8 (80% height) should be $2.4, top row 9 should not have label
|
|
130
130
|
// But bottom row 0 always has label
|
|
131
|
-
expect(barLines[barLines.length - 1]).toContain("$0
|
|
131
|
+
expect(barLines[barLines.length - 1]).toContain("$0");
|
|
132
132
|
expect(barLines[0]).toMatch(/\$\d/); // top row no label
|
|
133
133
|
});
|
|
134
134
|
|
|
@@ -198,7 +198,7 @@ describe("BarChart", () => {
|
|
|
198
198
|
expect(text).toContain("12h");
|
|
199
199
|
expect(text).toContain("23h");
|
|
200
200
|
// Should show cost on y-axis
|
|
201
|
-
expect(text).toContain("$2.
|
|
201
|
+
expect(text).toContain("$2.5");
|
|
202
202
|
expect(text).toContain("Hourly");
|
|
203
203
|
});
|
|
204
204
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { makeMockTUI, makeRangeSelector, makeTheme } from "
|
|
3
|
-
import { makeSummary } from "
|
|
4
|
-
import type { StatsSummary, TimeRange } from "
|
|
5
|
-
import { Dashboard } from "
|
|
2
|
+
import { makeMockTUI, makeRangeSelector, makeTheme } from "./components.fixtures";
|
|
3
|
+
import { makeSummary } from "../compute.fixtures";
|
|
4
|
+
import type { StatsSummary, TimeRange } from "../types";
|
|
5
|
+
import { Dashboard } from "./Dashboard";
|
|
6
6
|
|
|
7
7
|
const mockTui = makeMockTUI();
|
|
8
8
|
export const allRanges: TimeRange[] = ["1d", "7d", "30d", "All"];
|
|
@@ -9,6 +9,7 @@ import { Overview } from "../tabs/Overview";
|
|
|
9
9
|
import { Projects } from "../tabs/Projects";
|
|
10
10
|
import { Usage } from "../tabs/Usage";
|
|
11
11
|
import type { StatsSummary, TimeRange } from "../types";
|
|
12
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
12
13
|
import { RangeSelector } from "./RangeSelector";
|
|
13
14
|
import { TabBar } from "./TabBar";
|
|
14
15
|
|
|
@@ -49,7 +50,7 @@ export class Dashboard extends BorderBox {
|
|
|
49
50
|
|
|
50
51
|
super({
|
|
51
52
|
titles: [
|
|
52
|
-
|
|
53
|
+
{ text: theme.bold("Pi Atlas") + theme.fg("dim", ` · v${pkg.version}`), align: "left" },
|
|
53
54
|
rangeLabelTitle,
|
|
54
55
|
],
|
|
55
56
|
footers,
|
|
@@ -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
|
});
|