@krtclcdy/tersio 2.15.0 → 2.17.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
@@ -164,7 +164,9 @@
164
164
  });
165
165
  })();
166
166
 
167
+ var OPENAI_SVG = '<svg viewBox="0 0 24 24" fill="#000" aria-hidden="true"><path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/></svg>';
167
168
  var PROVIDERS = [
169
+ [/openai|codex|gpt-|o1/i, 'OpenAI', 'openai', '#fff', OPENAI_SVG],
168
170
  [/muse/i, 'Meta', 'meta', '#0082fb'],
169
171
  [/deepseek/i, 'DeepSeek', 'deepseek', '#4d6bfe'],
170
172
  [/qwen|qwq/i, 'Qwen', 'qwen', '#6950EF'],
@@ -175,21 +177,27 @@
175
177
  [/nemotron|nvidia/i, 'NVIDIA', 'nvidia', '#76b900'],
176
178
  [/mistral/i, 'Mistral', 'mistralai', '#ff7000'],
177
179
  [/claude|anthropic/i, 'Anthropic', 'anthropic', '#d97757'],
178
- [/gemini|google/i, 'Google', 'googlegemini', '#4285f4'],
180
+ [/gemini|google/i, 'Google', 'googlegemini', '#8E75B2'],
179
181
  ];
180
182
  function vendorOf(model) {
181
183
  for (var i = 0; i < PROVIDERS.length; i++) {
182
- if (PROVIDERS[i][0].test(model)) return { name: PROVIDERS[i][1], slug: PROVIDERS[i][2], color: PROVIDERS[i][3] };
184
+ if (PROVIDERS[i][0].test(model)) return { name: PROVIDERS[i][1], slug: PROVIDERS[i][2], color: PROVIDERS[i][3], svg: PROVIDERS[i][4] };
183
185
  }
184
186
  return { name: 'Other', slug: '', color: '#71717a' };
185
187
  }
186
- // Brand glyph from the Simple Icons CDN; a missing slug (or offline export)
187
- // falls back to a neutral bot glyph. Never a bare initial.
188
+ // Brand glyph: inline SVG when the provider ships one (OpenAI), else the
189
+ // Simple Icons CDN; unknown / Other providers render a clean Lucide
190
+ // sparkles icon with no solid background as a placeholder.
188
191
  function brandHTML(v, small) {
189
- var icon = v.slug
190
- ? '<img src="https://cdn.simpleicons.org/' + v.slug + '/white" alt="" loading="lazy" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'grid\';">'
191
- : '';
192
- var fb = '<i data-lucide="bot" class="fb"' + (v.slug ? '' : ' style="display:grid"') + '></i>';
192
+ if (v.svg) {
193
+ return '<span class="brandmark' + (small ? ' sm' : '') + '" style="background:' + v.color + '" title="' + v.name + '">' + v.svg + '</span>';
194
+ }
195
+ if (!v.slug) {
196
+ return '<span class="brandmark placeholder' + (small ? ' sm' : '') + '" title="' + v.name + '">' +
197
+ '<i data-lucide="sparkles" class="fb" style="display:grid"></i></span>';
198
+ }
199
+ var icon = '<img src="https://cdn.simpleicons.org/' + v.slug + '/white" alt="" loading="lazy" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'grid\';">';
200
+ var fb = '<i data-lucide="sparkles" class="fb"></i>';
193
201
  return '<span class="brandmark' + (small ? ' sm' : '') + '" style="background:' + v.color + '" title="' + v.name + '">' + icon + fb + '</span>';
194
202
  }
195
203
  var PALETTE = ['#34d399', '#818cf8', '#22d3ee', '#fbbf24', '#f472b6', '#a78bfa', '#fb923c', '#2dd4bf'];
@@ -204,6 +212,24 @@
204
212
  if (s < 86400) return Math.floor(s / 3600) + 'h ago';
205
213
  return Math.floor(s / 86400) + 'd ago';
206
214
  }
