pi-cost 0.1.0

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/public/app.js ADDED
@@ -0,0 +1,1290 @@
1
+ // #region STATE
2
+
3
+ let currentView = 'overview';
4
+ let overviewData = null;
5
+ let projectsData = null;
6
+ let sessionsData = null;
7
+ let sessionsDaily = null;
8
+ let sessionsModelDistribution = null;
9
+ let currentProjectInfo = null;
10
+ let sessionDetailData = null;
11
+ let currentProjectDir = null;
12
+ let currentProjectName = null;
13
+ let currentSessionId = null;
14
+ let parentView = 'projects';
15
+ let lastSelectedProject = null;
16
+ let lastSelectedSession = null;
17
+ const DEFAULT_SORT = {
18
+ overview: { field: 'totalCost', order: 'desc' },
19
+ projects: { field: 'lastActive', order: 'desc' },
20
+ sessions: { field: 'lastTimestamp', order: 'desc' },
21
+ };
22
+ const viewSort = structuredClone(DEFAULT_SORT);
23
+ let dateRange = 3;
24
+ const charts = {};
25
+ let lastRenderHash = {};
26
+ let navCounter = 0;
27
+ let selectedRowIdx = -1;
28
+
29
+ // #endregion
30
+
31
+ // #region UTILS
32
+
33
+ function esc(str) {
34
+ const d = document.createElement('div');
35
+ d.textContent = str == null ? '' : String(str);
36
+ return d.innerHTML;
37
+ }
38
+
39
+ function basename(p) {
40
+ const s = String(p ?? '');
41
+ return s.split(/[\\/]/).filter(Boolean).pop() || s;
42
+ }
43
+
44
+ // Escape for use inside a single-quoted JS string embedded in an HTML attribute.
45
+ // Without this, Windows paths like C:\Users\nikiforovall lose \U \n \d as JS escapes.
46
+ function jsStr(s) {
47
+ return String(s ?? '')
48
+ .replace(/\\/g, '\\\\')
49
+ .replace(/'/g, "\\'")
50
+ .replace(/&/g, '&')
51
+ .replace(/"/g, '"')
52
+ .replace(/</g, '&lt;')
53
+ .replace(/>/g, '&gt;');
54
+ }
55
+
56
+ function formatCost(usd) {
57
+ if (usd == null || usd === 0) return '$0.00';
58
+ if (usd < 0.01) return '<$0.01';
59
+ if (usd >= 100) return `$${usd.toFixed(0)}`;
60
+ return `$${usd.toFixed(2)}`;
61
+ }
62
+
63
+ function formatTokens(count) {
64
+ if (!count) return '0';
65
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
66
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
67
+ return count.toString();
68
+ }
69
+
70
+ function timeAgo(ts) {
71
+ if (!ts) return '';
72
+ const diff = Date.now() - new Date(ts).getTime();
73
+ const mins = Math.floor(diff / 60000);
74
+ if (mins < 1) return 'just now';
75
+ if (mins < 60) return `${mins}m ago`;
76
+ const hours = Math.floor(mins / 60);
77
+ if (hours < 24) return `${hours}h ago`;
78
+ const days = Math.floor(hours / 24);
79
+ if (days < 30) return `${days}d ago`;
80
+ return new Date(ts).toLocaleDateString();
81
+ }
82
+
83
+ function shortDate(dateStr) {
84
+ const d = new Date(`${dateStr}T00:00:00`);
85
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
86
+ }
87
+
88
+ function shortId(id) {
89
+ if (!id) return '—';
90
+ return String(id).slice(0, 8);
91
+ }
92
+
93
+ function shortModel(model) {
94
+ if (!model) return 'unknown';
95
+ return String(model)
96
+ .replace(/^(anthropic|openai|gemini|mistral|groq)\//, '')
97
+ .replace(/^claude-/, '')
98
+ .replace(/-\d{8}$/, '');
99
+ }
100
+
101
+ function srcBadge(src) {
102
+ if (src === 'jsonl') return `<span class="src-badge src-jsonl" title="Cost recorded by pi">actual</span>`;
103
+ if (src === 'pricing') return `<span class="src-badge src-pricing" title="Estimated from LiteLLM pricing">est</span>`;
104
+ return `<span class="src-badge src-none" title="No pricing match">n/a</span>`;
105
+ }
106
+
107
+ function sortCompare(a, b, field, order) {
108
+ let va = a[field], vb = b[field];
109
+ if (va == null) va = '';
110
+ if (vb == null) vb = '';
111
+ if (typeof va === 'string') va = va.toLowerCase();
112
+ if (typeof vb === 'string') vb = vb.toLowerCase();
113
+ if (va < vb) return order === 'asc' ? -1 : 1;
114
+ if (va > vb) return order === 'asc' ? 1 : -1;
115
+ return 0;
116
+ }
117
+
118
+ function currentSort() { return viewSort[currentView]; }
119
+
120
+ function sortArrow(field) {
121
+ const s = currentSort();
122
+ if (!s || s.field !== field) return '';
123
+ return `<span class="sort-arrow">${s.order === 'asc' ? '▲' : '▼'}</span>`;
124
+ }
125
+
126
+ function thClass(field) {
127
+ const s = currentSort();
128
+ return s && s.field === field ? 'sorted' : '';
129
+ }
130
+
131
+ function parentBreadcrumb() {
132
+ const label = parentView === 'overview' ? 'Overview' : 'Projects';
133
+ return `<a class="parent-breadcrumb">${label}</a>`;
134
+ }
135
+
136
+ function focusPreviousRow(view) {
137
+ let selector;
138
+ if (view === 'overview' || view === 'projects') {
139
+ if (!lastSelectedProject) return;
140
+ selector = `tr[data-key="${cssAttrEscape(lastSelectedProject)}"]`;
141
+ } else if (view === 'sessions') {
142
+ if (!lastSelectedSession) return;
143
+ selector = `tr[data-key="${cssAttrEscape(lastSelectedSession)}"]`;
144
+ }
145
+ if (!selector) return;
146
+ requestAnimationFrame(() => {
147
+ const rows = getVisibleRows();
148
+ const idx = rows.findIndex((r) => r.matches(selector));
149
+ selectRow(idx >= 0 ? idx : 0);
150
+ });
151
+ }
152
+
153
+ function cssAttrEscape(s) {
154
+ return String(s).replace(/"/g, '\\"');
155
+ }
156
+
157
+ // #endregion
158
+
159
+ // #region URL_STATE
160
+
161
+ function getUrlState() {
162
+ let p = new URLSearchParams(window.location.search);
163
+ if (!p.has('view') && !p.has('session') && !p.has('project')) {
164
+ const saved = sessionStorage.getItem('pi-cost:nav');
165
+ if (saved) p = new URLSearchParams(saved);
166
+ }
167
+ return {
168
+ view: p.get('view') || 'overview',
169
+ project: p.get('project'),
170
+ projectName: p.get('projectName'),
171
+ session: p.get('session'),
172
+ parentView: p.get('parentView') || 'projects',
173
+ };
174
+ }
175
+
176
+ function loadDateRange() {
177
+ const v = localStorage.getItem('pi-cost:range');
178
+ if (!v) return 3;
179
+ if (v === 'today') return 'today';
180
+ return parseInt(v, 10) || 3;
181
+ }
182
+
183
+ function saveDateRange(val) { localStorage.setItem('pi-cost:range', String(val)); }
184
+
185
+ function rangeLabel(r) { return r === 'today' ? 'today' : `${r} days`; }
186
+
187
+ function loadSort() {
188
+ for (const view of Object.keys(DEFAULT_SORT)) {
189
+ const raw = localStorage.getItem(`pi-cost:sort:${view}`);
190
+ if (raw) {
191
+ try {
192
+ const { field, order } = JSON.parse(raw);
193
+ if (field) viewSort[view].field = field;
194
+ if (order) viewSort[view].order = order;
195
+ } catch {}
196
+ }
197
+ }
198
+ }
199
+
200
+ function saveSort() {
201
+ localStorage.setItem(`pi-cost:sort:${currentView}`, JSON.stringify(currentSort()));
202
+ }
203
+
204
+ function updateUrl(mode = 'push') {
205
+ const p = new URLSearchParams();
206
+ if (currentView !== 'overview') p.set('view', currentView);
207
+ if (currentProjectDir) p.set('project', currentProjectDir);
208
+ if (currentProjectName) p.set('projectName', currentProjectName);
209
+ if (currentSessionId) p.set('session', currentSessionId);
210
+ if (parentView !== 'projects') p.set('parentView', parentView);
211
+ const qs = p.toString();
212
+ const url = qs ? `?${qs}` : '/';
213
+ const sameAsCurrent = (location.search || '') === (qs ? `?${qs}` : '');
214
+ const method = mode === 'push' && !sameAsCurrent ? 'pushState' : 'replaceState';
215
+ history[method](null, '', url);
216
+ sessionStorage.setItem('pi-cost:nav', qs);
217
+ }
218
+
219
+ // #endregion
220
+
221
+ // #region FETCH
222
+
223
+ let forceRefresh = false;
224
+
225
+ async function fetchJSON(url) {
226
+ const res = await fetch(url, forceRefresh ? { cache: 'reload' } : undefined);
227
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
228
+ return res.json();
229
+ }
230
+
231
+ async function fetchOverview() {
232
+ overviewData = await fetchJSON(`/api/overview?range=${dateRange}`);
233
+ }
234
+
235
+ async function fetchProjects() {
236
+ const data = await fetchJSON(`/api/projects?range=${dateRange}`);
237
+ projectsData = data.projects;
238
+ }
239
+
240
+ async function fetchSessions(projectDir) {
241
+ const data = await fetchJSON(`/api/projects/${encodeURIComponent(projectDir)}/sessions?range=${dateRange}`);
242
+ sessionsData = data.sessions || [];
243
+ sessionsDaily = data.daily || [];
244
+ sessionsModelDistribution = data.modelDistribution || [];
245
+ currentProjectInfo = data.project || null;
246
+ }
247
+
248
+ async function fetchSessionDetail(sessionId) {
249
+ sessionDetailData = await fetchJSON(`/api/sessions/${encodeURIComponent(sessionId)}`);
250
+ if (sessionDetailData && !currentProjectDir) {
251
+ currentProjectDir = sessionDetailData.projectDir;
252
+ currentProjectName = sessionDetailData.cwd;
253
+ }
254
+ }
255
+
256
+ // #endregion
257
+
258
+ // #region RENDER_OVERVIEW
259
+
260
+ function renderOverview() {
261
+ const el = document.getElementById('overview-view');
262
+ if (!el) return;
263
+ if (!overviewData) {
264
+ el.innerHTML = '<div class="loading-state"><div class="loading-spinner"></div><span>Loading...</span></div>';
265
+ return;
266
+ }
267
+
268
+ const s = overviewData.summary;
269
+ const h = JSON.stringify({ overviewData, sort: viewSort.overview });
270
+ if (lastRenderHash.overview === h) return;
271
+ lastRenderHash.overview = h;
272
+
273
+ const daily = overviewData.daily || [];
274
+ const models = overviewData.modelDistribution || [];
275
+ const projects = overviewData.projects || [];
276
+ const meta = overviewData.pricingMeta || {};
277
+ const sc = s.sourceCounts || {};
278
+ const totalRows = (sc.jsonl || 0) + (sc.pricing || 0) + (sc.none || 0);
279
+ const pctEst = totalRows ? Math.round(((sc.pricing || 0) / totalRows) * 100) : 0;
280
+
281
+ el.innerHTML = `
282
+ <div class="dashboard-content">
283
+ <div class="cards-row">
284
+ <div class="stat-card">
285
+ <div class="card-label">Today</div>
286
+ <div class="card-value cost">${formatCost(s.todayCost)}</div>
287
+ </div>
288
+ ${dateRange === 'today' ? '' : `
289
+ <div class="stat-card">
290
+ <div class="card-label">${dateRange} Days</div>
291
+ <div class="card-value cost">${formatCost(s.totalCost)}</div>
292
+ </div>`}
293
+ <div class="stat-card">
294
+ <div class="card-label">Sessions</div>
295
+ <div class="card-value">${s.totalSessions}</div>
296
+ </div>
297
+ <div class="stat-card">
298
+ <div class="card-label">Tokens</div>
299
+ <div class="card-value">${formatTokens(s.totalInput + s.totalOutput)}</div>
300
+ <div class="card-sub">In: ${formatTokens(s.totalInput)} / Out: ${formatTokens(s.totalOutput)}</div>
301
+ </div>
302
+ <div class="stat-card">
303
+ <div class="card-label">Cache</div>
304
+ <div class="card-value">${(s.cacheEfficiency * 100).toFixed(1)}%</div>
305
+ <div class="card-sub">Read: ${formatTokens(s.totalCacheRead)} / Write: ${formatTokens(s.totalCacheWrite)}</div>
306
+ </div>
307
+ <div class="stat-card">
308
+ <div class="card-label">Cost Source</div>
309
+ <div class="card-value" style="font-size:14px">
310
+ ${srcBadge('jsonl')} ${sc.jsonl || 0}
311
+ &nbsp;${srcBadge('pricing')} ${sc.pricing || 0}
312
+ </div>
313
+ <div class="card-sub">${pctEst}% estimated &middot; ${esc(meta.source || 'none')}</div>
314
+ </div>
315
+ </div>
316
+
317
+ <div class="charts-row">
318
+ <div class="chart-box">
319
+ <div class="chart-title">Daily Cost</div>
320
+ <canvas id="dailyChart"></canvas>
321
+ </div>
322
+ <div class="chart-box">
323
+ <div class="chart-title">Cost by Model</div>
324
+ <canvas id="modelChart"></canvas>
325
+ </div>
326
+ </div>
327
+
328
+ ${projects.length ? `
329
+ <div class="section-title">Projects</div>
330
+ <table class="data-table">
331
+ <thead><tr>
332
+ <th class="${thClass('name')}" onclick="sortBy('name')">Project ${sortArrow('name')}</th>
333
+ <th class="${thClass('totalCost')}" onclick="sortBy('totalCost')">Cost ${sortArrow('totalCost')}</th>
334
+ <th class="${thClass('sessions')}" onclick="sortBy('sessions')">Sessions ${sortArrow('sessions')}</th>
335
+ <th class="${thClass('lastActive')}" onclick="sortBy('lastActive')">Last Active ${sortArrow('lastActive')}</th>
336
+ <th>Model</th>
337
+ </tr></thead>
338
+ <tbody>
339
+ ${[...projects]
340
+ .sort((a, b) => sortCompare(a, b, viewSort.overview.field, viewSort.overview.order))
341
+ .map((p) => `
342
+ <tr data-clickable data-key="${esc(p.projectDir)}" onclick="navigateToSessions('${jsStr(p.projectDir)}','${jsStr(p.name)}')">
343
+ <td title="${esc(p.cwd)}">${esc(p.name)}</td>
344
+ <td class="cost-cell">${formatCost(p.totalCost)}</td>
345
+ <td>${p.sessions}</td>
346
+ <td class="muted">${timeAgo(p.lastActive)}</td>
347
+ <td><span class="model-badge">${esc(shortModel(p.primaryModel))}</span></td>
348
+ </tr>`).join('')}
349
+ </tbody>
350
+ </table>` : ''}
351
+ </div>`;
352
+
353
+ requestAnimationFrame(() => {
354
+ renderDailyChart(daily);
355
+ renderModelChart(models);
356
+ });
357
+ }
358
+
359
+ // #endregion
360
+
361
+ // #region RENDER_PROJECTS
362
+
363
+ function renderProjects() {
364
+ const el = document.getElementById('projects-view');
365
+ if (!el) return;
366
+ if (!projectsData) {
367
+ el.innerHTML = '<div class="loading-state"><div class="loading-spinner"></div><span>Loading...</span></div>';
368
+ return;
369
+ }
370
+
371
+ const h = JSON.stringify({ projectsData, sort: viewSort.projects });
372
+ if (lastRenderHash.projects === h) return;
373
+ lastRenderHash.projects = h;
374
+
375
+ const sorted = [...projectsData].sort((a, b) => sortCompare(a, b, viewSort.projects.field, viewSort.projects.order));
376
+
377
+ if (sorted.length === 0) {
378
+ el.innerHTML = `<div class="dashboard-content"><div class="empty-state">
379
+ <div class="empty-icon">$</div>
380
+ <div>No project data found</div>
381
+ <div>Make sure pi session files exist in ~/.pi/agent/sessions/</div>
382
+ </div></div>`;
383
+ return;
384
+ }
385
+
386
+ el.innerHTML = `
387
+ <div class="dashboard-content">
388
+ <div class="section-title">All Projects</div>
389
+ <table class="data-table">
390
+ <thead><tr>
391
+ <th class="${thClass('name')}" onclick="sortBy('name')">Project ${sortArrow('name')}</th>
392
+ <th class="${thClass('totalCost')}" onclick="sortBy('totalCost')">Cost ${sortArrow('totalCost')}</th>
393
+ <th class="${thClass('sessions')}" onclick="sortBy('sessions')">Sessions ${sortArrow('sessions')}</th>
394
+ <th class="${thClass('totalTokens')}" onclick="sortBy('totalTokens')">Tokens ${sortArrow('totalTokens')}</th>
395
+ <th class="${thClass('lastActive')}" onclick="sortBy('lastActive')">Last Active ${sortArrow('lastActive')}</th>
396
+ <th>Model</th>
397
+ </tr></thead>
398
+ <tbody>
399
+ ${sorted.map((p) => `
400
+ <tr data-clickable data-key="${esc(p.projectDir)}" onclick="navigateToSessions('${jsStr(p.projectDir)}','${jsStr(p.name)}')">
401
+ <td title="${esc(p.cwd)}">${esc(p.name)}</td>
402
+ <td class="cost-cell">${formatCost(p.totalCost)}</td>
403
+ <td>${p.sessions}</td>
404
+ <td>${formatTokens(p.totalTokens)}</td>
405
+ <td class="muted">${timeAgo(p.lastActive)}</td>
406
+ <td><span class="model-badge">${esc(shortModel(p.primaryModel))}</span></td>
407
+ </tr>`).join('')}
408
+ </tbody>
409
+ </table>
410
+ </div>`;
411
+ }
412
+
413
+ // #endregion
414
+
415
+ // #region RENDER_SESSIONS
416
+
417
+ function renderSessions() {
418
+ const el = document.getElementById('sessions-view');
419
+ if (!el) return;
420
+ if (!sessionsData) {
421
+ el.innerHTML = '<div class="loading-state"><div class="loading-spinner"></div><span>Loading...</span></div>';
422
+ return;
423
+ }
424
+
425
+ const h = JSON.stringify({ sessionsData, sessionsDaily, sessionsModelDistribution, sort: viewSort.sessions });
426
+ if (lastRenderHash.sessions === h) return;
427
+ lastRenderHash.sessions = h;
428
+
429
+ const total = sessionsData.reduce((s, x) => s + (x.totalCost || 0), 0);
430
+
431
+ if (sessionsData.length === 0) {
432
+ el.innerHTML = `<div class="dashboard-content">
433
+ <div class="breadcrumb">
434
+ ${parentBreadcrumb()}
435
+ <span class="sep">/</span>
436
+ <span class="current" title="${esc(currentProjectName || '')}">${esc(basename(currentProjectName) || 'Project')}</span>
437
+ </div>
438
+ <div class="empty-state"><div>No sessions found for this project</div></div>
439
+ </div>`;
440
+ return;
441
+ }
442
+
443
+ el.innerHTML = `
444
+ <div class="dashboard-content">
445
+ <div class="breadcrumb">
446
+ ${parentBreadcrumb()}
447
+ <span class="sep">/</span>
448
+ <span class="current" title="${esc(currentProjectName || '')}">${esc(basename(currentProjectName) || 'Project')}</span>
449
+ </div>
450
+ <div class="charts-row" style="grid-template-columns:3fr 2fr auto">
451
+ <div class="chart-box">
452
+ <div class="chart-title">Daily Cost</div>
453
+ <canvas id="sessionsDailyChart"></canvas>
454
+ </div>
455
+ <div class="chart-box">
456
+ <div class="chart-title">Cost by Model</div>
457
+ <canvas id="sessionsModelChart"></canvas>
458
+ </div>
459
+ <div class="chart-box" style="display:flex;flex-direction:column;justify-content:center;align-items:center;min-width:120px">
460
+ <div class="chart-title">Total Cost</div>
461
+ <div style="font-size:22px;font-weight:700;color:var(--accent)">${formatCost(total)}</div>
462
+ <div style="color:var(--text-tertiary);font-size:12px;margin-top:4px">${sessionsData.length} sessions</div>
463
+ </div>
464
+ </div>
465
+
466
+ <table class="data-table">
467
+ <thead><tr>
468
+ <th class="${thClass('customTitle')}" onclick="sortBy('customTitle')">Session ${sortArrow('customTitle')}</th>
469
+ <th class="${thClass('totalCost')}" onclick="sortBy('totalCost')">Cost ${sortArrow('totalCost')}</th>
470
+ <th class="${thClass('totalTokens')}" onclick="sortBy('totalTokens')">Tokens ${sortArrow('totalTokens')}</th>
471
+ <th class="${thClass('messageCount')}" onclick="sortBy('messageCount')">Msgs ${sortArrow('messageCount')}</th>
472
+ <th>Provider</th>
473
+ <th>Model</th>
474
+ <th class="${thClass('lastTimestamp')}" onclick="sortBy('lastTimestamp')">Last Active ${sortArrow('lastTimestamp')}</th>
475
+ </tr></thead>
476
+ <tbody>
477
+ ${[...sessionsData]
478
+ .sort((a, b) => sortCompare(a, b, viewSort.sessions.field, viewSort.sessions.order))
479
+ .map((s) => {
480
+ const label = s.customTitle || shortId(s.id);
481
+ const preview = s.firstUserText ? s.firstUserText.slice(0, 60) : '';
482
+ return `
483
+ <tr data-clickable data-key="${esc(s.id)}" onclick="navigateToDetail('${esc(s.id)}')">
484
+ <td class="truncate" title="${esc(s.id)}${preview ? ` — ${esc(s.firstUserText || '')}` : ''}"><code class="session-id">${esc(label)}</code>${preview ? ` <span class="muted">${esc(preview)}${s.firstUserText && s.firstUserText.length > 60 ? '…' : ''}</span>` : ''}${s.parentSession ? ' <span class="subagent-tag">subagent</span>' : ''}${s.subagentCount ? ` <span class="subagent-tag" title="${s.subagentCount} spawned subagent(s) totaling ${formatCost(s.subagentCost)}">+${s.subagentCount}</span>` : ''}</td>
485
+ <td class="cost-cell">${formatCost(s.totalCost)}</td>
486
+ <td>${formatTokens(s.totalTokens)}</td>
487
+ <td>${s.messageCount}</td>
488
+ <td class="muted">${esc(s.provider || '')}</td>
489
+ <td><span class="model-badge">${esc(shortModel(s.model))}</span></td>
490
+ <td class="muted">${timeAgo(s.lastTimestamp)}</td>
491
+ </tr>`;
492
+ }).join('')}
493
+ </tbody>
494
+ </table>
495
+ </div>`;
496
+
497
+ requestAnimationFrame(() => {
498
+ renderDailyChart(sessionsDaily || [], 'sessionsDailyChart', 'sessionsDaily');
499
+ renderModelChart(sessionsModelDistribution || [], 'sessionsModelChart', 'sessionsModel');
500
+ });
501
+ }
502
+
503
+ // #endregion
504
+
505
+ // #region RENDER_DETAIL
506
+
507
+ function renderDetail() {
508
+ const el = document.getElementById('detail-view');
509
+ if (!el) return;
510
+ if (!sessionDetailData) {
511
+ el.innerHTML = '<div class="loading-state"><div class="loading-spinner"></div><span>Loading...</span></div>';
512
+ return;
513
+ }
514
+
515
+ const d = sessionDetailData;
516
+ const h = JSON.stringify(d);
517
+ if (lastRenderHash.detail === h) return;
518
+ lastRenderHash.detail = h;
519
+
520
+ const projectDir = d.projectDir || currentProjectDir;
521
+ const projectCwd = d.cwd || currentProjectName || projectDir;
522
+ const projectName = basename(projectCwd);
523
+ const sc = d.sourceCounts || {};
524
+
525
+ el.innerHTML = `
526
+ <div class="dashboard-content">
527
+ <div class="breadcrumb">
528
+ ${parentBreadcrumb()}
529
+ <span class="sep">/</span>
530
+ <a onclick="navigateToSessions('${jsStr(projectDir)}','${jsStr(projectName)}')" title="${esc(projectCwd)}">${esc(projectName)}</a>
531
+ <span class="sep">/</span>
532
+ <span class="current" title="${esc(d.id || '')}"><code class="session-id">${esc(d.customTitle || shortId(d.id))}</code></span>
533
+ </div>
534
+
535
+ ${d.firstUserText ? `<div style="color:var(--text-tertiary);font-size:12px;margin-bottom:16px;font-style:italic">"${esc(d.firstUserText.slice(0, 240))}${d.firstUserText.length > 240 ? '...' : ''}"</div>` : ''}
536
+
537
+ <div class="detail-header">
538
+ <div class="detail-stat">
539
+ <div class="detail-label">Total Cost</div>
540
+ <div class="detail-value cost">${formatCost(d.totalCost)}</div>
541
+ ${d.subagentCount ? `<div class="detail-sub">+ ${formatCost(d.subagentCost)} subagents (${d.subagentCount}) = <strong>${formatCost(d.totalCost + d.subagentCost)}</strong></div>` : ''}
542
+ </div>
543
+ <div class="detail-stat">
544
+ <div class="detail-label">Input</div>
545
+ <div class="detail-value">${formatTokens(d.totalInput)}</div>
546
+ </div>
547
+ <div class="detail-stat">
548
+ <div class="detail-label">Output</div>
549
+ <div class="detail-value">${formatTokens(d.totalOutput)}</div>
550
+ </div>
551
+ <div class="detail-stat">
552
+ <div class="detail-label">Cache Write</div>
553
+ <div class="detail-value">${formatTokens(d.totalCacheWrite)}</div>
554
+ </div>
555
+ <div class="detail-stat">
556
+ <div class="detail-label">Cache Read</div>
557
+ <div class="detail-value">${formatTokens(d.totalCacheRead)}</div>
558
+ </div>
559
+ <div class="detail-stat">
560
+ <div class="detail-label">Messages</div>
561
+ <div class="detail-value">${d.messages.length}</div>
562
+ </div>
563
+ <div class="detail-stat">
564
+ <div class="detail-label">Models</div>
565
+ <div class="detail-value">${(d.models || []).map(shortModel).filter(Boolean).join(', ') || '—'}</div>
566
+ </div>
567
+ <div class="detail-stat">
568
+ <div class="detail-label">Source</div>
569
+ <div class="detail-value" style="font-size:12px">
570
+ ${srcBadge('jsonl')} ${sc.jsonl || 0}
571
+ &nbsp;${srcBadge('pricing')} ${sc.pricing || 0}
572
+ </div>
573
+ </div>
574
+ </div>
575
+
576
+ <div class="charts-row" style="margin-bottom:20px">
577
+ <div class="chart-box">
578
+ <div class="chart-title">Cumulative Cost</div>
579
+ <canvas id="cumulativeChart"></canvas>
580
+ </div>
581
+ <div class="chart-box">
582
+ <div class="chart-title">Token Breakdown per Message</div>
583
+ <canvas id="tokenBreakdownChart"></canvas>
584
+ </div>
585
+ </div>
586
+
587
+ ${d.childSessions && d.childSessions.length ? `
588
+ <div class="section-title">Spawned sessions (${d.childSessions.length})</div>
589
+ <table class="data-table" style="margin-bottom:20px">
590
+ <thead><tr><th>Session</th><th>Cost</th><th>Tokens</th><th>Msgs</th><th>Model</th><th>When</th></tr></thead>
591
+ <tbody>
592
+ ${d.childSessions.map((sa) => {
593
+ const label = sa.customTitle || shortId(sa.id);
594
+ const preview = sa.firstUserText ? sa.firstUserText.slice(0, 120) : '';
595
+ return `<tr data-clickable onclick="navigateToDetail('${esc(sa.id)}')">
596
+ <td class="truncate" title="${esc(sa.id)}${preview ? ` — ${esc(preview)}` : ''}"><code class="session-id">${esc(label)}</code>${preview ? ` <span class="muted">${esc(preview)}</span>` : ''}</td>
597
+ <td class="cost-cell">${formatCost(sa.totalCost)}</td>
598
+ <td>${formatTokens(sa.totalTokens)}</td>
599
+ <td>${sa.messageCount}</td>
600
+ <td><span class="model-badge">${esc(shortModel(sa.model))}</span></td>
601
+ <td class="muted">${timeAgo(sa.lastTimestamp)}</td>
602
+ </tr>`;
603
+ }).join('')}
604
+ </tbody>
605
+ </table>` : ''}
606
+
607
+ <div class="section-title">Messages (${d.messages.length})</div>
608
+ <table class="messages-table">
609
+ <thead><tr>
610
+ <th>#</th>
611
+ <th>Time</th>
612
+ <th>Model</th>
613
+ <th>Input</th>
614
+ <th>Output</th>
615
+ <th>Cache W.</th>
616
+ <th>Cache R.</th>
617
+ <th>Cost</th>
618
+ <th>Src</th>
619
+ <th>Cumulative</th>
620
+ </tr></thead>
621
+ <tbody>
622
+ ${[...d.messages].reverse().map((m, idxRev) => {
623
+ const idx = d.messages.length - idxRev;
624
+ const isSub = !!m.agent;
625
+ const sid = m.subagentSessionId;
626
+ const clickable = isSub && sid;
627
+ const rowAttrs = isSub
628
+ ? ` class="subagent-row${clickable ? ' clickable' : ''}" title="${esc(m.task || '')}${clickable ? ' — click to open subagent session' : ''}"${clickable ? ` onclick="navigateToDetail('${esc(sid)}')"` : ''}`
629
+ : '';
630
+ return `<tr${rowAttrs}>
631
+ <td class="muted">${idx}</td>
632
+ <td class="muted">${m.ts ? new Date(m.ts).toLocaleTimeString() : ''}</td>
633
+ <td>${isSub ? `<span class="subagent-tag">${esc(m.agent)}</span> ` : ''}<span class="model-badge">${esc(shortModel(m.model))}</span></td>
634
+ <td>${formatTokens(m.input)}</td>
635
+ <td>${formatTokens(m.output)}</td>
636
+ <td>${formatTokens(m.cacheWrite)}</td>
637
+ <td>${formatTokens(m.cacheRead)}</td>
638
+ <td class="cost-cell">${formatCost(m.cost)}</td>
639
+ <td>${srcBadge(m.costSource)}</td>
640
+ <td class="cumulative">${formatCost(m.cumulativeCost)}</td>
641
+ </tr>`;
642
+ }).join('')}
643
+ </tbody>
644
+ </table>
645
+ </div>`;
646
+
647
+ requestAnimationFrame(() => {
648
+ renderCumulativeChart(d.messages);
649
+ renderTokenBreakdownChart(d.messages);
650
+ });
651
+ }
652
+
653
+ // #endregion
654
+
655
+ // #region CHARTS
656
+
657
+ function getChartColors() {
658
+ const style = getComputedStyle(document.body);
659
+ return {
660
+ accent: style.getPropertyValue('--accent').trim() || '#e86f33',
661
+ chartFill: style.getPropertyValue('--chart-fill').trim() || 'rgba(232,111,51,0.6)',
662
+ text: style.getPropertyValue('--text-secondary').trim() || '#c2c4c9',
663
+ border: style.getPropertyValue('--border').trim() || '#363840',
664
+ bg: style.getPropertyValue('--bg-elevated').trim() || '#1e2025',
665
+ chart1: style.getPropertyValue('--chart-1').trim() || '#e86f33',
666
+ chart2: style.getPropertyValue('--chart-2').trim() || '#60a5fa',
667
+ chart3: style.getPropertyValue('--chart-3').trim() || '#3ecf8e',
668
+ chart4: style.getPropertyValue('--chart-4').trim() || '#f0b429',
669
+ };
670
+ }
671
+
672
+ function modelColor(name) {
673
+ const isLight = isLightTheme();
674
+ let h = 0;
675
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
676
+ const hue = Math.abs(h) % 360;
677
+ const sat = isLight ? 55 : 60;
678
+ const light = isLight ? 50 : 62;
679
+ return `hsl(${hue} ${sat}% ${light}%)`;
680
+ }
681
+
682
+ function chartDefaults() {
683
+ const c = getChartColors();
684
+ return {
685
+ responsive: true,
686
+ maintainAspectRatio: false,
687
+ plugins: {
688
+ legend: { display: false },
689
+ tooltip: { backgroundColor: c.bg, titleColor: c.text, bodyColor: c.text, borderColor: c.border, borderWidth: 1 },
690
+ },
691
+ scales: {
692
+ x: { ticks: { color: c.text, font: { size: 10 } }, grid: { color: c.border, drawBorder: false } },
693
+ y: { ticks: { color: c.text, font: { size: 10 } }, grid: { color: c.border, drawBorder: false } },
694
+ },
695
+ };
696
+ }
697
+
698
+ function destroyChart(id) {
699
+ if (charts[id]) { charts[id].destroy(); delete charts[id]; }
700
+ }
701
+
702
+ function renderDailyChart(daily, canvasId = 'dailyChart', chartKey = 'daily') {
703
+ const canvas = document.getElementById(canvasId);
704
+ if (!canvas || !daily?.length) return;
705
+ destroyChart(chartKey);
706
+ const c = getChartColors();
707
+ const defaults = chartDefaults();
708
+ charts[chartKey] = new Chart(canvas, {
709
+ type: 'bar',
710
+ data: {
711
+ labels: daily.map((d) => shortDate(d.date)),
712
+ datasets: [{
713
+ label: 'Daily Cost',
714
+ data: daily.map((d) => d.cost),
715
+ backgroundColor: c.accent,
716
+ borderColor: c.accent,
717
+ borderWidth: 1,
718
+ borderRadius: 3,
719
+ }],
720
+ },
721
+ options: {
722
+ ...defaults,
723
+ plugins: { ...defaults.plugins, tooltip: { ...defaults.plugins.tooltip, callbacks: { label: (ctx) => `$${ctx.parsed.y.toFixed(2)}` } } },
724
+ scales: { ...defaults.scales, y: { ...defaults.scales.y, ticks: { ...defaults.scales.y.ticks, callback: (v) => `$${v.toFixed(2)}` } } },
725
+ },
726
+ });
727
+ }
728
+
729
+ function renderModelChart(models, canvasId = 'modelChart', chartKey = 'model') {
730
+ const canvas = document.getElementById(canvasId);
731
+ if (!canvas || !models?.length) return;
732
+ destroyChart(chartKey);
733
+ const defaults = chartDefaults();
734
+ const total = models.reduce((s, m) => s + m.cost, 0);
735
+ charts[chartKey] = new Chart(canvas, {
736
+ type: 'bar',
737
+ data: {
738
+ labels: models.map((m) => shortModel(m.model)),
739
+ datasets: [{
740
+ data: models.map((m) => m.cost),
741
+ backgroundColor: models.map((m) => modelColor(m.model)),
742
+ borderWidth: 0,
743
+ borderRadius: 3,
744
+ }],
745
+ },
746
+ options: {
747
+ ...defaults,
748
+ indexAxis: 'y',
749
+ plugins: {
750
+ ...defaults.plugins,
751
+ tooltip: { ...defaults.plugins.tooltip, callbacks: { label: (ctx) => `$${ctx.parsed.x.toFixed(2)} (${((ctx.parsed.x / total) * 100).toFixed(1)}%)` } },
752
+ },
753
+ scales: { ...defaults.scales, x: { ...defaults.scales.x, ticks: { ...defaults.scales.x.ticks, callback: (v) => `$${v}` } } },
754
+ },
755
+ });
756
+ }
757
+
758
+ function renderCumulativeChart(messages) {
759
+ const canvas = document.getElementById('cumulativeChart');
760
+ if (!canvas || !messages?.length) return;
761
+ destroyChart('cumulative');
762
+ const c = getChartColors();
763
+ const defaults = chartDefaults();
764
+ charts.cumulative = new Chart(canvas, {
765
+ type: 'line',
766
+ data: {
767
+ labels: messages.map((_, i) => i + 1),
768
+ datasets: [{
769
+ label: 'Cumulative Cost',
770
+ data: messages.map((m) => m.cumulativeCost),
771
+ borderColor: c.accent,
772
+ backgroundColor: c.chartFill,
773
+ fill: true,
774
+ borderWidth: 2,
775
+ pointRadius: messages.length > 50 ? 0 : 3,
776
+ pointBackgroundColor: c.accent,
777
+ tension: 0.2,
778
+ }],
779
+ },
780
+ options: {
781
+ ...defaults,
782
+ scales: {
783
+ ...defaults.scales,
784
+ x: { ...defaults.scales.x, title: { display: true, text: 'Message #', color: c.text, font: { size: 10 } } },
785
+ y: { ...defaults.scales.y, ticks: { ...defaults.scales.y.ticks, callback: (v) => `$${v.toFixed(2)}` } },
786
+ },
787
+ },
788
+ });
789
+ }
790
+
791
+ function renderTokenBreakdownChart(messages) {
792
+ const canvas = document.getElementById('tokenBreakdownChart');
793
+ if (!canvas || !messages?.length) return;
794
+ destroyChart('tokenBreakdown');
795
+ const c = getChartColors();
796
+ const defaults = chartDefaults();
797
+ charts.tokenBreakdown = new Chart(canvas, {
798
+ type: 'bar',
799
+ data: {
800
+ labels: messages.map((_, i) => i + 1),
801
+ datasets: [
802
+ { label: 'Input', data: messages.map((m) => m.input), backgroundColor: c.chart1, borderRadius: 2 },
803
+ { label: 'Output', data: messages.map((m) => m.output), backgroundColor: c.chart2, borderRadius: 2 },
804
+ { label: 'Cache Write', data: messages.map((m) => m.cacheWrite), backgroundColor: c.chart3, borderRadius: 2 },
805
+ { label: 'Cache Read', data: messages.map((m) => m.cacheRead), backgroundColor: c.chart4, borderRadius: 2 },
806
+ ],
807
+ },
808
+ options: {
809
+ ...defaults,
810
+ plugins: {
811
+ ...defaults.plugins,
812
+ legend: {
813
+ display: true, position: 'bottom',
814
+ labels: { color: c.text, font: { size: 10, family: "'IBM Plex Mono', monospace" }, padding: 8, boxWidth: 12, boxHeight: 12 },
815
+ },
816
+ },
817
+ scales: {
818
+ ...defaults.scales,
819
+ x: { ...defaults.scales.x, stacked: true },
820
+ y: { ...defaults.scales.y, stacked: true, ticks: { ...defaults.scales.y.ticks, callback: (v) => formatTokens(v) } },
821
+ },
822
+ },
823
+ });
824
+ }
825
+
826
+ // #endregion
827
+
828
+ // #region THEME
829
+
830
+ const THEME_COLOR_TO_VAR = {
831
+ bgDeep: '--bg-deep', bgSurface: '--bg-surface', bgElevated: '--bg-elevated', bgHover: '--bg-hover',
832
+ border: '--border',
833
+ textPrimary: '--text-primary', textSecondary: '--text-secondary',
834
+ textTertiary: '--text-tertiary', textMuted: '--text-muted',
835
+ accent: '--accent', accentText: '--accent-text',
836
+ success: '--success', warning: '--warning', error: '--error',
837
+ };
838
+
839
+ let _themeCache = { light: null, dark: null };
840
+
841
+ function _hexToRgb(hex) {
842
+ const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex || '');
843
+ if (!m) return null;
844
+ return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)];
845
+ }
846
+
847
+ function _rgba(hex, a) {
848
+ const rgb = _hexToRgb(hex);
849
+ return rgb ? `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${a})` : null;
850
+ }
851
+
852
+ function isLightTheme() {
853
+ if (document.body.classList.contains('light')) return true;
854
+ if (document.body.classList.contains('dark-forced')) return false;
855
+ return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
856
+ }
857
+
858
+ function applyTheme(theme) {
859
+ if (!theme || !theme.colors) return;
860
+ const root = document.body.style;
861
+ for (const [k, cssVar] of Object.entries(THEME_COLOR_TO_VAR)) {
862
+ if (theme.colors[k]) root.setProperty(cssVar, theme.colors[k]);
863
+ }
864
+ const c = theme.colors;
865
+ const isLight = theme.mode === 'light';
866
+ const dimAlpha = isLight ? 0.15 : 0.18;
867
+ const accentDimAlpha = isLight ? 0.18 : 0.22;
868
+ const accentGlowAlpha = isLight ? 0.5 : 0.55;
869
+ const accentFaintAlpha = isLight ? 0.06 : 0.05;
870
+ const accentSoftAlpha = 0.12;
871
+ const accentTagAlpha = 0.15;
872
+ const chartFillAlpha = isLight ? 0.5 : 0.6;
873
+ const set = (name, val) => val && root.setProperty(name, val);
874
+ set('--accent-dim', _rgba(c.accent, accentDimAlpha));
875
+ set('--accent-glow', _rgba(c.accent, accentGlowAlpha));
876
+ set('--accent-faint', _rgba(c.accent, accentFaintAlpha));
877
+ set('--accent-soft', _rgba(c.accent, accentSoftAlpha));
878
+ set('--accent-tag', _rgba(c.accent, accentTagAlpha));
879
+ set('--chart-fill', _rgba(c.accent, chartFillAlpha));
880
+ set('--success-dim', _rgba(c.success, dimAlpha));
881
+ set('--warning-dim', _rgba(c.warning, dimAlpha));
882
+ set('--error-dim', _rgba(c.error, dimAlpha));
883
+ }
884
+
885
+ async function loadActiveThemes() {
886
+ try {
887
+ const res = await fetch('/api/themes');
888
+ if (res.ok) {
889
+ const data = await res.json();
890
+ _themeCache = { light: data.light, dark: data.dark };
891
+ }
892
+ } catch {}
893
+ }
894
+
895
+ function applyCurrentThemeColors() {
896
+ const t = isLightTheme() ? _themeCache.light : _themeCache.dark;
897
+ if (t) applyTheme(t);
898
+ }
899
+
900
+ function updateThemeIcon() {
901
+ const light = isLightTheme();
902
+ const dark$ = document.getElementById('theme-icon-dark');
903
+ const light$ = document.getElementById('theme-icon-light');
904
+ if (dark$) dark$.style.display = light ? 'none' : 'block';
905
+ if (light$) light$.style.display = light ? 'block' : 'none';
906
+ }
907
+
908
+ function toggleTheme() {
909
+ const isCurrentlyLight = isLightTheme();
910
+ if (isCurrentlyLight) {
911
+ document.body.classList.remove('light');
912
+ document.body.classList.add('dark-forced');
913
+ localStorage.setItem('theme', 'dark');
914
+ } else {
915
+ document.body.classList.add('light');
916
+ document.body.classList.remove('dark-forced');
917
+ localStorage.setItem('theme', 'light');
918
+ }
919
+ applyCurrentThemeColors();
920
+ updateThemeIcon();
921
+ lastRenderHash = {};
922
+ renderCurrentView();
923
+ }
924
+
925
+ function loadTheme() {
926
+ const saved = localStorage.getItem('theme');
927
+ if (saved === 'light') {
928
+ document.body.classList.add('light');
929
+ document.body.classList.remove('dark-forced');
930
+ } else if (saved === 'dark') {
931
+ document.body.classList.remove('light');
932
+ document.body.classList.add('dark-forced');
933
+ }
934
+ // No saved pref — prefers-color-scheme CSS handles the initial paint.
935
+ updateThemeIcon();
936
+ loadActiveThemes().then(() => {
937
+ applyCurrentThemeColors();
938
+ lastRenderHash = {};
939
+ renderCurrentView();
940
+ });
941
+ }
942
+
943
+ // #endregion
944
+
945
+ // #region ROUTER
946
+
947
+ function setActiveNav(view) {
948
+ document.querySelectorAll('.topbar-nav-btn').forEach((btn) => {
949
+ btn.classList.toggle('active', btn.dataset.view === view);
950
+ });
951
+ }
952
+
953
+ function showView(viewId) {
954
+ document.querySelectorAll('.view').forEach((v) => v.classList.remove('active'));
955
+ const el = document.getElementById(viewId);
956
+ if (el) el.classList.add('active');
957
+ }
958
+
959
+ async function navigate(view, params, urlMode = 'push') {
960
+ currentView = view;
961
+ if (params?.project) currentProjectDir = params.project;
962
+ if (params?.projectName) currentProjectName = params.projectName;
963
+ if (params?.session) currentSessionId = params.session;
964
+
965
+ if (view === 'overview' || view === 'projects') {
966
+ currentProjectDir = null;
967
+ currentProjectName = null;
968
+ currentSessionId = null;
969
+ }
970
+ if (view === 'sessions') {
971
+ currentSessionId = null;
972
+ }
973
+
974
+ const navView = view === 'sessions' || view === 'detail' ? parentView : view;
975
+ setActiveNav(navView);
976
+ updateUrl(urlMode);
977
+ await loadAndRender(view);
978
+ if (view !== 'detail') selectRow(0);
979
+ }
980
+
981
+ async function navigateToSessions(projectDir, name) {
982
+ if (currentView === 'overview' || currentView === 'projects') parentView = currentView;
983
+ lastSelectedProject = projectDir;
984
+ currentProjectDir = projectDir;
985
+ currentProjectName = name;
986
+ sessionsData = null;
987
+ await navigate('sessions', { project: projectDir, projectName: name });
988
+ }
989
+
990
+ async function navigateToDetail(sessionId) {
991
+ lastSelectedSession = sessionId;
992
+ currentSessionId = sessionId;
993
+ sessionDetailData = null;
994
+ await navigate('detail', { session: sessionId });
995
+ }
996
+
997
+ function sortBy(field) {
998
+ const s = currentSort();
999
+ if (s.field === field) s.order = s.order === 'desc' ? 'asc' : 'desc';
1000
+ else { s.field = field; s.order = 'desc'; }
1001
+ lastRenderHash[currentView] = null;
1002
+ saveSort();
1003
+ updateUrl('replace');
1004
+ if (currentView === 'overview') renderOverview();
1005
+ else if (currentView === 'sessions') renderSessions();
1006
+ else renderProjects();
1007
+ }
1008
+
1009
+ async function onRangeChange(val) {
1010
+ dateRange = val === 'today' ? 'today' : parseInt(val, 10) || 7;
1011
+ saveDateRange(dateRange);
1012
+ lastRenderHash = {};
1013
+ updateUrl('replace');
1014
+ const t = showToast(`Recalculating for ${rangeLabel(dateRange)}...`, true);
1015
+ await loadAndRender(currentView);
1016
+ dismissToast(t);
1017
+ }
1018
+
1019
+ async function refreshData() {
1020
+ const btn = document.getElementById('refreshBtn');
1021
+ btn.classList.add('loading');
1022
+ btn.disabled = true;
1023
+ const t = showToast(`Recalculating for ${rangeLabel(dateRange)}...`, true);
1024
+ const minWait = new Promise((r) => setTimeout(r, 250));
1025
+ try {
1026
+ await fetch('/api/refresh', { method: 'POST' });
1027
+ forceRefresh = true;
1028
+ lastRenderHash = {};
1029
+ overviewData = projectsData = sessionsData = sessionDetailData = null;
1030
+ await loadAndRender(currentView);
1031
+ forceRefresh = false;
1032
+ await minWait;
1033
+ showToast('Data refreshed', false, 'success');
1034
+ } finally {
1035
+ dismissToast(t);
1036
+ btn.classList.remove('loading');
1037
+ btn.disabled = false;
1038
+ }
1039
+ }
1040
+
1041
+ async function loadAndRender(view) {
1042
+ const myNav = ++navCounter;
1043
+ const prevSelectedIdx = selectedRowIdx;
1044
+ ensureViewElements();
1045
+ showView(`${view}-view`);
1046
+
1047
+ const viewEl = document.getElementById(`${view}-view`);
1048
+ if (viewEl && !viewEl.querySelector('.dashboard-content')) {
1049
+ viewEl.innerHTML = '<div class="loading-state"><div class="loading-spinner"></div><span>Loading...</span></div>';
1050
+ }
1051
+
1052
+ try {
1053
+ switch (view) {
1054
+ case 'overview':
1055
+ await fetchOverview();
1056
+ if (myNav !== navCounter) return;
1057
+ renderOverview();
1058
+ break;
1059
+ case 'projects':
1060
+ await fetchProjects();
1061
+ if (myNav !== navCounter) return;
1062
+ renderProjects();
1063
+ break;
1064
+ case 'sessions':
1065
+ if (currentProjectDir) {
1066
+ await fetchSessions(currentProjectDir);
1067
+ if (myNav !== navCounter) return;
1068
+ renderSessions();
1069
+ }
1070
+ break;
1071
+ case 'detail':
1072
+ if (currentSessionId) {
1073
+ await fetchSessionDetail(currentSessionId);
1074
+ if (myNav !== navCounter) return;
1075
+ renderDetail();
1076
+ }
1077
+ break;
1078
+ }
1079
+ if (prevSelectedIdx >= 0) selectRow(prevSelectedIdx);
1080
+ } catch (err) {
1081
+ if (myNav !== navCounter) return;
1082
+ console.error(`Failed to load ${view}:`, err);
1083
+ showToast(`Error: ${err.message}`);
1084
+ if (viewEl) viewEl.innerHTML = `<div class="loading-state"><span>Failed to load: ${esc(err.message)}</span></div>`;
1085
+ }
1086
+ }
1087
+
1088
+ function renderCurrentView() { loadAndRender(currentView); }
1089
+
1090
+ function ensureViewElements() {
1091
+ const app = document.getElementById('app');
1092
+ if (!app) return;
1093
+ for (const v of ['overview', 'projects', 'sessions', 'detail']) {
1094
+ if (!document.getElementById(`${v}-view`)) {
1095
+ const div = document.createElement('div');
1096
+ div.id = `${v}-view`;
1097
+ div.className = 'view';
1098
+ app.appendChild(div);
1099
+ }
1100
+ }
1101
+ const ls = document.getElementById('loadingState');
1102
+ if (ls) ls.remove();
1103
+ }
1104
+
1105
+ // #endregion
1106
+
1107
+ // #region MODAL
1108
+
1109
+ function toggleHelpModal() {
1110
+ document.getElementById('helpModal').classList.toggle('visible');
1111
+ }
1112
+
1113
+ // #endregion
1114
+
1115
+ // #region KEYBOARD
1116
+
1117
+ function getVisibleRows() {
1118
+ const viewEl = document.getElementById(`${currentView}-view`);
1119
+ if (!viewEl) return [];
1120
+ return Array.from(viewEl.querySelectorAll('tbody tr[data-clickable]'));
1121
+ }
1122
+
1123
+ function selectRow(idx) {
1124
+ const rows = getVisibleRows();
1125
+ rows.forEach((r) => r.classList.remove('kb-selected'));
1126
+ if (idx >= 0 && idx < rows.length) {
1127
+ selectedRowIdx = idx;
1128
+ rows[idx].classList.add('kb-selected');
1129
+ rows[idx].scrollIntoView({ block: 'nearest' });
1130
+ } else {
1131
+ selectedRowIdx = -1;
1132
+ }
1133
+ }
1134
+
1135
+ function activateSelectedRow() {
1136
+ const rows = getVisibleRows();
1137
+ if (selectedRowIdx >= 0 && selectedRowIdx < rows.length) rows[selectedRowIdx].click();
1138
+ }
1139
+
1140
+ async function goBack() {
1141
+ let target;
1142
+ if (currentView === 'detail') {
1143
+ if (currentProjectDir) {
1144
+ target = 'sessions';
1145
+ await navigate('sessions', { project: currentProjectDir, projectName: currentProjectName });
1146
+ } else {
1147
+ target = parentView;
1148
+ await navigate(parentView);
1149
+ }
1150
+ } else if (currentView === 'sessions') {
1151
+ target = parentView;
1152
+ await navigate(parentView);
1153
+ } else if (currentView === 'projects') {
1154
+ target = 'overview';
1155
+ await navigate('overview');
1156
+ }
1157
+ if (target) focusPreviousRow(target);
1158
+ }
1159
+
1160
+ document.addEventListener('keydown', (e) => {
1161
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
1162
+
1163
+ const anyModal = document.querySelector('.modal-overlay.visible');
1164
+ if (anyModal) {
1165
+ if (e.key === 'Escape') { anyModal.classList.remove('visible'); e.preventDefault(); }
1166
+ return;
1167
+ }
1168
+ if (e.ctrlKey || e.altKey || e.metaKey) return;
1169
+
1170
+ if (e.key === '?' || (e.key === '/' && e.shiftKey)) { e.preventDefault(); toggleHelpModal(); return; }
1171
+ if (e.key === '1') { e.preventDefault(); navigate('overview'); return; }
1172
+ if (e.key === '2') { e.preventDefault(); navigate('projects'); return; }
1173
+ if (e.key === 'r') { e.preventDefault(); refreshData(); return; }
1174
+ if (e.key === 't') { e.preventDefault(); toggleTheme(); return; }
1175
+ if (e.key === 'Escape' || e.key === 'Backspace') { e.preventDefault(); goBack(); return; }
1176
+
1177
+ if (e.key === 'j' || e.key === 'ArrowDown') {
1178
+ e.preventDefault();
1179
+ const rows = getVisibleRows();
1180
+ if (rows.length) selectRow(Math.min(selectedRowIdx + 1, rows.length - 1));
1181
+ return;
1182
+ }
1183
+ if (e.key === 'k' || e.key === 'ArrowUp') {
1184
+ e.preventDefault();
1185
+ const rows = getVisibleRows();
1186
+ if (rows.length) selectRow(Math.max(selectedRowIdx - 1, 0));
1187
+ return;
1188
+ }
1189
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activateSelectedRow(); return; }
1190
+ });
1191
+
1192
+ // #endregion
1193
+
1194
+ // #region TOAST
1195
+
1196
+ function showToast(msg, persistent, type) {
1197
+ const container = document.getElementById('toast');
1198
+ if (!container) return;
1199
+ const el = document.createElement('div');
1200
+ el.className = type ? `toast toast-${type}` : 'toast';
1201
+ el.textContent = msg;
1202
+ container.appendChild(el);
1203
+ if (!persistent) setTimeout(() => el.remove(), 3000);
1204
+ return el;
1205
+ }
1206
+
1207
+ function dismissToast(el) { if (el) el.remove(); }
1208
+
1209
+ // #endregion
1210
+
1211
+ // #region PI_INTEGRATION
1212
+
1213
+ (async function initPi() {
1214
+ try {
1215
+ const cfg = await fetch('/pi-config').then((r) => r.json());
1216
+ window.__PI__ = cfg;
1217
+ } catch {}
1218
+ })();
1219
+
1220
+ // #endregion
1221
+
1222
+ // #region PWA
1223
+
1224
+ if ('serviceWorker' in navigator) {
1225
+ navigator.serviceWorker.register('/sw.js');
1226
+ }
1227
+
1228
+ // #endregion
1229
+
1230
+ // #region INIT
1231
+
1232
+ loadTheme();
1233
+ loadSort();
1234
+
1235
+ document.addEventListener('click', async (e) => {
1236
+ if (e.target.matches('.parent-breadcrumb')) {
1237
+ const target = parentView;
1238
+ await navigate(target);
1239
+ focusPreviousRow(target);
1240
+ }
1241
+ });
1242
+
1243
+ document.addEventListener('DOMContentLoaded', async () => {
1244
+ const state = getUrlState();
1245
+ dateRange = loadDateRange();
1246
+ const rs = document.getElementById('rangeSelect');
1247
+ if (rs) rs.value = dateRange;
1248
+
1249
+ if (state.session) {
1250
+ currentSessionId = state.session;
1251
+ parentView = state.parentView;
1252
+ currentProjectDir = state.project;
1253
+ currentProjectName = state.projectName;
1254
+ await navigate('detail', { session: state.session }, 'replace');
1255
+ } else if (state.project) {
1256
+ parentView = state.parentView;
1257
+ currentProjectDir = state.project;
1258
+ currentProjectName = state.projectName;
1259
+ await navigate('sessions', { project: state.project, projectName: state.projectName }, 'replace');
1260
+ } else if (state.view === 'projects') {
1261
+ await navigate('projects', undefined, 'replace');
1262
+ } else {
1263
+ await navigate('overview', undefined, 'replace');
1264
+ }
1265
+ });
1266
+
1267
+ window.addEventListener('popstate', () => {
1268
+ const p = new URLSearchParams(window.location.search);
1269
+ currentView = p.get('view') || 'overview';
1270
+ parentView = p.get('parentView') || 'projects';
1271
+ currentProjectDir = p.get('project');
1272
+ currentProjectName = p.get('projectName');
1273
+ currentSessionId = p.get('session');
1274
+ lastRenderHash = {};
1275
+ sessionStorage.setItem('pi-cost:nav', p.toString());
1276
+ setActiveNav(currentView === 'sessions' || currentView === 'detail' ? parentView : currentView);
1277
+ loadAndRender(currentView);
1278
+ });
1279
+
1280
+ // Expose for inline handlers
1281
+ window.navigate = navigate;
1282
+ window.navigateToSessions = navigateToSessions;
1283
+ window.navigateToDetail = navigateToDetail;
1284
+ window.sortBy = sortBy;
1285
+ window.onRangeChange = onRangeChange;
1286
+ window.refreshData = refreshData;
1287
+ window.toggleTheme = toggleTheme;
1288
+ window.toggleHelpModal = toggleHelpModal;
1289
+
1290
+ // #endregion