@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,7 +1,7 @@
1
- import { describe, it, expect } from "bun:test";
2
- import { makeTheme } from "../../__tests__/components.fixtures";
3
- import { BarChart } from "../BarChart";
4
- import type { HourSpend } from "../../types";
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.00");
108
- expect(text).toContain("$3.00");
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.00, row 8 (80% height) should be $2.40, top row 9 should not have label
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.00");
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.50");
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 "../../__tests__/components.fixtures";
3
- import { makeSummary } from "../../__tests__/compute.fixtures";
4
- import type { StatsSummary, TimeRange } from "../../types";
5
- import { Dashboard } from "../Dashboard";
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
- { text: theme.bold("Pi Atlas") + theme.fg("dim", " · v0.1"), align: "left" },
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 "../../__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
  });