215
+ // Per-request generation time + output throughput from the message-level
216
+ // `duration` (ms). Missing → '–'. Throughput uses output tokens since
217
+ // that is what streams to the user during the measured window.
218
+ function fmtDur(ms) {
219
+ if (ms === undefined) return '–';
220
+ var s = ms / 1000;
221
+ return (s < 10 ? s.toFixed(1) : Math.round(s)) + 's';
222
+ }
223
+ function speedText(r) {
224
+ if (r.d === undefined) return '–';
225
+ var tps = r.d > 0 ? r.o / (r.d / 1000) : 0;
226
+ return fmtDur(r.d) + ' ⚡' + (tps < 10 ? tps.toFixed(1) : Math.round(tps)) + '/s';
227
+ }
228
+ function speedTitle(r) {
229
+ if (r.d === undefined) return 'no duration recorded';
230
+ var tps = r.d > 0 ? r.o / (r.d / 1000) : 0;
231
+ return '⏱ ' + (r.d / 1000).toFixed(1) + 's elapsed with the model, ⚡ ' + r.o + ' output tokens (' + tps.toFixed(1) + ' tok/s)';
232
+ }
207
233
  function modelTotal(byModel, m) {
208
234
  var b = byModel[m] || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
209
235
  return b.input + b.output + b.cacheRead + b.cacheWrite;
@@ -234,6 +260,7 @@
234
260
  function showTip(html, x, y) {
235
261
  var t = tip();
236
262
  t.innerHTML = html;
263
+ if (window.lucide) lucide.createIcons();
237
264
  t.classList.add('show');
238
265
  moveTip(x, y);
239
266
  }
@@ -265,19 +292,80 @@
265
292
  var denom = b.input + b.cacheRead;
266
293
  var hit = denom ? (b.cacheRead / denom * 100).toFixed(1) + '%' : '-';
267
294
  var total = b.input + b.output + b.cacheRead + b.cacheWrite;
268
- var h = '<div class="tt">' + shortName(m) + '</div><div class="tv">' + fmtShort(total) + ' total</div>';
269
- h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">input</span><span class="tvr">' + fmt(b.input) + '</span></div>';
270
- h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">output</span><span class="tvr">' + fmt(b.output) + '</span></div>';
295
+ var cb = ((DATA.byModelBucketUsd || {})[m]) || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
296
+ var h = '<div class="tt">' + shortName(m) + '</div><div class="tv">' + fmtShort(total) + ' total (' + fxMoney(cb.input + cb.output + cb.cacheRead + cb.cacheWrite) + ')</div>';
297
+ h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">input</span><span class="tvr">' + fmt(b.input) + ' (' + fxMoney(cb.input) + ')</span></div>';
298
+ h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">output</span><span class="tvr">' + fmt(b.output) + ' (' + fxMoney(cb.output) + ')</span></div>';
299
+ if (b.cacheRead > 0) h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">cache read</span><span class="tvr">' + fmt(b.cacheRead) + ' (' + fxMoney(cb.cacheRead) + ')</span></div>';
300
+ if (b.cacheWrite > 0) h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">cache write</span><span class="tvr">' + fmt(b.cacheWrite) + ' (' + fxMoney(cb.cacheWrite) + ')</span></div>';
271
301
  h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">requests</span><span class="tvr">' + fmt(req) + '</span></div>';
272
302
  h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">cache hit</span><span class="tvr">' + hit + '</span></div>';
273
303
  return h;
274
304
  }
305
+ function stampLocal(ts) {
306
+ var d = new Date(ts);
307
+ function p(n) { return String(n).padStart(2, '0'); }
308
+ return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
309
+ }
310
+ function tipRecentHTML(r) {
311
+ 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>';
313
+ 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
+ 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>';
317
+ 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
+ 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>';
319
+ return h;
320
+ }
321
+ // Zone indicators: Lucide face per savings-bento metric. Coarse by design;
322
+ // captions say est. where modeled. Icons paint via showTip/render createIcons.
323
+ function zoneIcon(glyph, cls) { return '<i data-lucide="' + glyph + '" class="zface ' + cls + '"></i>'; }
324
+ function co2Zone(g) {
325
+ if (!(g > 0)) return ['minus', 'no data', ''];
326
+ if (g <= 50) return ['sprout', 'light', 'good'];
327
+ if (g <= 500) return ['smile', 'moderate', 'good'];
328
+ if (g <= 2000) return ['meh', 'heavy', 'warn'];
329
+ return ['flame', 'very high', 'bad'];
330
+ }
331
+ function levZone(x) {
332
+ if (!(x > 0)) return ['minus', 'no savings yet', ''];
333
+ if (x >= 3) return ['rocket', 'high leverage', 'good'];
334
+ if (x >= 1) return ['smile', 'solid', 'good'];
335
+ return ['meh', 'light', 'warn'];
336
+ }
337
+ function shareZone(p) {
338
+ if (!(p > 0)) return ['minus', 'uncached', ''];
339
+ if (p >= 80) return ['rocket', 'high', 'good'];
340
+ if (p >= 50) return ['smile', 'good', 'good'];
341
+ return ['meh', 'low', 'warn'];
342
+ }
275
343
  function tipCo2HTML() {
344
+ var z = co2Zone(DATA.co2g || 0);
276
345
  var h = '<div class="tt">CO2</div><div class="tv">~' + (DATA.co2g || 0).toFixed(1) + 'g est.</div>';
346
+ h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">zone</span><span class="tvr">' + zoneIcon(z[0], z[2]) + ' ' + z[1] + '</span></div>';
277
347
  h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">energy</span><span class="tvr">~' + (DATA.energyWh || 0).toFixed(1) + ' Wh</span></div>';
278
- h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">method</span><span class="tvr">EcoLogits 0.8.2 port</span></div>';
279
- h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">served</span><span class="tvr">ceiling / 32</span></div>';
280
- h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">grid</span><span class="tvr">per provider</span></div>';
348
+ return h;
349
+ }
350
+ function tipSavedHTML() {
351
+ var usd = DATA.usd || 0, saved = DATA.savedUsd || 0;
352
+ var lev = usd ? saved / usd : 0;
353
+ var z = levZone(lev);
354
+ var t = DATA.tokens || { cacheRead: 0 };
355
+ var h = '<div class="tt">Saved by cache</div><div class="tv">' + fxMoney(saved) + ' est.</div>';
356
+ h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">zone</span><span class="tvr">' + zoneIcon(z[0], z[2]) + ' ' + z[1] + '</span></div>';
357
+ h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">leverage</span><span class="tvr">x' + lev.toFixed(1) + ' per $1</span></div>';
358
+ h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">cache read</span><span class="tvr">' + fmtShort(t.cacheRead || 0) + '</span></div>';
359
+ return h;
360
+ }
361
+ function tipShareHTML(share, pp) {
362
+ var z = shareZone(share);
363
+ var t = DATA.tokens || { cacheRead: 0, cacheWrite: 0 };
364
+ var h = '<div class="tt">Cache share</div><div class="tv">' + share + '%</div>';
365
+ h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">zone</span><span class="tvr">' + zoneIcon(z[0], z[2]) + ' ' + z[1] + '</span></div>';
366
+ h += '<div class="tr"><span class="sw" style="background:var(--accent)"></span><span class="tn">read</span><span class="tvr">' + fmtShort(t.cacheRead || 0) + '</span></div>';
367
+ if ((t.cacheWrite || 0) > 0) h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">write</span><span class="tvr">' + fmtShort(t.cacheWrite) + '</span></div>';
368
+ h += '<div class="tr"><span class="sw" style="background:var(--dim)"></span><span class="tn">7d trend</span><span class="tvr">' + (pp >= 0 ? '+' : '') + pp.toFixed(1) + 'pp</span></div>';
281
369
  return h;
282
370
  }
