@codemieai/code 0.4.1 → 0.4.2

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 (59) hide show
  1. package/dist/agents/core/metrics/types.d.ts +3 -0
  2. package/dist/agents/core/metrics/types.d.ts.map +1 -1
  3. package/dist/agents/core/session/BaseSessionAdapter.d.ts +3 -0
  4. package/dist/agents/core/session/BaseSessionAdapter.d.ts.map +1 -1
  5. package/dist/agents/plugins/claude/claude.session.d.ts.map +1 -1
  6. package/dist/agents/plugins/claude/claude.session.js +8 -1
  7. package/dist/agents/plugins/claude/claude.session.js.map +1 -1
  8. package/dist/agents/plugins/claude/session/claude-named-invocations.d.ts +28 -0
  9. package/dist/agents/plugins/claude/session/claude-named-invocations.d.ts.map +1 -0
  10. package/dist/agents/plugins/claude/session/claude-named-invocations.js +78 -0
  11. package/dist/agents/plugins/claude/session/claude-named-invocations.js.map +1 -0
  12. package/dist/agents/plugins/claude/session/processors/claude.metrics-processor.d.ts.map +1 -1
  13. package/dist/agents/plugins/claude/session/processors/claude.metrics-processor.js +10 -1
  14. package/dist/agents/plugins/claude/session/processors/claude.metrics-processor.js.map +1 -1
  15. package/dist/cli/commands/analytics/aggregator.d.ts +14 -0
  16. package/dist/cli/commands/analytics/aggregator.d.ts.map +1 -1
  17. package/dist/cli/commands/analytics/aggregator.js +81 -1
  18. package/dist/cli/commands/analytics/aggregator.js.map +1 -1
  19. package/dist/cli/commands/analytics/cost/cost-calculator.d.ts +10 -0
  20. package/dist/cli/commands/analytics/cost/cost-calculator.d.ts.map +1 -1
  21. package/dist/cli/commands/analytics/cost/cost-calculator.js +9 -5
  22. package/dist/cli/commands/analytics/cost/cost-calculator.js.map +1 -1
  23. package/dist/cli/commands/analytics/cost/cost-enricher.d.ts +9 -1
  24. package/dist/cli/commands/analytics/cost/cost-enricher.d.ts.map +1 -1
  25. package/dist/cli/commands/analytics/cost/cost-enricher.js +75 -7
  26. package/dist/cli/commands/analytics/cost/cost-enricher.js.map +1 -1
  27. package/dist/cli/commands/analytics/cost/dispatch-extractor.d.ts +17 -0
  28. package/dist/cli/commands/analytics/cost/dispatch-extractor.d.ts.map +1 -0
  29. package/dist/cli/commands/analytics/cost/dispatch-extractor.js +76 -0
  30. package/dist/cli/commands/analytics/cost/dispatch-extractor.js.map +1 -0
  31. package/dist/cli/commands/analytics/cost/types.d.ts +20 -0
  32. package/dist/cli/commands/analytics/cost/types.d.ts.map +1 -1
  33. package/dist/cli/commands/analytics/cost/types.js +4 -1
  34. package/dist/cli/commands/analytics/cost/types.js.map +1 -1
  35. package/dist/cli/commands/analytics/cost/usage-readers.d.ts +12 -0
  36. package/dist/cli/commands/analytics/cost/usage-readers.d.ts.map +1 -1
  37. package/dist/cli/commands/analytics/cost/usage-readers.js +38 -0
  38. package/dist/cli/commands/analytics/cost/usage-readers.js.map +1 -1
  39. package/dist/cli/commands/analytics/native-loader.d.ts.map +1 -1
  40. package/dist/cli/commands/analytics/native-loader.js +35 -0
  41. package/dist/cli/commands/analytics/native-loader.js.map +1 -1
  42. package/dist/cli/commands/analytics/report/client/app.js +531 -12
  43. package/dist/cli/commands/analytics/report/payload-builder.d.ts.map +1 -1
  44. package/dist/cli/commands/analytics/report/payload-builder.js +13 -0
  45. package/dist/cli/commands/analytics/report/payload-builder.js.map +1 -1
  46. package/dist/cli/commands/analytics/report/template.html +84 -8
  47. package/dist/cli/commands/analytics/report/types.d.ts +13 -2
  48. package/dist/cli/commands/analytics/report/types.d.ts.map +1 -1
  49. package/dist/cli/commands/analytics/types.d.ts +18 -0
  50. package/dist/cli/commands/analytics/types.d.ts.map +1 -1
  51. package/dist/migrations/004-skills-assistants-top-level.migration.d.ts.map +1 -1
  52. package/dist/migrations/004-skills-assistants-top-level.migration.js +8 -5
  53. package/dist/migrations/004-skills-assistants-top-level.migration.js.map +1 -1
  54. package/dist/migrations/005-skill-slug-format.migration.d.ts.map +1 -1
  55. package/dist/migrations/005-skill-slug-format.migration.js +14 -11
  56. package/dist/migrations/005-skill-slug-format.migration.js.map +1 -1
  57. package/dist/providers/plugins/sso/session/processors/conversations/constants.d.ts +1 -1
  58. package/dist/providers/plugins/sso/session/processors/conversations/constants.js +1 -1
  59. package/package.json +2 -1
@@ -14,6 +14,9 @@
14
14
  return;
15
15
  }
16
16
 
17
+ // sessionId -> record, for the session-detail modal (rows carry data-session).
18
+ var SESSION_BY_ID = (function () { var m = {}; (DATA.sessions || []).forEach(function (s) { m[s.sessionId] = s; }); return m; })();
19
+
17
20
  // ---- palette ------------------------------------------------------------
18
21
  var PALETTE = ['#7C5CFC', '#2297F6', '#F5A534', '#06B6D4', '#259F4C', '#F9303C', '#C084FC', '#E879A6'];
19
22
  var AGENT_COLORS = { claude: '#7C5CFC', 'claude-acp': '#9D7BFF', 'claude-desktop': '#B79DFF', gemini: '#F5A534', codex: '#06B6D4', opencode: '#259F4C', 'codemie-code': '#2297F6' };
@@ -48,6 +51,11 @@
48
51
  }
49
52
  function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
50
53
  function shortPath(p) { var parts = String(p || '').split('/'); return parts[parts.length - 1] || p; }
54
+ // Human-readable session label: the cleaned first-prompt title, falling back to a short id.
55
+ function sessTitle(s) { return (s && s.title && s.title.trim()) ? s.title.trim() : ('#' + String((s && s.sessionId) || '').slice(0, 8)); }
56
+ function truncStr(s, n) { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
57
+ // First n whitespace-delimited words (the "starting message" preview), '…' when truncated.
58
+ function firstWords(s, n) { s = String(s == null ? '' : s).trim(); var w = s.split(/\s+/); return w.length > n ? w.slice(0, n).join(' ') + '…' : s; }
51
59
 
52
60
  // ---- aggregation helpers ------------------------------------------------
53
61
  function sum(arr, f) { var t = 0; for (var i = 0; i < arr.length; i++) t += f(arr[i]) || 0; return t; }
@@ -222,6 +230,25 @@
222
230
  });
