@krtclcdy/tersio 2.17.0 → 2.19.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/dashboard/app.js CHANGED
@@ -27,9 +27,25 @@
27
27
  var savedFx = JSON.parse(localStorage.getItem('tersio-fx') || 'null');
28
28
  if (savedFx && savedFx.rates && savedFx.rates.USD === 1) fx.rates = savedFx.rates;
29
29
  } catch (e) { }
30
+ // Magnitude-aware decimals. Model prices keep falling, so a flat 2dp rounds
31
+ // real spend down to "$0.00"; small amounts keep enough digits to stay
32
+ // readable (0.000187, not 0). Larger amounts use the currency's own scale.
33
+ function moneyDecimals(v, base) {
34
+ var a = Math.abs(v);
35
+ if (a >= 0.1 || a === 0) return base;
36
+ if (a >= 0.001) return Math.max(base, 4);
37
+ if (a >= 0.00001) return Math.max(base, 6);
38
+ return Math.max(base, 8);
39
+ }
30
40
  function fxMoney(v) {
31
- var c = CURS[fx.cur] || CURS.USD, r = fx.rates[fx.cur] || 1;
32
- return c.s + (v * r).toLocaleString('en-US', { minimumFractionDigits: c.d, maximumFractionDigits: c.d });
41
+ var c = CURS[fx.cur] || CURS.USD, r = fx.rates[fx.cur] || 1, amt = v * r;
42
+ var d = moneyDecimals(amt, c.d);
43
+ return c.s + amt.toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
44
+ }
45
+ // Tooltips build innerHTML, and error notes come from providers, so they are
46
+ // never interpolated raw.
47
+ function esc(s) {
48
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
33
49
  }
34
50
 
35
51
  function countUp(el, target, money) {
@@ -100,6 +116,14 @@
100
116
  function setCur(k) {
101
117
  fx.cur = k; active = keys.indexOf(k);
102
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
+ }
103
127
  paint();
104
128
  if (DATA) render(DATA);
105
129
  }
@@ -177,7 +201,7 @@
177
201
  [/nemotron|nvidia/i, 'NVIDIA', 'nvidia', '#76b900'],
178
202
  [/mistral/i, 'Mistral', 'mistralai', '#ff7000'],
179
203
  [/claude|anthropic/i, 'Anthropic', 'anthropic', '#d97757'],
180
- [/gemini|google/i, 'Google', 'googlegemini', '#8E75B2'],
204
+ [/gemini|google|gemma/i, 'Google', 'google', '#4285F4'],
181
205
  ];