283
371
  function hoverModel(el, m) {
@@ -285,6 +373,11 @@
285
373
  el.addEventListener('mousemove', function(ev) { moveTip(ev.clientX, ev.clientY); });
286
374
  el.addEventListener('mouseleave', hideTip);
287
375
  }
376
+ function hoverRecent(el, r) {
377
+ el.addEventListener('mouseenter', function(ev) { showTip(tipRecentHTML(r), ev.clientX, ev.clientY); });
378
+ el.addEventListener('mousemove', function(ev) { moveTip(ev.clientX, ev.clientY); });
379
+ el.addEventListener('mouseleave', hideTip);
380
+ }
288
381
  function dayModelRows(key) {
289
382
  var per = (DATA.byDayModel || {})[key] || {};
290
383
  var tops = topModels(DATA.byModel || {}, 8), rows = [], other = 0;
@@ -455,9 +548,10 @@
455
548
  var p = Math.round((cur - prev) / prev * 100);
456
549
  return (p >= 0 ? '+' : '') + p + '%';
457
550
  }
551
+ var modelPage = 1, modelPer = 10, recentPage = 1, recentPer = 15;
458
552
  function renderModels() {
459
553
  var byModel = DATA.byModel || {};
460
- var tops = topModels(byModel, 8).filter(function(m) { return modelTotal(byModel, m) > 0; });
554
+ var tops = topModels(byModel, Object.keys(byModel).length).filter(function(m) { return modelTotal(byModel, m) > 0; });
461
555
  var cards = document.getElementById('modelCards');
462
556
  cards.innerHTML = '';
463
557
  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>';
@@ -465,12 +559,23 @@
465
559
  var v = vendorOf(m), d = wow(m);
466
560
  var card = document.createElement('div');
467
561
  card.className = 'modelcard relative p-4 rounded-xl overflow-hidden';
468
- if (v.slug) {
562
+ if (v.svg) {
563
+ var ghostSvg = document.createElement('span');
564
+ ghostSvg.className = 'ghostimg'; ghostSvg.setAttribute('aria-hidden', 'true');
565
+ ghostSvg.innerHTML = v.svg;
566
+ card.appendChild(ghostSvg);
567
+ } else if (v.slug) {
469
568
  var ghost = document.createElement('img');
470
569
  ghost.className = 'ghostimg'; ghost.alt = '';
471
570
  ghost.src = 'https://cdn.simpleicons.org/' + v.slug + '/white';
472
571
  ghost.setAttribute('aria-hidden', 'true');
473
572
  card.appendChild(ghost);
573
+ } else {
574
+ var ghostSparkle = document.createElement('i');
575
+ ghostSparkle.setAttribute('data-lucide', 'sparkles');
576
+ ghostSparkle.className = 'ghosticon';
577
+ ghostSparkle.setAttribute('aria-hidden', 'true');
578
+ card.appendChild(ghostSparkle);
474
579
  }
475
580
  card.style.cssText = 'border: 1px solid var(--line); background: var(--panel)';
476
581
  var head = document.createElement('p');
@@ -502,10 +607,13 @@
502
607
  ol.innerHTML = '';
503
608
  var max = 1;
504
609
  tops.forEach(function(m) { max = Math.max(max, modelTotal(byModel, m)); });
505
- tops.forEach(function(m, i) {
610
+ var modelPages = Math.max(1, Math.ceil(tops.length / modelPer));
611
+ if (modelPage > modelPages) modelPage = modelPages;
612
+ var modelStart = (modelPage - 1) * modelPer;
613
+ tops.slice(modelStart, modelStart + modelPer).forEach(function(m, i) {
506
614
  var v = vendorOf(m);
507
615
  var li = document.createElement('li');
508
- li.className = 'mrow flex items-center gap-3 px-4 py-3' + (i < tops.length - 1 ? ' rowline' : '');
616
+ li.className = 'mrow flex items-center gap-3 px-4 py-3' + (i < Math.min(tops.length - modelStart, modelPer) - 1 ? ' rowline' : '');
509
617
  var badgeWrap = document.createElement('span');
510
618
  badgeWrap.innerHTML = brandHTML(v, true);
511
619
  var badge = badgeWrap.firstChild;
@@ -527,6 +635,7 @@
527
635
  ol.appendChild(li);
528
636
  });
529
637
  document.getElementById('modelsCount').textContent = tops.length ? tops.length + ' models' : '';
638
+ paintPager('modelPages', 'modelRange', modelPage, modelPer, tops.length, function(p) { modelPage = p; renderModels(); });
530
639
  ol.style.display = tops.length ? '' : 'none';
531
640
  document.getElementById('emptyModels').classList.toggle('hidden', tops.length > 0);
532
641
  if (tops.length && 'IntersectionObserver' in window && !reduce) {
@@ -616,26 +725,31 @@
616
725
  g.commands ? fmt(tools) + ' tools · ' + fmt(g.commands) + ' commands · ' + fmtShort(g.saved) + ' saved' : 'tool calls in sessions';
617
726
  paintCmdPager(pages);
618
727
  }
619
- function paintCmdPager(pages) {
620
- var total = cmdRows.length, start = total ? (cmdPage - 1) * cmdPer + 1 : 0;
621
- document.getElementById('cmdRange').textContent =
622
- 'Showing ' + start + '–' + Math.min(total, cmdPage * cmdPer) + ' of ' + total;
623
- var wrap = document.getElementById('cmdPages');
728
+ // Shared page-number painter: prev/next plus windowed numbers with
729
+ // ellipsis. onPick(page) rerenders the owning card. Same controls as the
730
+ // Command tools pager; Models and Recent reuse it verbatim.
731
+ function paintPager(wrapId, rangeId, page, per, total, onPick) {
732
+ var start = total ? (page - 1) * per + 1 : 0;
733
+ document.getElementById(rangeId).textContent =
734
+ 'Showing ' + start + '–' + Math.min(total, page * per) + ' of ' + total;
735
+ var wrap = document.getElementById(wrapId);
624
736
  wrap.innerHTML = '';
625
- function btn(label, page, opts) {
737
+ var pages = Math.max(1, Math.ceil(total / per));
738
+ if (page > pages) onPick(pages);
739
+ function btn(label, next, opts) {
626
740
  var b = document.createElement('button');
627
741
  b.type = 'button'; b.className = 'pgbtn mono' + (opts && opts.on ? ' on' : '');
628
742
  b.textContent = label;
629
743
  if (opts && opts.dim) b.setAttribute('aria-label', opts.dim);
630
744
  if (opts && opts.off) b.disabled = true;
631
- else b.addEventListener('click', function() { cmdPage = page; renderCmd(); });
745
+ else b.addEventListener('click', function() { onPick(next); });
632
746
  wrap.appendChild(b);
633
747
  return b;
634
748
  }
635
- btn('‹', cmdPage - 1, { dim: 'Previous page', off: cmdPage <= 1 });
749
+ btn('‹', page - 1, { dim: 'Previous page', off: page <= 1 });
636
750
  var nums = [];
637
751
  for (var p = 1; p <= pages; p++) {
638
- if (p === 1 || p === pages || Math.abs(p - cmdPage) <= 1) nums.push(p);
752
+ if (p === 1 || p === pages || Math.abs(p - page) <= 1) nums.push(p);
639
753
  else if (nums[nums.length - 1] !== '…') nums.push('…');
640
754
  }
641
755
  nums.forEach(function(p) {
@@ -643,9 +757,27 @@
643
757
  var s = document.createElement('span');
644
758
  s.textContent = '…'; s.style.padding = '0 2px';
645
759
  wrap.appendChild(s);
646
- } else btn(String(p), p, { on: p === cmdPage });
760
+ } else btn(String(p), p, { on: p === page });
761
+ });
762
+ btn('›', page + 1, { dim: 'Next page', off: page >= pages });
763
+ }
764
+ function paintCmdPager(pages) {
765
+ var total = cmdRows.length;
766
+ if (cmdPage > pages) cmdPage = pages;
767
+ paintPager('cmdPages', 'cmdRange', cmdPage, cmdPer, total, function(p) { cmdPage = p; renderCmd(); });
768
+ }
769
+ function bindPerPage(sel, get, set, render) {
770
+ Array.prototype.forEach.call(document.querySelectorAll(sel + ' [data-pp]'), function(btn) {
771
+ if (btn.dataset.bound) return;
772
+ btn.dataset.bound = '1';
773
+ btn.addEventListener('click', function() {
774
+ if (Number(btn.dataset.pp) === get()) return;
775
+ Array.prototype.forEach.call(document.querySelectorAll(sel + ' [data-pp]'), function(b) { b.classList.remove('on'); });
776
+ btn.classList.add('on');
777
+ set(Number(btn.dataset.pp));
778
+ render();
779
+ });
647
780
  });
648
- btn('›', cmdPage + 1, { dim: 'Next page', off: cmdPage >= pages });
649
781
  }
650
782
  function bindCmdTable() {
651
783
  if (bindCmdTable.done) return;
@@ -659,17 +791,10 @@
659
791
  renderCmd();
660
792
  });
661
793
  });
662
- Array.prototype.forEach.call(document.querySelectorAll('#cmdPager [data-pp]'), function(btn) {
663
- btn.addEventListener('click', function() {
664
- Array.prototype.forEach.call(document.querySelectorAll('#cmdPager [data-pp]'), function(b) { b.classList.remove('on'); });
665
- btn.classList.add('on');
666
- cmdPer = Number(btn.dataset.pp);
667
- cmdPage = 1;
668
- renderCmd();
669
- });
670
- });
794
+ bindPerPage('#cmdPager', function() { return cmdPer; }, function(n) { cmdPer = n; cmdPage = 1; }, renderCmd);
795
+ bindPerPage('section[aria-label="All models"]', function() { return modelPer; }, function(n) { modelPer = n; modelPage = 1; }, renderModels);
796
+ bindPerPage('section[aria-label="Recent requests"]', function() { return recentPer; }, function(n) { recentPer = n; recentPage = 1; }, renderRecent);
671
797
  }