223
231
  host.appendChild(tgrid);
224
232
 
233
+ // efficiency headline KPIs (full detail on the Efficiency tab)
234
+ var effCacheReadCost = sum(fs, function (s) { return s.cacheReadCostUSD || 0; });
235
+ var effTotalCost = sum(fs, function (s) { return s.costUSD; });
236
+ var effCacheRead = sum(fs, function (s) { return s.tokens ? s.tokens.cacheRead : 0; });
237
+ var effTurns = sum(fs, function (s) { return s.turns; });
238
+ var effDead = fs.filter(function (s) { return s.costUSD > 0 && (s.filesChanged || 0) === 0 && s.netLines === 0; });
239
+ var effBloat = effTotalCost > 0 ? Math.round((effCacheReadCost / effTotalCost) * 1000) / 10 : 0;
240
+ var effAvgCtx = effTurns ? effCacheRead / effTurns : 0;
241
+ host.appendChild(el('div', 'kpi-section-label', 'Efficiency'));
242
+ var egrid = el('div', 'kpi-grid');
243
+ [['Cache-read cost', effTotalCost ? fmtUSD(effCacheReadCost) : '—', 'spend on context re-reads'],
244
+ ['Bloat %', effTotalCost ? (effBloat + '%') : '—', 'cache reads / total cost'],
245
+ ['Dead sessions', fmtNum(effDead.length), fs.length ? (Math.round((effDead.length / fs.length) * 100) + '% wasted') : ''],
246
+ ['Avg context / call', fmtTokens(Math.round(effAvgCtx)), 'see Efficiency tab']
247
+ ].forEach(function (k) {
248
+ var c = el('div', 'kpi'); c.appendChild(el('div', 'kpi-label', k[0])); c.appendChild(el('div', 'kpi-value', k[1])); if (k[2]) c.appendChild(el('div', 'kpi-sub', k[2])); egrid.appendChild(c);
249
+ });
250
+ host.appendChild(egrid);
251
+
225
252
  var row = el('div', 'grid-32 mb16');
226
253
  var trend = card('Net lines over time');
227
254
  row.appendChild(trend);
@@ -246,11 +273,24 @@
246
273
 
247
274
  // top projects
248
275
  var proj = groupBy(fs, function (s) { return s.project; });
249
- var pc = card('Top projects');
250
- var rows = Array.from(proj.entries()).map(function (e) { return { p: e[0], sessions: e[1].length, net: sum(e[1], function (s) { return s.netLines; }) }; })
251
- .sort(function (a, b) { return b.sessions - a.sessions; }).slice(0, 8);
252
- pc._body.innerHTML = tableHTML(['Project', 'Sessions', 'Net lines'],
253
- rows.map(function (r) { return ['<span title="' + esc(r.p) + '">' + esc(shortPath(r.p)) + '</span>', fmtNum(r.sessions), tdNum(r.net)]; }));
276
+ var pc = card('Top projects', 'files changed · lines added / removed · net');
277
+ var rows = Array.from(proj.entries()).map(function (e) {
278
+ return {
279
+ p: e[0], sessions: e[1].length,
280
+ files: sum(e[1], function (s) { return s.filesChanged || 0; }),
281
+ added: sum(e[1], function (s) { return s.linesAdded; }),
282
+ removed: sum(e[1], function (s) { return s.linesRemoved; }),
283
+ net: sum(e[1], function (s) { return s.netLines; })
284
+ };
285
+ }).sort(function (a, b) { return b.sessions - a.sessions; }).slice(0, 8);
286
+ pc._body.innerHTML = tableHTML(['Project', 'Sessions', 'Files', 'Lines +', 'Lines −', 'Net lines'],
287
+ rows.map(function (r) { return ['<span class="proj-link" data-proj-open="' + esc(r.p) + '" title="' + esc(r.p) + '">' + esc(shortPath(r.p)) + '</span>', fmtNum(r.sessions), tdNum(r.files), tdNum('+' + fmtNum(r.added)), tdNum('−' + fmtNum(r.removed)), tdNum(r.net)]; }));
288
+ pc._body.addEventListener('click', function (ev) {
289
+ var span = ev.target.closest('[data-proj-open]');
290
+ if (!span) return;
291
+ var path = span.getAttribute('data-proj-open');
292
+ openProjectModal(path, proj.get(path) || []);
293
+ });
254
294
  host.appendChild(pc);
255
295
  };
256
296
 
@@ -328,18 +368,29 @@
328
368
  var wrap = el('div', 'table-wrapper');
329
369
  var rows = Array.from(byProj.entries()).map(function (e) { return { p: e[0], ss: e[1] }; })
330
370
  .sort(function (a, b) { return b.ss.length - a.ss.length; });
