@krtclcdy/tersio 2.18.0 → 2.20.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.
Files changed (44) hide show
  1. package/README.md +2 -2
  2. package/cli/common.js +21 -3
  3. package/cli/currency.js +102 -0
  4. package/cli/dashboard.js +54 -2
  5. package/cli/doctor.js +9 -7
  6. package/cli/install.js +35 -101
  7. package/cli/profile.js +92 -0
  8. package/cli/reset.js +4 -2
  9. package/cli/settings.js +122 -0
  10. package/cli/usage.js +38 -24
  11. package/dashboard/app.js +297 -70
  12. package/dashboard/styles.css +58 -1
  13. package/dashboard/template.html +75 -3
  14. package/extensions/ai-addons-updater/index.ts +377 -0
  15. package/extensions/caveman-session/index.ts +161 -0
  16. package/extensions/combo-toggle/index.ts +217 -0
  17. package/extensions/lib/utils.ts +162 -0
  18. package/extensions/rtk-session/index.ts +164 -0
  19. package/extensions/shared/carbon.ts +123 -0
  20. package/extensions/shared/mode-reinforcement.ts +29 -0
  21. package/extensions/shared/plugin-settings.ts +57 -0
  22. package/extensions/shared/pricing.ts +168 -0
  23. package/extensions/shared/rtk-gain.ts +105 -0
  24. package/extensions/shared/session-state.ts +201 -0
  25. package/extensions/shared/types.ts +85 -0
  26. package/extensions/shared/usage-ledger.ts +406 -0
  27. package/extensions/shared/usage-store.ts +325 -0
  28. package/extensions/tersio-commands/index.ts +110 -0
  29. package/package.json +36 -24
  30. package/tersio.js +8 -1
  31. package/extensions/ai-addons-updater/index.js +0 -331
  32. package/extensions/caveman-session/index.js +0 -152
  33. package/extensions/combo-toggle/index.js +0 -165
  34. package/extensions/lib/utils.js +0 -154
  35. package/extensions/rtk-session/index.js +0 -148
  36. package/extensions/shared/carbon.js +0 -88
  37. package/extensions/shared/mode-reinforcement.js +0 -26
  38. package/extensions/shared/plugin-settings.js +0 -51
  39. package/extensions/shared/pricing.js +0 -163
  40. package/extensions/shared/rtk-gain.js +0 -82
  41. package/extensions/shared/session-state.js +0 -177
  42. package/extensions/shared/types.js +0 -3
  43. package/extensions/shared/usage-ledger.js +0 -333
  44. package/extensions/tersio-commands/index.js +0 -102
package/dashboard/app.js CHANGED
@@ -116,6 +116,14 @@
116
116
  function setCur(k) {
117
117
  fx.cur = k; active = keys.indexOf(k);
118
118
  try { localStorage.setItem('tersio-fx-cur', fx.cur); } catch (e) { }
119
+ // Served mode: also persist server-side so close → reopen keeps the
120
+ // choice. Every `tersio gain` run is a fresh ephemeral port (a new
121
+ // origin), so localStorage alone cannot survive a restart.
122
+ if (window.location.protocol !== 'file:' && typeof fetch === 'function') {
123
+ try {
124
+ fetch('currency', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currency: k }) }).catch(function() { });
125
+ } catch (e) { }
126
+ }
119
127
  paint();
120
128
  if (DATA) render(DATA);
121
129
  }
@@ -250,13 +258,29 @@
250
258
  var b = byModel[m] || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
251
259
  return b.input + b.output + b.cacheRead + b.cacheWrite;
252
260
  }
253
-
254
- var DATA = null, RANGE = 'daily', cardsShown = false;
255
-
261
+ var DATA = null, cardsShown = false;
256
262
  function topModels(byModel, n) {
257
263
  return Object.keys(byModel).sort(function(a, b) { return modelTotal(byModel, b) - modelTotal(byModel, a); }).slice(0, n);
258
264
  }
259
-
265
+ // Display form for folded keys: namespace stays lowercase, model segments
266
+ // title-case with version dots kept (`deepseek-v4.1-flash` →
267
+ // `Deepseek-V4.1-Flash`). Keys arrive folded, so this prettifies only.
268
+ function displayModel(m) {
269
+ var bare = String(m).replace(/(?::free|-free)$/i, '');
270
+ function cap(s) {
271
+ var low = s.toLowerCase();
272
+ if (low === 'openai') return 'OpenAI';
273
+ if (low === 'ai') return 'AI';
274
+ if (/^\d+[a-z]+$/.test(low)) return low.toUpperCase();
275
+ if (s.length <= 2) return s.toUpperCase();
276
+ return s[0].toUpperCase() + s.slice(1).toLowerCase();
277
+ }
278
+ function seg(s) { return s.split('.').map(cap).join('.'); }
279
+ function words(s) { return s.split(/[-_:]+/).filter(Boolean).map(seg).join('-'); }
280
+ var slash = bare.indexOf('/');
281
+ if (slash >= 0) return bare.slice(0, slash).toLowerCase() + '/' + words(bare.slice(slash + 1));
282
+ return words(bare);
283
+ }
260
284
  function allDays() {
261
285
  var set = {};
262
286
  Object.keys(DATA.byDay || {}).forEach(function(d) { set[d] = 1; });
@@ -293,7 +317,7 @@
293
317
  var dt = new Date(key + 'T12:00:00');
294
318
  return isNaN(dt) ? key : dt.toLocaleString('en-US', { month: 'short', day: 'numeric' }).toUpperCase();
295
319
  }