182
206
  function vendorOf(model) {
183
207
  for (var i = 0; i < PROVIDERS.length; i++) {
@@ -205,12 +229,12 @@
205
229
  function dayKey(d) {
206
230
  return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
207
231
  }
208
- function relTime(ts) {
209
- var s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
210
- if (s < 60) return 'just now';
211
- if (s < 3600) return Math.floor(s / 60) + 'm ago';
212
- if (s < 86400) return Math.floor(s / 3600) + 'h ago';
213
- return Math.floor(s / 86400) + 'd ago';
232
+ // Absolute local stamp for the When column: "Sep 18, 11:14:08 PM". The
233
+ // column is sortable, and a relative label made that ordering unreadable.
234
+ function whenStamp(ts) {
235
+ var d = new Date(ts);
236
+ return d.toLocaleString('en-US', { month: 'short', day: 'numeric' }) + ', ' +
237
+ d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', second: '2-digit' });
214
238
  }
215
239
  // Per-request generation time + output throughput from the message-level
216
240
  // `duration` (ms). Missing → '–'. Throughput uses output tokens since
@@ -234,13 +258,29 @@
234
258
  var b = byModel[m] || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
235
259
  return b.input + b.output + b.cacheRead + b.cacheWrite;
236
260
  }
237
-
238
- var DATA = null, RANGE = 'daily', cardsShown = false;
239
-
261
+ var DATA = null, cardsShown = false;
240
262
  function topModels(byModel, n) {
241
263
  return Object.keys(byModel).sort(function(a, b) { return modelTotal(byModel, b) - modelTotal(byModel, a); }).slice(0, n);
242
264
  }
243
-
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
+ }
244
284
  function allDays() {
245
285
  var set = {};
246
286
  Object.keys(DATA.byDay || {}).forEach(function(d) { set[d] = 1; });
@@ -277,7 +317,7 @@
277
317
  var dt = new Date(key + 'T12:00:00');
278
318
  return isNaN(dt) ? key : dt.toLocaleString('en-US', { month: 'short', day: 'numeric' }).toUpperCase();
279
319
  }
280
- 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; }
281
321
  function tipDayHTML(key, total, rows) {
282
322
  var h = '<div class="tt">' + dateHead(key) + '</div><div class="tv">' + fmtShort(total) + ' total</div>';
283
323
  rows.forEach(function(r) {
@@ -307,15 +347,39 @@
307
347
  function p(n) { return String(n).padStart(2, '0'); }
308
348
  return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
309
349
  }
350
+ // Per-request outcome. OMP records stopReason on every assistant turn and,
351
+ // on failure, the HTTP errorStatus + errorMessage; toolUse turns are
352
+ // ordinary completed turns, so only error and aborted stand apart.
353
+ var STATUS_RANK = { error: 0, aborted: 1, completed: 2 };
354
+ function statusRank(r) { var k = STATUS_RANK[r.st]; return k === undefined ? 2 : k; }
355
+ function statusLabel(r) {
356
+ if (r.st === 'error') return 'error' + (r.code ? ' ' + r.code : '');
357
+ return r.st === 'aborted' ? 'aborted' : 'completed';
358
+ }
359
+ function statusColor(r) { return r.st === 'error' ? '#f87171' : r.st === 'aborted' ? '#fbbf24' : 'var(--accent)'; }
360
+ // Cost precedence. A recorded charge > 0 is authoritative and shown bare.
361
+ // A recording of exactly 0 usually means a free/local provider (every row on
362
+ // a local model reports 0) and would blank the column, so those fall back to
363
+ // the modeled figure carrying the dashboard's usual "~" estimate marker.
364
+ // costRecorded() still reports the raw 0 so the tooltip can show both.
365
+ function costRecorded(r) { return typeof r.usd === 'number'; }
366
+ function costIsMeasured(r) { return costRecorded(r) && r.usd > 0; }
367
+ function displayCost(r) { return costIsMeasured(r) ? r.usd : (r.est || 0); }
368
+
310
369
  function tipRecentHTML(r) {
311
370
  var tps = r.d !== undefined && r.d > 0 ? r.o / (r.d / 1000) : 0;
312
- var h = '<div class="tt">' + shortName(r.m) + '</div><div class="tv">' + stampLocal(r.t) + '</div>';
371
+ var isEst = !costIsMeasured(r);
372
+ var h = '<div class="tt">' + esc(shortName(r.m)) + '</div><div class="tv">' + stampLocal(r.t) + '</div>';
313
373
  h += '<div class="tr"><span class="sw" style="background:#fb923c"></span><span class="tn">input</span><span class="tvr">' + fmt(r.i) + '</span></div>';
314
374
  h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">output</span><span class="tvr">' + fmt(r.o) + '</span></div>';
315
- if ((r.cr || 0) > 0) h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">cache</span><span class="tvr">' + fmtShort(r.cr) + '</span></div>';
316
- if ((r.cw || 0) > 0) h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">cache write</span><span class="tvr">' + fmtShort(r.cw) + '</span></div>';
375
+ if ((r.cr || 0) > 0) h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">cache read</span><span class="tvr">' + fmt(r.cr) + '</span></div>';
376
+ if ((r.cw || 0) > 0) h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">cache write</span><span class="tvr">' + fmt(r.cw) + '</span></div>';
317
377
  h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">elapsed</span><span class="tvr">' + fmtDur(r.d) + '</span></div>';
318
378
  h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">speed</span><span class="tvr">' + (r.d !== undefined ? (tps < 10 ? tps.toFixed(1) : Math.round(tps)) + ' tok/s' : '–') + '</span></div>';
379
+ h += '<div class="tr"><span class="sw" style="background:' + statusColor(r) + '"></span><span class="tn">status</span><span class="tvr">' + statusLabel(r) + '</span></div>';
380
+ h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">cost ' + (isEst ? '(est.)' : '(measured)') + '</span><span class="tvr">' + (isEst ? '~' : '') + fxMoney(displayCost(r)) + '</span></div>';
381
+ if (costRecorded(r)) h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">charged</span><span class="tvr">' + fxMoney(r.usd) + '</span></div>';
382
+ if (r.note) h += '<div class="tnote">' + esc(r.note) + '</div>';
319
383
  return h;
320
384
  }
321
385
  // Zone indicators: Lucide face per savings-bento metric. Coarse by design;
@@ -378,6 +442,222 @@
378
442
  el.addEventListener('mousemove', function(ev) { moveTip(ev.clientX, ev.clientY); });
379
443
  el.addEventListener('mouseleave', hideTip);
380
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
+ }
381
661
  function dayModelRows(key) {
382
662
  var per = (DATA.byDayModel || {})[key] || {};
383
663
  var tops = topModels(DATA.byModel || {}, 8), rows = [], other = 0;
@@ -537,18 +817,35 @@
537
817
  });
