@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.
Files changed (66) hide show
  1. package/.pi/extensions/guardrails.json +10 -0
  2. package/.pi/extensions/guardrails.v0.json +8 -0
  3. package/AGENTS.md +13 -0
  4. package/CONTEXT.md +119 -0
  5. package/LICENSE +21 -0
  6. package/README.md +40 -0
  7. package/bun.lock +325 -0
  8. package/docs/ARCHITECTURE.md +66 -0
  9. package/docs/adr/0001-global-session-project-map.md +9 -0
  10. package/docs/adr/0002-precomputed-summaries.md +9 -0
  11. package/docs/agents/domain.md +42 -0
  12. package/docs/agents/issue-tracker.md +22 -0
  13. package/docs/agents/triage-labels.md +14 -0
  14. package/package.json +49 -0
  15. package/src/__tests__/cache.test.ts +388 -0
  16. package/src/__tests__/components.fixtures.ts +54 -0
  17. package/src/__tests__/compute.fixtures.ts +49 -0
  18. package/src/__tests__/compute.test.ts +336 -0
  19. package/src/__tests__/e2e.test.ts +182 -0
  20. package/src/__tests__/format.test.ts +232 -0
  21. package/src/__tests__/parser.test.ts +1396 -0
  22. package/src/cache.ts +178 -0
  23. package/src/colorPalette.ts +119 -0
  24. package/src/components/BarChart.ts +288 -0
  25. package/src/components/Dashboard.ts +222 -0
  26. package/src/components/Header.ts +40 -0
  27. package/src/components/KpiCards.ts +104 -0
  28. package/src/components/LoadingView.ts +38 -0
  29. package/src/components/MarqueeText.ts +79 -0
  30. package/src/components/RangeSelector.ts +63 -0
  31. package/src/components/RankedBarList.ts +71 -0
  32. package/src/components/SortedTable.ts +221 -0
  33. package/src/components/StatCard.ts +64 -0
  34. package/src/components/TabBar.ts +59 -0
  35. package/src/components/UsageRow.ts +55 -0
  36. package/src/components/__tests__/Bar.test.ts +66 -0
  37. package/src/components/__tests__/BarChart.test.ts +224 -0
  38. package/src/components/__tests__/Dashboard.test.ts +452 -0
  39. package/src/components/__tests__/KpiCards.test.ts +83 -0
  40. package/src/components/__tests__/LoadingView.test.ts +26 -0
  41. package/src/components/__tests__/MarqueeText.test.ts +75 -0
  42. package/src/components/__tests__/RangeSelector.test.ts +34 -0
  43. package/src/components/__tests__/RankedBarList.test.ts +110 -0
  44. package/src/components/__tests__/SortedTable.integration.test.ts +228 -0
  45. package/src/components/__tests__/SortedTable.test.ts +723 -0
  46. package/src/components/__tests__/TabBar.test.ts +62 -0
  47. package/src/components/__tests__/cells.test.ts +193 -0
  48. package/src/components/cells.ts +108 -0
  49. package/src/components/shared/Bar.ts +22 -0
  50. package/src/components/shared/GridRow.ts +22 -0
  51. package/src/compute.ts +210 -0
  52. package/src/format.ts +219 -0
  53. package/src/index.ts +88 -0
  54. package/src/parser.ts +363 -0
  55. package/src/tabs/Languages.ts +102 -0
  56. package/src/tabs/Models.ts +108 -0
  57. package/src/tabs/Overview.ts +152 -0
  58. package/src/tabs/Projects.ts +92 -0
  59. package/src/tabs/Usage.ts +181 -0
  60. package/src/tabs/__tests__/Languages.test.ts +158 -0
  61. package/src/tabs/__tests__/Models.test.ts +143 -0
  62. package/src/tabs/__tests__/Overview.test.ts +92 -0
  63. package/src/tabs/__tests__/Projects.test.ts +142 -0
  64. package/src/tabs/__tests__/Usage.test.ts +174 -0
  65. package/src/types.ts +99 -0
  66. package/tsconfig.json +30 -0