296
- function shortName(m) { return m.length > 22 ? m.slice(0, 21) + '...' : m; }
320
+ function shortName(m) { var d = displayModel(m); return d.length > 22 ? d.slice(0, 21) + '...' : d; }
297
321
  function tipDayHTML(key, total, rows) {
298
322
  var h = '<div class="tt">' + dateHead(key) + '</div><div class="tv">' + fmtShort(total) + ' total</div>';
299
323
  rows.forEach(function(r) {
@@ -418,6 +442,222 @@
418
442
  el.addEventListener('mousemove', function(ev) { moveTip(ev.clientX, ev.clientY); });
419
443
  el.addEventListener('mouseleave', hideTip);
420
444
  }
445
+ // Model detail dialog: shadcn chart language over daily model volume.
446
+ // byDayModel is keyed by DAY (Record<day, Record<model, tokens>>); the old
447
+ // mdSeries read it keyed by model and rendered all-zero charts. Area chart
448
+ // with gradient + hover crosshair, downsampled rounded bars, token-mix
449
+ // donut, KPI strip, 30/90/ALL range tabs.
450
+ var mdModel = null, mdSpan = 'all', mdHover = { pts: [], days: [] };
451
+ function mdSeries(m) {
452
+ var days = allDays();
453
+ var per = DATA.byDayModel || {};
454
+ return days.map(function(d) { return { day: d, v: ((per[d] || {})[m] || 0) }; });
455
+ }
456
+ function mdSlice(series) {
457
+ if (mdSpan === 'all' || series.length <= mdSpan) return series;
458
+ return series.slice(series.length - mdSpan);
459
+ }
460
+ function mdMonthTicks(days) {
461
+ if (!days.length) return [];
462
+ var seen = {}, out = [];
463
+ days.forEach(function(d) {
464
+ var t = d.slice(0, 7);
465
+ if (!seen[t]) { seen[t] = 1; out.push({ day: d, label: new Date(d + 'T12:00:00').toLocaleString('en-US', { month: 'short' }) }); }
466
+ });
467
+ return out.slice(-6);
468
+ }
469
+ function mdShortDay(d) { return new Date(d + 'T12:00:00').toLocaleString('en-US', { month: 'short', day: 'numeric' }); }
470
+ function mdAreaChart(svg, pts, days) {
471
+ var W = 560, H = 190, max = 1, i, x, y;
472
+ pts.forEach(function(p) { max = Math.max(max, p); });
473
+ var step = pts.length > 1 ? (W - 16) / (pts.length - 1) : 0;
474
+ function X(i) { return 8 + i * step; }
475
+ function Y(p) { return 14 + (1 - p / max) * (H - 36); }
476
+ var grid = '';
477
+ for (i = 1; i <= 3; i++) { y = 14 + (H - 36) * i / 3; grid += '<line class="grid" x1="8" y1="' + y.toFixed(1) + '" x2="' + (W - 8) + '" y2="' + y.toFixed(1) + '"/>'; }
478
+ var d = pts.map(function(p, i) { return (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(p).toFixed(1); }).join(' ');
479
+ var last = pts.length ? { x: X(pts.length - 1), y: Y(pts[pts.length - 1]) } : { x: 8, y: H - 22 };
480
+ svg.innerHTML = '<defs><linearGradient id="mdGrad" x1="0" y1="0" x2="0" y2="1">' +
481
+ '<stop offset="0" style="stop-color:var(--accent)" stop-opacity="0.35"/>' +
482
+ '<stop offset="1" style="stop-color:var(--accent)" stop-opacity="0"/></linearGradient></defs>' +
483
+ grid +
484
+ (pts.length ? '<path class="area" d="' + d + ' L' + last.x.toFixed(1) + ' ' + (H - 22) + ' L8 ' + (H - 22) + ' Z"/>' : '') +
485
+ (pts.length ? '<path class="line" d="' + d + '"/>' : '') +
486
+ (pts.length ? '<circle class="dot" cx="' + last.x.toFixed(1) + '" cy="' + last.y.toFixed(1) + '" r="4"/>' : '') +
487
+ (pts.length ? '<circle class="hoverdot" id="mdHoverDot" cx="-10" cy="-10" r="4" style="display:none"/>' : '');
488
+ mdHover = { pts: pts, days: days };
489
+ svg.onmousemove = function(ev) {
490
+ var tip = document.getElementById('mdTip');
491
+ if (!tip || !mdHover.pts.length) return;
492
+ var r = svg.getBoundingClientRect();
493
+ var fx = (ev.clientX - r.left) / r.width * W;
494
+ var idx = Math.max(0, Math.min(mdHover.pts.length - 1, Math.round((fx - 8) / (step || 1))));
495
+ var dot = document.getElementById('mdHoverDot');
496
+ if (dot) { dot.setAttribute('cx', X(idx).toFixed(1)); dot.setAttribute('cy', Y(mdHover.pts[idx]).toFixed(1)); dot.style.display = ''; }
497
+ tip.textContent = mdShortDay(mdHover.days[idx]) + ' · ' + fmtShort(mdHover.pts[idx]);
498
+ tip.style.display = 'block';
499
+ // Raw % positioning overflows past the dialog edge on the first/last
500
+ // points (the dialog clips it). Measure and pin inside the card.
501
+ var chart = svg.closest ? svg.closest('.md-chart') : null;
502
+ var px = svg.offsetLeft + (X(idx) / W) * svg.clientWidth;
503
+ if (chart) {
504
+ var half = tip.offsetWidth / 2;
505
+ px = Math.max(half + 4, Math.min(chart.clientWidth - half - 4, px));
506
+ }
507
+ tip.style.left = px + 'px';
508
+ tip.style.top = '8px';
509
+ };
510
+ svg.onmouseleave = function() {
511
+ var tip = document.getElementById('mdTip');
512
+ if (tip) tip.style.display = 'none';
513
+ var dot = document.getElementById('mdHoverDot');
514
+ if (dot) dot.style.display = 'none';
515
+ };
516
+ }
517
+ function mdBars(el, vals, days) {
518
+ el.innerHTML = '';
519
+ var max = 1, i, total = 0, active = 0, peak = 0, peakI = 0;
520
+ vals.forEach(function(v, k) { max = Math.max(max, v); total += v; if (v > 0) { active++; if (v > peak) { peak = v; peakI = k; } } });
521
+ var n = vals.length, bucket = 1;
522
+ if (n > 90) bucket = Math.ceil(n / 90);
523
+ for (i = 0; i < n; i += bucket) {
524
+ var sum = 0, c = 0;
525
+ for (var j = i; j < Math.min(n, i + bucket); j++) { sum += vals[j]; c++; }
526
+ var avg = sum / c;
527
+ var s = document.createElement('span');
528
+ if (!avg) s.className = 'zero';
529
+ else if (avg === max) s.className = 'top';
530
+ s.style.height = Math.max(avg ? 5 : 2, Math.round(avg / max * 100)) + '%';
531
+ s.title = mdShortDay(days[i]) + ' · ' + fmtShort(Math.round(sum));
532
+ el.appendChild(s);
533
+ }
534
+ // Sparse models leave the bars mostly flat: name the peak day, its share
535
+ // of the period, and the per-active-day average instead of empty space.
536
+ var note = document.getElementById('mdBarsNote');
537
+ if (note) {
538
+ if (!active) note.textContent = 'no activity in range';
539
+ else note.textContent = 'Peak ' + mdShortDay(days[peakI]) + ' · ' + fmtShort(peak) + ' (' + (total ? Math.round(peak / total * 100) : 0) + '% of period) · avg ' + fmtShort(Math.round(total / active)) + ' / active day';
540
+ }
541
+ }
542
+ function mdDonut(svg, parts) {
543
+ var r = 54, C = 2 * Math.PI * r, off = 0;
544
+ var total = parts.reduce(function(a, p) { return a + p.v; }, 0) || 1;
545
+ var html = '<circle class="tk" cx="70" cy="70" r="' + r + '"/>';
546
+ parts.forEach(function(p) {
547
+ var len = p.v / total * C;
548
+ if (len <= 0) return;
549
+ html += '<circle cx="70" cy="70" r="' + r + '" style="stroke:' + p.color + '" stroke-dasharray="' + len.toFixed(1) + ' ' + (C - len).toFixed(1) + '" stroke-dashoffset="' + (-off).toFixed(1) + '" stroke-linecap="butt"/>';
550
+ off += len;
551
+ });
552
+ svg.innerHTML = html;
553
+ document.getElementById('mdMixTotal').textContent = fmtShort(total);
554
+ document.getElementById('mdMixLegend').innerHTML = parts.filter(function(p) { return p.v > 0; }).map(function(p) {
555
+ return '<div class="row"><span class="dot" style="background:' + p.color + '"></span><span>' + p.label + '</span><span class="pct">' + (p.v / total * 100).toFixed(1) + '% · ' + fmtShort(p.v) + '</span></div>';
556
+ }).join('');
557
+ }
558
+ function mdMonths(el, days) {
559
+ el.innerHTML = '';
560
+ mdMonthTicks(days).forEach(function(t) {
561
+ var s = document.createElement('span');
562
+ s.textContent = t.label;
563
+ el.appendChild(s);
564
+ });
565
+ }
566
+ function mdKpi(k, v, d, up) {
567
+ return '<div class="md-kpi"><p class="k mono">' + k + '</p><p class="v">' + v + '</p><p class="d mono ' + (up ? 'up' : 'flat') + '">' + d + '</p></div>';
568
+ }
569
+ function mdStat(k, v, s) {
570
+ return '<div class="md-stat"><p class="k mono">' + k + '</p><p class="v mono">' + v + '</p>' + (s ? '<p class="s mono">' + s + '</p>' : '') + '</div>';
571
+ }
572
+ function mdRange(days) {
573
+ if (!days.length) return '–';
574
+ return mdShortDay(days[0]).toUpperCase() + ' → ' + mdShortDay(days[days.length - 1]).toUpperCase();
575
+ }
576
+ function mdTrendText(vals) {
577
+ var max = 0, i;
578
+ vals.forEach(function(x) { max = Math.max(max, x); });
579
+ if (vals.length < 14) return max ? { t: 'peak ' + fmtShort(max) + ' in a day', up: true } : { t: 'no activity yet', up: false };
580
+ var a = 0, b = 0;
581
+ for (i = vals.length - 14; i < vals.length; i++) a += vals[i];
582
+ for (i = Math.max(0, vals.length - 28); i < vals.length - 14; i++) b += vals[i];
583
+ if (!b) return { t: fmtShort(a) + ' / 2 wks', up: a > 0 };
584
+ var pct = (a - b) / b * 100;
585
+ return { t: (pct >= 0 ? '+' : '') + pct.toFixed(0) + '% vs prior 2 wks', up: pct >= 0 };
586
+ }
587
+ function renderModelDialog(m) {
588
+ var byModel = DATA.byModel || {};
589
+ var tops = topModels(byModel, Object.keys(byModel).length);
590
+ var rank = tops.indexOf(m) + 1;
591
+ var b = byModel[m] || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
592
+ var total = modelTotal(byModel, m);
593
+ var grand = 1;
594
+ tops.forEach(function(k) { grand += modelTotal(byModel, k); });
595
+ var v = vendorOf(m);
596
+ var req = ((DATA.byModelMessages || {})[m]) || 0;
597
+ var usd = ((DATA.byModelUsd || {})[m]) || 0;
598
+ var denom = b.input + b.cacheRead;
599
+ var hit = denom ? (b.cacheRead / denom * 100) : 0;
600
+ var series = mdSlice(mdSeries(m));
601
+ var days = series.map(function(p) { return p.day; });
602
+ var vals = series.map(function(p) { return p.v; });
603
+ var active = vals.filter(function(x) { return x > 0; }).length;
604
+ var tr = mdTrendText(mdSeries(m).map(function(p) { return p.v; }));
605
+ document.getElementById('mdBadge').innerHTML = brandHTML(v, false);
606
+ var title = document.getElementById('mdTitle');
607
+ title.textContent = displayModel(m);
608
+ title.title = m;
609
+ document.getElementById('mdSub').textContent = v.name + ' · ' + fmt(req) + ' requests · ' + (total / grand * 100).toFixed(1) + '% of volume';
610
+ document.getElementById('mdRank').textContent = rank ? '#' + String(rank).padStart(2, '0') : '#–';
611
+ var trend = document.getElementById('mdTrend');
612
+ trend.textContent = tr.t;
613
+ trend.className = 'md-trend mono' + (tr.up ? ' up' : '');
614
+ document.getElementById('mdKpis').innerHTML =
615
+ mdKpi('Tokens', fmtShort(total), mdRange(days), true) +
616
+ mdKpi('Spend', fxMoney(usd), req ? fxMoney(usd / req) + ' / req' : 'no requests', usd > 0) +
617
+ mdKpi('Cache hit', hit.toFixed(0) + '%', fmtShort(b.cacheRead) + ' cached', hit >= 50) +
618
+ mdKpi('Requests', fmt(req), active + ' active days', active > 0);
619
+ document.getElementById('mdMomRange').textContent = mdRange(days);
620
+ mdAreaChart(document.getElementById('mdSpark'), vals, days);
621
+ mdMonths(document.getElementById('mdMonths'), days);
622
+ var act = document.getElementById('mdActive');
623
+ act.textContent = active + ' / ' + days.length + ' days';
624
+ act.className = 'md-trend mono' + (active ? ' up' : '');
625
+ mdBars(document.getElementById('mdBars'), vals, days);
626
+ mdMonths(document.getElementById('mdBarsMonths'), days);
627
+ mdDonut(document.getElementById('mdMix'), [
628
+ { label: 'Input', v: b.input, color: 'var(--accent)' },
629
+ { label: 'Output', v: b.output, color: '#818cf8' },
630
+ { label: 'Cache read', v: b.cacheRead, color: '#22d3ee' },
631
+ { label: 'Cache write', v: b.cacheWrite, color: 'var(--dim)' },
632
+ ]);
633
+ document.getElementById('mdStats').innerHTML =
634
+ mdStat('Input', fmtShort(b.input), null) +
635
+ mdStat('Output', fmtShort(b.output), null) +
636
+ mdStat('Cache read', fmtShort(b.cacheRead), hit.toFixed(0) + '% hit') +
637
+ mdStat('Cache write', fmtShort(b.cacheWrite), null);
638
+ if (window.lucide) lucide.createIcons();
639
+ if (hasGsap && !reduce) {
640
+ gsap.fromTo('#modelDialog .md-kpi', { y: 12, opacity: 0.2 }, { y: 0, opacity: 1, duration: 0.4, ease: 'power2.out', stagger: 0.05, overwrite: true });
641
+ gsap.fromTo('#modelDialog .md-chart', { y: 14, opacity: 0.2 }, { y: 0, opacity: 1, duration: 0.45, ease: 'power2.out', stagger: 0.07, overwrite: true });
642
+ gsap.fromTo('#modelDialog .md-stat', { scale: 0.94, opacity: 0.2 }, { scale: 1, opacity: 1, duration: 0.4, ease: 'power2.out', stagger: 0.04, overwrite: true });
643
+ }
644
+ }
645
+ function openModelDialog(m) {
646
+ var dlg = document.getElementById('modelDialog');
647
+ if (!dlg || typeof dlg.showModal !== 'function') return;
648
+ mdModel = m;
649
+ Array.prototype.forEach.call(document.querySelectorAll('#modelDialog [data-mdspan]'), function(btn) {
650
+ var on = btn.getAttribute('data-mdspan') === String(mdSpan);
651
+ btn.classList.toggle('on', on);
652
+ btn.onclick = function() {
653
+ mdSpan = btn.getAttribute('data-mdspan') === 'all' ? 'all' : Number(btn.getAttribute('data-mdspan'));
654
+ Array.prototype.forEach.call(document.querySelectorAll('#modelDialog [data-mdspan]'), function(o) { o.classList.toggle('on', o === btn); });
655
+ renderModelDialog(mdModel);
656
+ };
657
+ });
658
+ renderModelDialog(m);
659
+ if (!dlg.open) dlg.showModal();
660
+ }
421
661
  function dayModelRows(key) {
422
662
  var per = (DATA.byDayModel || {})[key] || {};
423
663
  var tops = topModels(DATA.byModel || {}, 8), rows = [], other = 0;
@@ -634,7 +874,8 @@
634
874
  ghostSparkle.setAttribute('aria-hidden', 'true');
635
875
  card.appendChild(ghostSparkle);
636
876
  }
637
- card.style.cssText = 'border: 1px solid var(--line); background: var(--panel)';
877
+ card.style.cssText = 'border: 1px solid var(--line); background: var(--panel); cursor: pointer';
878
+ card.setAttribute('role', 'button');
638
879
  var head = document.createElement('p');
639
880
  head.className = 'mono text-xs mb-3'; head.style.color = 'var(--dim)';
640
881
  head.textContent = '0' + (i + 1);
@@ -644,13 +885,14 @@
644
885
  badgeWrap.innerHTML = brandHTML(v, false);
645
886
  var badge = badgeWrap.firstChild;
646
887
  var mid = document.createElement('div'); mid.className = 'min-w-0 flex-1';
647
- var nm = document.createElement('p'); nm.className = 'mono text-sm font-bold truncate'; nm.textContent = m;
888
+ var nm = document.createElement('p'); nm.className = 'mono text-sm font-bold truncate'; nm.textContent = displayModel(m); nm.title = m;
648
889
  var vn = document.createElement('p'); vn.className = 'mono text-xs truncate'; vn.style.color = 'var(--dim)'; vn.textContent = v.name;
649
890
  mid.appendChild(nm); mid.appendChild(vn);
650
891
  var tot = document.createElement('p'); tot.className = 'mono font-bold text-lg shrink-0'; tot.textContent = fmtShort(modelTotal(byModel, m));
651
892
  row.appendChild(badge); row.appendChild(mid); row.appendChild(tot);
652
893
  card.appendChild(head); card.appendChild(row);
653
894
  hoverModel(card, m);
895
+ (function(model) { card.addEventListener('click', function() { openModelDialog(model); }); })(m);
654
896
  cards.appendChild(card);
655
897
  });
656
898
  var ol = document.getElementById('models');
@@ -668,7 +910,7 @@
668
910
  badgeWrap.innerHTML = brandHTML(v, true);
669
911
  var badge = badgeWrap.firstChild;
670
912
  var mid = document.createElement('div'); mid.className = 'min-w-0 flex-1';
671
- var nm = document.createElement('p'); nm.className = 'mono text-sm truncate'; nm.textContent = m;
913
+ var nm = document.createElement('p'); nm.className = 'mono text-sm truncate'; nm.textContent = displayModel(m); nm.title = m;
672
914
  var track = document.createElement('div'); track.className = 'bar-track mt-1.5 h-1.5 overflow-hidden';
673
915
  var fill = document.createElement('div'); fill.className = 'bar-fill h-full';
674
916
  var mv = modelTotal(byModel, m);
@@ -682,6 +924,7 @@
682
924
  right.appendChild(n); right.appendChild(cost);
683
925
  li.appendChild(badge); li.appendChild(mid); li.appendChild(right);
684
926
  hoverModel(li, m);
927
+ (function(model) { li.addEventListener('click', function() { openModelDialog(model); }); })(m);
685
928
  ol.appendChild(li);
686
929
  });