672
-
673
798
  function fmtMs(ms) {
674
799
  if (ms < 1000) return Math.round(ms) + 'ms';
675
800
  return (ms / 1000).toFixed(1) + 's';
@@ -783,7 +908,19 @@
783
908
  if (pel && pair[1]) { pel.textContent = pair[1]; pel.title = pair[1]; }
784
909
  });
785
910
  countUp(document.getElementById('saved'), d.savedUsd || 0, true);
786
- document.getElementById('co2').textContent = '~' + (d.co2g || 0).toFixed(1) + 'g';
911
+ (function() {
912
+ var lev = (d.usd || 0) ? (d.savedUsd || 0) / d.usd : 0;
913
+ var z = levZone(lev);
914
+ var note = document.getElementById('usdNote');
915
+ note.innerHTML = ((d.priced ? 'per-model price table' : 'incl. default pricing') + (fx.live ? ' · fx live' : ' · fx snapshot') + ' · ') + zoneIcon(z[0], z[2]) + ' ' + z[1];
916
+ note.title = 'Cache leverage x' + lev.toFixed(1) + ' — cache savings per $1 spent, vs full input price';
917
+ })();
918
+ (function() {
919
+ var z = co2Zone(d.co2g || 0);
920
+ var el = document.getElementById('co2');
921
+ el.innerHTML = '~' + (d.co2g || 0).toFixed(1) + 'g ' + zoneIcon(z[0], z[2]);
922
+ el.title = z[1] + ' footprint (est.)';
923
+ })();
787
924
  (function() {
788
925
  var el = document.getElementById('co2');
789
926
  var card = el ? el.closest('div.rounded-xl') : null;
@@ -793,16 +930,39 @@
793
930
  card.addEventListener('mousemove', function(ev) { moveTip(ev.clientX, ev.clientY); });
794
931
  card.addEventListener('mouseleave', hideTip);
795
932
  })();
