@krtclcdy/tersio 2.16.0 → 2.18.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) {
@@ -164,7 +180,9 @@
164
180
  });
165
181
  })();
166
182
 
183
+ 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
184
  var PROVIDERS = [
185
+ [/openai|codex|gpt-|o1/i, 'OpenAI', 'openai', '#fff', OPENAI_SVG],
168
186
  [/muse/i, 'Meta', 'meta', '#0082fb'],
169
187
  [/deepseek/i, 'DeepSeek', 'deepseek', '#4d6bfe'],
170
188
  [/qwen|qwq/i, 'Qwen', 'qwen', '#6950EF'],
@@ -175,21 +193,27 @@
175
193
  [/nemotron|nvidia/i, 'NVIDIA', 'nvidia', '#76b900'],
176
194
  [/mistral/i, 'Mistral', 'mistralai', '#ff7000'],
177
195
  [/claude|anthropic/i, 'Anthropic', 'anthropic', '#d97757'],
178
- [/gemini|google/i, 'Google', 'googlegemini', '#4285f4'],
196
+ [/gemini|google|gemma/i, 'Google', 'google', '#4285F4'],
179
197
  ];
180
198
  function vendorOf(model) {
181
199
  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] };
200
+ if (PROVIDERS[i][0].test(model)) return { name: PROVIDERS[i][1], slug: PROVIDERS[i][2], color: PROVIDERS[i][3], svg: PROVIDERS[i][4] };
183
201
  }
184
202
  return { name: 'Other', slug: '', color: '#71717a' };
185
203
  }
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.
204
+ // Brand glyph: inline SVG when the provider ships one (OpenAI), else the
205
+ // Simple Icons CDN; unknown / Other providers render a clean Lucide
206
+ // sparkles icon with no solid background as a placeholder.
188
207
  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>';
208
+ if (v.svg) {
209
+ return '<span class="brandmark' + (small ? ' sm' : '') + '" style="background:' + v.color + '" title="' + v.name + '">' + v.svg + '</span>';
210
+ }
211
+ if (!v.slug) {
212
+ return '<span class="brandmark placeholder' + (small ? ' sm' : '') + '" title="' + v.name + '">' +
213
+ '<i data-lucide="sparkles" class="fb" style="display:grid"></i></span>';
214
+ }
215
+ var icon = '<img src="https://cdn.simpleicons.org/' + v.slug + '/white" alt="" loading="lazy" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'grid\';">';
216
+ var fb = '<i data-lucide="sparkles" class="fb"></i>';
193
217
  return '<span class="brandmark' + (small ? ' sm' : '') + '" style="background:' + v.color + '" title="' + v.name + '">' + icon + fb + '</span>';
194
218
  }
195
219
  var PALETTE = ['#34d399', '#818cf8', '#22d3ee', '#fbbf24', '#f472b6', '#a78bfa', '#fb923c', '#2dd4bf'];
@@ -197,12 +221,30 @@
197
221
  function dayKey(d) {
198
222
  return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
199
223
  }
