@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
package/src/compute.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  LangStat,
7
7
  ModelStat,
8
8
  ProjectStat,
9
+ ProviderStat,
9
10
  StatsSummary,
10
11
  TimeRange,
11
12
  ToolStat,
@@ -98,6 +99,10 @@ export function summarize(days: DayAgg[], range: TimeRange): StatsSummary {
98
99
  const projectCost: Record<string, number> = {};
99
100
  const projectSessions: Record<string, Set<string>> = {};
100
101
  const toolCount: Record<string, number> = {};
102
+ let compactionCount = 0;
103
+ let compactedTokens = 0;
104
+ let modelChanges = 0;
105
+ const thinkingLevelCount: Record<string, number> = {};
101
106
 
102
107
  let modelToProvider: Map<string, string> = new Map();
103
108
 
@@ -110,10 +115,9 @@ export function summarize(days: DayAgg[], range: TimeRange): StatsSummary {
110
115
  totalCacheReadTokens += day.crTok;
111
116
  totalCacheWriteTokens += day.cwTok;
112
117
 
113
- modelToProvider = new Map([
114
- ...(modelToProvider.size > 0 ? modelToProvider.entries() : []),
115
- ...(day.modelToProvider.size > 0 ? day.modelToProvider.entries() : []),
116
- ]);
118
+ for (const [model, provider] of day.modelToProvider) {
119
+ modelToProvider.set(model, provider);
120
+ }
117
121
 
118
122
  if (day.date === todayStr) todayCost += day.cost;
119
123
 
@@ -157,6 +161,13 @@ export function summarize(days: DayAgg[], range: TimeRange): StatsSummary {
157
161
  for (const [tool, count] of Object.entries(day.toolCount)) {
158
162
  toolCount[tool] = (toolCount[tool] ?? 0) + count;
159
163
  }
164
+
165
+ compactionCount += day.compactionCount;
166
+ compactedTokens += day.compactedTokens;
167
+ modelChanges += day.modelChanges;
168
+ for (const [level, count] of Object.entries(day.thinkingLevelCount)) {
169
+ thinkingLevelCount[level] = (thinkingLevelCount[level] ?? 0) + count;
170
+ }
160
171
  }
161
172
 
162
173
  const daysActive = filtered.filter((d) => d.sessionIds.size > 0).length;
@@ -186,6 +197,10 @@ export function summarize(days: DayAgg[], range: TimeRange): StatsSummary {
186
197
  .map(([tool, count]) => ({ name: tool, count }))
187
198
  .sort((a, b) => b.count - a.count);
188
199
 
200
+ const providers: ProviderStat[] = Object.entries(providerCost)
201
+ .map(([provider, cost]) => ({ provider, cost, calls: providerCount[provider] ?? 0 }))
202
+ .sort((a, b) => b.cost - a.cost || b.calls - a.calls);
203
+
189
204
  const hourlySpend = buildHourlySpend(filtered, range);
190
205
 
191
206
  return {
@@ -204,6 +219,11 @@ export function summarize(days: DayAgg[], range: TimeRange): StatsSummary {
204
219
  models,
205
220
  projects,
206
221
  tools,
222
+ providers,
223
+ compactionCount,
224
+ compactedTokens,
225
+ modelChanges,
226
+ thinkingLevelCount,
207
227
  dailySpend: fillDailySpend(filtered, range),
208
228
  hourlySpend,
209
229
  };
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import {
3
+ EXT_TO_LANG,
3
4
  MONTH_NAMES,
4
5
  dateFromISOString,
5
6
  formatCacheTimestamp,
@@ -8,7 +9,8 @@ import {
8
9
  formatNumber,
9
10
  langFromPath,
10
11
  projectNameFromCwd,
11
- } from "../format";
12
+ stripAnsi,
13
+ } from "./format";
12
14
 
13
15
  describe("formatModelName", () => {
14
16
  it("handles standard model names", () => {
@@ -25,6 +27,18 @@ describe("formatModelName", () => {
25
27
  it("strips YYYY-MM-DD date suffix", () => {
26
28
  expect(formatModelName("some-model-2025-05-14")).toBe("Some Model");
27
29
  });
30
+
31
+ it("handles underscore separators", () => {
32
+ expect(formatModelName("deepseek_v4_pro")).toBe("Deepseek V4 Pro");
33
+ });
34
+
35
+ it("handles mixed separators", () => {
36
+ expect(formatModelName("claude-opus_4")).toBe("Claude Opus 4");
37
+ });
38
+
39
+ it("handles empty string", () => {
40
+ expect(formatModelName("")).toBe("");
41
+ });
28
42
  });
29
43
 
30
44
  describe("langFromPath", () => {
@@ -68,6 +82,28 @@ describe("langFromPath", () => {
68
82
  expect(langFromPath("/src/Foo.TS")).toBe("TypeScript");
69
83
  expect(langFromPath("/src/Foo.PY")).toBe("Python");
70
84
  });
85
+
86
+ it("handles multi-dot filenames", () => {
87
+ expect(langFromPath("/src/foo.min.js")).toBe("JavaScript");
88
+ });
89
+
90
+ it("handles hidden files (dotfiles) as extensions", () => {
91
+ expect(langFromPath("/src/.gitignore")).toBe("Gitignore");
92
+ });
93
+
94
+ it("handles Jinja compound extensions", () => {
95
+ expect(langFromPath("/templates/page.html.j2")).toBe("Jinja");
96
+ });
97
+
98
+ it("handles file named only .extension", () => {
99
+ expect(langFromPath("/src/.ts")).toBe("TypeScript");
100
+ });
101
+
102
+ it("maps all EXT_TO_LANG entries correctly", () => {
103
+ for (const [ext, expected] of Object.entries(EXT_TO_LANG)) {
104
+ expect(langFromPath(`/file.${ext}`)).toBe(expected);
105
+ }
106
+ });
71
107
  });
72
108
 
73
109
  describe("projectNameFromCwd", () => {
@@ -82,6 +118,19 @@ describe("projectNameFromCwd", () => {
82
118
  it("strips trailing slash like basename", () => {
83
119
  expect(projectNameFromCwd("/home/doe/proj/")).toBe("proj");
84
120
  });
121
+
122
+ it("handles root path", () => {
123
+ expect(projectNameFromCwd("/")).toBe("");
124
+ });
125
+
126
+ it("handles empty string", () => {
127
+ expect(projectNameFromCwd("")).toBe("");
128
+ });
129
+
130
+ it("handles relative path components", () => {
131
+ expect(projectNameFromCwd(".")).toBe(".");
132
+ expect(projectNameFromCwd("..")).toBe("..");
133
+ });
85
134
  });
86
135
 
87
136
  describe("dateFromISOString", () => {
@@ -92,6 +141,14 @@ describe("dateFromISOString", () => {
92
141
  it("works on date-only", () => {
93
142
  expect(dateFromISOString("2026-12-31")).toBe("2026-12-31");
94
143
  });
144
+
145
+ it("handles empty string", () => {
146
+ expect(dateFromISOString("")).toBe("");
147
+ });
148
+
149
+ it("handles short string", () => {
150
+ expect(dateFromISOString("2026")).toBe("2026");
151
+ });
95
152
  });
96
153
 
97
154
  describe("formatNumber", () => {
@@ -102,38 +159,111 @@ describe("formatNumber", () => {
102
159
  });
103
160
 
104
161
  it("formats thousands with k", () => {
105
- expect(formatNumber(1000)).toBe("1.0k");
162
+ expect(formatNumber(1000)).toBe("1k");
106
163
  expect(formatNumber(1500)).toBe("1.5k");
107
- expect(formatNumber(999999)).toBe("1000.0k");
164
+ expect(formatNumber(1510)).toBe("1.51k");
165
+ expect(formatNumber(1517)).toBe("1.52k");
166
+ expect(formatNumber(999999)).toBe("1,000k");
108
167
  });
109
168
 
110
169
  it("formats millions with M", () => {
111
- expect(formatNumber(1_000_000)).toBe("1.00M");
112
- expect(formatNumber(2_500_000)).toBe("2.50M");
170
+ expect(formatNumber(1_000_000)).toBe("1M");
171
+ expect(formatNumber(2_500_000)).toBe("2.5M");
113
172
  });
114
173
 
115
174
  it("formats billions with B", () => {
116
- expect(formatNumber(1_000_000_000)).toBe("1.00B");
117
- expect(formatNumber(2_500_000_000)).toBe("2.50B");
175
+ expect(formatNumber(1_000_000_000)).toBe("1B");
176
+ expect(formatNumber(2_500_000_000)).toBe("2.5B");
177
+ expect(formatNumber(12_530_000_000)).toBe("12.53B");
178
+ expect(formatNumber(12_533_000_000)).toBe("12.53B");
179
+ expect(formatNumber(12_538_000_000)).toBe("12.54B");
180
+ });
181
+
182
+ it("handles negative numbers (no suffix — current behavior)", () => {
183
+ // Negative numbers fall through all n >= threshold checks
184
+ expect(formatNumber(-500)).toBe("-500");
185
+ expect(formatNumber(-1500)).toBe("-1500");
186
+ expect(formatNumber(-2_500_000)).toBe("-2500000");
187
+ });
188
+
189
+ it("handles boundary values", () => {
190
+ expect(formatNumber(999)).toBe("999");
191
+ expect(formatNumber(1000)).toBe("1k");
192
+ expect(formatNumber(999_999)).toBe("1,000k");
193
+ expect(formatNumber(1_000_000)).toBe("1M");
194
+ expect(formatNumber(999_999_999)).toBe("1,000M");
195
+ expect(formatNumber(1_000_000_000)).toBe("1B");
196
+ });
197
+
198
+ it("handles large numbers beyond billions", () => {
199
+ expect(formatNumber(1_000_000_000_000)).toBe("1,000B");
118
200
  });
119
201
  });
120
202
 
121
203
  describe("formatCost", () => {
122
- it("formats small costs with $ and two decimals", () => {
123
- expect(formatCost(0)).toBe("$0.00");
124
- expect(formatCost(1.5)).toBe("$1.50");
204
+ it("formats small costs with $ and least decimals possible", () => {
205
+ expect(formatCost(0)).toBe("$0");
206
+ expect(formatCost(1.5)).toBe("$1.5");
125
207
  expect(formatCost(999.99)).toBe("$999.99");
126
208
  });
127
209
 
128
210
  it("formats thousands with k", () => {
129
- expect(formatCost(1000)).toBe("$1.0k");
211
+ expect(formatCost(1000)).toBe("$1k");
130
212
  expect(formatCost(1500)).toBe("$1.5k");
131
213
  });
132
214
 
133
215
  it("formats millions with M", () => {
134
- expect(formatCost(1_000_000)).toBe("$1.0M");
216
+ expect(formatCost(1_000_000)).toBe("$1M");
135
217
  expect(formatCost(2_500_000)).toBe("$2.5M");
136
218
  });
219
+
220
+ it("handles boundary values", () => {
221
+ expect(formatCost(999.99)).toBe("$999.99");
222
+ expect(formatCost(1000)).toBe("$1k");
223
+ expect(formatCost(999_999)).toBe("$1,000k");
224
+ expect(formatCost(1_000_000)).toBe("$1M");
225
+ });
226
+
227
+ it("handles costs above billions", () => {
228
+ expect(formatCost(1_000_000_000)).toBe("$1,000M");
229
+ });
230
+ });
231
+
232
+ describe("stripAnsi", () => {
233
+ it("passes through plain text unchanged", () => {
234
+ expect(stripAnsi("hello world")).toBe("hello world");
235
+ });
236
+
237
+ it("strips ANSI color codes", () => {
238
+ expect(stripAnsi("\x1b[32mgreen\x1b[0m")).toBe("green");
239
+ });
240
+
241
+ it("strips ANSI cursor and erase sequences", () => {
242
+ expect(stripAnsi("\x1b[2J\x1b[Hclear")).toBe("clear");
243
+ });
244
+
245
+ it("strips ANSI underline codes", () => {
246
+ expect(stripAnsi("\x1b[4munderlined\x1b[24m")).toBe("underlined");
247
+ });
248
+
249
+ it("strips control characters", () => {
250
+ expect(stripAnsi("line1\x00line2")).toBe("line1line2");
251
+ expect(stripAnsi("a\x08b")).toBe("ab");
252
+ });
253
+
254
+ it("strips zero-width and formatting characters", () => {
255
+ expect(stripAnsi("a\u200Bb")).toBe("ab");
256
+ expect(stripAnsi("a\uFEFFb")).toBe("ab");
257
+ });
258
+
259
+ it("strips OSC sequences (\x1b]...\x07)", () => {
260
+ const osc = "\x1b]0;My Title\x07content";
261
+ expect(stripAnsi(osc)).toBe("content");
262
+ });
263
+
264
+ it("handles empty string", () => {
265
+ expect(stripAnsi("")).toBe("");
266
+ });
137
267
  });
138
268
 
139
269
  describe("formatCacheTimestamp", () => {
@@ -155,60 +285,72 @@ describe("formatCacheTimestamp", () => {
155
285
  });
156
286
 
157
287
  it("shows date for older dates this year", () => {
158
- const old = new Date("2026-01-15T14:30:00Z");
288
+ const year = new Date().getFullYear();
289
+ const old = new Date(year, 0, 15, 14, 30, 0);
159
290
  const iso = old.toISOString();
160
291
  const result = formatCacheTimestamp(iso);
161
- expect(result).toMatch(/^Jan 15,/);
292
+ const monthName = MONTH_NAMES[old.getMonth()];
293
+ const day = old.getDate();
294
+ expect(result).toMatch(new RegExp(`^${monthName} ${day},`));
295
+ expect(result).not.toContain(String(year));
162
296
  });
163
297
 
164
298
  it("shows date with year for previous year", () => {
165
- const old = new Date("2025-06-10T09:15:00Z");
299
+ const year = new Date().getFullYear() - 1;
300
+ const old = new Date(year, 5, 10, 9, 15, 0);
166
301
  const iso = old.toISOString();
167
302
  const result = formatCacheTimestamp(iso);
168
- expect(result).toMatch(/2025/);
303
+ expect(result).toMatch(new RegExp(String(year)));
169
304
  });
170
305
 
171
306
  // ---- Timezone-awareness tests ----
172
307
 
173
- it("formats time in local timezone (not UTC)", () => {
308
+ it("formats time in local timezone", () => {
174
309
  const d = new Date("2026-06-15T07:30:00Z");
175
310
  const iso = d.toISOString();
176
311
  const result = formatCacheTimestamp(iso);
177
312
 
313
+ // Compute expected local time using the same algorithm as the source
178
314
  const localHr = d.getHours();
179
315
  const localMin = d.getMinutes();
180
- const utcHr = d.getUTCHours();
181
-
182
316
  const h12 = localHr % 12 || 12;
183
317
  const ampm = localHr >= 12 ? "PM" : "AM";
184
318
  const expectedLocal = `${h12}:${String(localMin).padStart(2, "0")} ${ampm}`;
185
319
 
320
+ // Always asserts local time format is shown
186
321
  expect(result).toContain(expectedLocal);
187
322
 
188
- // When local time differs from UTC, verify UTC time is NOT shown
189
- if (localHr !== utcHr) {
323
+ // When the machine is not in UTC, also verify the UTC time is absent.
324
+ // In UTC (offset=0) this guard is skipped because local===UTC,
325
+ // so timezone correctness can only be fully validated on non-UTC CI.
326
+ if (d.getTimezoneOffset() !== 0) {
327
+ const utcHr = d.getUTCHours();
190
328
  const utcH12 = utcHr % 12 || 12;
191
329
  const utcAmpm = utcHr >= 12 ? "PM" : "AM";
192
330
  expect(result).not.toContain(`${utcH12}:${String(localMin).padStart(2, "0")} ${utcAmpm}`);
193
331
  }
194
332
  });
195
333
 
196
- it("uses local month/day (not UTC) for older date display", () => {
334
+ it("uses local month/day for older date display", () => {
197
335
  const d = new Date("2026-01-15T12:00:00Z");
198
336
  const iso = d.toISOString();
199
337
  const result = formatCacheTimestamp(iso);
200
338
 
339
+ // Compute expected local date using the same algorithm as the source
201
340
  const localMonth = d.getMonth();
202
341
  const localDay = d.getDate();
203
- const utcMonth = d.getUTCMonth();
204
- const utcDay = d.getUTCDate();
205
-
206
- // Output should contain local month/day
207
- expect(result).toMatch(new RegExp(`${MONTH_NAMES[localMonth]} ${localDay},`));
208
-
209
- // If local date differs from UTC date, verify UTC date is NOT shown
210
- if (localMonth !== utcMonth || localDay !== utcDay) {
211
- expect(result).not.toMatch(new RegExp(`${MONTH_NAMES[utcMonth]} ${utcDay},`));
342
+ const localStr = `${MONTH_NAMES[localMonth]} ${localDay},`;
343
+
344
+ expect(result).toMatch(new RegExp(localStr));
345
+
346
+ // When the machine is not in UTC, also verify UTC date is absent.
347
+ if (d.getTimezoneOffset() !== 0) {
348
+ const utcMonth = d.getUTCMonth();
349
+ const utcDay = d.getUTCDate();
350
+ const utcStr = `${MONTH_NAMES[utcMonth]} ${utcDay},`;
351
+ if (localStr !== utcStr) {
352
+ expect(result).not.toMatch(new RegExp(utcStr));
353
+ }
212
354
  }
213
355
  });
214
356
 
package/src/format.ts CHANGED
@@ -137,19 +137,32 @@ export const MONTH_NAMES = [
137
137
  "Dec",
138
138
  ];
139
139
 
140
+ const numberFormatter = new Intl.NumberFormat("en-US", {
141
+ style: "decimal",
142
+ minimumFractionDigits: 0,
143
+ maximumFractionDigits: 2,
144
+ });
145
+
146
+ const usdFormatter = new Intl.NumberFormat("en-US", {
147
+ style: "currency",
148
+ currency: "USD",
149
+ minimumFractionDigits: 0,
150
+ maximumFractionDigits: 2,
151
+ });
152
+
140
153
  // ---- Number formatting ----
141
154
 
142
155
  export function formatNumber(n: number): string {
143
- if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + "B";
144
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
145
- if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
156
+ if (n >= 1_000_000_000) return numberFormatter.format(n / 1_000_000_000) + "B";
157
+ if (n >= 1_000_000) return numberFormatter.format(n / 1_000_000) + "M";
158
+ if (n >= 1_000) return numberFormatter.format(n / 1_000) + "k";
146
159
  return String(n);
147
160
  }
148
161
 
149
162
  export function formatCost(n: number): string {
150
- if (n >= 1_000_000) return "$" + (n / 1_000_000).toFixed(1) + "M";
151
- if (n >= 1_000) return "$" + (n / 1_000).toFixed(1) + "k";
152
- return "$" + n.toFixed(2);
163
+ if (n >= 1_000_000) return usdFormatter.format(n / 1_000_000) + "M";
164
+ if (n >= 1_000) return usdFormatter.format(n / 1_000) + "k";
165
+ return usdFormatter.format(n);
153
166
  }
154
167
 
155
168
  // ---- Timestamp formatting ----
@@ -204,7 +217,7 @@ export function formatCacheTimestamp(iso: string): string {
204
217
  export function stripAnsi(text: string): string {
205
218
  // First strip ANSI sequences
206
219
  let clean = text.replace(
207
- /[\u001B\u009B][[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]|(?:\u001B\][\s\S]*?(?:\u0007|\u001B\\|\u009C))/g,
220
+ /(?:\u001B\][\s\S]*?(?:\u0007|\u001B\\|\u009C))|[\u001B\u009B][[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]/g,
208
221
  "",
209
222
  );
210
223
  // Then strip control characters that can break terminal rendering
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import { summarize } from "./compute";
8
8
  import { formatCacheTimestamp } from "./format";
9
9
  import type { TimeRange } from "./types";
10
10
  import { RangeSelector, type RangeOption } from "./components/RangeSelector";
11
+ import type { OverlayOptions } from "@earendil-works/pi-tui";
11
12
 
12
13
  const SESSIONS_DIR = join(homedir(), ".pi", "agent", "sessions");
13
14
  const CACHE_PATH = join(homedir(), ".pi", "pi-atlas-cache.json");
@@ -22,14 +23,14 @@ export default function (pi: ExtensionAPI) {
22
23
  }
23
24
 
24
25
  const overlayOpts = {
25
- overlay: true as const,
26
+ overlay: true,
26
27
  overlayOptions: {
27
28
  minWidth: 100,
28
- width: "50%" as const,
29
- maxHeight: "80%" as const,
30
- anchor: "center" as const,
29
+ width: "50%",
30
+ maxHeight: "80%",
31
+ anchor: "top-center",
31
32
  margin: 2,
32
- },
33
+ } as OverlayOptions,
33
34
  };
34
35
 
35
36
  // Read last update timestamp before loading (cache may be rewritten)
@@ -37,28 +38,30 @@ export default function (pi: ExtensionAPI) {
37
38
  const updateLabel = lastUpdate ? `Last update : ${formatCacheTimestamp(lastUpdate)}` : null;
38
39
 
39
40
  // Phase 1: Show loading, parse session logs
40
- let days: Awaited<ReturnType<typeof loadAggregate>>;
41
+ let days: Awaited<ReturnType<typeof loadAggregate> | undefined>;
41
42
  try {
42
- days = await ctx.ui.custom<Awaited<ReturnType<typeof loadAggregate>>>(
43
- (tui, _theme, _kb, done) => {
44
- const loadingView = new LoadingView("Parsing session logs...", tui);
43
+ days = await ctx.ui.custom<typeof days>((tui, theme, _kb, done) => {
44
+ const loadingView = new LoadingView("Parsing session logs...", theme, () =>
45
+ done(undefined),
46
+ );
45
47
 
46
- loadAggregate(CACHE_PATH, SESSIONS_DIR, false, (p) => {
47
- loadingView.setProgress(p);
48
- tui.requestRender();
49
- })
50
- .then((result) => done(result))
51
- .catch(() => done([]));
48
+ loadAggregate(CACHE_PATH, SESSIONS_DIR, false, (p) => {
49
+ loadingView.setProgress(p);
50
+ tui.requestRender();
51
+ })
52
+ .then((result) => done(result))
53
+ .catch(() => done([]));
52
54
 
53
- return loadingView;
54
- },
55
- overlayOpts,
56
- );
57
- } catch {
55
+ return loadingView;
56
+ }, overlayOpts);
57
+ } catch (e) {
58
58
  ctx.ui.notify("Failed to parse session logs", "error");
59
+ ctx.ui.notify(e as string, "error");
59
60
  return;
60
61
  }
61
62
 
63
+ if (days === undefined) return;
64
+
62
65
  // Phase 2: Show dashboard (handles empty state internally)
63
66
  const rangesToSummarize: TimeRange[] = ["1d", "7d", "30d", "All"];
64
67
  const summaries = new Map(rangesToSummarize.map((r) => [r, summarize(days, r)] as const));