933
+ (function() {
934
+ var el = document.getElementById('saved');
935
+ var card = el ? el.closest('div.rounded-xl') : null;
936
+ if (!card || card.dataset.savetip) return;
937
+ card.dataset.savetip = '1';
938
+ card.addEventListener('mouseenter', function(ev) { showTip(tipSavedHTML(), ev.clientX, ev.clientY); });
939
+ card.addEventListener('mousemove', function(ev) { moveTip(ev.clientX, ev.clientY); });
940
+ card.addEventListener('mouseleave', hideTip);
941
+ })();
796
942
  var share = total ? Math.round((t.cacheRead + t.cacheWrite) / total * 100) : 0;
797
- document.getElementById('cacheShare').textContent = share + '%';
943
+ var shareZ = shareZone(share);
944
+ document.getElementById('cacheShare').innerHTML = share + '% ' + zoneIcon(shareZ[0], shareZ[2]);
945
+ document.getElementById('cacheShare').title = shareZ[1] + ' cache share';
798
946
  var shareOf = function(b) { var s = dayTotal(b); return s ? ((b.cacheRead || 0) + (b.cacheWrite || 0)) / s * 100 : 0; };
799
947
  var keys = Object.keys(byDay).sort(), cPct = 0, pPct = 0, cn = 0, pn = 0;
800
948
  keys.slice(-7).forEach(function(k) { cPct += shareOf(byDay[k]); cn++; });
801
949
  keys.slice(-14, -7).forEach(function(k) { pPct += shareOf(byDay[k]); pn++; });
802
950
  var pp = (cn ? cPct / cn : 0) - (pn ? pPct / pn : 0);
803
951
  var cd = document.getElementById('cacheDelta');
804
- cd.textContent = (pp >= 0 ? '▲ +' : '▼ ') + Math.abs(pp).toFixed(1) + 'pp vs prior 7d';
805
- cd.className = 'pill mono ' + (Math.abs(pp) < 0.05 ? 'flat' : (pp > 0 ? 'good' : 'bad'));
952
+ var cs = document.getElementById('cacheShare');
953
+ cs.dataset.share = String(share);
954
+ cs.dataset.pp = pp.toFixed(1);
955
+ (function() {
956
+ var el = document.getElementById('cacheShare');
957
+ var card = el ? el.closest('div.rounded-xl') : null;
958
+ if (!card || card.dataset.sharetip) return;
959
+ card.dataset.sharetip = '1';
960
+ card.addEventListener('mouseenter', function(ev) {
961
+ showTip(tipShareHTML(Number(el.dataset.share || 0), Number(el.dataset.pp || 0)), ev.clientX, ev.clientY);
962
+ });
963
+ card.addEventListener('mousemove', function(ev) { moveTip(ev.clientX, ev.clientY); });
964
+ card.addEventListener('mouseleave', hideTip);
965
+ })();
806
966
 