331
- var html = '<table class="table"><thead><tr><th>Project</th><th class="td-number">Sessions</th><th class="td-number">Turns</th><th class="td-number">Net lines</th><th class="td-number">Tool success</th><th class="td-number">Cost</th></tr></thead><tbody>';
371
+ var html = '<table class="table"><thead><tr><th>Project</th><th class="td-number">Sessions</th><th class="td-number">Turns</th><th class="td-number">Files</th><th class="td-number">Lines +</th><th class="td-number">Lines −</th><th class="td-number">Net lines</th><th class="td-number">Tool success</th><th class="td-number">Cost</th></tr></thead><tbody>';
372
+ var crudCells = function (ss) {
373
+ return '<td class="td-number">' + fmtNum(sum(ss, function (s) { return s.filesChanged || 0; })) + '</td>'
374
+ + '<td class="td-number">+' + fmtNum(sum(ss, function (s) { return s.linesAdded; })) + '</td>'
375
+ + '<td class="td-number">−' + fmtNum(sum(ss, function (s) { return s.linesRemoved; })) + '</td>';
376
+ };
332
377
  rows.forEach(function (r, i) {
333
- html += '<tr class="clickable" data-proj="' + i + '"><td>▸ ' + esc(shortPath(r.p)) + '</td><td class="td-number">' + fmtNum(r.ss.length) + '</td><td class="td-number">' + fmtNum(sum(r.ss, function (s) { return s.turns; })) + '</td><td class="td-number">' + fmtNum(sum(r.ss, function (s) { return s.netLines; })) + '</td><td class="td-number">' + successRate(r.ss) + '%</td><td class="td-number">' + fmtUSD(sum(r.ss, function (s) { return s.costUSD; })) + '</td></tr>';
378
+ html += '<tr class="clickable" data-proj="' + i + '" data-proj-path="' + esc(r.p) + '"><td>▸ <span class="proj-link" data-proj-open="' + esc(r.p) + '">' + esc(shortPath(r.p)) + '</span></td><td class="td-number">' + fmtNum(r.ss.length) + '</td><td class="td-number">' + fmtNum(sum(r.ss, function (s) { return s.turns; })) + '</td>' + crudCells(r.ss) + '<td class="td-number">' + fmtNum(sum(r.ss, function (s) { return s.netLines; })) + '</td><td class="td-number">' + successRate(r.ss) + '%</td><td class="td-number">' + fmtUSD(sum(r.ss, function (s) { return s.costUSD; })) + '</td></tr>';
334
379
  // branch sub-rows (hidden)
335
380
  var byBranch = groupBy(r.ss, function (s) { return s.branch || '(none)'; });
336
381
  byBranch.forEach(function (bss, b) {
337
- html += '<tr class="drill" data-parent="' + i + '" style="display:none"><td style="padding-left:28px">⎇ ' + esc(b) + '</td><td class="td-number">' + bss.length + '</td><td class="td-number">' + fmtNum(sum(bss, function (s) { return s.turns; })) + '</td><td class="td-number">' + fmtNum(sum(bss, function (s) { return s.netLines; })) + '</td><td class="td-number">' + successRate(bss) + '%</td><td class="td-number">' + fmtUSD(sum(bss, function (s) { return s.costUSD; })) + '</td></tr>';
382
+ html += '<tr class="drill" data-parent="' + i + '" style="display:none"><td style="padding-left:28px">⎇ ' + esc(b) + '</td><td class="td-number">' + bss.length + '</td><td class="td-number">' + fmtNum(sum(bss, function (s) { return s.turns; })) + '</td>' + crudCells(bss) + '<td class="td-number">' + fmtNum(sum(bss, function (s) { return s.netLines; })) + '</td><td class="td-number">' + successRate(bss) + '%</td><td class="td-number">' + fmtUSD(sum(bss, function (s) { return s.costUSD; })) + '</td></tr>';
338
383
  });
339
384
  });
340
385
  html += '</tbody></table>';
341
386
  wrap.innerHTML = html;
342
387
  wrap.addEventListener('click', function (ev) {
388
+ var open = ev.target.closest('[data-proj-open]');
389
+ if (open) {
390
+ var path = open.getAttribute('data-proj-open');
391
+ openProjectModal(path, byProj.get(path) || []);
392
+ return;
393
+ }
343
394
  var tr = ev.target.closest('tr[data-proj]');
344
395
  if (!tr) return;
345
396
  var id = tr.getAttribute('data-proj');
@@ -395,6 +446,51 @@
395
446
  }
396
447
  row.appendChild(modelCard);
397
448
  host.appendChild(row);
449
+
450
+ // ── Named invocation charts ──────────────────────────────────────────────
451
+ var invocationDefs = [
452
+ { field: 'skillInvocations', title: 'Skills invoked', color: '#7c4fff' },
453
+ { field: 'agentInvocations', title: 'Agent subtypes', color: '#ff6b6b' },
454
+ { field: 'commandInvocations', title: 'Slash commands', color: '#4a9eff' },
455
+ ];
456
+
457
+ invocationDefs.forEach(function (def) {
458
+ var agg = {};
459
+ fs.forEach(function (s) {
460
+ (s[def.field] || []).forEach(function (x) {
461
+ agg[x.name] = (agg[x.name] || 0) + x.totalCalls;
462
+ });
463
+ });
464
+ var entries = Object.entries(agg)
465
+ .sort(function (a, b) { return b[1] - a[1]; })
466
+ .slice(0, 10);
467
+
468
+ var invCard = card(def.title);
469
+ if (entries.length) {
470
+ makeChart(canvasIn(invCard._body), {
471
+ type: 'bar',
472
+ data: {
473
+ labels: entries.map(function (e) { return e[0]; }),
474
+ datasets: [{
475
+ data: entries.map(function (e) { return e[1]; }),
476
+ backgroundColor: def.color,
477
+ borderRadius: 4,
478
+ }],
479
+ },
480
+ options: {
481
+ indexAxis: 'y',
482
+ plugins: { legend: { display: false } },
483
+ scales: {
484
+ x: { grid: { color: GRID }, ticks: { precision: 0 } },
485
+ y: { grid: { display: false } },
486
+ },
487
+ },
488
+ });
489
+ } else {
490
+ invCard._body.appendChild(el('div', 'empty', 'No data.'));
491
+ }
492
+ host.appendChild(invCard);
493
+ });
398
494
  };
399
495
 
400
496
  VIEWS.activity = function (host, fs) {
@@ -449,6 +545,162 @@
449
545
  host.appendChild(row);
450
546
  };
451
547
 
