@mohndoe/pi-atlas 0.1.1
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/.pi/extensions/guardrails.json +10 -0
- package/.pi/extensions/guardrails.v0.json +8 -0
- package/AGENTS.md +13 -0
- package/CONTEXT.md +119 -0
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/bun.lock +325 -0
- package/docs/ARCHITECTURE.md +66 -0
- package/docs/adr/0001-global-session-project-map.md +9 -0
- package/docs/adr/0002-precomputed-summaries.md +9 -0
- package/docs/agents/domain.md +42 -0
- package/docs/agents/issue-tracker.md +22 -0
- package/docs/agents/triage-labels.md +14 -0
- package/package.json +49 -0
- package/src/__tests__/cache.test.ts +388 -0
- package/src/__tests__/components.fixtures.ts +54 -0
- package/src/__tests__/compute.fixtures.ts +49 -0
- package/src/__tests__/compute.test.ts +336 -0
- package/src/__tests__/e2e.test.ts +182 -0
- package/src/__tests__/format.test.ts +232 -0
- package/src/__tests__/parser.test.ts +1396 -0
- package/src/cache.ts +178 -0
- package/src/colorPalette.ts +119 -0
- package/src/components/BarChart.ts +288 -0
- package/src/components/Dashboard.ts +222 -0
- package/src/components/Header.ts +40 -0
- package/src/components/KpiCards.ts +104 -0
- package/src/components/LoadingView.ts +38 -0
- package/src/components/MarqueeText.ts +79 -0
- package/src/components/RangeSelector.ts +63 -0
- package/src/components/RankedBarList.ts +71 -0
- package/src/components/SortedTable.ts +221 -0
- package/src/components/StatCard.ts +64 -0
- package/src/components/TabBar.ts +59 -0
- package/src/components/UsageRow.ts +55 -0
- package/src/components/__tests__/Bar.test.ts +66 -0
- package/src/components/__tests__/BarChart.test.ts +224 -0
- package/src/components/__tests__/Dashboard.test.ts +452 -0
- package/src/components/__tests__/KpiCards.test.ts +83 -0
- package/src/components/__tests__/LoadingView.test.ts +26 -0
- package/src/components/__tests__/MarqueeText.test.ts +75 -0
- package/src/components/__tests__/RangeSelector.test.ts +34 -0
- package/src/components/__tests__/RankedBarList.test.ts +110 -0
- package/src/components/__tests__/SortedTable.integration.test.ts +228 -0
- package/src/components/__tests__/SortedTable.test.ts +723 -0
- package/src/components/__tests__/TabBar.test.ts +62 -0
- package/src/components/__tests__/cells.test.ts +193 -0
- package/src/components/cells.ts +108 -0
- package/src/components/shared/Bar.ts +22 -0
- package/src/components/shared/GridRow.ts +22 -0
- package/src/compute.ts +210 -0
- package/src/format.ts +219 -0
- package/src/index.ts +88 -0
- package/src/parser.ts +363 -0
- package/src/tabs/Languages.ts +102 -0
- package/src/tabs/Models.ts +108 -0
- package/src/tabs/Overview.ts +152 -0
- package/src/tabs/Projects.ts +92 -0
- package/src/tabs/Usage.ts +181 -0
- package/src/tabs/__tests__/Languages.test.ts +158 -0
- package/src/tabs/__tests__/Models.test.ts +143 -0
- package/src/tabs/__tests__/Overview.test.ts +92 -0
- package/src/tabs/__tests__/Projects.test.ts +142 -0
- package/src/tabs/__tests__/Usage.test.ts +174 -0
- package/src/types.ts +99 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { makeTheme } from "../../__tests__/components.fixtures";
|
|
3
|
+
import { TabBar } from "../TabBar";
|
|
4
|
+
|
|
5
|
+
describe("TabBar", () => {
|
|
6
|
+
const tabs = ["Overview", "Languages", "Models", "Projects + Tools"];
|
|
7
|
+
|
|
8
|
+
it("renders all tab names", () => {
|
|
9
|
+
const tb = new TabBar(tabs, makeTheme(), 0);
|
|
10
|
+
const lines = tb.render(80);
|
|
11
|
+
expect(lines).toHaveLength(1);
|
|
12
|
+
const line = lines[0];
|
|
13
|
+
for (const tab of tabs) {
|
|
14
|
+
expect(line).toContain(tab);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("renders within width", () => {
|
|
19
|
+
const tb = new TabBar(tabs, makeTheme(), 0);
|
|
20
|
+
const lines = tb.render(40);
|
|
21
|
+
expect(lines).toHaveLength(1);
|
|
22
|
+
expect(lines[0]!.length).toBeLessThanOrEqual(40);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("highlights the active tab", () => {
|
|
26
|
+
const tb = new TabBar(tabs, makeTheme(), 2);
|
|
27
|
+
const lines = tb.render(80);
|
|
28
|
+
// Models tab (index 2) should stand out from the others
|
|
29
|
+
expect(lines[0]).toContain("Models");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("moves active tab left with handleInput", () => {
|
|
33
|
+
const tb = new TabBar(tabs, makeTheme(), 2);
|
|
34
|
+
tb.handleInput("\x1b[D"); // left arrow
|
|
35
|
+
expect((tb as { activeIndex: number }).activeIndex).toBe(1);
|
|
36
|
+
|
|
37
|
+
tb.handleInput("\x1b[D");
|
|
38
|
+
expect((tb as { activeIndex: number }).activeIndex).toBe(0);
|
|
39
|
+
|
|
40
|
+
// Wraps around? Or stays at 0?
|
|
41
|
+
tb.handleInput("\x1b[D");
|
|
42
|
+
expect((tb as { activeIndex: number }).activeIndex).toBe(0); // stays
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("moves active tab right with handleInput", () => {
|
|
46
|
+
const tb = new TabBar(tabs, makeTheme(), 2);
|
|
47
|
+
tb.handleInput("\x1b[C"); // right arrow
|
|
48
|
+
expect((tb as { activeIndex: number }).activeIndex).toBe(3);
|
|
49
|
+
|
|
50
|
+
tb.handleInput("\x1b[C");
|
|
51
|
+
expect((tb as { activeIndex: number }).activeIndex).toBe(3); // stays
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("invalidates render cache", () => {
|
|
55
|
+
const tb = new TabBar(tabs, makeTheme(), 0);
|
|
56
|
+
tb.render(80);
|
|
57
|
+
tb.invalidate();
|
|
58
|
+
// After invalidate, next render should recompute
|
|
59
|
+
const lines = tb.render(60); // different width → should still work
|
|
60
|
+
expect(lines[0]!.length).toBeLessThanOrEqual(60);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
3
|
+
import { makeMockTUI } from "../../__tests__/components.fixtures";
|
|
4
|
+
import { cell } from "../cells";
|
|
5
|
+
|
|
6
|
+
describe("cell.text", () => {
|
|
7
|
+
it("renders content truncated to width", () => {
|
|
8
|
+
const c = cell.text("Hello World");
|
|
9
|
+
const result = c.render(5);
|
|
10
|
+
expect(result).toBe(truncateToWidth("Hello World", 5, ""));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns exact line when content fits within width", () => {
|
|
14
|
+
const c = cell.text("Hi");
|
|
15
|
+
const result = c.render(10);
|
|
16
|
+
expect(result).toBe(truncateToWidth("Hi", 10, ""));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("handles empty content", () => {
|
|
20
|
+
const c = cell.text("");
|
|
21
|
+
const result = c.render(10);
|
|
22
|
+
expect(result).toBe("");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("handles zero width", () => {
|
|
26
|
+
const c = cell.text("Hello");
|
|
27
|
+
const result = c.render(0);
|
|
28
|
+
expect(result).toBe("");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("cell.header", () => {
|
|
33
|
+
it("appends ▲ when sortDirection is asc", () => {
|
|
34
|
+
const c = cell.header("Name");
|
|
35
|
+
const result = c.render(10, { sortDirection: "asc" });
|
|
36
|
+
expect(result).toBe(truncateToWidth("Name", 8, "") + " ▲");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("appends ▼ when sortDirection is desc", () => {
|
|
40
|
+
const c = cell.header("Name");
|
|
41
|
+
const result = c.render(10, { sortDirection: "desc" });
|
|
42
|
+
expect(result).toBe(truncateToWidth("Name", 8, "") + " ▼");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("appends nothing when sortDirection is null", () => {
|
|
46
|
+
const c = cell.header("Name");
|
|
47
|
+
const result = c.render(10, { sortDirection: null });
|
|
48
|
+
expect(result).toBe(truncateToWidth("Name", 10, ""));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("truncates content to fit indicator when narrow", () => {
|
|
52
|
+
const c = cell.header("VeryLongColumnName");
|
|
53
|
+
const result = c.render(10, { sortDirection: "desc" });
|
|
54
|
+
expect(result).toBe(truncateToWidth("VeryLongColumnName", 8, "") + " ▼");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("handles narrow width with no indicator", () => {
|
|
58
|
+
const c = cell.header("Hello World");
|
|
59
|
+
const result = c.render(5);
|
|
60
|
+
expect(result).toBe(truncateToWidth("Hello World", 5, ""));
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("cell.marquee", () => {
|
|
65
|
+
let tui: ReturnType<typeof makeMockTUI>;
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
vi.useFakeTimers();
|
|
69
|
+
tui = makeMockTUI();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
vi.useRealTimers();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("shows scrolling content when focused", () => {
|
|
77
|
+
const c = cell.marquee("Hello World", tui);
|
|
78
|
+
const result = c.render(5, { isFocused: true });
|
|
79
|
+
// tick=0, offset=0 → first 5 chars
|
|
80
|
+
expect(result).toBe("Hello");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("shows ellipsis when unfocused with overflow", () => {
|
|
84
|
+
const c = cell.marquee("Hello World", tui);
|
|
85
|
+
const result = c.render(5);
|
|
86
|
+
expect(result).toBe(truncateToWidth("Hello World", 5, "…"));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("shows full content when unfocused without overflow", () => {
|
|
90
|
+
const c = cell.marquee("Hi", tui);
|
|
91
|
+
const result = c.render(10);
|
|
92
|
+
expect(result).toBe("Hi");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("clears interval on invalidate", () => {
|
|
96
|
+
const c = cell.marquee("Hello World", tui);
|
|
97
|
+
c.render(5, { isFocused: true }); // starts timer
|
|
98
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
99
|
+
c.invalidate();
|
|
100
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("creates new marquee on render after invalidate", () => {
|
|
104
|
+
const c = cell.marquee("Hello World", tui);
|
|
105
|
+
c.render(5, { isFocused: true });
|
|
106
|
+
c.invalidate();
|
|
107
|
+
// After invalidate, focused render should start fresh
|
|
108
|
+
const result = c.render(5, { isFocused: true });
|
|
109
|
+
expect(result).toBe("Hello");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("advances marquee tick via timer", () => {
|
|
113
|
+
const c = cell.marquee("Hello World", tui);
|
|
114
|
+
c.render(5, { isFocused: true }); // tick=0
|
|
115
|
+
|
|
116
|
+
vi.advanceTimersByTime(150); // 1 tick
|
|
117
|
+
const result = c.render(5, { isFocused: true });
|
|
118
|
+
expect(result).toBe("ello ");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("handles empty content", () => {
|
|
122
|
+
const c = cell.marquee("", tui);
|
|
123
|
+
const result = c.render(5, { isFocused: true });
|
|
124
|
+
expect(result).toBe("");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("invalidate does not throw when no marquee has been created", () => {
|
|
128
|
+
const c = cell.marquee("Hello", tui);
|
|
129
|
+
expect(() => c.invalidate()).not.toThrow();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("cell.bar", () => {
|
|
134
|
+
it("renders bar filling the requested width", () => {
|
|
135
|
+
const c = cell.bar(
|
|
136
|
+
50,
|
|
137
|
+
(s) => s,
|
|
138
|
+
(s) => "░".repeat(s.length),
|
|
139
|
+
);
|
|
140
|
+
const result = c.render(10);
|
|
141
|
+
expect(result).toHaveLength(10);
|
|
142
|
+
expect(result).toBe("■■■■■░░░░░");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("uses filled style for filled portion", () => {
|
|
146
|
+
const c = cell.bar(
|
|
147
|
+
100,
|
|
148
|
+
(s) => `F{${s}}`,
|
|
149
|
+
(s) => "e".repeat(s.length),
|
|
150
|
+
);
|
|
151
|
+
const result = c.render(5);
|
|
152
|
+
expect(result).toBe("F{■■■■■}");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("uses empty style for empty portion", () => {
|
|
156
|
+
const c = cell.bar(
|
|
157
|
+
0,
|
|
158
|
+
(s) => "f".repeat(s.length),
|
|
159
|
+
(s) => `E{${s}}`,
|
|
160
|
+
);
|
|
161
|
+
const result = c.render(5);
|
|
162
|
+
expect(result).toBe("E{■■■■■}");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("handles zero width", () => {
|
|
166
|
+
const c = cell.bar(
|
|
167
|
+
50,
|
|
168
|
+
(s) => s,
|
|
169
|
+
(s) => "░".repeat(s.length),
|
|
170
|
+
);
|
|
171
|
+
const result = c.render(0);
|
|
172
|
+
expect(result).toBe("");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("handles 100% fill with no empty chars", () => {
|
|
176
|
+
const c = cell.bar(
|
|
177
|
+
100,
|
|
178
|
+
(s) => `F{${s}}`,
|
|
179
|
+
(s) => "E".repeat(s.length),
|
|
180
|
+
);
|
|
181
|
+
const result = c.render(8);
|
|
182
|
+
expect(result).toBe("F{■■■■■■■■}");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("invalidate does nothing (no internal cache)", () => {
|
|
186
|
+
const c = cell.bar(
|
|
187
|
+
50,
|
|
188
|
+
(s) => s,
|
|
189
|
+
(s) => "░".repeat(s.length),
|
|
190
|
+
);
|
|
191
|
+
expect(() => c.invalidate()).not.toThrow();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { MarqueeText } from "./MarqueeText";
|
|
4
|
+
import { renderBar } from "./shared/Bar";
|
|
5
|
+
|
|
6
|
+
export interface CellState {
|
|
7
|
+
isFocused?: boolean;
|
|
8
|
+
sortDirection?: "asc" | "desc" | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CellComponent {
|
|
12
|
+
render(width: number, state?: CellState): string;
|
|
13
|
+
invalidate(): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class TextCell implements CellComponent {
|
|
17
|
+
constructor(private content: string) {}
|
|
18
|
+
|
|
19
|
+
render(width: number, _state?: CellState): string {
|
|
20
|
+
return truncateToWidth(this.content, width, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
invalidate(): void {
|
|
24
|
+
// No internal cache
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class HeaderCell implements CellComponent {
|
|
29
|
+
constructor(private content: string) {}
|
|
30
|
+
|
|
31
|
+
render(width: number, state?: CellState): string {
|
|
32
|
+
const indicator =
|
|
33
|
+
state?.sortDirection === "asc" ? " ▲" : state?.sortDirection === "desc" ? " ▼" : "";
|
|
34
|
+
const contentWidth = Math.max(0, width - indicator.length);
|
|
35
|
+
const truncated = truncateToWidth(this.content, contentWidth, "");
|
|
36
|
+
return truncated + indicator;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
invalidate(): void {
|
|
40
|
+
// No internal cache
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class MarqueeCell implements CellComponent {
|
|
45
|
+
private marquee: MarqueeText | undefined;
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
private content: string,
|
|
49
|
+
private tui: TUI,
|
|
50
|
+
) {}
|
|
51
|
+
|
|
52
|
+
render(width: number, state?: CellState): string {
|
|
53
|
+
if ((state?.isFocused ?? false) && this.content.length > width) {
|
|
54
|
+
if (!this.marquee) {
|
|
55
|
+
this.marquee = new MarqueeText(this.content, this.tui);
|
|
56
|
+
}
|
|
57
|
+
return this.marquee.render(width)[0]!;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Unfocused or content fits — truncate with ellipsis
|
|
61
|
+
return truncateToWidth(this.content, width, "…");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
invalidate(): void {
|
|
65
|
+
if (this.marquee) {
|
|
66
|
+
this.marquee.destroy();
|
|
67
|
+
this.marquee = undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class BarCell implements CellComponent {
|
|
73
|
+
constructor(
|
|
74
|
+
private fillPct: number,
|
|
75
|
+
private filledStyle: (text: string) => string,
|
|
76
|
+
private emptyStyle: "transparent" | ((text: string) => string),
|
|
77
|
+
) {}
|
|
78
|
+
|
|
79
|
+
render(width: number, _state?: CellState): string {
|
|
80
|
+
return renderBar(width, this.fillPct, this.filledStyle, this.emptyStyle);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
invalidate(): void {
|
|
84
|
+
// No internal cache
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const cell = {
|
|
89
|
+
text(content: string): CellComponent {
|
|
90
|
+
return new TextCell(content);
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
header(content: string): CellComponent {
|
|
94
|
+
return new HeaderCell(content);
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
marquee(content: string, tui: TUI): CellComponent {
|
|
98
|
+
return new MarqueeCell(content, tui);
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
bar(
|
|
102
|
+
fillPct: number,
|
|
103
|
+
filledStyle: (text: string) => string,
|
|
104
|
+
emptyStyle: "transparent" | ((text: string) => string),
|
|
105
|
+
): CellComponent {
|
|
106
|
+
return new BarCell(fillPct, filledStyle, emptyStyle);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a horizontal bar using ■ characters.
|
|
3
|
+
*
|
|
4
|
+
* @param width Total character width for the bar
|
|
5
|
+
* @param fillPct Fill percentage (0–100, clamped)
|
|
6
|
+
* @param filledStyle Styling function for the filled portion (e.g. chalk.green)
|
|
7
|
+
* @param emptyStyle Styling function for the empty/unfilled portion (e.g. s => theme.fg("dim", s))
|
|
8
|
+
*/
|
|
9
|
+
export function renderBar(
|
|
10
|
+
width: number,
|
|
11
|
+
fillPct: number,
|
|
12
|
+
filledStyle: (text: string) => string,
|
|
13
|
+
emptyStyle: "transparent" | ((text: string) => string),
|
|
14
|
+
): string {
|
|
15
|
+
const clamped = Math.max(0, Math.min(100, fillPct));
|
|
16
|
+
const filledCount = Math.round((clamped / 100) * Math.max(0, width));
|
|
17
|
+
const emptyCount = Math.max(0, width - filledCount);
|
|
18
|
+
return (
|
|
19
|
+
filledStyle("■".repeat(filledCount)) +
|
|
20
|
+
(emptyStyle !== "transparent" ? emptyStyle("■".repeat(emptyCount)) : " ".repeat(emptyCount))
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
export class GridRow implements Component {
|
|
4
|
+
constructor(
|
|
5
|
+
private children: Component[],
|
|
6
|
+
private cols: number[],
|
|
7
|
+
) {}
|
|
8
|
+
// cols = [33, 33, 33] meaning % widths, or absolute chars
|
|
9
|
+
|
|
10
|
+
render(width: number): string[] {
|
|
11
|
+
const colWidths = this.cols.map((c) => Math.floor((width * c) / 100));
|
|
12
|
+
const rendered = this.children.map((c, i) => c.render(colWidths[i]!));
|
|
13
|
+
const maxLines = Math.max(...rendered.map((r) => r.length));
|
|
14
|
+
return Array.from({ length: maxLines }, (_, i) =>
|
|
15
|
+
rendered.map((r, j) => (r[i] ?? "").padEnd(colWidths[j]!)).join(""),
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
invalidate(): void {
|
|
20
|
+
this.children.forEach((c) => c.invalidate?.());
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/compute.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { dateFromISOString } from "./format";
|
|
2
|
+
import type {
|
|
3
|
+
DayAgg,
|
|
4
|
+
DaySpend,
|
|
5
|
+
HourSpend,
|
|
6
|
+
LangStat,
|
|
7
|
+
ModelStat,
|
|
8
|
+
ProjectStat,
|
|
9
|
+
StatsSummary,
|
|
10
|
+
TimeRange,
|
|
11
|
+
ToolStat,
|
|
12
|
+
} from "./types";
|
|
13
|
+
|
|
14
|
+
function daysInRange(days: DayAgg[], range: TimeRange): DayAgg[] {
|
|
15
|
+
if (range === "All") return days;
|
|
16
|
+
|
|
17
|
+
const todayStr = dateFromISOString(new Date().toISOString());
|
|
18
|
+
|
|
19
|
+
if (range === "1d") {
|
|
20
|
+
return days.filter((d) => d.date === todayStr);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Build cutoff as a Date, then convert back to ISO string for comparison
|
|
24
|
+
const cutoff = new Date(todayStr + "T00:00:00Z");
|
|
25
|
+
if (range === "7d") {
|
|
26
|
+
cutoff.setUTCDate(cutoff.getUTCDate() - 6);
|
|
27
|
+
} else if (range === "30d") {
|
|
28
|
+
cutoff.setUTCDate(cutoff.getUTCDate() - 29);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
|
32
|
+
return days.filter((d) => d.date >= cutoffStr);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fillDailySpend(days: DayAgg[], range: TimeRange): DaySpend[] {
|
|
36
|
+
if (days.length === 0) return [];
|
|
37
|
+
|
|
38
|
+
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
|
39
|
+
|
|
40
|
+
if (range === "All") {
|
|
41
|
+
return sorted.map((d) => ({ date: d.date, cost: d.cost }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// For bounded ranges, zero-fill gaps
|
|
45
|
+
const first = sorted[0]!.date;
|
|
46
|
+
const last = sorted[sorted.length - 1]!.date;
|
|
47
|
+
|
|
48
|
+
const spendMap = new Map<string, number>();
|
|
49
|
+
for (const d of sorted) spendMap.set(d.date, d.cost);
|
|
50
|
+
|
|
51
|
+
const result: DaySpend[] = [];
|
|
52
|
+
const d = new Date(first + "T00:00:00Z");
|
|
53
|
+
const end = new Date(last + "T00:00:00Z");
|
|
54
|
+
|
|
55
|
+
while (d <= end) {
|
|
56
|
+
const ds = d.toISOString().slice(0, 10);
|
|
57
|
+
result.push({ date: ds, cost: spendMap.get(ds) ?? 0 });
|
|
58
|
+
d.setUTCDate(d.getUTCDate() + 1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildHourlySpend(filtered: DayAgg[], range: TimeRange): HourSpend[] {
|
|
65
|
+
if (range !== "1d" || filtered.length !== 1) return [];
|
|
66
|
+
|
|
67
|
+
const day = filtered[0]!;
|
|
68
|
+
const hourly: HourSpend[] = [];
|
|
69
|
+
for (let h = 0; h < 24; h++) {
|
|
70
|
+
hourly.push({ hour: h, cost: day.hourCost[h] ?? 0 });
|
|
71
|
+
}
|
|
72
|
+
return hourly;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function summarize(days: DayAgg[], range: TimeRange): StatsSummary {
|
|
76
|
+
const filtered = daysInRange(days, range);
|
|
77
|
+
|
|
78
|
+
const todayStr = dateFromISOString(new Date().toISOString());
|
|
79
|
+
let todayCost = 0;
|
|
80
|
+
|
|
81
|
+
let totalCost = 0;
|
|
82
|
+
let sessionCount = 0;
|
|
83
|
+
let totalMessages = 0;
|
|
84
|
+
let totalTokens = 0;
|
|
85
|
+
let totalInputTokens = 0;
|
|
86
|
+
let totalOutputTokens = 0;
|
|
87
|
+
let totalCacheReadTokens = 0;
|
|
88
|
+
let totalCacheWriteTokens = 0;
|
|
89
|
+
const allSessions = new Set<string>();
|
|
90
|
+
|
|
91
|
+
// accumulators
|
|
92
|
+
const langLines: Record<string, number> = {};
|
|
93
|
+
const langEdits: Record<string, number> = {};
|
|
94
|
+
const modelCost: Record<string, number> = {};
|
|
95
|
+
const modelCount: Record<string, number> = {};
|
|
96
|
+
const providerCost: Record<string, number> = {};
|
|
97
|
+
const providerCount: Record<string, number> = {};
|
|
98
|
+
const projectCost: Record<string, number> = {};
|
|
99
|
+
const projectSessions: Record<string, Set<string>> = {};
|
|
100
|
+
const toolCount: Record<string, number> = {};
|
|
101
|
+
|
|
102
|
+
let modelToProvider: Map<string, string> = new Map();
|
|
103
|
+
|
|
104
|
+
for (const day of filtered) {
|
|
105
|
+
totalCost += day.cost;
|
|
106
|
+
totalMessages += day.userMsgs + day.asstMsgs + day.toolResults;
|
|
107
|
+
totalTokens += day.inTok + day.outTok + day.crTok + day.cwTok;
|
|
108
|
+
totalInputTokens += day.inTok;
|
|
109
|
+
totalOutputTokens += day.outTok;
|
|
110
|
+
totalCacheReadTokens += day.crTok;
|
|
111
|
+
totalCacheWriteTokens += day.cwTok;
|
|
112
|
+
|
|
113
|
+
modelToProvider = new Map([
|
|
114
|
+
...(modelToProvider.size > 0 ? modelToProvider.entries() : []),
|
|
115
|
+
...(day.modelToProvider.size > 0 ? day.modelToProvider.entries() : []),
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
if (day.date === todayStr) todayCost += day.cost;
|
|
119
|
+
|
|
120
|
+
for (const id of day.sessionIds) allSessions.add(id);
|
|
121
|
+
sessionCount = allSessions.size;
|
|
122
|
+
|
|
123
|
+
// merge languages
|
|
124
|
+
for (const [lang, lines] of Object.entries(day.langLines)) {
|
|
125
|
+
langLines[lang] = (langLines[lang] ?? 0) + lines;
|
|
126
|
+
}
|
|
127
|
+
for (const [lang, edits] of Object.entries(day.langEdits)) {
|
|
128
|
+
langEdits[lang] = (langEdits[lang] ?? 0) + edits;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// merge models
|
|
132
|
+
for (const [model, cost] of Object.entries(day.modelCost)) {
|
|
133
|
+
modelCost[model] = (modelCost[model] ?? 0) + cost;
|
|
134
|
+
}
|
|
135
|
+
for (const [model, count] of Object.entries(day.modelCount)) {
|
|
136
|
+
modelCount[model] = (modelCount[model] ?? 0) + count;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// merge providers
|
|
140
|
+
for (const [provider, cost] of Object.entries(day.providerCost)) {
|
|
141
|
+
providerCost[provider] = (providerCost[provider] ?? 0) + cost;
|
|
142
|
+
}
|
|
143
|
+
for (const [provider, count] of Object.entries(day.providerCount)) {
|
|
144
|
+
providerCount[provider] = (providerCount[provider] ?? 0) + count;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// merge projects
|
|
148
|
+
for (const [proj, cost] of Object.entries(day.projectCost)) {
|
|
149
|
+
projectCost[proj] = (projectCost[proj] ?? 0) + cost;
|
|
150
|
+
}
|
|
151
|
+
for (const [proj, sessions] of Object.entries(day.projectSessions)) {
|
|
152
|
+
if (!projectSessions[proj]) projectSessions[proj] = new Set();
|
|
153
|
+
for (const s of sessions) projectSessions[proj].add(s);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// merge tools
|
|
157
|
+
for (const [tool, count] of Object.entries(day.toolCount)) {
|
|
158
|
+
toolCount[tool] = (toolCount[tool] ?? 0) + count;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const daysActive = filtered.filter((d) => d.sessionIds.size > 0).length;
|
|
163
|
+
const avgCostPerDay = daysActive > 0 ? totalCost / daysActive : 0;
|
|
164
|
+
|
|
165
|
+
// build sorted result arrays
|
|
166
|
+
const languages: LangStat[] = Object.entries(langLines)
|
|
167
|
+
.map(([language, lines]) => ({ language, lines, edits: langEdits[language] ?? 0 }))
|
|
168
|
+
.sort((a, b) => b.lines - a.lines);
|
|
169
|
+
|
|
170
|
+
const models: ModelStat[] = Object.entries(modelCost)
|
|
171
|
+
.map(([model, cost]) => ({
|
|
172
|
+
provider: modelToProvider.get(model) || undefined,
|
|
173
|
+
model,
|
|
174
|
+
cost,
|
|
175
|
+
calls: modelCount[model] ?? 0,
|
|
176
|
+
}))
|
|
177
|
+
.sort((a, b) => b.calls - a.calls)
|
|
178
|
+
.sort((a, b) => b.cost - a.cost);
|
|
179
|
+
|
|
180
|
+
const projects: ProjectStat[] = Object.entries(projectCost)
|
|
181
|
+
.map(([project, cost]) => ({ project, cost, sessions: projectSessions[project]?.size ?? 0 }))
|
|
182
|
+
.sort((a, b) => b.sessions - a.sessions)
|
|
183
|
+
.sort((a, b) => b.cost - a.cost);
|
|
184
|
+
|
|
185
|
+
const tools: ToolStat[] = Object.entries(toolCount)
|
|
186
|
+
.map(([tool, count]) => ({ name: tool, count }))
|
|
187
|
+
.sort((a, b) => b.count - a.count);
|
|
188
|
+
|
|
189
|
+
const hourlySpend = buildHourlySpend(filtered, range);
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
totalCost,
|
|
193
|
+
sessionCount,
|
|
194
|
+
totalMessages,
|
|
195
|
+
totalTokens,
|
|
196
|
+
totalInputTokens,
|
|
197
|
+
totalOutputTokens,
|
|
198
|
+
totalCacheReadTokens,
|
|
199
|
+
totalCacheWriteTokens,
|
|
200
|
+
daysActive,
|
|
201
|
+
avgCostPerDay,
|
|
202
|
+
todayCost,
|
|
203
|
+
languages,
|
|
204
|
+
models,
|
|
205
|
+
projects,
|
|
206
|
+
tools,
|
|
207
|
+
dailySpend: fillDailySpend(filtered, range),
|
|
208
|
+
hourlySpend,
|
|
209
|
+
};
|
|
210
|
+
}
|