538
818
  }
539
819
 
540
- // 30-day token line: daily totals, smoothed path, hover dot + per-model tip.
541
- function wow(model) {
542
- var days = allDays().slice(-14), cur = 0, prev = 0;
543
- days.forEach(function(d, i) {
544
- var v = ((DATA.byDayModel || {})[d] || {})[model] || 0;
545
- if (i < days.length - 7) prev += v; else cur += v;
820
+ var modelPage = 1, modelPer = 10, recentPage = 1, recentPer = 25;
821
+ // Recent requests default to newest-first, which is how the transcript
822
+ // arrives; every column is sortable like the command table.
823
+ var recentSort = { key: 'when', dir: -1 }, recentRows = [];
824
+ function recentVal(r, key) {
825
+ if (key === 'model') return r.m.toLowerCase();
826
+ if (key === 'input') return r.i;
827
+ if (key === 'output') return r.o;
828
+ if (key === 'time') return r.d === undefined ? null : r.d;
829
+ if (key === 'status') return statusRank(r);
830
+ if (key === 'cost') return displayCost(r);
831
+ return r.t;
832
+ }
833
+ function sortRecent() {
834
+ var k = recentSort.key, d = recentSort.dir;
835
+ recentRows.sort(function(a, b) {
836
+ var av = recentVal(a, k), bv = recentVal(b, k);
837
+ if (av === null || av === undefined) return 1;
838
+ if (bv === null || bv === undefined) return -1;
839
+ if (av < bv) return -d;
840
+ if (av > bv) return d;
841
+ return b.t - a.t;
842
+ });
843
+ Array.prototype.forEach.call(document.querySelectorAll('#recentTable .thsort'), function(btn) {
844
+ var on = btn.dataset.sort === k;
845
+ btn.querySelector('.arr').textContent = on ? (d === 1 ? '↑' : '↓') : '⇅';
846
+ btn.closest('th').setAttribute('aria-sort', on ? (d === 1 ? 'ascending' : 'descending') : 'none');
546
847
  });
547
- if (!prev) return cur ? 'new' : '-';
548
- var p = Math.round((cur - prev) / prev * 100);
549
- return (p >= 0 ? '+' : '') + p + '%';
550
848
  }
551
- var modelPage = 1, modelPer = 10, recentPage = 1, recentPer = 15;
552
849
  function renderModels() {
553
850
  var byModel = DATA.byModel || {};
554
851
  var tops = topModels(byModel, Object.keys(byModel).length).filter(function(m) { return modelTotal(byModel, m) > 0; });
@@ -556,7 +853,7 @@
556
853
  cards.innerHTML = '';
557
854
  if (!tops.length) cards.innerHTML = '<div class="empty md:col-span-3">' + emptyState('boxes', 'No models yet', 'Model token totals will appear here once sessions report tokens.') + '</div>';
558
855
  tops.slice(0, 3).forEach(function(m, i) {
559
- var v = vendorOf(m), d = wow(m);
856
+ var v = vendorOf(m);
560
857
  var card = document.createElement('div');
561
858
  card.className = 'modelcard relative p-4 rounded-xl overflow-hidden';
562
859
  if (v.svg) {
@@ -577,7 +874,8 @@
577
874
  ghostSparkle.setAttribute('aria-hidden', 'true');
578
875
  card.appendChild(ghostSparkle);
579
876
  }
580
- 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');
581
879
  var head = document.createElement('p');
582
880
  head.className = 'mono text-xs mb-3'; head.style.color = 'var(--dim)';
583
881
  head.textContent = '0' + (i + 1);
@@ -587,20 +885,14 @@
587
885
  badgeWrap.innerHTML = brandHTML(v, false);
588
886
  var badge = badgeWrap.firstChild;
589
887
  var mid = document.createElement('div'); mid.className = 'min-w-0 flex-1';
590
- 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;
591
889
  var vn = document.createElement('p'); vn.className = 'mono text-xs truncate'; vn.style.color = 'var(--dim)'; vn.textContent = v.name;
592
890
  mid.appendChild(nm); mid.appendChild(vn);
593
891
  var tot = document.createElement('p'); tot.className = 'mono font-bold text-lg shrink-0'; tot.textContent = fmtShort(modelTotal(byModel, m));
594
892
  row.appendChild(badge); row.appendChild(mid); row.appendChild(tot);
595
893
  card.appendChild(head); card.appendChild(row);
596
- if (d !== 'new') {
597
- var delta = document.createElement('p');
598
- delta.className = 'mono text-xs mt-3';
599
- delta.style.color = d.charAt(0) === '-' ? '#f87171' : 'var(--accent)';
600
- delta.textContent = d;
601
- card.appendChild(delta);
602
- }
603
894
  hoverModel(card, m);
895
+ (function(model) { card.addEventListener('click', function() { openModelDialog(model); }); })(m);
604
896
  cards.appendChild(card);
605
897
  });
606
898
  var ol = document.getElementById('models');
@@ -618,7 +910,7 @@
618
910
  badgeWrap.innerHTML = brandHTML(v, true);
619
911
  var badge = badgeWrap.firstChild;
620
912
  var mid = document.createElement('div'); mid.className = 'min-w-0 flex-1';
621
- 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;
622
914
  var track = document.createElement('div'); track.className = 'bar-track mt-1.5 h-1.5 overflow-hidden';
623
915
  var fill = document.createElement('div'); fill.className = 'bar-fill h-full';
624
916
  var mv = modelTotal(byModel, m);
@@ -632,6 +924,7 @@
632
924
  right.appendChild(n); right.appendChild(cost);
633
925
  li.appendChild(badge); li.appendChild(mid); li.appendChild(right);
634
926
  hoverModel(li, m);
927
+ (function(model) { li.addEventListener('click', function() { openModelDialog(model); }); })(m);
635
928
  ol.appendChild(li);
636
929
  });
637
930
  document.getElementById('modelsCount').textContent = tops.length ? tops.length + ' models' : '';
@@ -662,8 +955,12 @@
662
955
 
663
956
  // Union of session tool calls + RTK-metered commands, sortable + paged.
664
957
  // Session tools were never metered per command: saved / avg / time show – and sort last.
958
+ // Rows group by command alone, machine-wide: same command in any repo is one row.
665
959
  var cmdSort = { key: 'count', dir: -1 }, cmdPage = 1, cmdPer = 15, cmdRows = [];
666
- function cmdVal(r, key) { return key === 'name' ? r.name.toLowerCase() : r[key]; }
960
+ function cmdVal(r, key) {
961
+ if (key === 'name') return r.name.toLowerCase();
962
+ return r[key];
963
+ }
667
964
  function sortCmd() {
668
965
  var k = cmdSort.key, d = cmdSort.dir;
669
966
  cmdRows.sort(function(a, b) {
@@ -682,12 +979,16 @@
682
979
  }
683
980
  function renderCmd() {
684
981
  var g = (DATA.rtkGain || {});
685
- cmdRows = (DATA.byTool || []).map(function(r) {
686
- return { name: r[0], count: r[1], saved: null, avgPct: null, avgMs: null };
687
- });
688
- (g.byCommand || []).forEach(function(r) {
689
- cmdRows.push({ name: r.command, count: r.count, saved: r.saved, avgPct: r.avgPct, avgMs: r.avgMs });
690
- });
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]; });
691
992
  sortCmd();
692
993
  var pages = Math.max(1, Math.ceil(cmdRows.length / cmdPer));
693
994
  if (cmdPage > pages) cmdPage = pages;
@@ -720,9 +1021,8 @@
720
1021
  });