548
+ VIEWS.efficiency = function (host, fs) {
549
+ host.appendChild(el('h2', 'view-title', 'Efficiency'));
550
+ host.appendChild(el('p', 'view-sub', 'Context usage, waste, and how efficiently sessions convert spend into work. Estimates are labelled.'));
551
+ if (!fs.length) { host.appendChild(el('div', 'empty', 'No sessions in view.')); return; }
552
+
553
+ // ---- context & waste KPIs ----
554
+ var totalCacheRead = sum(fs, function (s) { return s.tokens ? s.tokens.cacheRead : 0; });
555
+ var totalTurns = sum(fs, function (s) { return s.turns; });
556
+ var totalCost = sum(fs, function (s) { return s.costUSD; });
557
+ var totalCacheReadCost = sum(fs, function (s) { return s.cacheReadCostUSD || 0; });
558
+ var avgCtx = totalTurns ? totalCacheRead / totalTurns : 0;
559
+ var worst = 0, worstS = null;
560
+ fs.forEach(function (s) {
561
+ var t = s.turns || 0, cr = s.tokens ? s.tokens.cacheRead : 0, v = t ? cr / t : 0;
562
+ if (v > worst) { worst = v; worstS = s; }
563
+ });
564
+ var bloatPct = totalCost > 0 ? Math.round((totalCacheReadCost / totalCost) * 1000) / 10 : 0;
565
+
566
+ var grid = el('div', 'kpi-grid');
567
+ [['Avg context / call', fmtTokens(Math.round(avgCtx)), 'cache tokens re-read each call'],
568
+ ['Worst session ctx / call', fmtTokens(Math.round(worst)), worstS ? esc(truncStr(sessTitle(worstS), 28)) : ''],
569
+ ['Cache-read cost', totalCacheReadCost ? fmtUSD(totalCacheReadCost) : '—', totalCost ? (bloatPct + '% of spend') : 'tokens × pricing'],
570
+ ['Bloat %', totalCost ? (bloatPct + '%') : '—', 'spend on context re-reads']
571
+ ].forEach(function (k) {
572
+ var c = el('div', 'kpi'); c.appendChild(el('div', 'kpi-label', k[0])); c.appendChild(el('div', 'kpi-value', k[1])); if (k[2]) c.appendChild(el('div', 'kpi-sub', k[2])); grid.appendChild(c);
573
+ });
574
+ host.appendChild(grid);
575
+
576
+ // ---- avg context/call per session (bar) + most bloated sessions (table) ----
577
+ var row1 = el('div', 'grid-2 mb16');
578
+ var ctxCard = card('Avg context / call per session', 'top 20 by context re-read');
579
+ var withCtx = fs.map(function (s) {
580
+ var t = s.turns || 0, cr = s.tokens ? s.tokens.cacheRead : 0;
581
+ return { s: s, v: t ? cr / t : 0 };
582
+ }).filter(function (x) { return x.v > 0; }).sort(function (a, b) { return b.v - a.v; }).slice(0, 20);
583
+ if (withCtx.length) {
584
+ var ctxTitles = withCtx.map(function (x) { return sessTitle(x.s); });
585
+ makeChart(canvasIn(ctxCard._body), {
586
+ type: 'bar',
587
+ data: { labels: withCtx.map(function (x) { return truncStr(sessTitle(x.s), 18); }), datasets: [{ data: withCtx.map(function (x) { return Math.round(x.v); }), backgroundColor: '#F5A534', borderRadius: 4 }] },
588
+ options: { plugins: { legend: { display: false }, tooltip: { callbacks: { title: function (items) { return ctxTitles[items[0].dataIndex]; }, label: function (c) { return fmtTokens(c.parsed.y) + ' / call'; } } } }, scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: 12 } }, y: { grid: { color: GRID }, ticks: { callback: function (v) { return fmtTokens(v); } } } } }
589
+ });
590
+ } else { ctxCard._body.appendChild(el('div', 'empty', 'No cache-read data.')); }
591
+ row1.appendChild(ctxCard);
592
+
593
+ var bloatCard = card('Most bloated sessions', 'highest context re-read per call');
594
+ bloatCard._body.style.paddingTop = '0';
595
+ var bloated = fs.map(function (s) {
596
+ var t = s.turns || 0, cr = s.tokens ? s.tokens.cacheRead : 0;
597
+ return { s: s, ctx: t ? cr / t : 0, bloat: s.costUSD > 0 ? ((s.cacheReadCostUSD || 0) / s.costUSD) * 100 : 0 };
598
+ }).filter(function (x) { return x.ctx > 0; }).sort(function (a, b) { return b.ctx - a.ctx; }).slice(0, 10);
599
+ bloatCard._body.innerHTML = '<div class="table-wrapper">' + tableHTML(
600
+ ['Session', 'Agent', 'Model', 'Ctx/call', 'Cache read', 'Cost', 'Bloat%'],
601
+ bloated.map(function (x) {
602
+ var s = x.s;
603
+ return ['<span title="' + esc(sessTitle(s)) + '">' + esc(truncStr(sessTitle(s), 44)) + '</span>',
604
+ '<span class="tag tag-sm" style="text-transform:capitalize">' + esc(s.agentName) + '</span>',
605
+ '<span class="tag tag-sm">' + esc((s.models && s.models[0]) || '—') + '</span>',
606
+ fmtTokens(Math.round(x.ctx)), fmtTokens(s.tokens ? s.tokens.cacheRead : 0), fmtUSD(s.costUSD), (Math.round(x.bloat * 10) / 10) + '%'];
607
+ }),
608
+ [false, false, false, true, true, true, true],
609
+ bloated.map(function (x) { return 'class="clickable" data-session="' + esc(x.s.sessionId) + '"'; })) + '</div>';
610
+ bloatCard._body.addEventListener('click', function (ev) {
611
+ var tr = ev.target.closest('tr[data-session]'); if (!tr) return;
612
+ openSessionModal(SESSION_BY_ID[tr.getAttribute('data-session')]);
613
+ });
614
+ row1.appendChild(bloatCard);
615
+ host.appendChild(row1);
616
+
617
+ // ---- dead sessions ----
618
+ var dead = fs.filter(function (s) { return s.costUSD > 0 && (s.filesChanged || 0) === 0 && s.netLines === 0; });
619
+ var deadCost = sum(dead, function (s) { return s.costUSD; });
620
+ var deadCard = card('Dead sessions', 'cost spent, zero files changed and zero net lines — pure inference waste');
621
+ var dkv = el('div', 'kpi-grid'); dkv.style.gridTemplateColumns = 'repeat(3,1fr)';
622
+ [['Dead sessions', fmtNum(dead.length), fs.length ? (Math.round((dead.length / fs.length) * 100) + '% of sessions') : ''],
623
+ ['Wasted cost', fmtUSD(deadCost), totalCost ? (Math.round((deadCost / totalCost) * 100) + '% of spend') : ''],
624
+ ['Avg cost / dead', dead.length ? fmtUSD(deadCost / dead.length) : '—', 'per unproductive session']
625
+ ].forEach(function (k) {
626
+ var c = el('div', 'kpi'); c.appendChild(el('div', 'kpi-label', k[0])); c.appendChild(el('div', 'kpi-value', k[1])); if (k[2]) c.appendChild(el('div', 'kpi-sub', k[2])); dkv.appendChild(c);
627
+ });
628
+ deadCard._body.appendChild(dkv);
629
+ if (dead.length) {
630
+ var topDead = dead.slice().sort(function (a, b) { return b.costUSD - a.costUSD; }).slice(0, 10);
631
+ var dw = el('div', 'table-wrapper');
632
+ dw.innerHTML = tableHTML(['Session', 'Agent', 'Model', 'Turns', 'Cost'],
633
+ topDead.map(function (s) {
634
+ return ['<span title="' + esc(sessTitle(s)) + '">' + esc(truncStr(sessTitle(s), 44)) + '</span>',
635
+ '<span class="tag tag-sm" style="text-transform:capitalize">' + esc(s.agentName) + '</span>',
636
+ '<span class="tag tag-sm">' + esc((s.models && s.models[0]) || '—') + '</span>',
637
+ fmtNum(s.turns), fmtUSD(s.costUSD)];
638
+ }), [false, false, false, true, true]);
639
+ deadCard._body.appendChild(dw);
640
+ }
641
+ host.appendChild(deadCard);
642
+
643
+ // ---- session depth + command effectiveness ----
644
+ var row2 = el('div', 'grid-2 mb16');
645
+ var depthCard = card('Session depth', 'turns per session — long runs that never compact/restart');
646
+ var buckets = [['1', function (t) { return t <= 1; }], ['2–5', function (t) { return t >= 2 && t <= 5; }], ['6–10', function (t) { return t >= 6 && t <= 10; }], ['11–25', function (t) { return t >= 11 && t <= 25; }], ['26–50', function (t) { return t >= 26 && t <= 50; }], ['50+', function (t) { return t > 50; }]];
647
+ var counts = buckets.map(function (b) { return fs.filter(function (s) { return b[1](s.turns || 0); }).length; });
648
+ makeChart(canvasIn(depthCard._body), {
649
+ type: 'bar', data: { labels: buckets.map(function (b) { return b[0]; }), datasets: [{ data: counts, backgroundColor: '#7C5CFC', borderRadius: 4 }] },
650
+ options: { plugins: { legend: { display: false } }, scales: { x: { grid: { display: false } }, y: { grid: { color: GRID }, ticks: { precision: 0 } } } }
651
+ });
652
+ var turnsArr = fs.map(function (s) { return s.turns || 0; }).sort(function (a, b) { return a - b; });
653
+ var median = 0;
654
+ if (turnsArr.length) {
655
+ var mid = Math.floor(turnsArr.length / 2);
656
+ median = turnsArr.length % 2 ? turnsArr[mid] : Math.round((turnsArr[mid - 1] + turnsArr[mid]) / 2);
657
+ }
658
+ var avgTurns = fs.length ? Math.round(totalTurns / fs.length) : 0;
659
+ depthCard._body.appendChild(el('p', 'text-muted', '<span style="font-size:12px">median ' + median + ' · avg ' + avgTurns + ' turns / session</span>'));
660
+ row2.appendChild(depthCard);
661
+
662
+ var cmdCard = card('Command effectiveness', 'est. cost per file changed, by command (session cost → its dominant command)');
663
+ // null-proto map: command names are data-derived, so a name like "__proto__" must not alias Object.prototype.
664
+ var cmdAgg = Object.create(null);
665
+ fs.forEach(function (s) {
666
+ var cmds = s.commandInvocations || [];
667
+ if (!cmds.length) return;
668
+ var dom = cmds.slice().sort(function (a, b) { return b.totalCalls - a.totalCalls; })[0];
669
+ var cur = cmdAgg[dom.name] || { cost: 0, artifacts: 0 };
670
+ cur.cost += s.costUSD; cur.artifacts += (s.filesChanged || 0);
671
+ cmdAgg[dom.name] = cur;
672
+ });
673
+ var cmdEntries = Object.keys(cmdAgg).map(function (name) {
674
+ var a = cmdAgg[name];
675
+ return { name: name, perArtifact: a.artifacts > 0 ? a.cost / a.artifacts : 0 };
676
+ }).filter(function (x) { return x.perArtifact > 0; }).sort(function (a, b) { return a.perArtifact - b.perArtifact; }).slice(0, 10);
677
+ if (cmdEntries.length) {
678
+ makeChart(canvasIn(cmdCard._body), {
679
+ type: 'bar',
680
+ data: { labels: cmdEntries.map(function (e) { return e.name; }), datasets: [{ data: cmdEntries.map(function (e) { return Math.round(e.perArtifact * 100) / 100; }), backgroundColor: '#06B6D4', borderRadius: 4 }] },
681
+ options: { indexAxis: 'y', plugins: { legend: { display: false }, tooltip: { callbacks: { label: function (c) { return fmtUSD(c.parsed.x) + ' / file'; } } } }, scales: { x: { grid: { color: GRID }, ticks: { callback: function (v) { return fmtUSD(v); } } }, y: { grid: { display: false } } } }
682
+ });
683
+ } else { cmdCard._body.appendChild(el('div', 'empty', 'No command + artifact data.')); }
684
+ row2.appendChild(cmdCard);
685
+ host.appendChild(row2);
686
+
687
+ // ---- code changes ----
688
+ var changeCard = card('Code changes', 'files & lines changed across sessions in view');
689
+ var cg = el('div', 'kpi-grid');
690
+ [['Files changed', fmtNum(sum(fs, function (s) { return s.filesChanged || 0; })), 'written or edited'],
691
+ ['Files written', fmtNum(sum(fs, function (s) { return s.filesWritten || 0; })), 'Write tool'],
692
+ ['Files edited', fmtNum(sum(fs, function (s) { return s.filesEdited || 0; })), 'Edit tool'],
693
+ ['Lines added', '+' + fmtNum(sum(fs, function (s) { return s.linesAdded; })), ''],
694
+ ['Lines removed', '−' + fmtNum(sum(fs, function (s) { return s.linesRemoved; })), ''],
695
+ ['Net lines', (sum(fs, function (s) { return s.netLines; }) >= 0 ? '+' : '') + fmtNum(sum(fs, function (s) { return s.netLines; })), '']
696
+ ].forEach(function (k) {
697
+ var c = el('div', 'kpi'); c.appendChild(el('div', 'kpi-label', k[0])); c.appendChild(el('div', 'kpi-value', k[1])); if (k[2]) c.appendChild(el('div', 'kpi-sub', k[2])); cg.appendChild(c);
698
+ });
699
+ changeCard._body.appendChild(cg);
700
+ changeCard._body.appendChild(el('p', 'text-muted', '<span style="font-size:12px">File deletions and line-level "modified" counts aren\'t tracked; Write counts the whole file as added.</span>'));
701
+ host.appendChild(changeCard);
702
+ };
703
+
452
704
  VIEWS.cost = function (host, fs) {
453
705
  host.appendChild(el('h2', 'view-title', 'Cost'));
454
706
  host.appendChild(el('p', 'view-sub', 'Estimated cost (API-equivalent) — token usage × model pricing. On a subscription you don’t pay per token; this is the equivalent metered API value.'));
@@ -536,6 +788,12 @@
536
788
  bar.appendChild(input); host.appendChild(bar);
537
789
  var c = card('All sessions'); c._body.style.paddingTop = '0';
538
790
  var holder = el('div', 'table-wrapper'); c._body.appendChild(holder); host.appendChild(c);
791
+ // Delegated row → modal. Attached ONCE on the persistent holder (draw() re-sets innerHTML
792
+ // on every keystroke, so a listener inside draw would stack and fire N times).
793
+ holder.addEventListener('click', function (ev) {
794
+ var tr = ev.target.closest('tr[data-session]'); if (!tr) return;
795
+ openSessionModal(SESSION_BY_ID[tr.getAttribute('data-session')]);
796
+ });
539
797
 
540
798
  function draw(q) {
541
799
  var list = fs.slice().sort(function (a, b) { return b.startTime - a.startTime; });
@@ -543,33 +801,293 @@
543
801
  var ql = q.toLowerCase();
544
802
  list = list.filter(function (s) { return (s.sessionId + ' ' + s.agentName + ' ' + s.project + ' ' + s.branch).toLowerCase().indexOf(ql) >= 0; });
545
803
  }
804
+ var shown = list.slice(0, 300);
546
805
  holder.innerHTML = tableHTML(
547
806
  ['Date', 'Agent', 'Project', 'Branch', 'Turns', 'Net lines', 'Input', 'Output', 'Cached', 'Cost'],
548
- list.slice(0, 300).map(function (s) {
807
+ shown.map(function (s) {
549
808
  return [new Date(s.startTime).toISOString().slice(0, 16).replace('T', ' '),
550
809
  '<span class="tag tag-sm" style="text-transform:capitalize">' + esc(s.agentName) + '</span>',
551
810
  '<span title="' + esc(s.project) + '">' + esc(shortPath(s.project)) + '</span>', esc(s.branch || '—'),
552
811
  fmtNum(s.turns), fmtNum(s.netLines), fmtTokens(tkIn(s)), fmtTokens(tkOut(s)), fmtTokens(tkCached(s)), fmtUSD(s.costUSD)];
553
812
  }),
554
- [false, false, false, false, true, true, true, true, true, true]);
813
+ [false, false, false, false, true, true, true, true, true, true],
814
+ shown.map(function (s) { return 'class="clickable" data-session="' + esc(s.sessionId) + '"'; }));
555
815
  if (list.length > 300) holder.appendChild(el('p', 'text-muted', '<span style="font-size:12px">Showing first 300 of ' + list.length + '.</span>'));
556
816
  }
557
817
  input.addEventListener('input', function () { draw(input.value.trim()); });
558
818
  draw('');
559
819
  };