200
- function relTime(ts) {
201
- var s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
202
- if (s < 60) return 'just now';
203
- if (s < 3600) return Math.floor(s / 60) + 'm ago';
204
- if (s < 86400) return Math.floor(s / 3600) + 'h ago';
205
- return Math.floor(s / 86400) + 'd ago';
224
+ // Absolute local stamp for the When column: "Sep 18, 11:14:08 PM". The
225
+ // column is sortable, and a relative label made that ordering unreadable.
226
+ function whenStamp(ts) {
227
+ var d = new Date(ts);
228
+ return d.toLocaleString('en-US', { month: 'short', day: 'numeric' }) + ', ' +
229
+ d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', second: '2-digit' });
230
+ }
231
+ // Per-request generation time + output throughput from the message-level
232
+ // `duration` (ms). Missing → '–'. Throughput uses output tokens since
233
+ // that is what streams to the user during the measured window.
234
+ function fmtDur(ms) {
235
+ if (ms === undefined) return '–';
236
+ var s = ms / 1000;
237
+ return (s < 10 ? s.toFixed(1) : Math.round(s)) + 's';
238
+ }
239
+ function speedText(r) {
240
+ if (r.d === undefined) return '–';
241
+ var tps = r.d > 0 ? r.o / (r.d / 1000) : 0;
242
+ return fmtDur(r.d) + ' ⚡' + (tps < 10 ? tps.toFixed(1) : Math.round(tps)) + '/s';
243
+ }
244
+ function speedTitle(r) {
245
+ if (r.d === undefined) return 'no duration recorded';
246
+ var tps = r.d > 0 ? r.o / (r.d / 1000) : 0;
247
+ return '⏱ ' + (r.d / 1000).toFixed(1) + 's elapsed with the model, ⚡ ' + r.o + ' output tokens (' + tps.toFixed(1) + ' tok/s)';
206
248
  }
207
249
  function modelTotal(byModel, m) {
208
250
  var b = byModel[m] || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
@@ -234,6 +276,7 @@
234
276
  function showTip(html, x, y) {
235
277
  var t = tip();
236
278
  t.innerHTML = html;
279
+ if (window.lucide) lucide.createIcons();
237
280
  t.classList.add('show');
238
281
  moveTip(x, y);
239
282
  }
@@ -265,19 +308,104 @@
265
308
  var denom = b.input + b.cacheRead;
266
309
  var hit = denom ? (b.cacheRead / denom * 100).toFixed(1) + '%' : '-';
267
310
  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>';
311
+ var cb = ((DATA.byModelBucketUsd || {})[m]) || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
312
+ var h = '<div class="tt">' + shortName(m) + '</div><div class="tv">' + fmtShort(total) + ' total (' + fxMoney(cb.input + cb.output + cb.cacheRead + cb.cacheWrite) + ')</div>';
313
+ 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>';
314
+ 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>';
315
+ 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>';
316
+ 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
317
  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
318
  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
319
  return h;
274
320
  }
321
+ function stampLocal(ts) {
322
+ var d = new Date(ts);
323
+ function p(n) { return String(n).padStart(2, '0'); }
324
+ return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
325
+ }
326
+ // Per-request outcome. OMP records stopReason on every assistant turn and,
327
+ // on failure, the HTTP errorStatus + errorMessage; toolUse turns are
328
+ // ordinary completed turns, so only error and aborted stand apart.
329
+ var STATUS_RANK = { error: 0, aborted: 1, completed: 2 };
330
+ function statusRank(r) { var k = STATUS_RANK[r.st]; return k === undefined ? 2 : k; }
331
+ function statusLabel(r) {
332
+ if (r.st === 'error') return 'error' + (r.code ? ' ' + r.code : '');
333
+ return r.st === 'aborted' ? 'aborted' : 'completed';
334
+ }
335
+ function statusColor(r) { return r.st === 'error' ? '#f87171' : r.st === 'aborted' ? '#fbbf24' : 'var(--accent)'; }
336
+ // Cost precedence. A recorded charge > 0 is authoritative and shown bare.
337
+ // A recording of exactly 0 usually means a free/local provider (every row on
338
+ // a local model reports 0) and would blank the column, so those fall back to
339
+ // the modeled figure carrying the dashboard's usual "~" estimate marker.
340
+ // costRecorded() still reports the raw 0 so the tooltip can show both.
341
+ function costRecorded(r) { return typeof r.usd === 'number'; }
342
+ function costIsMeasured(r) { return costRecorded(r) && r.usd > 0; }
343
+ function displayCost(r) { return costIsMeasured(r) ? r.usd : (r.est || 0); }
344
+
345
+ function tipRecentHTML(r) {
346
+ var tps = r.d !== undefined && r.d > 0 ? r.o / (r.d / 1000) : 0;
347
+ var isEst = !costIsMeasured(r);
348
+ var h = '<div class="tt">' + esc(shortName(r.m)) + '</div><div class="tv">' + stampLocal(r.t) + '</div>';
349
+ h += '<div class="tr"><span class="sw" style="background:#fb923c"></span><span class="tn">input</span><span class="tvr">' + fmt(r.i) + '</span></div>';
350
+ 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>';
351
+ 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>';
352
+ 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>';
353
+ 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>';
354
+ 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>';
355
+ h += '<div class="tr"><span class="sw" style="background:' + statusColor(r) + '"></span><span class="tn">status</span><span class="tvr">' + statusLabel(r) + '</span></div>';
356
+ 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>';
357
+ 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>';
358
+ if (r.note) h += '<div class="tnote">' + esc(r.note) + '</div>';
359
+ return h;
360
+ }
361
+ // Zone indicators: Lucide face per savings-bento metric. Coarse by design;
362
+ // captions say est. where modeled. Icons paint via showTip/render createIcons.
363
+ function zoneIcon(glyph, cls) { return '<i data-lucide="' + glyph + '" class="zface ' + cls + '"></i>'; }
364
+ function co2Zone(g) {
365
+ if (!(g > 0)) return ['minus', 'no data', ''];
366
+ if (g <= 50) return ['sprout', 'light', 'good'];
367
+ if (g <= 500) return ['smile', 'moderate', 'good'];
368
+ if (g <= 2000) return ['meh', 'heavy', 'warn'];
369
+ return ['flame', 'very high', 'bad'];
370
+ }
371
+ function levZone(x) {
372
+ if (!(x > 0)) return ['minus', 'no savings yet', ''];
373
+ if (x >= 3) return ['rocket', 'high leverage', 'good'];
374
+ if (x >= 1) return ['smile', 'solid', 'good'];
375
+ return ['meh', 'light', 'warn'];
376
+ }
377
+ function shareZone(p) {
378
+ if (!(p > 0)) return ['minus', 'uncached', ''];
379
+ if (p >= 80) return ['rocket', 'high', 'good'];
380
+ if (p >= 50) return ['smile', 'good', 'good'];
381
+ return ['meh', 'low', 'warn'];
382
+ }
275
383
  function tipCo2HTML() {
384
+ var z = co2Zone(DATA.co2g || 0);
276
385
  var h = '<div class="tt">CO2</div><div class="tv">~' + (DATA.co2g || 0).toFixed(1) + 'g est.</div>';
386
+ 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
387
  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>';
388
+ return h;
389
+ }
390
+ function tipSavedHTML() {
391
+ var usd = DATA.usd || 0, saved = DATA.savedUsd || 0;
392
+ var lev = usd ? saved / usd : 0;
393
+ var z = levZone(lev);
394
+ var t = DATA.tokens || { cacheRead: 0 };
395
+ var h = '<div class="tt">Saved by cache</div><div class="tv">' + fxMoney(saved) + ' est.</div>';
396
+ 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>';
397
+ 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>';
398
+ 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>';
399
+ return h;
400
+ }
401
+ function tipShareHTML(share, pp) {
402
+ var z = shareZone(share);
403
+ var t = DATA.tokens || { cacheRead: 0, cacheWrite: 0 };
404
+ var h = '<div class="tt">Cache share</div><div class="tv">' + share + '%</div>';
405
+ 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>';
406
+ 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>';
407
+ 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>';
408
+ 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
409
  return h;
282
410
  }
283
411
  function hoverModel(el, m) {
@@ -285,6 +413,11 @@
285
413
  el.addEventListener('mousemove', function(ev) { moveTip(ev.clientX, ev.clientY); });
286
414
  el.addEventListener('mouseleave', hideTip);
287
415
  }
416
+ function hoverRecent(el, r) {
417
+ el.addEventListener('mouseenter', function(ev) { showTip(tipRecentHTML(r), ev.clientX, ev.clientY); });
418
+ el.addEventListener('mousemove', function(ev) { moveTip(ev.clientX, ev.clientY); });
419
+ el.addEventListener('mouseleave', hideTip);
420
+ }
288
421
  function dayModelRows(key) {
289
422
  var per = (DATA.byDayModel || {})[key] || {};
290
423
  var tops = topModels(DATA.byModel || {}, 8), rows = [], other = 0;
@@ -444,33 +577,62 @@
444
577
  });