687
930
  document.getElementById('modelsCount').textContent = tops.length ? tops.length + ' models' : '';
@@ -712,43 +955,12 @@
712
955
 
713
956
  // Union of session tool calls + RTK-metered commands, sortable + paged.
714
957
  // Session tools were never metered per command: saved / avg / time show – and sort last.
715
- // RTK rows arrive per (command, project) because rtk's history is machine-wide:
716
- // fusing projects let one repo's `rtk grep` stand in for every project's.
717
- var cmdSort = { key: 'count', dir: -1 }, cmdPage = 1, cmdPer = 15, cmdRows = [], cmdProject = '';
958
+ // Rows group by command alone, machine-wide: same command in any repo is one row.
959
+ var cmdSort = { key: 'count', dir: -1 }, cmdPage = 1, cmdPer = 15, cmdRows = [];
718
960
  function cmdVal(r, key) {
719
961
  if (key === 'name') return r.name.toLowerCase();
720
- if (key === 'project') return (r.project || '').toLowerCase();
721
962
  return r[key];
722
963
  }
723
- function baseName(p) {
724
- if (!p) return '–';
725
- var parts = String(p).replace(/\/+$/, '').split('/');
726
- return parts[parts.length - 1] || p;
727
- }
728
- // Options come from the rows themselves so the list follows the machine.
729
- // Repaint is skipped while the option set is unchanged: render runs every
730
- // 5s and a rebuild would close an open dropdown mid-selection.
731
- function paintProjectFilter(all) {
732
- var sel = document.getElementById('cmdProject');
733
- if (!sel) return;
734
- var seen = {};
735
- all.forEach(function(r) { if (r.project) seen[r.project] = 1; });
736
- var keys = Object.keys(seen).sort();
737
- if (cmdProject && keys.indexOf(cmdProject) < 0) cmdProject = '';
738
- var sig = keys.join('\n') + '\u0000' + cmdProject;
739
- if (sel.dataset.sig === sig) return;
740
- sel.dataset.sig = sig;
741
- sel.innerHTML = '';
742
- var any = document.createElement('option');
743
- any.value = ''; any.textContent = 'All projects (' + keys.length + ')';
744
- sel.appendChild(any);
745
- keys.forEach(function(k) {
746
- var o = document.createElement('option');
747
- o.value = k; o.textContent = baseName(k); o.title = k;
748
- sel.appendChild(o);
749
- });
750
- sel.value = cmdProject;
751
- }
752
964
  function sortCmd() {
753
965
  var k = cmdSort.key, d = cmdSort.dir;
754
966
  cmdRows.sort(function(a, b) {
@@ -767,14 +979,16 @@
767
979
  }
768
980
  function renderCmd() {
769
981
  var g = (DATA.rtkGain || {});
770
- var all = (DATA.byTool || []).map(function(r) {
771
- return { name: r[0], project: '', count: r[1], saved: null, avgPct: null, avgMs: null };
772
- });
773
- (g.byCommand || []).forEach(function(r) {
774
- all.push({ name: r.command, project: r.project || '', count: r.count, saved: r.saved, avgPct: r.avgPct, avgMs: r.avgMs });
775
- });
776
- paintProjectFilter(all);
777
- cmdRows = cmdProject ? all.filter(function(r) { return r.project === cmdProject; }) : all;
982
+ var byName = {};
983
+ function add(name, count, saved, avgPct, avgMs) {
984
+ var r = byName[name];
985
+ if (!r) { byName[name] = { name: name, count: count, saved: saved, avgPct: avgPct, avgMs: avgMs }; return; }
986
+ r.count += count;
987
+ if (saved !== null && saved !== undefined) r.saved = (r.saved || 0) + saved;
988
+ }
989
+ (DATA.byTool || []).forEach(function(r) { add(r[0], r[1], null, null, null); });
990
+ (g.byCommand || []).forEach(function(r) { add(r.command, r.count, r.saved, r.avgPct, r.avgMs); });
991
+ cmdRows = Object.keys(byName).map(function(k) { return byName[k]; });
778
992
  sortCmd();
779
993
  var pages = Math.max(1, Math.ceil(cmdRows.length / cmdPer));
780
994
  if (cmdPage > pages) cmdPage = pages;
@@ -789,7 +1003,6 @@
789
1003
  tr.className = 'mrow' + (i < Math.min(cmdRows.length - start, cmdPer) - 1 ? ' rowline' : '');
790
1004
  tr.innerHTML = '<td class="text-right pr-3 py-2.5 mono text-xs w-10" style="color: var(--dim)"></td>' +
791
1005
  '<td class="py-2.5 pr-3 truncate" style="max-width: 280px"></td>' +
792
- '<td class="py-2.5 pr-3 truncate" style="max-width: 120px; color: var(--dim)"></td>' +
793
1006
  '<td class="text-right py-2.5 pr-3 font-bold"></td>' +
794
1007
  '<td class="text-right py-2.5 pr-3 font-bold"></td>' +
795
1008
  '<td class="text-right py-2.5 pr-3" style="color: var(--accent)"></td>' +
@@ -799,20 +1012,17 @@
799
1012
  tds[0].textContent = String(n + 1).padStart(2, '0');
800
1013
  tds[1].textContent = r.name;
801
1014
  tds[1].title = r.name;
802
- tds[2].textContent = baseName(r.project);
803
- tds[2].title = r.project || 'session tool call (no project recorded)';
804
- tds[3].textContent = fmt(r.count);
805
- tds[4].textContent = r.saved === null ? '–' : fmtShort(r.saved);
806
- tds[5].textContent = r.avgPct === null ? '–' : r.avgPct.toFixed(1) + '%';
807
- tds[6].textContent = r.avgMs === null || r.avgMs === undefined ? '–' : fmtMs(r.avgMs);
808
- tds[7].firstChild.firstChild.style.width = r.count ? Math.round(r.count / max * 100) + '%' : '0';
1015
+ tds[2].textContent = fmt(r.count);
1016
+ tds[3].textContent = r.saved === null ? '–' : fmtShort(r.saved);
1017
+ tds[4].textContent = r.avgPct === null ? '–' : r.avgPct.toFixed(1) + '%';
1018
+ tds[5].textContent = r.avgMs === null || r.avgMs === undefined ? '–' : fmtMs(r.avgMs);
1019
+ tds[6].firstChild.firstChild.style.width = r.count ? Math.round(r.count / max * 100) + '%' : '0';
809
1020
  body.appendChild(tr);
810
1021
  });
811
1022
  document.getElementById('cmdTable').style.display = cmdRows.length ? '' : 'none';
812
1023
  document.getElementById('emptyCmd').classList.toggle('hidden', cmdRows.length > 0);
813
- var tools = (DATA.byTool || []).length;
814
1024
  document.getElementById('toolsScope').textContent =
815
- g.commands ? fmt(tools) + ' tools · ' + fmt(g.commands) + ' commands · ' + fmtShort(g.saved) + ' saved' : 'tool calls in sessions';
1025
+ g.commands ? fmt(cmdRows.length) + ' commands · ' + fmt(g.commands) + ' runs · ' + fmtShort(g.saved) + ' saved' : 'tool calls in sessions';
816
1026
  paintCmdPager(pages);
817
1027
  }
