@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,110 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { makeTheme } from "../../__tests__/components.fixtures";
|
|
4
|
+
import { RankedBarList } from "../RankedBarList";
|
|
5
|
+
|
|
6
|
+
describe("RankedBarList", () => {
|
|
7
|
+
it("renders a single item with 100% bar width", () => {
|
|
8
|
+
const theme = makeTheme();
|
|
9
|
+
const list = new RankedBarList(
|
|
10
|
+
[
|
|
11
|
+
{
|
|
12
|
+
name: "TypeScript",
|
|
13
|
+
primaryValue: 100,
|
|
14
|
+
mainValueText: "100 ln",
|
|
15
|
+
secondaryValueText: "5 edits",
|
|
16
|
+
color: chalk.green,
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
theme,
|
|
20
|
+
);
|
|
21
|
+
const lines = list.render(80);
|
|
22
|
+
expect(lines.length).toBe(3); // name+value, bar+%, spacer
|
|
23
|
+
const text = lines.join("\n");
|
|
24
|
+
expect(text).toContain("TypeScript");
|
|
25
|
+
expect(text).toContain("100 ln");
|
|
26
|
+
expect(text).toContain("5 edits");
|
|
27
|
+
expect(text).toContain("100.00%");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns empty array for empty items", () => {
|
|
31
|
+
const list = new RankedBarList([], makeTheme());
|
|
32
|
+
expect(list.render(80)).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("computes proportional bars for multiple items", () => {
|
|
36
|
+
const list = new RankedBarList(
|
|
37
|
+
[
|
|
38
|
+
{ name: "Python", primaryValue: 60, mainValueText: "60 ln", color: chalk.blue },
|
|
39
|
+
{ name: "Rust", primaryValue: 40, mainValueText: "40 ln", color: chalk.red },
|
|
40
|
+
],
|
|
41
|
+
makeTheme(),
|
|
42
|
+
);
|
|
43
|
+
const lines = list.render(80);
|
|
44
|
+
const text = lines.join("\n");
|
|
45
|
+
// Python should be 60% and 100% bar, Rust should be 40% and 66.67% bar
|
|
46
|
+
expect(text).toContain("60.00%");
|
|
47
|
+
expect(text).toContain("40.00%");
|
|
48
|
+
expect(text).toContain("Python");
|
|
49
|
+
expect(text).toContain("Rust");
|
|
50
|
+
expect(lines.length).toBe(6); // 2 items × 3 lines each
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("renders without secondary value", () => {
|
|
54
|
+
const list = new RankedBarList(
|
|
55
|
+
[{ name: "bash", primaryValue: 10, mainValueText: "10", color: chalk.white }],
|
|
56
|
+
makeTheme(),
|
|
57
|
+
);
|
|
58
|
+
const lines = list.render(80);
|
|
59
|
+
const text = lines.join("\n");
|
|
60
|
+
expect(text).toContain("bash");
|
|
61
|
+
expect(text).toContain("10");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("renders 0% bars when all primaryValues are zero", () => {
|
|
65
|
+
const list = new RankedBarList(
|
|
66
|
+
[
|
|
67
|
+
{ name: "Empty1", primaryValue: 0, mainValueText: "0", color: chalk.gray },
|
|
68
|
+
{ name: "Empty2", primaryValue: 0, mainValueText: "0", color: chalk.gray },
|
|
69
|
+
],
|
|
70
|
+
makeTheme(),
|
|
71
|
+
);
|
|
72
|
+
const lines = list.render(80);
|
|
73
|
+
const text = lines.join("\n");
|
|
74
|
+
expect(text).toContain("0.00%");
|
|
75
|
+
// Should not crash with division by zero
|
|
76
|
+
expect(lines.length).toBe(6);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("caches rendered output for same width", () => {
|
|
80
|
+
const list = new RankedBarList(
|
|
81
|
+
[{ name: "Rust", primaryValue: 50, mainValueText: "50 ln", color: chalk.red }],
|
|
82
|
+
makeTheme(),
|
|
83
|
+
);
|
|
84
|
+
const lines1 = list.render(80);
|
|
85
|
+
const lines2 = list.render(80);
|
|
86
|
+
expect(lines1).toBe(lines2); // same reference (cached)
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("re-renders when width changes", () => {
|
|
90
|
+
const list = new RankedBarList(
|
|
91
|
+
[{ name: "Rust", primaryValue: 50, mainValueText: "50 ln", color: chalk.red }],
|
|
92
|
+
makeTheme(),
|
|
93
|
+
);
|
|
94
|
+
const lines1 = list.render(80);
|
|
95
|
+
const lines2 = list.render(40);
|
|
96
|
+
expect(lines1).not.toBe(lines2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("invalidates cache", () => {
|
|
100
|
+
const list = new RankedBarList(
|
|
101
|
+
[{ name: "Rust", primaryValue: 50, mainValueText: "50 ln", color: chalk.red }],
|
|
102
|
+
makeTheme(),
|
|
103
|
+
);
|
|
104
|
+
list.render(80);
|
|
105
|
+
list.invalidate();
|
|
106
|
+
const lines = list.render(80);
|
|
107
|
+
// Should render (not throw), tested by the fact it returns 3 lines
|
|
108
|
+
expect(lines.length).toBe(3);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
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";
|
|
7
|
+
import { Dashboard } from "../Dashboard";
|
|
8
|
+
import { SortedTable } from "../SortedTable";
|
|
9
|
+
import { allRanges, mapAllSummaries } from "./Dashboard.test";
|
|
10
|
+
import type { StatsSummary, TimeRange } from "../../types";
|
|
11
|
+
|
|
12
|
+
const CURSOR = SortedTable.DEFAULT_CURSOR_CHAR;
|
|
13
|
+
const mockTui = makeMockTUI();
|
|
14
|
+
|
|
15
|
+
const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
16
|
+
|
|
17
|
+
describe("Dashboard → Models → SortedTable arrow key integration", () => {
|
|
18
|
+
/** Check if any line contains cursor and a model name substring. */
|
|
19
|
+
function cursorOnModel(lines: string[], model: string): boolean {
|
|
20
|
+
return lines.some((l) => l.includes(CURSOR) && l.includes(model));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
it("initial cursor on first model", () => {
|
|
24
|
+
const summary = {
|
|
25
|
+
...makeSummary(),
|
|
26
|
+
models: [
|
|
27
|
+
{ model: "alpha-model", cost: 10, calls: 100, provider: "p1" },
|
|
28
|
+
{ model: "beta-model", cost: 5, calls: 50, provider: "p2" },
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
const dash = new Dashboard(
|
|
32
|
+
mapAllSummaries(allRanges, summary),
|
|
33
|
+
makeTheme(),
|
|
34
|
+
mockTui,
|
|
35
|
+
null,
|
|
36
|
+
makeRangeSelector(makeTheme()),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Navigate to Models tab
|
|
40
|
+
dash.handleInput("\x1b[C"); // → Languages
|
|
41
|
+
dash.handleInput("\x1b[C"); // → Models
|
|
42
|
+
const lines = dash.render(80);
|
|
43
|
+
|
|
44
|
+
// Model column is 6-char fill at width 80 — only first word visible
|
|
45
|
+
expect(cursorOnModel(lines, "Alpha")).toBe(true);
|
|
46
|
+
expect(cursorOnModel(lines, "Beta")).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("down arrow moves cursor to next row via Dashboard dispatch", () => {
|
|
50
|
+
const summary = {
|
|
51
|
+
...makeSummary(),
|
|
52
|
+
models: [
|
|
53
|
+
{ model: "alpha-model", cost: 10, calls: 100, provider: "p1" },
|
|
54
|
+
{ model: "beta-model", cost: 5, calls: 50, provider: "p2" },
|
|
55
|
+
{ model: "gamma-model", cost: 1, calls: 10, provider: "p3" },
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
const dash = new Dashboard(
|
|
59
|
+
mapAllSummaries(allRanges, summary),
|
|
60
|
+
makeTheme(),
|
|
61
|
+
mockTui,
|
|
62
|
+
null,
|
|
63
|
+
makeRangeSelector(makeTheme()),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
dash.handleInput("\x1b[C"); // → Languages
|
|
67
|
+
dash.handleInput("\x1b[C"); // → Models
|
|
68
|
+
dash.render(80);
|
|
69
|
+
|
|
70
|
+
// Press down
|
|
71
|
+
dash.handleInput("\x1b[B");
|
|
72
|
+
const lines = dash.render(80);
|
|
73
|
+
// Cursor moves from row 0 to row 1
|
|
74
|
+
expect(cursorOnModel(lines, "Beta")).toBe(true);
|
|
75
|
+
expect(cursorOnModel(lines, "Alpha")).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("up arrow moves cursor up", () => {
|
|
79
|
+
const summary = {
|
|
80
|
+
...makeSummary(),
|
|
81
|
+
models: [
|
|
82
|
+
{ model: "alpha-model", cost: 10, calls: 100, provider: "p1" },
|
|
83
|
+
{ model: "beta-model", cost: 5, calls: 50, provider: "p2" },
|
|
84
|
+
{ model: "gamma-model", cost: 1, calls: 10, provider: "p3" },
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
const dash = new Dashboard(
|
|
88
|
+
mapAllSummaries(allRanges, summary),
|
|
89
|
+
makeTheme(),
|
|
90
|
+
mockTui,
|
|
91
|
+
null,
|
|
92
|
+
makeRangeSelector(makeTheme()),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
dash.handleInput("\x1b[C"); // → Languages
|
|
96
|
+
dash.handleInput("\x1b[C"); // → Models
|
|
97
|
+
dash.render(80);
|
|
98
|
+
|
|
99
|
+
// Move down twice, then back up once
|
|
100
|
+
dash.handleInput("\x1b[B");
|
|
101
|
+
dash.handleInput("\x1b[B");
|
|
102
|
+
dash.handleInput("\x1b[A");
|
|
103
|
+
const lines = dash.render(80);
|
|
104
|
+
|
|
105
|
+
expect(cursorOnModel(lines, "Beta")).toBe(true);
|
|
106
|
+
expect(cursorOnModel(lines, "Alpha")).toBe(false);
|
|
107
|
+
expect(cursorOnModel(lines, "Gamma")).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("arrow keys work across range switches", () => {
|
|
111
|
+
const summary1d = {
|
|
112
|
+
...makeSummary(),
|
|
113
|
+
models: [{ model: "alpha-model", cost: 1, calls: 10, provider: "p1" }],
|
|
114
|
+
};
|
|
115
|
+
const summaryAll = {
|
|
116
|
+
...makeSummary(),
|
|
117
|
+
models: [
|
|
118
|
+
{ model: "alpha-model", cost: 10, calls: 100, provider: "p1" },
|
|
119
|
+
{ model: "beta-model", cost: 5, calls: 50, provider: "p2" },
|
|
120
|
+
{ model: "gamma-model", cost: 1, calls: 10, provider: "p3" },
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const summaries: Map<TimeRange, StatsSummary> = new Map([
|
|
125
|
+
["1d", summary1d],
|
|
126
|
+
["7d", summaryAll],
|
|
127
|
+
["30d", summaryAll],
|
|
128
|
+
["All", summaryAll],
|
|
129
|
+
]);
|
|
130
|
+
const dash = new Dashboard(
|
|
131
|
+
summaries,
|
|
132
|
+
makeTheme(),
|
|
133
|
+
mockTui,
|
|
134
|
+
null,
|
|
135
|
+
makeRangeSelector(makeTheme()),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Navigate to Models, switch to 1d
|
|
139
|
+
dash.handleInput("\x1b[C"); // → Languages
|
|
140
|
+
dash.handleInput("\x1b[C"); // → Models
|
|
141
|
+
dash.handleInput("r"); // All → 1d
|
|
142
|
+
let lines = dash.render(80);
|
|
143
|
+
// 1d: only Alpha Model
|
|
144
|
+
expect(cursorOnModel(lines, "Alpha")).toBe(true);
|
|
145
|
+
|
|
146
|
+
// Switch back to All
|
|
147
|
+
// Current range index = 0 (1d), need to cycle to 3 (All)
|
|
148
|
+
// r → 1, r → 2, r → 3
|
|
149
|
+
dash.handleInput("r");
|
|
150
|
+
dash.handleInput("r");
|
|
151
|
+
dash.handleInput("r");
|
|
152
|
+
|
|
153
|
+
// tabIndex is still 2 (Models) — no need to re-navigate.
|
|
154
|
+
// buildTabs was called on each r, creating a new SortedTable with cursor at row 0.
|
|
155
|
+
lines = dash.render(80);
|
|
156
|
+
|
|
157
|
+
// 3 models now. Move down twice.
|
|
158
|
+
dash.handleInput("\x1b[B");
|
|
159
|
+
dash.handleInput("\x1b[B");
|
|
160
|
+
lines = dash.render(80);
|
|
161
|
+
|
|
162
|
+
expect(cursorOnModel(lines, "Gamma")).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// FIX:
|
|
166
|
+
// describe("marquee animation persists across render cycles", () => {
|
|
167
|
+
// beforeEach(() => {
|
|
168
|
+
// vi.useFakeTimers();
|
|
169
|
+
// });
|
|
170
|
+
//
|
|
171
|
+
// afterEach(() => {
|
|
172
|
+
// vi.useRealTimers();
|
|
173
|
+
// });
|
|
174
|
+
//
|
|
175
|
+
// /** Extract the full visible text of the focused row. */
|
|
176
|
+
// function focusedRowText(lines: string[]): string {
|
|
177
|
+
// for (const line of lines) {
|
|
178
|
+
// if (line.includes(CURSOR)) {
|
|
179
|
+
// return strip(line);
|
|
180
|
+
// }
|
|
181
|
+
// }
|
|
182
|
+
// return "";
|
|
183
|
+
// }
|
|
184
|
+
//
|
|
185
|
+
// it("marquee advances on successive renders (not stuck at offset 0)", () => {
|
|
186
|
+
// // A model name long enough to overflow the ~27-char Model column at width=80
|
|
187
|
+
// const longModel = "A-Very-Long-Model-Name-That-Overflows-And-Should-Scroll";
|
|
188
|
+
// const summary = {
|
|
189
|
+
// ...makeSummary(),
|
|
190
|
+
// models: [{ model: longModel, cost: 10, calls: 100, provider: "p1" }],
|
|
191
|
+
// };
|
|
192
|
+
// const dash = new Dashboard(
|
|
193
|
+
// mapAllSummaries(allRanges, summary),
|
|
194
|
+
// makeTheme(),
|
|
195
|
+
// mockTui,
|
|
196
|
+
// null,
|
|
197
|
+
// makeRangeSelector(makeTheme()),
|
|
198
|
+
// );
|
|
199
|
+
//
|
|
200
|
+
// // Navigate to Models tab
|
|
201
|
+
// dash.handleInput("\x1b[C"); // → Languages
|
|
202
|
+
// dash.handleInput("\x1b[C"); // → Models
|
|
203
|
+
//
|
|
204
|
+
// // First render — marquee starts at offset 0
|
|
205
|
+
// let lines = dash.render(80);
|
|
206
|
+
// // Model column is 6-char fill — marquee starts at offset 0
|
|
207
|
+
// const render1 = focusedRowText(lines);
|
|
208
|
+
// expect(render1).toContain("A Very");
|
|
209
|
+
//
|
|
210
|
+
// // Advance 150ms = 1 timer tick → marquee text should scroll
|
|
211
|
+
// vi.advanceTimersByTime(150);
|
|
212
|
+
// lines = dash.render(80);
|
|
213
|
+
// const render2 = focusedRowText(lines);
|
|
214
|
+
// expect(render2).not.toBe("");
|
|
215
|
+
//
|
|
216
|
+
// // Content must have scrolled — slice differs from render1
|
|
217
|
+
// expect(render2).not.toBe(render1);
|
|
218
|
+
//
|
|
219
|
+
// // Advance another 150ms = 2nd tick → should scroll again
|
|
220
|
+
// vi.advanceTimersByTime(150);
|
|
221
|
+
// lines = dash.render(80);
|
|
222
|
+
// const render3 = focusedRowText(lines);
|
|
223
|
+
// expect(render3).not.toBe("");
|
|
224
|
+
// expect(render3).not.toBe(render1);
|
|
225
|
+
// expect(render3).not.toBe(render2);
|
|
226
|
+
// });
|
|
227
|
+
// });
|
|
228
|
+
});
|