445
578
  }
446
579
 
447
- // 30-day token line: daily totals, smoothed path, hover dot + per-model tip.
448
- function wow(model) {
449
- var days = allDays().slice(-14), cur = 0, prev = 0;
450
- days.forEach(function(d, i) {
451
- var v = ((DATA.byDayModel || {})[d] || {})[model] || 0;
452
- if (i < days.length - 7) prev += v; else cur += v;
580
+ var modelPage = 1, modelPer = 10, recentPage = 1, recentPer = 25;
581
+ // Recent requests default to newest-first, which is how the transcript
582
+ // arrives; every column is sortable like the command table.
583
+ var recentSort = { key: 'when', dir: -1 }, recentRows = [];
584
+ function recentVal(r, key) {
585
+ if (key === 'model') return r.m.toLowerCase();
586
+ if (key === 'input') return r.i;
587
+ if (key === 'output') return r.o;
588
+ if (key === 'time') return r.d === undefined ? null : r.d;
589
+ if (key === 'status') return statusRank(r);
590
+ if (key === 'cost') return displayCost(r);
591
+ return r.t;
592
+ }
593
+ function sortRecent() {
594
+ var k = recentSort.key, d = recentSort.dir;
595
+ recentRows.sort(function(a, b) {
596
+ var av = recentVal(a, k), bv = recentVal(b, k);
597
+ if (av === null || av === undefined) return 1;
598
+ if (bv === null || bv === undefined) return -1;
599
+ if (av < bv) return -d;
600
+ if (av > bv) return d;
601
+ return b.t - a.t;
602
+ });
603
+ Array.prototype.forEach.call(document.querySelectorAll('#recentTable .thsort'), function(btn) {
604
+ var on = btn.dataset.sort === k;
605
+ btn.querySelector('.arr').textContent = on ? (d === 1 ? '↑' : '↓') : '⇅';
606
+ btn.closest('th').setAttribute('aria-sort', on ? (d === 1 ? 'ascending' : 'descending') : 'none');
453
607
  });
454
- if (!prev) return cur ? 'new' : '-';
455
- var p = Math.round((cur - prev) / prev * 100);
456
- return (p >= 0 ? '+' : '') + p + '%';
457
608
  }
458
609
  function renderModels() {
459
610
  var byModel = DATA.byModel || {};
460
- var tops = topModels(byModel, 8).filter(function(m) { return modelTotal(byModel, m) > 0; });
611
+ var tops = topModels(byModel, Object.keys(byModel).length).filter(function(m) { return modelTotal(byModel, m) > 0; });
461
612
  var cards = document.getElementById('modelCards');
462
613
  cards.innerHTML = '';
463
614
  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>';
464
615
  tops.slice(0, 3).forEach(function(m, i) {
465
- var v = vendorOf(m), d = wow(m);
616
+ var v = vendorOf(m);
466
617
  var card = document.createElement('div');
467
618
  card.className = 'modelcard relative p-4 rounded-xl overflow-hidden';
468
- if (v.slug) {
619
+ if (v.svg) {
620
+ var ghostSvg = document.createElement('span');
621
+ ghostSvg.className = 'ghostimg'; ghostSvg.setAttribute('aria-hidden', 'true');
622
+ ghostSvg.innerHTML = v.svg;
623
+ card.appendChild(ghostSvg);
624
+ } else if (v.slug) {
469
625
  var ghost = document.createElement('img');
470
626
  ghost.className = 'ghostimg'; ghost.alt = '';
471
627
  ghost.src = 'https://cdn.simpleicons.org/' + v.slug + '/white';
472
628
  ghost.setAttribute('aria-hidden', 'true');
473
629
  card.appendChild(ghost);
630
+ } else {
631
+ var ghostSparkle = document.createElement('i');
632
+ ghostSparkle.setAttribute('data-lucide', 'sparkles');
633
+ ghostSparkle.className = 'ghosticon';
634
+ ghostSparkle.setAttribute('aria-hidden', 'true');
635
+ card.appendChild(ghostSparkle);
474
636
  }
475
637
  card.style.cssText = 'border: 1px solid var(--line); background: var(--panel)';
476
638
  var head = document.createElement('p');
@@ -488,13 +650,6 @@
488
650
  var tot = document.createElement('p'); tot.className = 'mono font-bold text-lg shrink-0'; tot.textContent = fmtShort(modelTotal(byModel, m));
489
651
  row.appendChild(badge); row.appendChild(mid); row.appendChild(tot);
490
652
  card.appendChild(head); card.appendChild(row);
491
- if (d !== 'new') {
492
- var delta = document.createElement('p');
493
- delta.className = 'mono text-xs mt-3';
494
- delta.style.color = d.charAt(0) === '-' ? '#f87171' : 'var(--accent)';
495
- delta.textContent = d;
496
- card.appendChild(delta);
497
- }
498
653
  hoverModel(card, m);
499
654
  cards.appendChild(card);
500
655
  });
@@ -502,10 +657,13 @@
502
657
  ol.innerHTML = '';
503
658
  var max = 1;
504
659
  tops.forEach(function(m) { max = Math.max(max, modelTotal(byModel, m)); });
505
- tops.forEach(function(m, i) {
660
+ var modelPages = Math.max(1, Math.ceil(tops.length / modelPer));
661
+ if (modelPage > modelPages) modelPage = modelPages;
662
+ var modelStart = (modelPage - 1) * modelPer;
663
+ tops.slice(modelStart, modelStart + modelPer).forEach(function(m, i) {
506
664
  var v = vendorOf(m);
507
665
  var li = document.createElement('li');
508
- li.className = 'mrow flex items-center gap-3 px-4 py-3' + (i < tops.length - 1 ? ' rowline' : '');
666
+ li.className = 'mrow flex items-center gap-3 px-4 py-3' + (i < Math.min(tops.length - modelStart, modelPer) - 1 ? ' rowline' : '');
509
667
  var badgeWrap = document.createElement('span');
510
668
  badgeWrap.innerHTML = brandHTML(v, true);
511
669
  var badge = badgeWrap.firstChild;
@@ -527,6 +685,7 @@
527
685
  ol.appendChild(li);
528
686
  });
529
687
  document.getElementById('modelsCount').textContent = tops.length ? tops.length + ' models' : '';
688
+ paintPager('modelPages', 'modelRange', modelPage, modelPer, tops.length, function(p) { modelPage = p; renderModels(); });
530
689
  ol.style.display = tops.length ? '' : 'none';
531
690
  document.getElementById('emptyModels').classList.toggle('hidden', tops.length > 0);
532
691
  if (tops.length && 'IntersectionObserver' in window && !reduce) {
@@ -553,8 +712,43 @@
553
712
 
554
713
  // Union of session tool calls + RTK-metered commands, sortable + paged.
555
714
  // Session tools were never metered per command: saved / avg / time show – and sort last.
556
- var cmdSort = { key: 'count', dir: -1 }, cmdPage = 1, cmdPer = 15, cmdRows = [];
557
- function cmdVal(r, key) { return key === 'name' ? r.name.toLowerCase() : r[key]; }
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 = '';
718
+ function cmdVal(r, key) {
719
+ if (key === 'name') return r.name.toLowerCase();
720
+ if (key === 'project') return (r.project || '').toLowerCase();
721
+ return r[key];
722
+ }
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
+ }
558
752
  function sortCmd() {
559
753
  var k = cmdSort.key, d = cmdSort.dir;
560
754
  cmdRows.sort(function(a, b) {
@@ -573,12 +767,14 @@
573
767
  }
574
768
  function renderCmd() {
575
769
  var g = (DATA.rtkGain || {});
576
- cmdRows = (DATA.byTool || []).map(function(r) {
577
- return { name: r[0], count: r[1], saved: null, avgPct: null, avgMs: null };
770
+ var all = (DATA.byTool || []).map(function(r) {
771
+ return { name: r[0], project: '', count: r[1], saved: null, avgPct: null, avgMs: null };
578
772
  });
579
773
  (g.byCommand || []).forEach(function(r) {
580
- cmdRows.push({ name: r.command, count: r.count, saved: r.saved, avgPct: r.avgPct, avgMs: r.avgMs });
774
+ all.push({ name: r.command, project: r.project || '', count: r.count, saved: r.saved, avgPct: r.avgPct, avgMs: r.avgMs });
581
775
  });
776
+ paintProjectFilter(all);
777
+ cmdRows = cmdProject ? all.filter(function(r) { return r.project === cmdProject; }) : all;
582
778
  sortCmd();
583
779
  var pages = Math.max(1, Math.ceil(cmdRows.length / cmdPer));
584
780
  if (cmdPage > pages) cmdPage = pages;
@@ -593,6 +789,7 @@
593
789
  tr.className = 'mrow' + (i < Math.min(cmdRows.length - start, cmdPer) - 1 ? ' rowline' : '');
594
790
  tr.innerHTML = '<td class="text-right pr-3 py-2.5 mono text-xs w-10" style="color: var(--dim)"></td>' +
595
791
  '<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>' +
596
793
  '<td class="text-right py-2.5 pr-3 font-bold"></td>' +
597
794
  '<td class="text-right py-2.5 pr-3 font-bold"></td>' +
598
795
  '<td class="text-right py-2.5 pr-3" style="color: var(--accent)"></td>' +
@@ -602,11 +799,13 @@
602
799
  tds[0].textContent = String(n + 1).padStart(2, '0');
603
800
  tds[1].textContent = r.name;
604
801
  tds[1].title = r.name;
605
- tds[2].textContent = fmt(r.count);
606
- tds[3].textContent = r.saved === null ? '–' : fmtShort(r.saved);
607
- tds[4].textContent = r.avgPct === null ? '–' : r.avgPct.toFixed(1) + '%';
608
- tds[5].textContent = r.avgMs === null || r.avgMs === undefined ? '–' : fmtMs(r.avgMs);
609
- tds[6].firstChild.firstChild.style.width = r.count ? Math.round(r.count / max * 100) + '%' : '0';
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';
610
809
  body.appendChild(tr);
611
810
  });
612
811
  document.getElementById('cmdTable').style.display = cmdRows.length ? '' : 'none';
@@ -616,26 +815,31 @@
616
815
  g.commands ? fmt(tools) + ' tools · ' + fmt(g.commands) + ' commands · ' + fmtShort(g.saved) + ' saved' : 'tool calls in sessions';
617
816
  paintCmdPager(pages);
618
817
  }
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');
818
+ // Shared page-number painter: prev/next plus windowed numbers with
819
+ // ellipsis. onPick(page) rerenders the owning card. Same controls as the
820
+ // Command tools pager; Models and Recent reuse it verbatim.
821
+ function paintPager(wrapId, rangeId, page, per, total, onPick) {
822
+ var start = total ? (page - 1) * per + 1 : 0;
823
+ document.getElementById(rangeId).textContent =
824
+ 'Showing ' + start + '–' + Math.min(total, page * per) + ' of ' + total;
825
+ var wrap = document.getElementById(wrapId);
624
826
  wrap.innerHTML = '';
625
- function btn(label, page, opts) {
827
+ var pages = Math.max(1, Math.ceil(total / per));
828
+ if (page > pages) onPick(pages);
829
+ function btn(label, next, opts) {
626
830
  var b = document.createElement('button');
627
831
  b.type = 'button'; b.className = 'pgbtn mono' + (opts && opts.on ? ' on' : '');
628
832
  b.textContent = label;
629
833
  if (opts && opts.dim) b.setAttribute('aria-label', opts.dim);
630
834
  if (opts && opts.off) b.disabled = true;
631
- else b.addEventListener('click', function() { cmdPage = page; renderCmd(); });
835
+ else b.addEventListener('click', function() { onPick(next); });
632
836
  wrap.appendChild(b);
633
837
  return b;
634
838
  }
635
- btn('‹', cmdPage - 1, { dim: 'Previous page', off: cmdPage <= 1 });
839
+ btn('‹', page - 1, { dim: 'Previous page', off: page <= 1 });
636
840
  var nums = [];
637
841
  for (var p = 1; p <= pages; p++) {
638
- if (p === 1 || p === pages || Math.abs(p - cmdPage) <= 1) nums.push(p);
842
+ if (p === 1 || p === pages || Math.abs(p - page) <= 1) nums.push(p);
639
843
  else if (nums[nums.length - 1] !== '…') nums.push('…');
640
844
  }
641
845
  nums.forEach(function(p) {
@@ -643,9 +847,27 @@
643
847
  var s = document.createElement('span');
644
848
  s.textContent = '…'; s.style.padding = '0 2px';
645
849
  wrap.appendChild(s);
646
- } else btn(String(p), p, { on: p === cmdPage });
850
+ } else btn(String(p), p, { on: p === page });
851
+ });
852
+ btn('›', page + 1, { dim: 'Next page', off: page >= pages });
853
+ }
854
+ function paintCmdPager(pages) {
855
+ var total = cmdRows.length;
856
+ if (cmdPage > pages) cmdPage = pages;
857
+ paintPager('cmdPages', 'cmdRange', cmdPage, cmdPer, total, function(p) { cmdPage = p; renderCmd(); });
858
+ }
859
+ function bindPerPage(sel, get, set, render) {
860
+ Array.prototype.forEach.call(document.querySelectorAll(sel + ' [data-pp]'), function(btn) {
861
+ if (btn.dataset.bound) return;
862
+ btn.dataset.bound = '1';
863
+ btn.addEventListener('click', function() {
864
+ if (Number(btn.dataset.pp) === get()) return;
865
+ Array.prototype.forEach.call(document.querySelectorAll(sel + ' [data-pp]'), function(b) { b.classList.remove('on'); });
866
+ btn.classList.add('on');
867
+ set(Number(btn.dataset.pp));
868
+ render();
869
+ });
647
870
  });
648
- btn('›', cmdPage + 1, { dim: 'Next page', off: cmdPage >= pages });
649
871
  }
650
872
  function bindCmdTable() {
651
873
  if (bindCmdTable.done) return;
@@ -659,17 +881,25 @@
659
881
  renderCmd();
660
882
  });
661
883
  });
662
- Array.prototype.forEach.call(document.querySelectorAll('#cmdPager [data-pp]'), function(btn) {
884
+ 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
+ bindPerPage('section[aria-label="All models"]', function() { return modelPer; }, function(n) { modelPer = n; modelPage = 1; }, renderModels);
888
+ bindPerPage('section[aria-label="Recent requests"]', function() { return recentPer; }, function(n) { recentPer = n; recentPage = 1; }, renderRecent);
889
+ }
890
+ function bindRecentTable() {
891
+ if (bindRecentTable.done) return;
892
+ bindRecentTable.done = true;
893
+ Array.prototype.forEach.call(document.querySelectorAll('#recentTable .thsort'), function(btn) {
663
894
  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();
895
+ var k = btn.dataset.sort;
896
+ if (recentSort.key === k) recentSort.dir = -recentSort.dir;
897
+ else { recentSort.key = k; recentSort.dir = k === 'model' || k === 'status' ? 1 : -1; }
898
+ recentPage = 1;
899
+ renderRecent();
669
900
  });
670
901
  });
671
902
  }
672
-
673
903
  function fmtMs(ms) {
674
904
  if (ms < 1000) return Math.round(ms) + 'ms';
675
905
  return (ms / 1000).toFixed(1) + 's';
@@ -783,7 +1013,19 @@
783
1013
  if (pel && pair[1]) { pel.textContent = pair[1]; pel.title = pair[1]; }
784
1014
  });
785
1015
  countUp(document.getElementById('saved'), d.savedUsd || 0, true);
786
- document.getElementById('co2').textContent = '~' + (d.co2g || 0).toFixed(1) + 'g';
1016
+ (function() {
1017
+ var lev = (d.usd || 0) ? (d.savedUsd || 0) / d.usd : 0;
1018
+ var z = levZone(lev);
1019
+ var note = document.getElementById('usdNote');
1020
+ note.innerHTML = ((d.priced ? 'per-model price table' : 'incl. default pricing') + (fx.live ? ' · fx live' : ' · fx snapshot') + ' · ') + zoneIcon(z[0], z[2]) + ' ' + z[1];
1021
+ note.title = 'Cache leverage x' + lev.toFixed(1) + ' — cache savings per $1 spent, vs full input price';
1022
+ })();
1023
+ (function() {
1024
+ var z = co2Zone(d.co2g || 0);
1025
+ var el = document.getElementById('co2');
1026
+ el.innerHTML = '~' + (d.co2g || 0).toFixed(1) + 'g ' + zoneIcon(z[0], z[2]);
1027
+ el.title = z[1] + ' footprint (est.)';
1028
+ })();
787
1029
  (function() {
788
1030
  var el = document.getElementById('co2');
789
1031
  var card = el ? el.closest('div.rounded-xl') : null;
@@ -793,16 +1035,39 @@
793
1035
  card.addEventListener('mousemove', function(ev) { moveTip(ev.clientX, ev.clientY); });
794
1036
  card.addEventListener('mouseleave', hideTip);
795
1037
  })();
1038
+ (function() {
1039
+ var el = document.getElementById('saved');
1040
+ var card = el ? el.closest('div.rounded-xl') : null;
1041
+ if (!card || card.dataset.savetip) return;
1042
+ card.dataset.savetip = '1';
1043
+ card.addEventListener('mouseenter', function(ev) { showTip(tipSavedHTML(), ev.clientX, ev.clientY); });
1044
+ card.addEventListener('mousemove', function(ev) { moveTip(ev.clientX, ev.clientY); });
1045
+ card.addEventListener('mouseleave', hideTip);
1046
+ })();
796
1047
  var share = total ? Math.round((t.cacheRead + t.cacheWrite) / total * 100) : 0;
797
- document.getElementById('cacheShare').textContent = share + '%';
1048
+ var shareZ = shareZone(share);
1049
+ document.getElementById('cacheShare').innerHTML = share + '% ' + zoneIcon(shareZ[0], shareZ[2]);
1050
+ document.getElementById('cacheShare').title = shareZ[1] + ' cache share';
798
1051
  var shareOf = function(b) { var s = dayTotal(b); return s ? ((b.cacheRead || 0) + (b.cacheWrite || 0)) / s * 100 : 0; };
799
1052
  var keys = Object.keys(byDay).sort(), cPct = 0, pPct = 0, cn = 0, pn = 0;
800
1053
  keys.slice(-7).forEach(function(k) { cPct += shareOf(byDay[k]); cn++; });
801
1054
  keys.slice(-14, -7).forEach(function(k) { pPct += shareOf(byDay[k]); pn++; });
802
1055
  var pp = (cn ? cPct / cn : 0) - (pn ? pPct / pn : 0);
803
1056
  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'));
1057
+ var cs = document.getElementById('cacheShare');
1058
+ cs.dataset.share = String(share);
1059
+ cs.dataset.pp = pp.toFixed(1);
1060
+ (function() {
1061
+ var el = document.getElementById('cacheShare');
1062
+ var card = el ? el.closest('div.rounded-xl') : null;
1063
+ if (!card || card.dataset.sharetip) return;
1064
+ card.dataset.sharetip = '1';
1065
+ card.addEventListener('mouseenter', function(ev) {
1066
+ showTip(tipShareHTML(Number(el.dataset.share || 0), Number(el.dataset.pp || 0)), ev.clientX, ev.clientY);
1067
+ });
1068
+ card.addEventListener('mousemove', function(ev) { moveTip(ev.clientX, ev.clientY); });
1069
+ card.addEventListener('mouseleave', hideTip);
1070
+ })();
806
1071
 
807
1072
  (function() {
808
1073
  var el = document.getElementById('ticker');
@@ -821,6 +1086,7 @@
821
1086
  renderStrip(t);
822
1087
  bindGraphTabs();
823
1088
  bindCmdTable();
1089
+ bindRecentTable();
824
1090
  renderGraph(d.byDay || {});
825
1091
  renderModels();
826
1092
  renderRecent();
@@ -833,33 +1099,73 @@
833
1099
  }
834
1100
  observe();
835
1101
  }
836
-
1102
+ // shadcn/ui badge: default/secondary/destructive/outline carry the meaning,
1103
+ // the label carries the detail. Built as DOM nodes so provider error notes
1104
+ // never reach innerHTML.
1105
+ function statusBadge(r) {
1106
+ var b = document.createElement('span');
1107
+ b.className = 'badge';
1108
+ if (r.st === 'error') {
1109
+ b.classList.add('destructive');
1110
+ b.textContent = statusLabel(r);
1111
+ b.title = 'error' + (r.code ? ' ' + r.code : '') + (r.note ? ' — ' + r.note : '');
1112
+ } else if (r.st === 'aborted') {
1113
+ b.classList.add('outline');
1114
+ b.textContent = 'aborted';
1115
+ b.title = r.note || 'Interrupted by user';
1116
+ } else {
1117
+ b.classList.add('secondary');
1118
+ b.textContent = 'completed';
1119
+ if (r.note) b.title = r.note;
1120
+ }
1121
+ return b;
1122
+ }
837
1123
  function renderRecent() {
838
1124
  var body = document.getElementById('recent');
839
1125
  body.innerHTML = '';
840
- var rows = (DATA.recent || []).slice(0, 13);
1126
+ var all = DATA.recent || [];
1127
+ recentRows = all.slice();
1128
+ sortRecent();
1129
+ var recentPages = Math.max(1, Math.ceil(recentRows.length / recentPer));
1130
+ if (recentPage > recentPages) recentPage = recentPages;
1131
+ var recentStart = (recentPage - 1) * recentPer;
1132
+ var rows = recentRows.slice(recentStart, recentStart + recentPer);
841
1133
  rows.forEach(function(r, i) {
842
1134
  var v = vendorOf(r.m);
1135
+ var measured = costIsMeasured(r);
843
1136
  var tr = document.createElement('tr');
844
- tr.className = 'rrow' + (i < rows.length - 1 ? ' rowline' : '');
845
- 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>' +
1137
+ tr.className = 'rrow' + (i < Math.min(recentRows.length - recentStart, recentPer) - 1 ? ' rowline' : '');
1138
+ tr.innerHTML = '<td class="py-2.5 pr-3 truncate" style="min-width: 0"></td>' +
1139
+ '<td class="text-right py-2.5 pr-3 whitespace-nowrap" style="color:#fb923c"></td>' +
1140
+ '<td class="text-right py-2.5 pr-3 whitespace-nowrap" style="color:var(--accent)"></td>' +
1141
+ '<td class="text-right py-2.5 pr-3 whitespace-nowrap" style="color:var(--dim)">–</td>' +
1142
+ '<td class="py-2.5 pr-3 whitespace-nowrap"></td>' +
847
1143
  '<td class="text-right py-2.5 pr-3 whitespace-nowrap"></td>' +
848
1144
  '<td class="text-right py-2.5 whitespace-nowrap" style="color: var(--dim)"></td>';
849
1145
  var tds = tr.children;
1146
+ var dot = '<span style="display:inline-block;width:8px;height:8px;border-radius:99px;background:' + v.color + ';margin-right:8px"></span>';
850
1147
  tds[0].innerHTML = dot + '<span></span>';
851
1148
  tds[0].querySelector('span:last-child').textContent = r.m;
852
- tds[0].title = r.m;
853
- 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);
1149
+ tds[1].textContent = fmt(r.i);
1150
+ tds[2].textContent = fmt(r.o);
1151
+ tds[3].textContent = speedText(r);
1152
+ tds[4].appendChild(statusBadge(r));
1153
+ tds[5].textContent = (measured ? '' : '~') + fxMoney(displayCost(r));
1154
+ tds[5].title = measured
1155
+ ? 'measured — charged by the provider'
1156
+ : (costRecorded(r)
1157
+ ? 'est. — provider charged ' + fxMoney(r.usd) + ' (free or local), modeled from tokens'
1158
+ : 'est. — modeled from tokens');
1159
+ if (measured) tds[5].style.color = 'var(--ink)';
1160
+ tds[6].textContent = whenStamp(r.t);
1161
+ tds[6].title = stampLocal(r.t);
1162
+ hoverRecent(tr, r);
858
1163
  body.appendChild(tr);
859
1164
  });
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);
1165
+ document.getElementById('recentCount').textContent = all.length ? recentRows.length + ' requests' : '';
1166
+ paintPager('recentPages', 'recentRange', recentPage, recentPer, recentRows.length, function(p) { recentPage = p; renderRecent(); });
1167
+ document.getElementById('recentTable').style.display = all.length ? '' : 'none';
1168
+ document.getElementById('emptyRecent').classList.toggle('hidden', all.length > 0);
863
1169
  }
864
1170
 
865
1171
  var seen = new WeakSet();