818
1028
  // Shared page-number painter: prev/next plus windowed numbers with
@@ -882,8 +1092,6 @@
882
1092
  });
883
1093
  });
884
1094
  bindPerPage('#cmdPager', function() { return cmdPer; }, function(n) { cmdPer = n; cmdPage = 1; }, renderCmd);
885
- var pf = document.getElementById('cmdProject');
886
- if (pf) pf.addEventListener('change', function() { cmdProject = pf.value; cmdPage = 1; renderCmd(); });
887
1095
  bindPerPage('section[aria-label="All models"]', function() { return modelPer; }, function(n) { modelPer = n; modelPage = 1; }, renderModels);
888
1096
  bindPerPage('section[aria-label="Recent requests"]', function() { return recentPer; }, function(n) { recentPer = n; recentPage = 1; }, renderRecent);
889
1097
  }
@@ -992,6 +1200,22 @@
992
1200
 
993
1201
  function render(d) {
994
1202
  DATA = d;
1203
+ // Server-provided default currency (tersio gain --currency); a saved
1204
+ // picker choice in localStorage always wins.
1205
+ if (!render.fxInit) {
1206
+ render.fxInit = true;
1207
+ var saved = null;
1208
+ try { saved = localStorage.getItem('tersio-fx-cur'); } catch (e) { }
1209
+ if (!saved && d.currency && CURS[d.currency]) {
1210
+ fx.cur = d.currency;
1211
+ var curEl = document.getElementById('fxCur');
1212
+ if (curEl) curEl.textContent = FLAGS[fx.cur] + ' ' + fx.cur;
1213
+ var panel = document.getElementById('fxPanel');
1214
+ if (panel) Array.prototype.forEach.call(panel.children, function(o) {
1215
+ o.setAttribute('aria-selected', o.dataset.cur === fx.cur ? 'true' : 'false');
1216
+ });
1217
+ }
1218
+ }
995
1219
  var t = d.tokens || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
996
1220
  var total = t.input + t.output + t.cacheRead + t.cacheWrite;
997
1221
  var totalEl = document.getElementById('total');
@@ -1008,7 +1232,7 @@
1008
1232
  renderSpark(byDay, d.usd || 0, d.savedUsd || 0, total);
1009
1233
  document.getElementById('verChip').textContent = 'tersio v' + (d.version || '?');
1010
1234
  var dp = d.paths || {};
1011
- [['pathLedger', dp.ledger], ['pathSessions', dp.sessions]].forEach(function(pair) {
1235
+ [['pathLedger', dp.ledger], ['pathSessions', dp.sessions], ['pathUsageDb', dp.usageDb]].forEach(function(pair) {
1012
1236
  var pel = document.getElementById(pair[0]);
1013
1237
  if (pel && pair[1]) { pel.textContent = pair[1]; pel.title = pair[1]; }
1014
1238
  });
@@ -1145,7 +1369,7 @@
1145
1369
  var tds = tr.children;
1146
1370
  var dot = '<span style="display:inline-block;width:8px;height:8px;border-radius:99px;background:' + v.color + ';margin-right:8px"></span>';
1147
1371
  tds[0].innerHTML = dot + '<span></span>';
1148
- tds[0].querySelector('span:last-child').textContent = r.m;
1372
+ tds[0].querySelector('span:last-child').textContent = displayModel(r.m);
1149
1373
  tds[1].textContent = fmt(r.i);
1150
1374
  tds[2].textContent = fmt(r.o);
1151
1375
  tds[3].textContent = speedText(r);
@@ -1158,7 +1382,6 @@
1158
1382
  : 'est. — modeled from tokens');
1159
1383
  if (measured) tds[5].style.color = 'var(--ink)';
1160
1384
  tds[6].textContent = whenStamp(r.t);
1161
- tds[6].title = stampLocal(r.t);
1162
1385
  hoverRecent(tr, r);
1163
1386
  body.appendChild(tr);
1164
1387
  });
