@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
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
2
- import { makeMockTUI, makeTheme, testPalette } from "../../__tests__/components.fixtures";
3
- import { type ModelStat } from "../../types";
4
- import { Models } from "../Models";
2
+ import { makeMockTUI, makeTheme, testPalette } from "../components/components.fixtures";
3
+ import { type ModelStat } from "../types";
4
+ import { Models } from "./Models";
5
5
 
6
6
  describe("Models", () => {
7
7
  const mockTui = makeMockTUI();
@@ -19,7 +19,6 @@ describe("Models", () => {
19
19
  const text = lines.join("\n");
20
20
 
21
21
  expect(lines[0]).toContain("Models");
22
- expect(lines[0]).toContain(models.length.toString());
23
22
 
24
23
  // formatModelName strips date suffix and capitalizes
25
24
  // Model column is 6-char fill at width 80 — truncated name visible
@@ -41,8 +40,6 @@ describe("Models", () => {
41
40
  const text = lines.join("\n");
42
41
 
43
42
  expect(lines[0]).toContain("Models");
44
- // don't display 0 counter
45
- expect(lines[0]).not.toContain("0");
46
43
  expect(text).toContain("No model data for this time range");
47
44
  });
48
45
 
@@ -36,10 +36,8 @@ export class Models extends Container {
36
36
  const totalCost = this.models.reduce((sum, item) => sum + item.cost, 0);
37
37
  const maxCost = Math.max(...this.models.map((m) => m.cost), 0);
38
38
  this.rows = this.models.map((m) => {
39
- let pct = 0;
40
39
  let barPct = 0;
41
40
  if (totalCost > 0) {
42
- pct = (m.cost * 100) / totalCost;
43
41
  barPct = maxCost > 0 ? (m.cost / maxCost) * 100 : 0;
44
42
  }
45
43
  return [
@@ -62,8 +60,8 @@ export class Models extends Container {
62
60
  const bb = new BorderBox({
63
61
  ...baseBorderBoxOptions,
64
62
  titles: [
65
- { text: "Models", align: "left" },
66
- { text: this.theme.fg("dim", formatNumber(this.models.length)), align: "right" },
63
+ { text: this.theme.bold("Models"), align: "left" },
64
+ { text: this.theme.fg("muted", "by cost"), align: "right" },
67
65
  ],
68
66
  });
69
67
  if (!this.table) {
@@ -1,8 +1,9 @@
1
- import { describe, it, expect } from "bun:test";
2
- import { makeTheme } from "../../__tests__/components.fixtures";
3
- import { Overview } from "../Overview";
4
- import { type StatsSummary } from "../../types";
5
- import { makeSummary } from "../../__tests__/compute.fixtures";
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import { describe, expect, it } from "bun:test";
3
+ import { makeTheme } from "../components/components.fixtures";
4
+ import { makeSummary } from "../compute.fixtures";
5
+ import { type StatsSummary } from "../types";
6
+ import { Overview } from "./Overview";
6
7
 
7
8
  describe("Overview", () => {
8
9
  const mockSummary: StatsSummary = {
@@ -24,7 +25,7 @@ describe("Overview", () => {
24
25
  ],
25
26
  };
26
27
 
27
- it("renders KpiCards followed by spacer followed by BarChart", () => {
28
+ it("renders KpiCards followed by BarChart", () => {
28
29
  const overview = new Overview(mockSummary, "7d", makeTheme(), 15);
29
30
  const lines = overview.render(80);
30
31
 
@@ -43,14 +44,12 @@ describe("Overview", () => {
43
44
  const kpiCostIdx = lines.findIndex(
44
45
  (l) => l.includes("12.34") || l.includes("$12.34") || l.includes("Total"),
45
46
  );
46
- const spacerIdx = lines.findIndex((l) => l.trim() === "");
47
- expect(spacerIdx).toBeGreaterThan(kpiCostIdx);
48
47
 
49
- // Chart content (█ or label) should appear after the spacer
48
+ // Chart content (█ or label) should appear after the KPIs
50
49
  const chartIdx = lines.findIndex(
51
- (l, i) => i > spacerIdx && (l.includes("█") || l.includes("No data") || l.includes("Mon")),
50
+ (l) => l.includes("█") || l.includes("No data") || l.includes("Mon"),
52
51
  );
53
- expect(chartIdx).toBeGreaterThan(spacerIdx);
52
+ expect(chartIdx).toBeGreaterThan(kpiCostIdx);
54
53
  });
55
54
 
56
55
  it("adapts bar chart height to available space after KpiCards", () => {
@@ -61,8 +60,6 @@ describe("Overview", () => {
61
60
  // Chart should still render (not zero lines)
62
61
  const text = lines.join("\n");
63
62
  expect(text).toContain("█");
64
- // Should have spacer before chart
65
- expect(lines.some((l) => l.trim() === "")).toBe(true);
66
63
  });
67
64
 
68
65
  it("shows 'No data' when daily spend is empty", () => {
@@ -81,12 +78,18 @@ describe("Overview", () => {
81
78
 
82
79
  it("invalidate clears cache and re-renders at new width", () => {
83
80
  const overview = new Overview(mockSummary, "7d", makeTheme(), 15);
84
- overview.render(80);
81
+
82
+ const linesBefore = overview.render(80);
83
+ for (const line of linesBefore) {
84
+ expect(visibleWidth(line)).toBeLessThanOrEqual(80);
85
+ expect(visibleWidth(line)).toBeGreaterThanOrEqual(78);
86
+ }
85
87
  overview.invalidate();
86
88
 
87
89
  const lines = overview.render(60);
88
90
  for (const line of lines) {
89
- expect(line.length).toBeLessThanOrEqual(60);
91
+ expect(visibleWidth(line)).toBeLessThanOrEqual(60);
92
+ expect(visibleWidth(line)).toBeGreaterThanOrEqual(58);
90
93
  }
91
94
  });
92
95
  });
@@ -6,7 +6,7 @@ import { BarChart } from "../components/BarChart";
6
6
  import { KpiCards, type KpiData } from "../components/KpiCards";
7
7
  import { GridRow } from "../components/shared/GridRow";
8
8
  import { StatCard } from "../components/StatCard";
9
- import { formatCost, formatNumber } from "../format";
9
+ import { formatCost, formatModelName, formatNumber } from "../format";
10
10
  import { type StatsSummary, type TimeRange } from "../types";
11
11
 
12
12
  const SPACER_HEIGHT = 1;
@@ -39,65 +39,76 @@ export class Overview extends Container {
39
39
  const topProject = summary.projects[0];
40
40
 
41
41
  const langBox = new BorderBox({
42
+ borderStyle: "heavy",
42
43
  titles: [{ text: this.theme.bold("Top Language"), align: "left" }],
44
+ footers: topLanguage
45
+ ? [
46
+ {
47
+ text: this.theme.fg("muted", formatNumber(topLanguage.lines) + " ln"),
48
+ align: "right",
49
+ },
50
+ ]
51
+ : [],
43
52
  padding: { left: 1, right: 1 },
44
53
  borderFn: topLanguage
45
54
  ? langPalette.getColor(topLanguage.language)
46
55
  : (s: string) => this.theme.fg("borderMuted", s),
47
56
  });
48
57
  langBox.addChild(
49
- topLanguage
50
- ? new StatCard(
51
- {
52
- label: { text: topLanguage.language },
53
- value: {
54
- text: this.theme.bold(formatNumber(topLanguage.lines) + " lines"),
55
- color: "text",
56
- },
57
- },
58
- this.theme,
59
- )
60
- : new Text("No data"),
58
+ new Text(
59
+ topLanguage ? this.theme.fg("text", topLanguage.language) : this.theme.fg("dim", "No data"),
60
+ 0,
61
+ 0,
62
+ ),
61
63
  );
62
64
 
63
65
  const modelBox = new BorderBox({
66
+ borderStyle: "heavy",
64
67
  titles: [{ text: this.theme.bold("Top model"), align: "left" }],
68
+ footers: topModel
69
+ ? [
70
+ {
71
+ text: this.theme.fg("muted", formatCost(topModel.cost)),
72
+ align: "right",
73
+ },
74
+ ]
75
+ : [],
65
76
  padding: { left: 1, right: 1 },
66
- borderFn: modelPalette.getColor(topModel?.provider || ""),
77
+ borderFn: topModel
78
+ ? modelPalette.getColor(topModel.provider || "")
79
+ : (s: string) => this.theme.fg("borderMuted", s),
67
80
  });
68
81
  modelBox.addChild(
69
- topModel
70
- ? new StatCard(
71
- {
72
- label: { text: topModel.model },
73
- value: {
74
- text: this.theme.bold(formatCost(topModel.cost)),
75
- color: "text",
76
- },
77
- },
78
- this.theme,
79
- )
80
- : new Text("No data."),
82
+ new Text(
83
+ topModel
84
+ ? this.theme.fg("text", formatModelName(topModel.model))
85
+ : this.theme.fg("dim", "No data"),
86
+ 0,
87
+ 0,
88
+ ),
81
89
  );
82
90
 
83
91
  const projectBox = new BorderBox({
92
+ borderStyle: "heavy",
84
93
  titles: [{ text: this.theme.bold("Top project"), align: "left" }],
94
+ footers: topProject
95
+ ? [
96
+ {
97
+ text: this.theme.fg("muted", formatCost(topProject.cost)),
98
+ align: "right",
99
+ },
100
+ ]
101
+ : [],
85
102
  padding: { left: 1, right: 1 },
86
- borderFn: (s: string) => this.theme.fg("borderMuted", s),
103
+ borderFn: (s: string) => this.theme.fg("text", s),
87
104
  });
105
+
88
106
  projectBox.addChild(
89
- topProject
90
- ? new StatCard(
91
- {
92
- label: { text: topProject.project },
93
- value: {
94
- text: this.theme.bold(formatCost(topProject.cost)),
95
- color: "text",
96
- },
97
- },
98
- this.theme,
99
- )
100
- : new Text("No data."),
107
+ new Text(
108
+ topProject ? this.theme.fg("text", topProject.project) : this.theme.fg("dim", "No data"),
109
+ 0,
110
+ 0,
111
+ ),
101
112
  );
102
113
 
103
114
  this.topCards = new GridRow([langBox, modelBox, projectBox], [33, 33, 34]);
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
2
- import { makeMockTUI, makeTheme } from "../../__tests__/components.fixtures";
3
- import { type ProjectStat } from "../../types";
4
- import { Projects } from "../Projects";
2
+ import { makeMockTUI, makeTheme } from "../components/components.fixtures";
3
+ import { type ProjectStat } from "../types";
4
+ import { Projects } from "./Projects";
5
5
 
6
6
  describe("Projects", () => {
7
7
  const mockTui = makeMockTUI();
@@ -18,7 +18,6 @@ describe("Projects", () => {
18
18
  const text = lines.join("\n");
19
19
 
20
20
  expect(lines[0]).toContain("Projects");
21
- expect(lines[0]).toContain(projects.length.toString());
22
21
 
23
22
  // Headers
24
23
  expect(text).toContain("Name");
@@ -37,8 +36,8 @@ describe("Projects", () => {
37
36
  expect(text).toContain("5");
38
37
 
39
38
  // Costs formatted
40
- expect(text).toContain("$15.50");
41
- expect(text).toContain("$8.20");
39
+ expect(text).toContain("$15.5");
40
+ expect(text).toContain("$8.2");
42
41
  expect(text).toContain("$1.25");
43
42
  });
44
43
 
@@ -48,8 +47,6 @@ describe("Projects", () => {
48
47
  const text = lines.join("\n");
49
48
 
50
49
  expect(lines[0]).toContain("Projects");
51
- // don't display 0 counter
52
- expect(lines[0]).not.toContain("0");
53
50
  expect(text).toContain("No projects data for this time range");
54
51
  });
55
52
 
@@ -29,9 +29,14 @@ export class Projects extends Container {
29
29
  /** Build row cells once in constructor. Data is stable per Projects instance. */
30
30
  private buildRows(): void {
31
31
  if (this.isEmpty) return;
32
+
33
+ const totalCost = this.projects.reduce((sum, item) => sum + item.cost, 0);
32
34
  const maxCost = Math.max(...this.projects.map((p) => p.cost), 0);
33
35
  this.rows = this.projects.map((p) => {
34
- const barPct = maxCost > 0 ? (p.cost / maxCost) * 100 : 0;
36
+ let barPct = 0;
37
+ if (totalCost > 0) {
38
+ barPct = maxCost > 0 ? (p.cost / maxCost) * 100 : 0;
39
+ }
35
40
  return [
36
41
  cell.marquee(p.project, this.tui),
37
42
  cell.bar(barPct, (s) => s, "transparent"),
@@ -47,13 +52,13 @@ export class Projects extends Container {
47
52
  const borderBoxOptions: BorderBoxOptions = {
48
53
  borderStyle: "singleRounded",
49
54
  borderFn: (s) => this.theme.fg("border", s),
50
- titles: [{ text: "Projects", align: "left" }],
55
+ titles: [{ text: this.theme.bold("Projects"), align: "left" }],
51
56
  };
52
57
  let borderBox = new BorderBox(borderBoxOptions);
53
58
  if (!this.isEmpty) {
54
59
  borderBoxOptions.titles = [
55
60
  ...borderBoxOptions.titles!,
56
- { text: this.theme.fg("dim", formatNumber(this.projects.length)), align: "right" },
61
+ { text: this.theme.fg("muted", "by cost"), align: "right" },
57
62
  ];
58
63
  if (!this.table) {
59
64
  this.table = new SortedTable(
@@ -62,7 +67,7 @@ export class Projects extends Container {
62
67
  { header: cell.header("Name"), width: 20 },
63
68
  { header: cell.header("Share %"), width: "fill" },
64
69
  { header: cell.header("Sessions"), width: 14 },
65
- { header: cell.header("Cost"), width: 8 },
70
+ { header: cell.header("Cost"), width: 10 },
66
71
  ],
67
72
  rows: this.rows,
68
73
  maxHeight: this.maxHeight,
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
2
- import { makeMockTUI, makeTheme } from "../../__tests__/components.fixtures";
3
- import { type ToolStat } from "../../types";
4
- import { Usage } from "../Usage";
2
+ import { makeMockTUI, makeTheme } from "../components/components.fixtures";
3
+ import type { ToolStat } from "../types";
4
+ import { Usage } from "./Usage";
5
5
 
6
6
  describe("Usage", () => {
7
7
  const mockTui = makeMockTUI();
@@ -25,11 +25,11 @@ describe("Usage", () => {
25
25
  const text = tab.render(80).join("\n");
26
26
 
27
27
  expect(text).toContain("Tokens");
28
- expect(text).toContain("10.0k");
28
+ expect(text).toContain("10k");
29
29
  expect(text).toContain("Input");
30
- expect(text).toContain("5.0k");
30
+ expect(text).toContain("5k");
31
31
  expect(text).toContain("Output");
32
- expect(text).toContain("4.0k");
32
+ expect(text).toContain("4k");
33
33
  expect(text).toContain("Cache Read");
34
34
  expect(text).toContain("500");
35
35
  expect(text).toContain("Cache Write");
@@ -42,7 +42,6 @@ describe("Usage", () => {
42
42
  const text = lines.join("\n");
43
43
 
44
44
  expect(lines[0]).toContain("Tools");
45
- expect(lines[0]).toContain(tools.length.toString());
46
45
 
47
46
  // Headers
48
47
  expect(text).toContain("Command");
@@ -60,21 +59,12 @@ describe("Usage", () => {
60
59
  expect(text).toContain("45");
61
60
  });
62
61
 
63
- it("does NOT show 'Tool Calls' title", () => {
64
- const tab = new Usage(tools, tokenUsage, makeTheme(), mockTui, 10);
65
- const text = tab.render(80).join("\n");
66
-
67
- expect(text).not.toContain("Tool Calls");
68
- });
69
-
70
62
  it("shows empty state when tools is empty", () => {
71
63
  const tab = new Usage([], tokenUsage, makeTheme(), mockTui, 10);
72
64
  const lines = tab.render(80).slice(4);
73
65
  const text = lines.join("\n");
74
66
 
75
67
  expect(lines[0]).toContain("Tools");
76
- // don't display 0 counter
77
- expect(lines[0]).not.toContain("0");
78
68
  expect(text).toContain("No tools data for this time range");
79
69
  });
80
70
 
@@ -138,8 +128,8 @@ describe("Usage", () => {
138
128
  expect(text).toContain("Command");
139
129
  expect(text).toContain("Calls ▼");
140
130
  // Token section still intact
141
- expect(text).toContain("5.0k");
142
- expect(text).toContain("4.0k");
131
+ expect(text).toContain("5k");
132
+ expect(text).toContain("4k");
143
133
 
144
134
  for (const line of lines2) {
145
135
  const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
package/src/tabs/Usage.ts CHANGED
@@ -47,9 +47,13 @@ export class Usage extends Container {
47
47
  /** Build row cells once in constructor. */
48
48
  private buildRows(): void {
49
49
  if (this.isEmpty) return;
50
+ const totalCount = this.tools.reduce((sum, item) => sum + item.count, 0);
50
51
  const maxCount = Math.max(...this.tools.map((t) => t.count), 0);
51
52
  this.rows = this.tools.map((t) => {
52
- const barPct = maxCount > 0 ? (t.count / maxCount) * 100 : 0;
53
+ let barPct = 0;
54
+ if (totalCount > 0) {
55
+ barPct = maxCount > 0 ? (t.count / maxCount) * 100 : 0;
56
+ }
53
57
  return [
54
58
  cell.marquee(stripAnsi(t.name).slice(0, TOOL_NAME_MAX_LENGTH), this.tui),
55
59
  cell.bar(barPct, (s) => s, "transparent"),
@@ -132,7 +136,7 @@ export class Usage extends Container {
132
136
  const borderBoxOptions: BorderBoxOptions = {
133
137
  borderStyle: "singleRounded",
134
138
  borderFn: (s) => this.theme.fg("border", s),
135
- titles: [{ text: "Tools", align: "left" }],
139
+ titles: [{ text: this.theme.bold("Tools"), align: "left" }],
136
140
  };
137
141
 
138
142
  let borderBox = new BorderBox(borderBoxOptions);
@@ -141,7 +145,7 @@ export class Usage extends Container {
141
145
  if (!this.isEmpty) {
142
146
  borderBoxOptions.titles = [
143
147
  ...borderBoxOptions.titles!,
144
- { text: this.theme.fg("dim", formatNumber(this.rows.length)), align: "right" },
148
+ { text: this.theme.fg("muted", "by calls"), align: "right" },
145
149
  ];
146
150
 
147
151
  if (!this.table) {
package/src/types.ts CHANGED
@@ -63,6 +63,12 @@ export interface ToolStat {
63
63
  count: number;
64
64
  }
65
65
 
66
+ export interface ProviderStat {
67
+ provider: string;
68
+ cost: number;
69
+ calls: number;
70
+ }
71
+
66
72
  export interface StatsSummary {
67
73
  totalCost: number;
68
74
  sessionCount: number;
@@ -79,6 +85,11 @@ export interface StatsSummary {
79
85
  models: ModelStat[];
80
86
  projects: ProjectStat[];
81
87
  tools: ToolStat[];
88
+ providers: ProviderStat[];
89
+ compactionCount: number;
90
+ compactedTokens: number;
91
+ modelChanges: number;
92
+ thinkingLevelCount: Record<string, number>;
82
93
  dailySpend: DaySpend[];
83
94
  hourlySpend: HourSpend[];
84
95
  }
@@ -1,26 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { LoadingView } from "../LoadingView";
3
-
4
- describe("LoadingView", () => {
5
- it("renders with 0% progress", () => {
6
- const lv = new LoadingView();
7
- const lines = lv.render(80);
8
- expect(lines.join("\n")).toContain("Parsing session logs...");
9
- expect(lines.join("\n")).toContain("0%");
10
- });
11
-
12
- it("updates progress", () => {
13
- const lv = new LoadingView();
14
- lv.setProgress(50);
15
- const lines = lv.render(80);
16
- expect(lines.join("\n")).toContain("50%");
17
- });
18
-
19
- it("renders progress bar with block chars", () => {
20
- const lv = new LoadingView();
21
- lv.setProgress(75);
22
- const lines = lv.render(80);
23
- expect(lines.join("\n")).toContain("█");
24
- expect(lines.join("\n")).toContain("75%");
25
- });
26
- });