721
1022
  document.getElementById('cmdTable').style.display = cmdRows.length ? '' : 'none';
722
1023
  document.getElementById('emptyCmd').classList.toggle('hidden', cmdRows.length > 0);
723
- var tools = (DATA.byTool || []).length;
724
1024
  document.getElementById('toolsScope').textContent =
725
- 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';
726
1026
  paintCmdPager(pages);
727
1027
  }
728
1028
  // Shared page-number painter: prev/next plus windowed numbers with
@@ -795,6 +1095,19 @@
795
1095
  bindPerPage('section[aria-label="All models"]', function() { return modelPer; }, function(n) { modelPer = n; modelPage = 1; }, renderModels);
796
1096
  bindPerPage('section[aria-label="Recent requests"]', function() { return recentPer; }, function(n) { recentPer = n; recentPage = 1; }, renderRecent);
797
1097
  }
1098
+ function bindRecentTable() {
1099
+ if (bindRecentTable.done) return;
1100
+ bindRecentTable.done = true;
1101
+ Array.prototype.forEach.call(document.querySelectorAll('#recentTable .thsort'), function(btn) {
1102
+ btn.addEventListener('click', function() {
1103
+ var k = btn.dataset.sort;
1104
+ if (recentSort.key === k) recentSort.dir = -recentSort.dir;
1105
+ else { recentSort.key = k; recentSort.dir = k === 'model' || k === 'status' ? 1 : -1; }
1106
+ recentPage = 1;
1107
+ renderRecent();
1108
+ });
1109
+ });
1110
+ }
798
1111
  function fmtMs(ms) {
799
1112
  if (ms < 1000) return Math.round(ms) + 'ms';
800
1113
  return (ms / 1000).toFixed(1) + 's';
@@ -887,6 +1200,22 @@
887
1200
 
888
1201
  function render(d) {
889
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
+ }
890
1219
  var t = d.tokens || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
891
1220
  var total = t.input + t.output + t.cacheRead + t.cacheWrite;
892
1221
  var totalEl = document.getElementById('total');
@@ -903,7 +1232,7 @@
903
1232
  renderSpark(byDay, d.usd || 0, d.savedUsd || 0, total);
904
1233
  document.getElementById('verChip').textContent = 'tersio v' + (d.version || '?');
905
1234
  var dp = d.paths || {};
906
- [['pathLedger', dp.ledger], ['pathSessions', dp.sessions]].forEach(function(pair) {
1235
+ [['pathLedger', dp.ledger], ['pathSessions', dp.sessions], ['pathUsageDb', dp.usageDb]].forEach(function(pair) {
907
1236
  var pel = document.getElementById(pair[0]);
908
1237
  if (pel && pair[1]) { pel.textContent = pair[1]; pel.title = pair[1]; }
909
1238
  });
@@ -981,6 +1310,7 @@
981
1310
  renderStrip(t);
982
1311
  bindGraphTabs();
983
1312
  bindCmdTable();
1313
+ bindRecentTable();
984
1314
  renderGraph(d.byDay || {});
985
1315
  renderModels();
986
1316
  renderRecent();
@@ -993,36 +1323,70 @@
993
1323
  }
994
1324
  observe();
995
1325
  }
1326
+ // shadcn/ui badge: default/secondary/destructive/outline carry the meaning,
1327
+ // the label carries the detail. Built as DOM nodes so provider error notes
1328
+ // never reach innerHTML.
1329
+ function statusBadge(r) {
1330
+ var b = document.createElement('span');
1331
+ b.className = 'badge';
1332
+ if (r.st === 'error') {
1333
+ b.classList.add('destructive');
1334
+ b.textContent = statusLabel(r);
1335
+ b.title = 'error' + (r.code ? ' ' + r.code : '') + (r.note ? ' — ' + r.note : '');
1336
+ } else if (r.st === 'aborted') {
1337
+ b.classList.add('outline');
1338
+ b.textContent = 'aborted';
1339
+ b.title = r.note || 'Interrupted by user';
1340
+ } else {
1341
+ b.classList.add('secondary');
1342
+ b.textContent = 'completed';
1343
+ if (r.note) b.title = r.note;
1344
+ }
1345
+ return b;
1346
+ }
996
1347
  function renderRecent() {
997
1348
  var body = document.getElementById('recent');
998
1349
  body.innerHTML = '';
999
1350
  var all = DATA.recent || [];
1000
- var recentPages = Math.max(1, Math.ceil(all.length / recentPer));
1351
+ recentRows = all.slice();
1352
+ sortRecent();
1353
+ var recentPages = Math.max(1, Math.ceil(recentRows.length / recentPer));
1001
1354
  if (recentPage > recentPages) recentPage = recentPages;
1002
1355
  var recentStart = (recentPage - 1) * recentPer;
1003
- var rows = all.slice(recentStart, recentStart + recentPer);
1356
+ var rows = recentRows.slice(recentStart, recentStart + recentPer);
1004
1357
  rows.forEach(function(r, i) {
1005
1358
  var v = vendorOf(r.m);
1359
+ var measured = costIsMeasured(r);
1006
1360
  var tr = document.createElement('tr');
1007
- tr.className = 'rrow' + (i < Math.min(all.length - recentStart, recentPer) - 1 ? ' rowline' : '');
1008
- var dot = '<span style="display:inline-block;width:8px;height:8px;border-radius:99px;background:' + v.color + ';margin-right:8px"></span>';
1361
+ tr.className = 'rrow' + (i < Math.min(recentRows.length - recentStart, recentPer) - 1 ? ' rowline' : '');
1009
1362
  tr.innerHTML = '<td class="py-2.5 pr-3 truncate" style="min-width: 0"></td>' +
1010
- '<td class="text-right py-2.5 pr-3 whitespace-nowrap"></td>' +
1363
+ '<td class="text-right py-2.5 pr-3 whitespace-nowrap" style="color:#fb923c"></td>' +
1364
+ '<td class="text-right py-2.5 pr-3 whitespace-nowrap" style="color:var(--accent)"></td>' +
1365
+ '<td class="text-right py-2.5 pr-3 whitespace-nowrap" style="color:var(--dim)">–</td>' +
1366
+ '<td class="py-2.5 pr-3 whitespace-nowrap"></td>' +
1011
1367
  '<td class="text-right py-2.5 pr-3 whitespace-nowrap"></td>' +
1012
1368
  '<td class="text-right py-2.5 whitespace-nowrap" style="color: var(--dim)"></td>';
1013
1369
  var tds = tr.children;
1370
+ var dot = '<span style="display:inline-block;width:8px;height:8px;border-radius:99px;background:' + v.color + ';margin-right:8px"></span>';
1014
1371
  tds[0].innerHTML = dot + '<span></span>';
1015
- tds[0].querySelector('span:last-child').textContent = r.m;
1016
- tds[1].innerHTML = '<span style="color:#fb923c"></span> <span style="color:var(--accent)"></span>';
1017
- tds[1].children[0].textContent = fmt(r.i) + '↑';
1018
- tds[1].children[1].textContent = fmt(r.o) + '↓';
1019
- tds[2].textContent = speedText(r);
1020
- tds[3].textContent = relTime(r.t);
1372
+ tds[0].querySelector('span:last-child').textContent = displayModel(r.m);
1373
+ tds[1].textContent = fmt(r.i);
1374
+ tds[2].textContent = fmt(r.o);
1375
+ tds[3].textContent = speedText(r);
1376
+ tds[4].appendChild(statusBadge(r));
1377
+ tds[5].textContent = (measured ? '' : '~') + fxMoney(displayCost(r));
1378
+ tds[5].title = measured
1379
+ ? 'measured — charged by the provider'
1380
+ : (costRecorded(r)
1381
+ ? 'est. — provider charged ' + fxMoney(r.usd) + ' (free or local), modeled from tokens'
1382
+ : 'est. — modeled from tokens');
1383
+ if (measured) tds[5].style.color = 'var(--ink)';
1384
+ tds[6].textContent = whenStamp(r.t);
1021
1385
  hoverRecent(tr, r);
1022
1386
  body.appendChild(tr);
1023
1387
  });
1024
- document.getElementById('recentCount').textContent = all.length ? all.length + ' requests' : '';
1025
- paintPager('recentPages', 'recentRange', recentPage, recentPer, all.length, function(p) { recentPage = p; renderRecent(); });
1388
+ document.getElementById('recentCount').textContent = all.length ? recentRows.length + ' requests' : '';
1389
+ paintPager('recentPages', 'recentRange', recentPage, recentPer, recentRows.length, function(p) { recentPage = p; renderRecent(); });
1026
1390
  document.getElementById('recentTable').style.display = all.length ? '' : 'none';
1027
1391
  document.getElementById('emptyRecent').classList.toggle('hidden', all.length > 0);
1028
1392
  }
@@ -1091,12 +1455,16 @@
1091
1455
  document.getElementById('reload').addEventListener('click', load);
1092
1456
  (function() {
1093
1457
  var dlg = document.getElementById('settings');
1094
- if (!dlg || typeof dlg.showModal !== 'function') return;
1095
- document.getElementById('settingsBtn').addEventListener('click', function() {
1096
- if (typeof dlg.showModal === 'function') dlg.showModal();
1097
- });
1098
- document.getElementById('settingsClose').addEventListener('click', function() { dlg.close(); });
1099
- 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
+ }
1100
1468
  })();
1101
1469
  (function() {
1102
1470
  var resetBtn = document.getElementById('reset');