560
820
 
821
+ // ---- session-detail modal ----------------------------------------------
822
+ var modalChart = null; // owned by the modal; NOT in the global charts[] (decoupled from destroyCharts()).
823
+ var modalEsc = null;
824
+ function closeSessionModal() {
825
+ if (modalChart) { try { modalChart.destroy(); } catch (e) {} modalChart = null; }
826
+ if (modalEsc) { document.removeEventListener('keydown', modalEsc); modalEsc = null; }
827
+ var ov = document.getElementById('session-modal'); if (ov && ov.parentNode) ov.parentNode.removeChild(ov);
828
+ }
829
+ var MODAL_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
830
+ function fmtWhen(ms) { var d = new Date(ms); return MODAL_MONTHS[d.getMonth()] + ' ' + d.getDate() + ', ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); }
831
+ function hashStr(s) { var h = 0; s = String(s); for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return Math.abs(h); }
832
+ // Lighter, borderless stat grid (label + value), replacing the heavy box-in-box KPI cards.
833
+ function statsEl(items) { // items: [label, value(html-safe), sub]
834
+ var g = el('div', 'modal-stats');
835
+ items.forEach(function (k) {
836
+ var c = el('div', 'mstat');
837
+ c.appendChild(el('div', 'mlabel', esc(k[0])));
838
+ c.appendChild(el('div', 'mval', k[1])); // value is formatter output (fmt*), not user text
839
+ if (k[2]) c.appendChild(el('div', 'msub', esc(k[2])));
840
+ g.appendChild(c);
841
+ });
842
+ return g;
843
+ }
844
+ function sumCalls(list) { return (list || []).reduce(function (a, n) { return a + (n.totalCalls || 0); }, 0); }
845
+ // Name→count list for a dispatch kind, derived from the timed native-log dispatches (the timeline's
846
+ // source) so counts/chips stay consistent with it; falls back to the tracked named-invocation
847
+ // counts when this session has no dispatch data (e.g. older/tracked-only sessions).
848
+ function dispatchCounts(s, kind) {
849
+ var disp = (s.dispatches || []).filter(function (d) { return d.kind === kind; });
850
+ if (disp.length) {
851
+ var m = {};
852
+ disp.forEach(function (d) { m[d.name] = (m[d.name] || 0) + 1; });
853
+ return Object.keys(m).map(function (n) { return { name: n, totalCalls: m[n] }; });
854
+ }
855
+ return kind === 'agent' ? (s.agentInvocations || []) : kind === 'skill' ? (s.skillInvocations || []) : (s.commandInvocations || []);
856
+ }
857
+ function chipsEl(title, list) { // list: NamedInvocationStats[]
858
+ var sec = el('div', 'dispatch-col');
859
+ sec.appendChild(el('h4', null, esc(title) + ' (' + (list ? list.length : 0) + ')'));
860
+ if (!list || !list.length) { sec.appendChild(el('div', 'text-muted', '<span style="font-size:12px">none</span>')); return sec; }
861
+ var wrap = el('div', 'modal-chips');
862
+ list.slice().sort(function (a, b) { return b.totalCalls - a.totalCalls; }).forEach(function (n) {
863
+ wrap.appendChild(el('span', 'chip', esc(n.name) + '<b>×' + fmtNum(n.totalCalls) + '</b>'));
864
+ });
865
+ sec.appendChild(wrap); return sec;
866
+ }
867
+ // One timeline row: label + track with a positioned bar + a right-aligned duration. All text esc()'d.
868
+ function tlRow(label, color, leftPct, wPct, durText, barText, strong) {
869
+ var row = el('div', 'tl-row');
870
+ row.appendChild(el('div', 'tl-label' + (strong ? ' tl-label-strong' : ''), esc(label)));
871
+ var track = el('div', 'tl-track');
872
+ var bar = el('div', 'tl-bar');
873
+ bar.style.left = leftPct + '%'; bar.style.width = wPct + '%'; bar.style.background = color;
874
+ if (barText) bar.innerHTML = '<span class="tl-bar-text">' + esc(barText) + '</span>'; // only wide (session) bar
875
+ bar.title = label + ' · ' + durText; // .title property = safe (not parsed as HTML)
876
+ track.appendChild(bar); row.appendChild(track);
877
+ row.appendChild(el('div', 'tl-dur', esc(durText)));
878
+ return row;
879
+ }
880
+ // Gantt of top-level agent dispatches: session span on top, each dispatch positioned by start.
881
+ function timelineEl(s) {
882
+ var agents = (s.dispatches || []).filter(function (d) { return d.kind === 'agent'; });
883
+ var start = s.startTime;
884
+ var end = start + (s.durationMs || 0);
885
+ (s.dispatches || []).forEach(function (d) { var e = d.start + (d.durationMs || 0); if (e > end) end = e; });
886
+ var span = Math.max(1, end - start);
887
+ var wrap = el('div', 'timeline');
888
+ wrap.appendChild(tlRow('session', '#259F4C', 0, 100, fmtDuration(s.durationMs || 0), fmtUSD(s.costUSD), true));
889
+ var occ = {};
890
+ agents.forEach(function (d) {
891
+ var total = agents.filter(function (x) { return x.name === d.name; }).length;
892
+ occ[d.name] = (occ[d.name] || 0) + 1;
893
+ var label = d.name + (total > 1 ? ' #' + occ[d.name] : '');
894
+ var leftPct = ((d.start - start) / span) * 100;
895
+ var wPct = Math.max(((d.durationMs || 0) / span) * 100, 1.5);
896
+ if (leftPct + wPct > 100) leftPct = Math.max(0, 100 - wPct);
897
+ wrap.appendChild(tlRow(label, PALETTE[hashStr(d.name) % PALETTE.length], leftPct, wPct, fmtDuration(d.durationMs || 0), '', false));
898
+ });
899
+ return wrap;
900
+ }
901
+ function openSessionModal(s, onBack) {
902
+ if (!s) return;
903
+ closeSessionModal();
904
+ var ov = el('div', 'modal-overlay'); ov.id = 'session-modal';
905
+ var modal = el('div', 'modal');
906
+
907
+ // header — every interpolation through esc(); title is arbitrary user text (XSS vector).
908
+ var head = el('div', 'modal-head');
909
+ var htxt = el('div');
910
+ // onBack (optional): when this modal was reached from another modal (e.g. the project
911
+ // sessions list), render a Back affordance that returns there instead of just closing.
912
+ if (onBack) {
913
+ var back = el('button', 'modal-back', '← Back');
914
+ back.setAttribute('aria-label', 'Back to project');
915
+ back.addEventListener('click', function () { closeSessionModal(); onBack(); });
916
+ htxt.appendChild(back);
917
+ }
918
+ htxt.appendChild(el('div', 'modal-title', esc(truncStr(firstWords(sessTitle(s), 10), 120))));
919
+ var metaBits = [s.agentName, (s.models && s.models[0]) || null, shortPath(s.project), s.branch].filter(Boolean);
920
+ htxt.appendChild(el('div', 'modal-meta', metaBits.map(function (b) { return esc(b); }).join(' · ')));
921
+ head.appendChild(htxt);
922
+ var close = el('button', 'modal-close', '✕'); close.setAttribute('aria-label', 'Close'); close.addEventListener('click', closeSessionModal);
923
+ head.appendChild(close);
924
+ modal.appendChild(head);
925
+
926
+ var body = el('div', 'modal-body');
927
+
928
+ // Cost & Time / Token Usage / Activity — light borderless stats, equal-height cards.
929
+ var t = s.tokens || { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, total: 0 };
930
+ var grid3 = el('div', 'grid-3');
931
+ var costCard = card('Cost & Time'); costCard._body.appendChild(statsEl([
932
+ ['Cost', fmtUSD(s.costUSD), 'API-equivalent'],
933
+ ['Cache-read', s.cacheReadCostUSD ? fmtUSD(s.cacheReadCostUSD) : '—', ''],
934
+ ['Duration', fmtDuration(s.durationMs || 0), ''],
935
+ ['Started', '<span class="mval-sm">' + esc(fmtWhen(s.startTime)) + '</span>', '']
936
+ ]));
937
+ var tokCard = card('Token usage'); tokCard._body.appendChild(statsEl([
938
+ ['Input', fmtTokens(t.input), ''], ['Output', fmtTokens(t.output), ''],
939
+ ['Cache read', fmtTokens(t.cacheRead), ''], ['Cache create', fmtTokens(t.cacheCreation), ''],
940
+ ['Total', fmtTokens(t.total), '']
941
+ ]));
942
+ var actCard = card('Activity'); actCard._body.appendChild(statsEl([
943
+ ['Turns / API', fmtNum(s.turns), ''],
944
+ ['Tool calls', fmtNum(s.toolCallsTotal), (s.toolCallsTotal ? Math.round((s.toolCallsSuccess / s.toolCallsTotal) * 100) + '% ok' : '')],
945
+ ['Agents', fmtNum(sumCalls(dispatchCounts(s, 'agent'))), ''],
946
+ ['Skills', fmtNum(sumCalls(dispatchCounts(s, 'skill'))), ''],
947
+ ['Commands', fmtNum(sumCalls(dispatchCounts(s, 'command'))), '']
948
+ ]));
949
+ grid3.appendChild(costCard); grid3.appendChild(tokCard); grid3.appendChild(actCard);
950
+ body.appendChild(grid3);
951
+
952
+ // Code changes
953
+ var ccCard = card('Code changes'); ccCard._body.appendChild(statsEl([
954
+ ['Files changed', fmtNum(s.filesChanged || 0), 'written or edited'],
955
+ ['Lines added', '+' + fmtNum(s.linesAdded || 0), ''],
956
+ ['Lines removed', '−' + fmtNum(s.linesRemoved || 0), ''],
957
+ ['Net lines', ((s.netLines || 0) >= 0 ? '+' : '') + fmtNum(s.netLines || 0), '']
958
+ ]));
959
+ body.appendChild(ccCard);
960
+
961
+ // Token & cost growth chart (backfilled per-turn series; honest fallback when absent)
962
+ var growth = card('Token & cost growth', 'cumulative per turn — from the native log');
963
+ var series = s.costSeries || [];
964
+ if (series.length >= 2 && window.Chart) {
965
+ var useTs = series[0].t > 1e12; // epoch ms vs turn ordinal
966
+ var t0 = series[0].t;
967
+ var labels = series.map(function (p) { return useTs ? fmtDuration(Math.max(0, p.t - t0)) : ('turn ' + p.t); });
968
+ var cv = canvasIn(growth._body, 220);
969
+ modalChart = new Chart(cv, {
970
+ type: 'line',
971
+ data: { labels: labels, datasets: [
972
+ { label: 'Cost ($)', data: series.map(function (p) { return Math.round(p.cost * 10000) / 10000; }), borderColor: '#7C5CFC', backgroundColor: 'rgba(124,92,252,0.12)', fill: true, yAxisID: 'y', tension: 0.25, pointRadius: 0 },
973
+ { label: 'Tokens', data: series.map(function (p) { return p.tokens; }), borderColor: '#F5A534', backgroundColor: 'transparent', fill: false, yAxisID: 'y1', tension: 0.25, pointRadius: 0 }
974
+ ] },
975
+ options: { interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: true } },
976
+ scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: 8 } },
977
+ y: { position: 'left', grid: { color: GRID }, ticks: { callback: function (v) { return fmtUSD(v); } } },
978
+ y1: { position: 'right', grid: { display: false }, ticks: { callback: function (v) { return fmtTokens(v); } } } } }
979
+ });
980
+ } else {
981
+ growth._body.appendChild(el('div', 'empty', 'Per-turn data not available for this session.'));
982
+ }
983
+ body.appendChild(growth);
984
+
985
+ // Timeline — Gantt of top-level agent dispatches with durations (per-agent cost is not captured).
986
+ var hasAgents = (s.dispatches || []).some(function (d) { return d.kind === 'agent'; });
987
+ var tlCard = card('Timeline', hasAgents ? 'agent dispatches over the session · durations (per-agent cost is not captured)' : '');
988
+ if (hasAgents) {
989
+ tlCard._body.appendChild(timelineEl(s));
990
+ } else {
991
+ tlCard._body.appendChild(el('div', 'empty', 'No sub-agent dispatches recorded for this session.'));
992
+ }
993
+ body.appendChild(tlCard);
994
+
995
+ // Skills & commands — invoked names + counts.
996
+ var scCard = card('Skills & commands', 'invoked by name');
997
+ var cols = el('div', 'dispatch-cols');
998
+ cols.appendChild(chipsEl('Skills', dispatchCounts(s, 'skill')));
999
+ cols.appendChild(chipsEl('Commands', dispatchCounts(s, 'command')));
1000
+ scCard._body.appendChild(cols);
1001
+ body.appendChild(scCard);
1002
+
1003
+ modal.appendChild(body);
1004
+ ov.appendChild(modal);
1005
+ ov.addEventListener('click', function (ev) { if (ev.target === ov) closeSessionModal(); });
1006
+ modalEsc = function (ev) { if (ev.key === 'Escape') closeSessionModal(); };
1007
+ document.addEventListener('keydown', modalEsc);
1008
+ document.body.appendChild(ov);
1009
+ }
1010
+
1011
+ // ---- project-sessions modal --------------------------------------------
1012
+ var projModalEsc = null;
1013
+ function closeProjectModal() {
1014
+ if (projModalEsc) { document.removeEventListener('keydown', projModalEsc); projModalEsc = null; }
1015
+ var ov = document.getElementById('project-modal'); if (ov && ov.parentNode) ov.parentNode.removeChild(ov);
1016
+ }
1017
+ function openProjectModal(projectPath, sessions) {
1018
+ closeProjectModal();
1019
+ var sorted = sessions.slice().sort(function (a, b) { return b.startTime - a.startTime; });
1020
+ var ov = el('div', 'modal-overlay'); ov.id = 'project-modal';
1021
+ var modal = el('div', 'modal');
1022
+
1023
+ var head = el('div', 'modal-head');
1024
+ var htxt = el('div');
1025
+ htxt.appendChild(el('div', 'modal-title', esc(shortPath(projectPath))));
1026
+ htxt.appendChild(el('div', 'modal-meta', esc(projectPath)));
1027
+ head.appendChild(htxt);
1028
+ var close = el('button', 'modal-close', '✕'); close.setAttribute('aria-label', 'Close'); close.addEventListener('click', closeProjectModal);
1029
+ head.appendChild(close);
1030
+ modal.appendChild(head);
1031
+
1032
+ var body = el('div', 'modal-body');
1033
+ var totalCost = sum(sessions, function (s) { return s.costUSD || 0; });
1034
+ var totalTurns = sum(sessions, function (s) { return s.turns || 0; });
1035
+ var netLines = sum(sessions, function (s) { return s.netLines || 0; });
1036
+ body.appendChild(statsEl([
1037
+ ['Sessions', fmtNum(sessions.length)],
1038
+ ['Total cost', fmtUSD(totalCost)],
1039
+ ['Turns', fmtNum(totalTurns)],
1040
+ ['Net lines', (netLines >= 0 ? '+' : '') + fmtNum(netLines)]
1041
+ ]));
1042
+
1043
+ var tbl = el('div', 'table-wrapper proj-sessions');
1044
+ tbl.innerHTML = tableHTML(
1045
+ ['Session', 'Date', 'Turns', 'Cost', ''],
1046
+ sorted.map(function (s) {
1047
+ return [
1048
+ // Truncate to a short preview (~12 words / 100 chars): keeps the table compact and
1049
+ // avoids exposing the full first-prompt text in the project list.
1050
+ esc(truncStr(firstWords(sessTitle(s), 12), 100)),
1051
+ esc(fmtWhen(s.startTime)),
1052
+ fmtNum(s.turns || 0),
1053
+ fmtUSD(s.costUSD || 0),
1054
+ '<button class="btn-open" data-open-session="' + esc(s.sessionId) + '">Open ↗</button>'
1055
+ ];
1056
+ }),
1057
+ [false, false, true, true, false]
1058
+ );
1059
+ tbl.addEventListener('click', function (ev) {
1060
+ var btn = ev.target.closest('[data-open-session]');
1061
+ if (!btn) return;
1062
+ var sid = btn.getAttribute('data-open-session');
1063
+ closeProjectModal();
1064
+ // Pass an onBack closure so the session modal can return to this project list.
1065
+ openSessionModal(SESSION_BY_ID[sid], function () { openProjectModal(projectPath, sessions); });
1066
+ });
1067
+ body.appendChild(tbl);
1068
+ modal.appendChild(body);
1069
+ ov.appendChild(modal);
1070
+ ov.addEventListener('click', function (ev) { if (ev.target === ov) closeProjectModal(); });
1071
+ projModalEsc = function (ev) { if (ev.key === 'Escape') closeProjectModal(); };
1072
+ document.addEventListener('keydown', projModalEsc);
1073
+ document.body.appendChild(ov);
1074
+ }
1075
+
561
1076
  // ---- table + misc helpers ----------------------------------------------