@@ -0,0 +1,49 @@
1
+ import { type StatsSummary } from "../types";
2
+
3
+ export const makeSummary = (): StatsSummary => ({
4
+ totalCost: 5.0,
5
+ sessionCount: 3,
6
+ totalMessages: 50,
7
+ totalInputTokens: 500,
8
+ totalOutputTokens: 500,
9
+ totalCacheReadTokens: 250,
10
+ totalCacheWriteTokens: 250,
11
+ totalTokens: 10000,
12
+ daysActive: 3,
13
+ avgCostPerDay: 1.67,
14
+ todayCost: 1.0,
15
+ languages: [
16
+ {
17
+ language: "TypeScript",
18
+ lines: 10000,
19
+ edits: 5,
20
+ },
21
+ ],
22
+ models: [
23
+ {
24
+ model: "deeepseek-v4",
25
+ cost: 0.5,
26
+ provider: "deepseek",
27
+ calls: 1000,
28
+ },
29
+ ],
30
+ projects: [
31
+ {
32
+ cost: 0,
33
+ project: "pi-atlas",
34
+ sessions: 12,
35
+ },
36
+ ],
37
+ tools: [
38
+ {
39
+ count: 10,
40
+ name: "bash",
41
+ },
42
+ ],
43
+ dailySpend: [
44
+ { date: "2026-06-06", cost: 1.0 },
45
+ { date: "2026-06-07", cost: 2.0 },
46
+ { date: "2026-06-08", cost: 2.0 },
47
+ ],
48
+ hourlySpend: [],
49
+ });
@@ -0,0 +1,336 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { summarize } from "../compute";
3
+ import { dateFromISOString } from "../format";
4
+ import { emptyDay, mergeDay } from "../parser";
5
+
6
+ describe("summarize", () => {
7
+ it("returns zeros for empty day list", () => {
8
+ const s = summarize([], "All");
9
+ expect(s.totalCost).toBe(0);
10
+ expect(s.sessionCount).toBe(0);
11
+ expect(s.totalMessages).toBe(0);
12
+ expect(s.totalOutputTokens).toBe(0);
13
+ expect(s.totalInputTokens).toBe(0);
14
+ expect(s.totalCacheWriteTokens).toBe(0);
15
+ expect(s.totalCacheReadTokens).toBe(0);
16
+ expect(s.totalTokens).toBe(0);
17
+ expect(s.daysActive).toBe(0);
18
+ expect(s.avgCostPerDay).toBe(0);
19
+ expect(s.dailySpend).toEqual([]);
20
+ expect(s.languages).toEqual([]);
21
+ expect(s.models).toEqual([]);
22
+ expect(s.projects).toEqual([]);
23
+ expect(s.tools).toEqual([]);
24
+ });
25
+
26
+ it("computes KPIs from a single day", () => {
27
+ const today = dateFromISOString(new Date().toISOString());
28
+ const d = emptyDay(today);
29
+ mergeDay(d, {
30
+ ...emptyDay(today),
31
+ cost: 1.5,
32
+ sessionIds: new Set(["s1", "s2"]),
33
+ userMsgs: 3,
34
+ asstMsgs: 5,
35
+ toolResults: 4,
36
+ inTok: 1000,
37
+ outTok: 500,
38
+ crTok: 100,
39
+ cwTok: 50,
40
+ modelCost: { sonnet: 1.0, haiku: 0.5 },
41
+ modelCount: { sonnet: 2, haiku: 3 },
42
+ toolCount: { bash: 2, read: 2 },
43
+ langLines: { typescript: 100 },
44
+ langEdits: { typescript: 5 },
45
+ });
46
+ const days = [d];
47
+
48
+ const s = summarize(days, "1d");
49
+ expect(s.totalCost).toBe(1.5);
50
+ expect(s.sessionCount).toBe(2);
51
+ expect(s.totalMessages).toBe(12); // 3+5+4
52
+ expect(s.totalInputTokens).toBe(1000);
53
+ expect(s.totalOutputTokens).toBe(500);
54
+ expect(s.totalCacheReadTokens).toBe(100);
55
+ expect(s.totalCacheWriteTokens).toBe(50);
56
+ expect(s.totalTokens).toBe(1650); // 1000+500+100+50
57
+ expect(s.daysActive).toBe(1);
58
+ expect(s.avgCostPerDay).toBe(1.5);
59
+
60
+ expect(s.models).toHaveLength(2);
61
+ expect(s.models[0]).toEqual({ model: "sonnet", cost: 1.0, calls: 2 });
62
+ expect(s.models[1]).toEqual({ model: "haiku", cost: 0.5, calls: 3 });
63
+
64
+ expect(s.tools).toHaveLength(2);
65
+ expect(s.tools).toContainEqual({ name: "bash", count: 2 });
66
+ expect(s.tools).toContainEqual({ name: "read", count: 2 });
67
+
68
+ expect(s.languages).toEqual([{ language: "typescript", lines: 100, edits: 5 }]);
69
+ });
70
+
71
+ it("filters by time range", () => {
72
+ const today = new Date().toISOString().slice(0, 10);
73
+ const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
74
+ const eightDaysAgo = new Date(Date.now() - 8 * 86400000).toISOString().slice(0, 10);
75
+
76
+ const d1 = emptyDay(today);
77
+ d1.cost = 1;
78
+ d1.sessionIds = new Set(["a"]);
79
+ const d2 = emptyDay(yesterday);
80
+ d2.cost = 2;
81
+ d2.sessionIds = new Set(["b"]);
82
+ const d3 = emptyDay(eightDaysAgo);
83
+ d3.cost = 3;
84
+ d3.sessionIds = new Set(["c"]);
85
+ const days = [d1, d2, d3];
86
+
87
+ // "1d" - only today
88
+ expect(summarize(days, "1d").totalCost).toBe(1);
89
+
90
+ // "7d" - today + yesterday
91
+ const s7 = summarize(days, "7d");
92
+ expect(s7.totalCost).toBe(3);
93
+ expect(s7.daysActive).toBe(2);
94
+
95
+ // "30d" - all three (since 8 days is within 30)
96
+ const s30 = summarize(days, "30d");
97
+ expect(s30.totalCost).toBe(6);
98
+ expect(s30.daysActive).toBe(3);
99
+
100
+ // "All"
101
+ const sAll = summarize(days, "All");
102
+ expect(sAll.totalCost).toBe(6);
103
+ expect(sAll.daysActive).toBe(3);
104
+ });
105
+
106
+ it("computes daily spend with zero-fill for gaps", () => {
107
+ const today = new Date();
108
+ // day0 = 6 days ago, day3 = 3 days ago (both within 7d range)
109
+ const d0 = new Date(today);
110
+ d0.setUTCDate(d0.getUTCDate() - 6);
111
+ const day0 = dateFromISOString(d0.toISOString());
112
+ const d3 = new Date(today);
113
+ d3.setUTCDate(d3.getUTCDate() - 3);
114
+ const day3 = dateFromISOString(d3.toISOString());
115
+
116
+ const d1 = emptyDay(day0);
117
+ d1.cost = 1;
118
+ const d2 = emptyDay(day3);
119
+ d2.cost = 2;
120
+ const days = [d1, d2];
121
+
122
+ const s = summarize(days, "7d");
123
+ expect(s.dailySpend.length).toBeGreaterThanOrEqual(4);
124
+ // Should include all dates from earliest to latest, with zeros for gaps
125
+ const dates = s.dailySpend.map((d) => d.date);
126
+ expect(dates).toContain(day0);
127
+ expect(dates).toContain(day3);
128
+
129
+ // Check gaps are zero-filled (dates between day0 and day3)
130
+ const spendByDate: Record<string, number> = {};
131
+ for (const ds of s.dailySpend) spendByDate[ds.date] = ds.cost;
132
+ expect(spendByDate[day0]).toBe(1);
133
+ expect(spendByDate[day3]).toBe(2);
134
+
135
+ // Verify zeros in between
136
+ const dMid = new Date(d0);
137
+ dMid.setUTCDate(dMid.getUTCDate() + 1);
138
+ const midStr = dateFromISOString(dMid.toISOString());
139
+ expect(spendByDate[midStr]).toBe(0);
140
+ });
141
+
142
+ it("sorts models by cost descending (then calls descending), tools by count descending", () => {
143
+ const d = emptyDay("2026-06-08");
144
+ mergeDay(d, {
145
+ ...emptyDay(""),
146
+ modelCost: {
147
+ free: 0,
148
+ secondFree: 0,
149
+ cheap: 0.1,
150
+ duplicatedCheap: 0.1,
151
+ expensive: 5.0,
152
+ mid: 1.0,
153
+ },
154
+ modelCount: { free: 12, secondFree: 15, cheap: 10, duplicatedCheap: 8, expensive: 2, mid: 5 },
155
+ toolCount: { bash: 1, read: 10, edit: 5 },
156
+ });
157
+ const days = [d];
158
+
159
+ const s = summarize(days, "All");
160
+ expect(s.models.map((m) => m.model)).toEqual([
161
+ "expensive",
162
+ "mid",
163
+ "cheap",
164
+ "duplicatedCheap",
165
+ "secondFree",
166
+ "free",
167
+ ]);
168
+ expect(s.tools.map((t) => t.name)).toEqual(["read", "edit", "bash"]);
169
+ });
170
+
171
+ it("reports todayCost separately", () => {
172
+ const today = new Date().toISOString().slice(0, 10);
173
+ const d1 = emptyDay("2026-01-01");
174
+ d1.cost = 100;
175
+ const todayAgg = emptyDay(today);
176
+ todayAgg.cost = 5;
177
+ const days = [d1, todayAgg];
178
+
179
+ const s = summarize(days, "All");
180
+ expect(s.todayCost).toBe(5);
181
+ });
182
+
183
+ it("returns todayCost 0 when today is not in filtered range", () => {
184
+ const d1 = emptyDay("2026-06-01");
185
+ d1.cost = 10;
186
+ d1.sessionIds = new Set(["s1"]);
187
+ const days = [d1];
188
+
189
+ // 1d range filters to today only, which has no data
190
+ const s = summarize(days, "1d");
191
+ expect(s.todayCost).toBe(0);
192
+ expect(s.totalCost).toBe(0);
193
+ });
194
+
195
+ it("dailySpend for All range is sorted dates without zero-fill", () => {
196
+ const d1 = emptyDay("2026-06-01");
197
+ d1.cost = 1;
198
+ d1.sessionIds = new Set(["a"]);
199
+ const d2 = emptyDay("2026-06-05");
200
+ d2.cost = 5;
201
+ d2.sessionIds = new Set(["b"]);
202
+ const d3 = emptyDay("2026-06-10");
203
+ d3.cost = 10;
204
+ d3.sessionIds = new Set(["c"]);
205
+ const days = [d3, d1, d2]; // unsorted input
206
+
207
+ const s = summarize(days, "All");
208
+ // All range does NOT zero-fill gaps — returns only days with data
209
+ expect(s.dailySpend).toHaveLength(3);
210
+ expect(s.dailySpend[0]).toEqual({ date: "2026-06-01", cost: 1 });
211
+ expect(s.dailySpend[1]).toEqual({ date: "2026-06-05", cost: 5 });
212
+ expect(s.dailySpend[2]).toEqual({ date: "2026-06-10", cost: 10 });
213
+ });
214
+
215
+ it("computes all KPIs for multiple-day 7d range", () => {
216
+ const today = new Date();
217
+ const day2ago = new Date(today);
218
+ day2ago.setUTCDate(day2ago.getUTCDate() - 1);
219
+ const day1ago = new Date(today);
220
+ day1ago.setUTCDate(day1ago.getUTCDate() - 0);
221
+
222
+ const d1date = dateFromISOString(day2ago.toISOString());
223
+ const d2date = dateFromISOString(day1ago.toISOString());
224
+
225
+ const d1 = emptyDay(d1date);
226
+ mergeDay(d1, {
227
+ ...emptyDay(""),
228
+ cost: 2.5,
229
+ sessionIds: new Set(["s1"]),
230
+ userMsgs: 5,
231
+ asstMsgs: 8,
232
+ toolResults: 3,
233
+ inTok: 500,
234
+ outTok: 200,
235
+ crTok: 10,
236
+ cwTok: 5,
237
+ modelCost: { sonnet: 2.0, haiku: 0.5 },
238
+ modelCount: { sonnet: 4, haiku: 2 },
239
+ toolCount: { bash: 3, read: 2 },
240
+ langLines: { TypeScript: 100, Python: 50 },
241
+ langEdits: { TypeScript: 3, Python: 1 },
242
+ });
243
+ const d2 = emptyDay(d2date);
244
+ mergeDay(d2, {
245
+ ...emptyDay(""),
246
+ cost: 1.5,
247
+ sessionIds: new Set(["s2"]),
248
+ userMsgs: 3,
249
+ asstMsgs: 4,
250
+ toolResults: 1,
251
+ inTok: 300,
252
+ outTok: 100,
253
+ crTok: 0,
254
+ cwTok: 0,
255
+ modelCost: { sonnet: 1.5 },
256
+ modelCount: { sonnet: 3 },
257
+ toolCount: { edit: 1, write: 1 },
258
+ langLines: { Python: 200 },
259
+ langEdits: { Python: 2 },
260
+ });
261
+ const days = [d1, d2];
262
+
263
+ const s = summarize(days, "7d");
264
+ expect(s.totalCost).toBe(4.0);
265
+ expect(s.sessionCount).toBe(2);
266
+ expect(s.totalMessages).toBe(24); // 5+8+3 + 3+4+1
267
+ expect(s.totalInputTokens).toBe(800); // 500 + 300
268
+ expect(s.totalOutputTokens).toBe(300); // 200 + 100
269
+ expect(s.totalCacheReadTokens).toBe(10); // 10 + 0
270
+ expect(s.totalCacheWriteTokens).toBe(5); // 5 + 0
271
+ expect(s.totalTokens).toBe(1115); // 500+200+10+5 + 300+100
272
+ expect(s.daysActive).toBe(2);
273
+ expect(s.avgCostPerDay).toBeCloseTo(2.0);
274
+
275
+ // Languages sorted by lines descending
276
+ expect(s.languages).toEqual([
277
+ { language: "Python", lines: 250, edits: 3 },
278
+ { language: "TypeScript", lines: 100, edits: 3 },
279
+ ]);
280
+
281
+ // Models sorted by cost
282
+ expect(s.models).toEqual([
283
+ { model: "sonnet", cost: 3.5, calls: 7 },
284
+ { model: "haiku", cost: 0.5, calls: 2 },
285
+ ]);
286
+ });
287
+
288
+ it("hourlySpend for 1d range has 24 zero-filled entries when no hourly cost", () => {
289
+ const today = dateFromISOString(new Date().toISOString());
290
+ const d = emptyDay(today);
291
+ d.cost = 5;
292
+ d.sessionIds = new Set(["s1"]);
293
+ const days = [d];
294
+
295
+ const s = summarize(days, "1d");
296
+ expect(s.hourlySpend).toHaveLength(24);
297
+ for (const h of s.hourlySpend) {
298
+ expect(h.cost).toBe(0);
299
+ }
300
+ // Zero-cost days still produce 24 entries
301
+ const d2 = emptyDay(dateFromISOString(new Date().toISOString()));
302
+ d2.cost = 0;
303
+ d2.sessionIds = new Set(["s2"]);
304
+ const s2 = summarize([d2], "1d");
305
+ expect(s2.hourlySpend).toHaveLength(24);
306
+ });
307
+
308
+ it("hourlySpend for 1d maps cost to correct hours", () => {
309
+ const today = dateFromISOString(new Date().toISOString());
310
+ const d = emptyDay(today);
311
+ mergeDay(d, {
312
+ ...emptyDay(""),
313
+ cost: 3.5,
314
+ hourCost: { 10: 1.5, 14: 2.0 },
315
+ });
316
+ const days = [d];
317
+
318
+ const s = summarize(days, "1d");
319
+ expect(s.hourlySpend).toHaveLength(24);
320
+ expect(s.hourlySpend[10]!.cost).toBe(1.5);
321
+ expect(s.hourlySpend[14]!.cost).toBe(2.0);
322
+ expect(s.hourlySpend[0]!.cost).toBe(0);
323
+ expect(s.hourlySpend[23]!.cost).toBe(0);
324
+ });
325
+
326
+ it("hourlySpend is empty for 7d, 30d, All ranges", () => {
327
+ const d = emptyDay("2026-06-01");
328
+ d.cost = 5;
329
+ d.sessionIds = new Set(["s1"]);
330
+ const days = [d];
331
+
332
+ expect(summarize(days, "7d").hourlySpend).toEqual([]);
333
+ expect(summarize(days, "30d").hourlySpend).toEqual([]);
334
+ expect(summarize(days, "All").hourlySpend).toEqual([]);
335
+ });
336
+ });
@@ -0,0 +1,182 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { allRanges } from "../components/__tests__/Dashboard.test";
6
+ import { Dashboard } from "../components/Dashboard";
7
+ import { summarize } from "../compute";
8
+ import { parseFile } from "../parser";
9
+ import { type DayAgg } from "../types";
10
+ import { makeMockTUI, makeRangeSelector, makeTheme } from "./components.fixtures";
11
+
12
+ const mockTui = makeMockTUI();
13
+
14
+ describe("JSONL → Dashboard", () => {
15
+ let tmpDir: string;
16
+
17
+ beforeEach(async () => {
18
+ tmpDir = join(tmpdir(), `pi-atlas-integration-${Date.now()}`);
19
+ await mkdir(tmpDir, { recursive: true });
20
+ });
21
+
22
+ afterEach(async () => {
23
+ await rm(tmpDir, { recursive: true, force: true });
24
+ });
25
+
26
+ function daysFromMap(map: Map<string, DayAgg>): DayAgg[] {
27
+ return [...map.values()].sort((a, b) => a.date.localeCompare(b.date));
28
+ }
29
+
30
+ it("end-to-end: JSONL with session and messages → Dashboard Overview shows KPIs", async () => {
31
+ const filePath = join(tmpDir, "session.jsonl");
32
+ const jsonlLines = [
33
+ JSON.stringify({
34
+ type: "session",
35
+ version: 3,
36
+ id: "s1",
37
+ timestamp: "2026-06-08T10:00:00.000Z",
38
+ cwd: "/home/doe/proj",
39
+ }),
40
+ JSON.stringify({
41
+ type: "message",
42
+ id: "m1",
43
+ parentId: "p",
44
+ timestamp: "2026-06-08T10:01:00.000Z",
45
+ message: { role: "user", content: [{ type: "text", text: "hello" }] },
46
+ }),
47
+ JSON.stringify({
48
+ type: "message",
49
+ id: "m2",
50
+ parentId: "m1",
51
+ timestamp: "2026-06-08T10:02:00.000Z",
52
+ message: {
53
+ role: "assistant",
54
+ content: [
55
+ { type: "text", text: "hi there" },
56
+ {
57
+ type: "toolCall",
58
+ id: "t1",
59
+ name: "read",
60
+ arguments: { path: "/src/foo.ts" },
61
+ },
62
+ ],
63
+ model: "claude-sonnet-4-20250514",
64
+ usage: {
65
+ input: 200,
66
+ output: 100,
67
+ cacheRead: 10,
68
+ cacheWrite: 0,
69
+ totalTokens: 310,
70
+ cost: {
71
+ input: 0.001,
72
+ output: 0.0005,
73
+ cacheRead: 0.00001,
74
+ cacheWrite: 0,
75
+ total: 0.00151,
76
+ },
77
+ },
78
+ },
79
+ }),
80
+ ];
81
+ await writeFile(filePath, jsonlLines.join("\n"));
82
+
83
+ // Parse
84
+ const map = parseFile(filePath);
85
+ const days = daysFromMap(map);
86
+ expect(days.length).toBeGreaterThan(0);
87
+
88
+ // Summarize for all ranges
89
+ const ranges = allRanges;
90
+ const summaries = new Map(ranges.map((r) => [r, summarize(days, r)] as const));
91
+
92
+ // Render dashboard
93
+ const dash = new Dashboard(
94
+ summaries,
95
+ makeTheme(),
96
+ mockTui,
97
+ null,
98
+ makeRangeSelector(makeTheme()),
99
+ );
100
+ const rendered = dash.render(80);
101
+ const text = rendered.join("\n");
102
+
103
+ // Should contain dashboard chrome
104
+ expect(text).toContain("Overview");
105
+ expect(text).toContain("Languages");
106
+ expect(text).toContain("Models");
107
+ expect(text).toContain("Projects");
108
+ expect(text).toContain("Usage");
109
+
110
+ // Range selector
111
+ expect(rendered[0]).toContain("Pi Atlas");
112
+ expect(rendered[0]).toContain("All time [r]");
113
+ });
114
+
115
+ it("end-to-end: Navigate to Languages tab shows ranked table from parsed data", async () => {
116
+ const filePath = join(tmpDir, "lang-session.jsonl");
117
+ const jsonlLines = [
118
+ JSON.stringify({
119
+ type: "session",
120
+ version: 3,
121
+ id: "s1",
122
+ timestamp: "2026-06-08T10:00:00.000Z",
123
+ cwd: "/home/doe/proj",
124
+ }),
125
+ JSON.stringify({
126
+ type: "message",
127
+ id: "m1",
128
+ parentId: "p",
129
+ timestamp: "2026-06-08T10:01:00.000Z",
130
+ message: {
131
+ role: "assistant",
132
+ content: [
133
+ {
134
+ type: "toolCall",
135
+ id: "t1",
136
+ name: "edit",
137
+ arguments: { path: "/src/main.ts", edits: [{ newText: "console.log('hi')" }] },
138
+ },
139
+ {
140
+ type: "toolCall",
141
+ id: "t2",
142
+ name: "write",
143
+ arguments: { path: "/src/lib.rs", content: "fn main() {}" },
144
+ },
145
+ ],
146
+ model: "sonnet",
147
+ usage: {
148
+ input: 0,
149
+ output: 0,
150
+ cacheRead: 0,
151
+ cacheWrite: 0,
152
+ totalTokens: 0,
153
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
154
+ },
155
+ },
156
+ }),
157
+ ];
158
+ await writeFile(filePath, jsonlLines.join("\n"));
159
+
160
+ const map = parseFile(filePath);
161
+ const days = daysFromMap(map);
162
+ const ranges = allRanges;
163
+ const summaries = new Map(ranges.map((r) => [r, summarize(days, r)] as const));
164
+
165
+ const dash = new Dashboard(
166
+ summaries,
167
+ makeTheme(),
168
+ mockTui,
169
+ null,
170
+ makeRangeSelector(makeTheme()),
171
+ );
172
+ // Navigate to Languages tab (index 1)
173
+ dash.handleInput("\x1b[C"); // right arrow
174
+
175
+ const rendered = dash.render(80);
176
+ const text = rendered.join("\n");
177
+
178
+ // Should show language data from parsed file
179
+ expect(text).toContain("TypeScript");
180
+ expect(text).toContain("Rust");
181
+ });
182
+ });