@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,158 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
2
+ import { makeMockTUI, testPalette, makeTheme } from "../../__tests__/components.fixtures";
3
+ import { Languages } from "../Languages";
4
+ import { type LangStat } from "../../types";
5
+
6
+ describe("Languages", () => {
7
+ const mockTui = makeMockTUI();
8
+
9
+ const languages: LangStat[] = [
10
+ { language: "TypeScript", lines: 1500, edits: 45 },
11
+ { language: "Python", lines: 800, edits: 20 },
12
+ { language: "JSON", lines: 300, edits: 5 },
13
+ ];
14
+
15
+ it("renders data rows with formatted values", () => {
16
+ const tab = new Languages(languages, makeTheme(), testPalette(), mockTui, 10);
17
+ const lines = tab.render(80);
18
+ const text = lines.join("\n");
19
+
20
+ expect(lines[0]).toContain("Languages");
21
+ expect(lines[0]).toContain(languages.length.toString());
22
+
23
+ // Headers
24
+ expect(text).toContain("Name");
25
+ expect(text).toContain("Edits");
26
+ expect(text).toContain("Lines");
27
+ expect(text).toContain("Share %");
28
+
29
+ // Language names
30
+ expect(text).toContain("TypeScript");
31
+ expect(text).toContain("Python");
32
+ expect(text).toContain("JSON");
33
+
34
+ // Edits
35
+ expect(text).toContain("45");
36
+ expect(text).toContain("20");
37
+ expect(text).toContain("5");
38
+
39
+ // Lines formatted (no suffix)
40
+ expect(text).toContain("1.5k");
41
+ expect(text).toContain("800");
42
+ expect(text).toContain("300");
43
+ });
44
+
45
+ it("shows empty state when languages is empty", () => {
46
+ const tab = new Languages([], makeTheme(), testPalette(), mockTui, 10);
47
+
48
+ const lines = tab.render(80);
49
+ const text = lines.join("\n");
50
+
51
+ expect(lines[0]).toContain("Languages");
52
+ // don't display 0 counter
53
+ expect(lines[0]).not.toContain("0");
54
+
55
+ expect(text).toContain("No language data for this time range");
56
+ });
57
+
58
+ it("renders within width", () => {
59
+ const tab = new Languages(languages, makeTheme(), testPalette(), mockTui, 10);
60
+ const lines = tab.render(50);
61
+ for (const line of lines) {
62
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
63
+ expect(visLen).toBeLessThanOrEqual(50);
64
+ }
65
+ });
66
+
67
+ it("fill column adapts to width", () => {
68
+ const tab = new Languages(languages, makeTheme(), testPalette(), mockTui, 10);
69
+
70
+ // At width 30, columns shrink — no line exceeds render width
71
+ const narrowLines = tab.render(30);
72
+ for (const line of narrowLines) {
73
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
74
+ expect(visLen).toBeLessThanOrEqual(30);
75
+ }
76
+ // Language column shrinks to ~9-10 chars — "TypeScript" (10 chars) truncates
77
+ const narrowText = narrowLines.join("\n");
78
+ expect(narrowText).not.toContain("TypeScript");
79
+
80
+ // At width 80, fill column is spacious — full names visible
81
+ const wideLines = tab.render(80);
82
+ const wideText = wideLines.join("\n");
83
+ expect(wideText).toContain("TypeScript");
84
+ expect(wideText).toContain("1.5k");
85
+ });
86
+
87
+ it("shows sort indicator on Lines column", () => {
88
+ const tab = new Languages(languages, makeTheme(), testPalette(), mockTui, 10);
89
+ const lines = tab.render(80);
90
+ const text = lines.join("\n");
91
+ // Lines is column 2, sort direction "desc" → ▼
92
+ expect(text).toContain("Lines ▼");
93
+ });
94
+
95
+ it("invalidates render cache", () => {
96
+ const tab = new Languages(languages, makeTheme(), testPalette(), mockTui, 10);
97
+ tab.render(80);
98
+ tab.invalidate();
99
+ const lines = tab.render(60);
100
+ for (const line of lines) {
101
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
102
+ expect(visLen).toBeLessThanOrEqual(60);
103
+ }
104
+ });
105
+
106
+ it("supports re-render after invalidation (lifecycle path)", () => {
107
+ const tab = new Languages(languages, makeTheme(), testPalette(), mockTui, 10);
108
+
109
+ const lines1 = tab.render(80);
110
+ expect(lines1.join("\n")).toContain("TypeScript");
111
+
112
+ tab.invalidate();
113
+
114
+ const lines2 = tab.render(80);
115
+ const text = lines2.join("\n");
116
+ expect(text).toContain("TypeScript");
117
+ expect(text).toContain("Lines ▼");
118
+ for (const line of lines2) {
119
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
120
+ expect(visLen).toBeLessThanOrEqual(80);
121
+ }
122
+ });
123
+
124
+ describe("marquee lifecycle", () => {
125
+ beforeEach(() => {
126
+ vi.useFakeTimers();
127
+ });
128
+
129
+ afterEach(() => {
130
+ vi.useRealTimers();
131
+ });
132
+
133
+ it("clears marquee timers on invalidate", () => {
134
+ const longNames: LangStat[] = [
135
+ {
136
+ language: "TypeScript with a very long name that should overflow",
137
+ lines: 1500,
138
+ edits: 45,
139
+ },
140
+ ];
141
+ const tab = new Languages(longNames, makeTheme(), testPalette(), mockTui, 10);
142
+
143
+ // Render at narrow width where language name overflows fill column
144
+ // → MarqueeCell starts timer on focused row
145
+ tab.render(30);
146
+ expect(vi.getTimerCount()).toBe(1);
147
+
148
+ // Invalidate propagates: Languages → SortedTable → cells → MarqueeCell → clearInterval
149
+ tab.invalidate();
150
+ expect(vi.getTimerCount()).toBe(0);
151
+
152
+ // Re-render at wider width so fill column has room for the name
153
+ const lines = tab.render(80);
154
+ const text = lines.join("\n");
155
+ expect(text).toContain("TypeScript");
156
+ });
157
+ });
158
+ });
@@ -0,0 +1,143 @@
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";
5
+
6
+ describe("Models", () => {
7
+ const mockTui = makeMockTUI();
8
+
9
+ const models: ModelStat[] = [
10
+ { model: "claude-sonnet-4-20250514", provider: "anthropic", cost: 150.5, calls: 42 },
11
+ { model: "gemini-2.5-pro", provider: "Google", cost: 85.25, calls: 28 },
12
+ { model: "gpt-4o", provider: "OpenAI", cost: 0.75, calls: 5 },
13
+ ];
14
+
15
+ it("renders data rows with formatted model names and costs", () => {
16
+ const tab = new Models(models, makeTheme(), testPalette(), mockTui, 10);
17
+ const lines = tab.render(80);
18
+
19
+ const text = lines.join("\n");
20
+
21
+ expect(lines[0]).toContain("Models");
22
+ expect(lines[0]).toContain(models.length.toString());
23
+
24
+ // formatModelName strips date suffix and capitalizes
25
+ // Model column is 6-char fill at width 80 — truncated name visible
26
+ expect(text).toContain("Claude");
27
+ expect(text).toContain("anthropic");
28
+ // Row 1 (Gemini) not focused — truncateToWidth shows "Gemin…"
29
+ expect(text).toContain("Gemin");
30
+ expect(text).toContain("Google");
31
+ expect(text).toContain("Gpt 4o");
32
+ expect(text).toContain("OpenAI");
33
+ // formatCost
34
+ expect(text).toContain("$150.5");
35
+ expect(text).toContain("$0.75");
36
+ });
37
+
38
+ it("shows empty state when models is empty", () => {
39
+ const tab = new Models([], makeTheme(), testPalette(), mockTui, 10);
40
+ const lines = tab.render(80);
41
+ const text = lines.join("\n");
42
+
43
+ expect(lines[0]).toContain("Models");
44
+ // don't display 0 counter
45
+ expect(lines[0]).not.toContain("0");
46
+ expect(text).toContain("No model data for this time range");
47
+ });
48
+
49
+ it("renders within width", () => {
50
+ const tab = new Models(models, makeTheme(), testPalette(), mockTui, 10);
51
+ const lines = tab.render(50);
52
+ for (const line of lines) {
53
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
54
+ expect(visLen).toBeLessThanOrEqual(50);
55
+ }
56
+ });
57
+
58
+ it("fill column adapts to width", () => {
59
+ const tab = new Models(models, makeTheme(), testPalette(), mockTui, 10);
60
+
61
+ // At width 18, fill column is small — full model name truncated
62
+ const narrowLines = tab.render(18);
63
+ const narrowText = narrowLines.join("\n");
64
+ expect(narrowText).not.toContain("Claude Sonnet");
65
+
66
+ // At width 80, fill column is spacious — full name visible
67
+ const wideLines = tab.render(80);
68
+ const wideText = wideLines.join("\n");
69
+ expect(wideText).toContain("Claude Sonnet");
70
+ });
71
+
72
+ it("shows sort indicator on Cost column", () => {
73
+ const tab = new Models(models, makeTheme(), testPalette(), mockTui, 10);
74
+ const lines = tab.render(80);
75
+ const text = lines.join("\n");
76
+ // Cost column has sort: { column: 3, direction: "desc" } → ▼
77
+ expect(text).toContain("Cost ▼");
78
+ });
79
+
80
+ it("invalidates render cache", () => {
81
+ const tab = new Models(models, makeTheme(), testPalette(), mockTui, 10);
82
+ tab.render(80); // cache at width 80
83
+ tab.invalidate();
84
+ const lines = tab.render(60); // should re-render at new width
85
+ for (const line of lines) {
86
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
87
+ expect(visLen).toBeLessThanOrEqual(60);
88
+ }
89
+ });
90
+
91
+ it("supports re-render after invalidation (lifecycle path)", () => {
92
+ const tab = new Models(models, makeTheme(), testPalette(), mockTui, 10);
93
+
94
+ // First render cycle — creates table
95
+ const lines1 = tab.render(80);
96
+ expect(lines1.join("\n")).toContain("Claude");
97
+
98
+ // Invalidate — simulates Dashboard.buildTabs() lifecycle cleanup
99
+ tab.invalidate();
100
+
101
+ // Second render cycle — creates new table from clean state
102
+ const lines2 = tab.render(80);
103
+ const text = lines2.join("\n");
104
+ expect(text).toContain("Claude");
105
+ expect(text).toContain("Cost ▼");
106
+ for (const line of lines2) {
107
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
108
+ expect(visLen).toBeLessThanOrEqual(80);
109
+ }
110
+ });
111
+
112
+ describe("marquee lifecycle", () => {
113
+ beforeEach(() => {
114
+ vi.useFakeTimers();
115
+ });
116
+
117
+ afterEach(() => {
118
+ vi.useRealTimers();
119
+ });
120
+
121
+ it("clears marquee timers on invalidate (Models→SortedTable→cells chain)", () => {
122
+ const longModels: ModelStat[] = [
123
+ { model: "claude-sonnet-4-20250514", provider: "anthropic", cost: 150.5, calls: 42 },
124
+ ];
125
+ const tab = new Models(longModels, makeTheme(), testPalette(), mockTui, 10);
126
+
127
+ // Render at narrow width where "Claude Sonnet 4 20250514" overflows fill column
128
+ // → MarqueeCell starts timer on focused row
129
+ tab.render(30);
130
+ expect(vi.getTimerCount()).toBe(1);
131
+
132
+ // Invalidate propagates: Models → SortedTable → cells → MarqueeCell → clearInterval
133
+ tab.invalidate();
134
+ expect(vi.getTimerCount()).toBe(0);
135
+
136
+ // Re-render still produces clean output (fill col ~2 chars, provider ~7 chars at width 30)
137
+ const lines = tab.render(30);
138
+ const text = lines.join("\n");
139
+ expect(text).toContain("C"); // first char of "Claude Sonnet 4"
140
+ expect(text).toContain("anthr"); // provider truncated to ~5 chars at width 30
141
+ });
142
+ });
143
+ });
@@ -0,0 +1,92 @@
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";
6
+
7
+ describe("Overview", () => {
8
+ const mockSummary: StatsSummary = {
9
+ ...makeSummary(),
10
+ totalCost: 12.34,
11
+ sessionCount: 42,
12
+ totalMessages: 1500,
13
+ totalTokens: 250000,
14
+ daysActive: 7,
15
+ avgCostPerDay: 1.76,
16
+ dailySpend: [
17
+ { date: "2026-06-01", cost: 1.0 },
18
+ { date: "2026-06-02", cost: 0.0 },
19
+ { date: "2026-06-03", cost: 2.5 },
20
+ { date: "2026-06-04", cost: 0.5 },
21
+ { date: "2026-06-05", cost: 0.0 },
22
+ { date: "2026-06-06", cost: 1.2 },
23
+ { date: "2026-06-07", cost: 3.0 },
24
+ ],
25
+ };
26
+
27
+ it("renders KpiCards followed by spacer followed by BarChart", () => {
28
+ const overview = new Overview(mockSummary, "7d", makeTheme(), 15);
29
+ const lines = overview.render(80);
30
+
31
+ // Should have KPI + spacer + chart
32
+ expect(lines.length).toBeGreaterThanOrEqual(3);
33
+
34
+ const text = lines.join("\n");
35
+ // KPI metrics present
36
+ expect(text).toContain("12.34");
37
+ expect(text).toContain("42");
38
+ // Chart bars present
39
+ expect(text).toContain("█");
40
+
41
+ // Verify order: KPI lines before spacer before chart bars
42
+ // Find first occurrence of spacer (empty line) after KPI content
43
+ const kpiCostIdx = lines.findIndex(
44
+ (l) => l.includes("12.34") || l.includes("$12.34") || l.includes("Total"),
45
+ );
46
+ const spacerIdx = lines.findIndex((l) => l.trim() === "");
47
+ expect(spacerIdx).toBeGreaterThan(kpiCostIdx);
48
+
49
+ // Chart content (█ or label) should appear after the spacer
50
+ const chartIdx = lines.findIndex(
51
+ (l, i) => i > spacerIdx && (l.includes("█") || l.includes("No data") || l.includes("Mon")),
52
+ );
53
+ expect(chartIdx).toBeGreaterThan(spacerIdx);
54
+ });
55
+
56
+ it("adapts bar chart height to available space after KpiCards", () => {
57
+ // Use a small maxHeight to verify chart still renders
58
+ const overview = new Overview(mockSummary, "7d", makeTheme(), 10);
59
+ const lines = overview.render(80);
60
+
61
+ // Chart should still render (not zero lines)
62
+ const text = lines.join("\n");
63
+ expect(text).toContain("█");
64
+ // Should have spacer before chart
65
+ expect(lines.some((l) => l.trim() === "")).toBe(true);
66
+ });
67
+
68
+ it("shows 'No data' when daily spend is empty", () => {
69
+ const summary: StatsSummary = {
70
+ ...mockSummary,
71
+ dailySpend: [],
72
+ };
73
+ const overview = new Overview(summary, "7d", makeTheme(), 15);
74
+ const lines = overview.render(80);
75
+
76
+ const text = lines.join("\n");
77
+ expect(text).toContain("No data");
78
+ // KPI cards still render
79
+ expect(text).toContain("Total");
80
+ });
81
+
82
+ it("invalidate clears cache and re-renders at new width", () => {
83
+ const overview = new Overview(mockSummary, "7d", makeTheme(), 15);
84
+ overview.render(80);
85
+ overview.invalidate();
86
+
87
+ const lines = overview.render(60);
88
+ for (const line of lines) {
89
+ expect(line.length).toBeLessThanOrEqual(60);
90
+ }
91
+ });
92
+ });
@@ -0,0 +1,142 @@
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";
5
+
6
+ describe("Projects", () => {
7
+ const mockTui = makeMockTUI();
8
+
9
+ const projects: ProjectStat[] = [
10
+ { project: "pi-atlas", cost: 15.5, sessions: 42 },
11
+ { project: "dotfiles", cost: 8.2, sessions: 20 },
12
+ { project: "sandbox", cost: 1.25, sessions: 5 },
13
+ ];
14
+
15
+ it("renders data rows with formatted costs", () => {
16
+ const tab = new Projects(projects, makeTheme(), mockTui, 10);
17
+ const lines = tab.render(80);
18
+ const text = lines.join("\n");
19
+
20
+ expect(lines[0]).toContain("Projects");
21
+ expect(lines[0]).toContain(projects.length.toString());
22
+
23
+ // Headers
24
+ expect(text).toContain("Name");
25
+ expect(text).toContain("Sessions");
26
+ expect(text).toContain("Cost");
27
+ expect(text).toContain("Share %");
28
+
29
+ // Project names
30
+ expect(text).toContain("pi-atlas");
31
+ expect(text).toContain("dotfiles");
32
+ expect(text).toContain("sandbox");
33
+
34
+ // Sessions
35
+ expect(text).toContain("42");
36
+ expect(text).toContain("20");
37
+ expect(text).toContain("5");
38
+
39
+ // Costs formatted
40
+ expect(text).toContain("$15.50");
41
+ expect(text).toContain("$8.20");
42
+ expect(text).toContain("$1.25");
43
+ });
44
+
45
+ it("shows empty state when projects is empty", () => {
46
+ const tab = new Projects([], makeTheme(), mockTui, 10);
47
+ const lines = tab.render(80);
48
+ const text = lines.join("\n");
49
+
50
+ expect(lines[0]).toContain("Projects");
51
+ // don't display 0 counter
52
+ expect(lines[0]).not.toContain("0");
53
+ expect(text).toContain("No projects data for this time range");
54
+ });
55
+
56
+ it("renders within width", () => {
57
+ const tab = new Projects(projects, makeTheme(), mockTui, 10);
58
+ const lines = tab.render(50);
59
+ for (const line of lines) {
60
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
61
+ expect(visLen).toBeLessThanOrEqual(50);
62
+ }
63
+ });
64
+
65
+ it("fill column adapts to width", () => {
66
+ const tab = new Projects(projects, makeTheme(), mockTui, 10);
67
+
68
+ // At width 30, columns shrink — no line exceeds render width
69
+ const narrowLines = tab.render(30);
70
+ for (const line of narrowLines) {
71
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
72
+ expect(visLen).toBeLessThanOrEqual(30);
73
+ }
74
+
75
+ const wideLines = tab.render(80);
76
+ const wideText = wideLines.join("\n");
77
+ expect(wideText).toContain("pi-atlas");
78
+ });
79
+
80
+ it("shows sort indicator on Cost column", () => {
81
+ const tab = new Projects(projects, makeTheme(), mockTui, 10);
82
+ const lines = tab.render(80);
83
+ const text = lines.join("\n");
84
+ expect(text).toContain("Cost ▼");
85
+ });
86
+
87
+ it("invalidates render cache", () => {
88
+ const tab = new Projects(projects, makeTheme(), mockTui, 10);
89
+ tab.render(80);
90
+ tab.invalidate();
91
+ const lines = tab.render(60);
92
+ for (const line of lines) {
93
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
94
+ expect(visLen).toBeLessThanOrEqual(60);
95
+ }
96
+ });
97
+
98
+ it("supports re-render after invalidation (lifecycle path)", () => {
99
+ const tab = new Projects(projects, makeTheme(), mockTui, 10);
100
+
101
+ const lines1 = tab.render(80);
102
+ expect(lines1.join("\n")).toContain("pi-atlas");
103
+
104
+ tab.invalidate();
105
+
106
+ const lines2 = tab.render(80);
107
+ const text = lines2.join("\n");
108
+ expect(text).toContain("pi-atlas");
109
+ expect(text).toContain("Cost ▼");
110
+ for (const line of lines2) {
111
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
112
+ expect(visLen).toBeLessThanOrEqual(80);
113
+ }
114
+ });
115
+
116
+ describe("marquee lifecycle", () => {
117
+ beforeEach(() => {
118
+ vi.useFakeTimers();
119
+ });
120
+
121
+ afterEach(() => {
122
+ vi.useRealTimers();
123
+ });
124
+
125
+ it("clears marquee timers on invalidate", () => {
126
+ const longProjects: ProjectStat[] = [
127
+ { project: "a-very-long-project", cost: 15.5, sessions: 42 },
128
+ ];
129
+ const tab = new Projects(longProjects, makeTheme(), mockTui, 10);
130
+
131
+ tab.render(30);
132
+ expect(vi.getTimerCount()).toBe(1);
133
+
134
+ tab.invalidate();
135
+ expect(vi.getTimerCount()).toBe(0);
136
+
137
+ const lines = tab.render(80);
138
+ const text = lines.join("\n");
139
+ expect(text).toContain("a-very-long-project");
140
+ });
141
+ });
142
+ });