562
1077
  function tdNum(v) { return '<span class="td-number">' + (typeof v === 'number' ? fmtNum(v) : v) + '</span>'; }
563
1078
  // numericCols: optional boolean[] marking right-aligned numeric columns. Default = every
564
1079
  // column except the first (back-compat). Text columns (agent, project, branch, status) must
565
1080
  // be left-aligned, so callers with interleaved/leading text pass an explicit mask.
566
- function tableHTML(headers, rows, numericCols) {
1081
+ function tableHTML(headers, rows, numericCols, rowAttrs) {
567
1082
  var isNum = numericCols || headers.map(function (_, i) { return i > 0; });
568
1083
  var h = '<table class="table"><thead><tr>';
569
1084
  headers.forEach(function (x, i) { h += '<th' + (isNum[i] ? ' class="td-number"' : '') + '>' + esc(x) + '</th>'; });
570
1085
  h += '</tr></thead><tbody>';
571
1086
  if (!rows.length) h += '<tr><td colspan="' + headers.length + '" class="text-muted">No data</td></tr>';
572
- rows.forEach(function (r) { h += '<tr>' + r.map(function (cell, i) { return '<td' + (isNum[i] ? ' class="td-number"' : '') + '>' + cell + '</td>'; }).join('') + '</tr>'; });
1087
+ rows.forEach(function (r, ri) {
1088
+ var attrs = rowAttrs && rowAttrs[ri] ? ' ' + rowAttrs[ri] : '';
1089
+ h += '<tr' + attrs + '>' + r.map(function (cell, i) { return '<td' + (isNum[i] ? ' class="td-number"' : '') + '>' + cell + '</td>'; }).join('') + '</tr>';
1090
+ });
573
1091
  return h + '</tbody></table>';
574
1092
  }
575
1093
  // tokens shorthand: cached = cacheRead + cacheCreation (the prompt-cache reuse + writes)
@@ -586,6 +1104,7 @@
586
1104
  }
587
1105
 
588
1106
  function render() {
1107
+ closeSessionModal();
589
1108
  applyChartTheme();
590
1109
  destroyCharts();
591
1110
  root.innerHTML = '';