807
967
  (function() {
808
968
  var el = document.getElementById('ticker');
@@ -833,33 +993,38 @@
833
993
  }
834
994
  observe();
835
995
  }
836
-
837
996
  function renderRecent() {
838
997
  var body = document.getElementById('recent');
839
998
  body.innerHTML = '';
840
- var rows = (DATA.recent || []).slice(0, 13);
999
+ var all = DATA.recent || [];
1000
+ var recentPages = Math.max(1, Math.ceil(all.length / recentPer));
1001
+ if (recentPage > recentPages) recentPage = recentPages;
1002
+ var recentStart = (recentPage - 1) * recentPer;
1003
+ var rows = all.slice(recentStart, recentStart + recentPer);
841
1004
  rows.forEach(function(r, i) {
842
1005
  var v = vendorOf(r.m);
843
1006
  var tr = document.createElement('tr');
844
- tr.className = 'rrow' + (i < rows.length - 1 ? ' rowline' : '');
1007
+ tr.className = 'rrow' + (i < Math.min(all.length - recentStart, recentPer) - 1 ? ' rowline' : '');
845
1008
  var dot = '<span style="display:inline-block;width:8px;height:8px;border-radius:99px;background:' + v.color + ';margin-right:8px"></span>';
846
- tr.innerHTML = '<td class="py-2.5 pr-3 truncate" style="max-width: 180px"></td>' +
1009
+ 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>' +
847
1011
  '<td class="text-right py-2.5 pr-3 whitespace-nowrap"></td>' +
848
1012
  '<td class="text-right py-2.5 whitespace-nowrap" style="color: var(--dim)"></td>';
849
1013
  var tds = tr.children;
850
1014
  tds[0].innerHTML = dot + '<span></span>';
851
1015
  tds[0].querySelector('span:last-child').textContent = r.m;
852
- tds[0].title = r.m;
853
1016
  tds[1].innerHTML = '<span style="color:#fb923c"></span> <span style="color:var(--accent)"></span>';
854
- tds[1].children[0].textContent = fmt(r.i) + '\u2191';
855
- tds[1].children[1].textContent = fmt(r.o) + '\u2193';
856
- tds[1].title = fmt(r.i) + ' in / ' + fmt(r.o) + ' out';
857
- tds[2].textContent = relTime(r.t);
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);
1021
+ hoverRecent(tr, r);
858
1022
  body.appendChild(tr);
859
1023
  });
860
- document.getElementById('recentCount').textContent = rows.length ? rows.length + ' requests' : '';
861
- document.getElementById('recentTable').style.display = rows.length ? '' : 'none';
862
- document.getElementById('emptyRecent').classList.toggle('hidden', rows.length > 0);
1024
+ document.getElementById('recentCount').textContent = all.length ? all.length + ' requests' : '';
1025
+ paintPager('recentPages', 'recentRange', recentPage, recentPer, all.length, function(p) { recentPage = p; renderRecent(); });
1026
+ document.getElementById('recentTable').style.display = all.length ? '' : 'none';
1027
+ document.getElementById('emptyRecent').classList.toggle('hidden', all.length > 0);
863
1028
  }
864
1029
 
865
1030
  var seen = new WeakSet();
@@ -58,7 +58,11 @@
58
58
  .pill { display: inline-flex; align-items: center; gap: 4px; border-radius: 999px; padding: 3px 10px; font-size: 11px; white-space: nowrap; }
59
59
  .pill.good { background: var(--accent-soft); color: var(--accent); }