@@ -1232,12 +1455,16 @@
1232
1455
  document.getElementById('reload').addEventListener('click', load);
1233
1456
  (function() {
1234
1457
  var dlg = document.getElementById('settings');
1235
- if (!dlg || typeof dlg.showModal !== 'function') return;
1236
- document.getElementById('settingsBtn').addEventListener('click', function() {
1237
- if (typeof dlg.showModal === 'function') dlg.showModal();
1238
- });
1239
- document.getElementById('settingsClose').addEventListener('click', function() { dlg.close(); });
1240
- dlg.addEventListener('click', function(ev) { if (ev.target === dlg) dlg.close(); });
1458
+ if (dlg && typeof dlg.showModal === 'function') {
1459
+ document.getElementById('settingsBtn').addEventListener('click', function() { dlg.showModal(); });
1460
+ document.getElementById('settingsClose').addEventListener('click', function() { dlg.close(); });
1461
+ dlg.addEventListener('click', function(ev) { if (ev.target === dlg) dlg.close(); });
1462
+ }
1463
+ var md = document.getElementById('modelDialog');
1464
+ if (md && typeof md.showModal === 'function') {
1465
+ document.getElementById('mdClose').addEventListener('click', function() { md.close(); });
1466
+ md.addEventListener('click', function(ev) { if (ev.target === md) md.close(); });
1467
+ }
1241
1468
  })();
1242
1469
  (function() {
1243
1470
  var resetBtn = document.getElementById('reset');
@@ -126,7 +126,7 @@
126
126
  .mrow:hover { background: var(--track); box-shadow: var(--shadow-xs); }
127
127
  .stripcell { transition: background .25s; }
128
128
  .stripcell:hover { box-shadow: var(--shadow-xs); }
129
- button, .tilt, .cell, .stripcell { cursor: pointer; }
129
+ button, .tilt, .cell, .stripcell, .modelcard, .mrow { cursor: pointer; }
130
130
  .btn-push { transition: transform .12s, background .2s; }
131
131
  .btn-push:hover { background: var(--accent-soft); }
132
132
  .btn-push:active { transform: scale(.96); }
@@ -245,3 +245,60 @@ html[data-theme="dark"] .brandmark.placeholder { background: #fff !important; co
245
245
  .toast-desc { font-size: 12px; color: var(--dim); margin: 1px 0 0; }
246
246
  @keyframes toast-in { from { opacity: 0; transform: translateY(8px) scale(.98); } }
247
247
  @keyframes toast-out { to { opacity: 0; transform: translateY(6px) scale(.98); } }
248
+ /* Model detail dialog (shadcn chart language, native modal). KPI strip,
249
+ gradient area chart with range tabs, rounded bars, token-mix donut. */
250
+ .dlg.mdlg { width: min(880px, calc(100vw - 32px)); max-height: min(88vh, 960px); padding: 0; overflow: hidden; display: none; flex-direction: column; }
251
+ .dlg.mdlg[open] { display: flex; }
252
+ .dlg.mdlg .dlg-head { padding: 20px 20px 0; }
253
+ .md-body { overflow-y: auto; padding: 0 20px 20px; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--line) transparent; display: grid; gap: 12px; margin-top: 16px; }
254
+ .md-rankpill { border: 1px solid var(--line); background: var(--accent-soft); color: var(--accent); border-radius: 999px; padding: 4px 12px; font-size: 12px; font-weight: 700; white-space: nowrap; }
255
+ .md-kpis { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-flow: dense; gap: 8px; }
256
+ @media (min-width: 640px) { .md-kpis { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
257
+ .md-kpi { border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; background: var(--panel); }
258
+ .md-kpi p { margin: 0; }
259
+ .md-kpi .k { font-size: 10px; text-transform: uppercase; letter-spacing: .14em; color: var(--dim); }
260
+ .md-kpi .v { font-size: 22px; font-weight: 800; letter-spacing: -0.02em; margin-top: 4px; font-variant-numeric: tabular-nums; }
261
+ .md-kpi .d { font-size: 11px; margin-top: 2px; font-variant-numeric: tabular-nums; }
262
+ .md-kpi .d.up { color: var(--accent); }
263
+ .md-kpi .d.flat { color: var(--dim); }
264
+ .md-chart { position: relative; border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: var(--panel); }
265
+ .md-chart-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
266
+ .md-chart-title { font-size: 13px; font-weight: 700; letter-spacing: -0.01em; margin: 0; }
267
+ .md-chart-sub { font-size: 11px; color: var(--dim); margin: 2px 0 0; text-transform: uppercase; letter-spacing: .12em; }
268
+ .md-trend { font-size: 11px; font-weight: 700; white-space: nowrap; border-radius: 999px; padding: 3px 10px; background: var(--track); color: var(--dim); }
269
+ .md-trend.up { background: var(--accent-soft); color: var(--accent); }
270
+ #mdSpark { width: 100%; height: 190px; display: block; }
271
+ #mdSpark .grid { stroke: var(--line); stroke-width: 1; }
272
+ #mdSpark .area { fill: url(#mdGrad); stroke: none; }
273
+ #mdSpark .line { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
274
+ #mdSpark .dot { fill: var(--accent); stroke: var(--panel); stroke-width: 2; }
275
+ #mdSpark .hoverdot { fill: var(--accent); stroke: var(--panel); stroke-width: 2; }
276
+ .md-tip { position: absolute; pointer-events: none; background: var(--panel); color: var(--ink); border: 1px solid var(--line); box-shadow: 0 8px 24px rgba(0, 0, 0, .28); border-radius: 8px; padding: 6px 10px; font-size: 11px; line-height: 1.5; white-space: nowrap; max-width: calc(100% - 28px); overflow: hidden; text-overflow: ellipsis; transform: translate(-50%, -110%); display: none; z-index: 5; font-variant-numeric: tabular-nums; }
277
+ .md-cols { display: grid; grid-template-columns: minmax(0, 1fr); grid-auto-flow: dense; gap: 12px; }
278
+ @media (min-width: 720px) { .md-cols { grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); } }
279
+ .md-months { display: flex; justify-content: space-between; margin-top: 6px; font-size: 10px; color: var(--dim); text-transform: uppercase; letter-spacing: .12em; }
280
+ .md-bars { display: flex; align-items: flex-end; gap: 3px; height: 132px; background-image: linear-gradient(var(--line) 1px, transparent 1px); background-size: 100% 33px; background-position: 0 bottom; }
281
+ .md-bars > span { flex: 1; min-width: 3px; border-radius: 4px 4px 2px 2px; background: var(--accent); opacity: .4; cursor: default; }
282
+ .md-bars > span:hover { opacity: 1; }
283
+ .md-bars > span.top { opacity: 1; box-shadow: 0 0 8px var(--accent-soft); }
284
+ .md-bars > span.zero { background: var(--track); opacity: 1; box-shadow: none; }
285
+ .md-note { font-size: 11px; color: var(--dim); margin: 8px 0 0; font-variant-numeric: tabular-nums; }
286
+ .md-donut-wrap { position: relative; width: 150px; height: 150px; margin: 4px auto 8px; }
287
+ #mdMix { width: 150px; height: 150px; display: block; transform: rotate(-90deg); }
288
+ #mdMix circle { fill: none; stroke-width: 16; }
289
+ #mdMix .tk { stroke: var(--track); }
290
+ .md-donut-center { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; }
291
+ .md-donut-center p { margin: 0; font-size: 17px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
292
+ .md-legend { display: grid; gap: 6px; font-size: 11px; }
293
+ .md-legend .row { display: flex; align-items: center; gap: 8px; }
294
+ .md-legend .dot { width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0; }
295
+ .md-legend .pct { margin-left: auto; color: var(--dim); font-variant-numeric: tabular-nums; }
296
+ .md-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-flow: dense; gap: 8px; }
297
+ @media (min-width: 640px) { .md-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
298
+ .md-stat { border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; background: var(--panel); }
299
+ .md-stat p { margin: 0; }
300
+ .md-stat .k { font-size: 10px; text-transform: uppercase; letter-spacing: .14em; color: var(--dim); }
301
+ .md-stat .v { font-size: 20px; font-weight: 800; letter-spacing: -0.02em; margin-top: 4px; font-variant-numeric: tabular-nums; }
302
+ .md-stat .s { font-size: 11px; color: var(--dim); margin-top: 2px; }
303
+ #mdBadge .brandmark { width: 44px; height: 44px; border-radius: 12px; }
304
+ @media (prefers-reduced-motion: reduce) { .md-bars > span { transition: none; } }