60
60
  .pill.bad { background: rgba(248, 113, 113, .12); color: #f87171; }
61
- .pill.flat { background: var(--track); color: var(--dim); }
61
+ .zface { display: inline-block; vertical-align: -2px; width: 14px; height: 14px; }
62
+ .zface.good { color: var(--accent); }
63
+ .zface.warn { color: #fbbf24; }
64
+ .zface.bad { color: #f87171; }
65
+ #tip .tvr .zface { vertical-align: -3px; }
62
66
  .fxbtn { background: var(--track); color: var(--ink); border: 0; cursor: pointer; gap: 6px; }
63
67
  .fxbtn:focus-visible { outline: 1px solid var(--accent); }
64
68
  .fxbtn svg { transition: transform .15s; }
@@ -117,25 +121,32 @@
117
121
  .cell.l1 { background: var(--cell-1); } .cell.l2 { background: var(--cell-2); }
118
122
  .cell.l3 { background: var(--cell-3); } .cell.l4 { background: var(--cell-4); }
119
123
  .brandmark { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; flex-shrink: 0; color: #fff; }
124
+ .brandmark.placeholder { background: transparent !important; border: 1px solid var(--line); color: var(--dim); }
120
125
  .brandmark img { width: 18px; height: 18px; }
126
+ .brandmark svg { width: 18px; height: 18px; display: block; }
121
127
  .brandmark.sm { width: 28px; height: 28px; border-radius: 8px; }
122
128
  .brandmark.sm img { width: 14px; height: 14px; }
129
+ .brandmark.sm svg { width: 14px; height: 14px; }
123
130
  .brandmark .fb { display: none; width: 18px; height: 18px; }
124
131
  .brandmark.sm .fb { width: 14px; height: 14px; }
125
132
  .ghostimg { position: absolute; right: -6px; bottom: -10px; width: 88px; height: 88px;
126
133
  opacity: .08; pointer-events: none; user-select: none; }
134
+ span.ghostimg { display: grid; place-items: center; }
135
+ span.ghostimg svg { width: 72px; height: 72px; display: block; }
136
+ .ghosticon { position: absolute; right: 4px; bottom: -6px; width: 72px; height: 72px;
137
+ opacity: .06; pointer-events: none; user-select: none; color: var(--dim); }
127
138
  @media (prefers-color-scheme: light) {
128
139
  :root:not([data-theme="dark"]) .ghostimg { opacity: .06; filter: brightness(0); }
129
140
  }
130
141
  html[data-theme="light"] .ghostimg { opacity: .06; filter: brightness(0); }
131
- #tip { position: fixed; z-index: 100; pointer-events: none; min-width: 230px; max-width: 300px;
142
+ #tip { position: fixed; z-index: 100; pointer-events: none; min-width: 250px; max-width: 370px;
132
143
  background: var(--panel);
133
144
  border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px;
134
145
  box-shadow: var(--shadow-xs); opacity: 0; transition: opacity .15s; }
135
146
  #tip.show { opacity: 1; }
136
147
  #tip .tt { font-size: 11px; text-transform: uppercase; letter-spacing: .14em; font-weight: 700; }
137
148
  #tip .tv { font-size: 13px; margin: 2px 0 8px; }
138
- #tip .tr { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 2.5px 0; }
149
+ #tip .tr { display: flex; align-items: center; gap: 10px; font-size: 12px; padding: 3px 0; }
139
150
  #tip .sw { width: 9px; height: 9px; border-radius: 2.5px; flex-shrink: 0; }
140
151
  #tip .tn { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
141
152
  .scrollarea { overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
@@ -145,7 +156,7 @@
145
156
  .rrow { transition: background .2s; cursor: pointer; }
146
157
  .rrow:hover { box-shadow: var(--shadow-xs); }
147
158
  #recentTable thead th { position: sticky; top: 0; background: var(--panel); z-index: 1; }
148
- #tip .tvr { flex-shrink: 0; }
159
+ #tip .tvr { flex-shrink: 0; white-space: nowrap; font-variant-numeric: tabular-nums; }
149
160
  .modelcard { transition: background .25s; }
150
161
  .modelcard:hover { box-shadow: var(--shadow-xs); }
151
162
 
@@ -46,7 +46,7 @@
46
46
  <div class="dlg-head">
47
47
  <div class="min-w-0">
48
48
  <p class="dlg-title" id="settingsTitle">Settings</p>
49
- <p class="dlg-desc">Appearance, data, and where statistics live.</p>
49
+ <p class="dlg-desc">Appearance, data, and where statistics live. <span id="verChip" class="mono">tersio</span></p>
50
50
  </div>
51
51
  <button id="settingsClose" class="btn-push flex shrink-0 items-center p-2 rounded-xl" style="border: 1px solid var(--line); color: var(--ink)" aria-label="Close settings">
52
52
  <i data-lucide="x" class="size-4"></i>
@@ -206,34 +206,45 @@
206
206
 
207
207
  <!-- Models + recent: twin cards -->
208
208
  <div class="rise grid grid-cols-12 grid-flow-dense gap-3 mt-8">
209
- <section class="col-span-12 lg:col-span-7 rounded-xl p-5 overflow-hidden" style="border: 1px solid var(--line); background: var(--panel)" aria-label="All models">
209
+ <section class="col-span-12 lg:col-span-6 rounded-xl p-5 overflow-hidden flex flex-col" style="border: 1px solid var(--line); background: var(--panel)" aria-label="All models">
210
210
  <div class="flex items-center gap-2 mb-1">
211
211
  <i data-lucide="layers" class="size-4" style="color: var(--accent)"></i>
212
212
  <h2 class="display font-bold tracking-tight text-xl truncate min-w-0">Models</h2>
213
213
  <span class="mono text-xs ml-auto truncate shrink-0" style="color: var(--dim)" id="modelsCount"></span>
214
214
  </div>
215
- <p class="mono text-xs mb-4" style="color: var(--dim)">ranked by tokens / top 8</p>
216
- <div class="scrollarea" style="max-height:410px"><ol id="models" class="overflow-hidden"></ol></div>
215
+ <p class="mono text-xs mb-4" style="color: var(--dim)">ranked by tokens / top 10</p>
216
+ <div class="scrollarea"><ol id="models" class="overflow-hidden"></ol></div>
217
+ <div class="mono text-xs mt-4 pt-4 flex flex-wrap items-center gap-x-4 gap-y-3 mt-auto" style="color: var(--dim); border-top: 1px solid var(--line)">
218
+ <span id="modelRange">Showing 0 of 0</span>
219
+ <span class="seg flex items-center gap-1 p-1 rounded-[10px]" style="border: 1px solid var(--line)" role="group" aria-label="Models per page">
220
+ <button data-pp="10" class="on px-2.5 py-1" role="button">10</button>
221
+ <button data-pp="15" class="px-2.5 py-1" role="button">15</button>
222
+ <button data-pp="25" class="px-2.5 py-1" role="button">25</button>
223
+ <button data-pp="50" class="px-2.5 py-1" role="button">50</button>
224
+ </span>
225
+ <span class="ml-auto flex items-center gap-1" id="modelPages" role="navigation" aria-label="Model pages"></span>
226
+ </div>
217
227
  <div id="emptyModels" class="empty hidden">
218
228
  <span class="empty-icon"><i data-lucide="boxes" class="size-5"></i></span>
219
229
  <p class="empty-title">No models yet</p>
220
230
  <p class="empty-desc">Model token totals will appear here once sessions report tokens.</p>
221
231
  </div>
222
232
  </section>
223
- <section class="col-span-12 lg:col-span-5 rounded-xl p-5 overflow-hidden" style="border: 1px solid var(--line); background: var(--panel)" aria-label="Recent requests">
233
+ <section class="col-span-12 lg:col-span-6 rounded-xl p-5 overflow-hidden flex flex-col" style="border: 1px solid var(--line); background: var(--panel)" aria-label="Recent requests">
224
234
  <div class="flex items-center gap-2 mb-1">
225
235
  <i data-lucide="history" class="size-4" style="color: var(--accent)"></i>
226
236
  <h2 class="display font-bold tracking-tight text-xl truncate min-w-0">Recent requests</h2>
227
237
  <span class="mono text-xs ml-auto truncate shrink-0" style="color: var(--dim)" id="recentCount"></span>
228
238
  </div>
229
- <p class="mono text-xs mb-4 truncate" style="color: var(--dim)">latest assistant messages</p>
230
- <div class="scrollarea overflow-x-auto" style="max-height:410px">
239
+ <p class="mono text-xs mb-4 truncate" style="color: var(--dim)" title="Time = elapsed time with the model; Speed = output tokens per second">latest assistant messages · ⏱ elapsed · ⚡ tok/s</p>
240
+ <div class="scrollarea overflow-x-auto">
231
241
  <table class="w-full mono text-[13px]" id="recentTable">
232
- <colgroup><col><col style="width:150px"><col style="width:90px"></colgroup>
242
+ <colgroup><col><col style="width:120px"><col style="width:110px"><col style="width:80px"></colgroup>
233
243
  <thead>
234
244
  <tr class="text-left text-[11px] uppercase tracking-[0.14em]" style="color: var(--dim)">
235
245
  <th class="font-normal py-2 pr-3">Model</th>
236
246
  <th class="font-normal text-right py-2 pr-3">In / Out</th>
247
+ <th class="font-normal text-right py-2 pr-3" title="Elapsed time with the model / output token speed">Time</th>
237
248
  <th class="font-normal text-right py-2">When</th>
238
249
  </tr>
239
250
  </thead>
@@ -245,6 +256,16 @@
245
256
  <p class="empty-title">No requests yet</p>
246
257
  <p class="empty-desc">Recent assistant messages will show here once sessions report tokens.</p>
247
258
  </div>
259
+ <div class="mono text-xs mt-4 pt-4 flex flex-wrap items-center gap-x-4 gap-y-3 mt-auto" style="color: var(--dim); border-top: 1px solid var(--line)">
260
+ <span id="recentRange">Showing 0 of 0</span>
261
+ <span class="seg flex items-center gap-1 p-1 rounded-[10px]" style="border: 1px solid var(--line)" role="group" aria-label="Requests per page">
262
+ <button data-pp="10" class="px-2.5 py-1" role="button">10</button>
263
+ <button data-pp="15" class="on px-2.5 py-1" role="button">15</button>
264
+ <button data-pp="25" class="px-2.5 py-1" role="button">25</button>
265
+ <button data-pp="50" class="px-2.5 py-1" role="button">50</button>
266
+ </span>
267
+ <span class="ml-auto flex items-center gap-1" id="recentPages" role="navigation" aria-label="Recent pages"></span>
268
+ </div>
248
269
  </section>
249
270
  </div>
250
271
 
@@ -294,7 +315,6 @@
294
315
  <footer class="mono text-xs mt-12 pt-4" style="color: var(--dim); border-top: 1px solid var(--line)">
295
316
  <div class="flex flex-wrap items-center gap-x-4 gap-y-3">
296
317
  <span>© 2026 Tersio. KurutoDenzeru. All rights reserved.</span>
297
- <span class="pill flat" id="verChip">tersio</span>
298
318
  <span class="ml-auto flex items-center gap-1" style="color: var(--ink)">
299
319
  <a href="https://github.com/KurutoDenzeru/tersio" target="_blank" rel="noopener" aria-label="GitHub" class="btn-push social grid size-8 place-items-center rounded-lg"><img height="16" width="16" src="https://cdn.jsdelivr.net/npm/simple-icons@v16/icons/github.svg" alt="GitHub"></a>
300
320
  <a href="https://linkedin.com/in/kurtcalacday/" target="_blank" rel="noopener" aria-label="LinkedIn" class="btn-push social grid size-8 place-items-center rounded-lg"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg></a>