@krtclcdy/tersio 2.17.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- ![Banner](/TersioBanner.webp)
1
+ ![Banner](/assets/Banner.webp)
2
2
 
3
3
  # ✂️ Tersio — Token-saving OMP Add-ons
4
4
 
@@ -8,7 +8,7 @@
8
8
  [![Build](https://shieldcn.dev/github/ci/KurutoDenzeru/tersio.svg?variant=branded&size=xs&logo=githubactions&label=Build)](https://github.com/KurutoDenzeru/tersio/actions)
9
9
  [![MIT](https://shieldcn.dev/badge/license-MIT-2563eb.svg?variant=branded&size=xs&logo=opensourceinitiative)](./LICENSE)
10
10
 
11
- Terse replies, compact shell output, and minimal code decisions for [Oh My Pi (OMP)](https://github.com/can1357/oh-my-pi) — one-command combo presets.
11
+ Terse replies, compact shell output, and minimal code decisions for [Oh My Pi (OMP)](https://github.com/can1357/oh-my-pi) — one-command combo presets.
12
12
 
13
13
  ## ⚡ Getting Started
14
14
 
@@ -80,6 +80,22 @@ Measured savings against the same workload without the modes. Token counts are r
80
80
 
81
81
  Break-even math, balanced preset (caveman full + rtk + ponytail): a mixed turn costs 941 tok baseline vs 399 with Tersio (0.42×, **−57.6% per turn**). Overhead repays in turn 1 with the bundled floor (464 tok); turn 4 with the real installed Ponytail plugin (v4.9.0, 1,264 tok). RTK concentrates savings where noise lives — hook-wired sessions record **−97.5% on summarized test suites** (rtk `history.db`, 4 runs).
82
82
 
83
+ ## 📈 Gain Dashboard
84
+
85
+ `tersio gain --open` serves a local dashboard (127.0.0.1 only) that charts your own savings from `~/.omp/plugins/tersio-usage.jsonl`. Three views:
86
+
87
+ **Live feed and savings.** Token throughput, cost, savings bento, and recent activity — the top of the dashboard.
88
+
89
+ ![Gain dashboard: live token feed, cost, savings, and activity](/assets/GainHero.webp)
90
+
91
+ **Top models and recent requests.** Per-model cost breakdown plus the last requests with elapsed time and token speed.
92
+
93
+ ![Gain dashboard: top models with per-model cost, recent requests with elapsed time and token speed](/assets/GainModels.webp)
94
+
95
+ **Command tools.** Token savings, average rate, and timing per shell tool.
96
+
97
+ ![Gain dashboard: command tools with token savings, average rate, and timing](/assets/GainTools.webp)
98
+
83
99
  ## 🖥️ CLI
84
100
 
85
101
  | Command | Purpose |
package/cli/usage.js CHANGED
@@ -1,8 +1,15 @@
1
1
  // cli/usage.ts — ledger + session-token usage report, tokscale-style.
2
+ import path from 'node:path';
2
3
  import { co2GramsFor, energyWhFor, importSessionTokens, ledgerPath, priceFor, readResetWatermark, readUsage, refreshPricesIfStale, sessionsDir, usdCost, } from '../extensions/shared/usage-ledger.js';
3
4
  import { readRtkGain } from '../extensions/shared/rtk-gain.js';
4
5
  import { withInteractiveSpinner } from './interactive.js';
5
6
  import { PACKAGE_VERSION } from './common.js';
7
+ // RTK's history is machine-wide, so every project's commands have to reach
8
+ // the table or a busy repo silently stands in for all of them. The old cap of
9
+ // 10 rows ranked by tokens saved hid whole projects whose commands save little
10
+ // to nothing; 2000 groups covers a long history (~1.7k today) while still
11
+ // bounding /data.json.
12
+ const RTK_COMMAND_ROWS = 2000;
6
13
  export function summarizeUsage(rows) {
7
14
  refreshPricesIfStale();
8
15
  const byKind = {};
@@ -48,8 +55,11 @@ export function summarizeUsage(rows) {
48
55
  byDay: session.byDay,
49
56
  byDayModel: session.byDayModel,
50
57
  byTool,
51
- recent: session.recent,
52
- rtkGain: readRtkGain(10, watermark || undefined),
58
+ recent: session.recent.map((r) => ({
59
+ ...r,
60
+ est: usdCost({ input: r.i, output: r.o, cacheRead: r.cr ?? 0, cacheWrite: r.cw ?? 0 }, r.m).usd,
61
+ })),
62
+ rtkGain: readRtkGain(RTK_COMMAND_ROWS, watermark || undefined),
53
63
  usd,
54
64
  priced,
55
65
  savedUsd,
@@ -122,28 +132,30 @@ function printReport(report) {
122
132
  console.log(l);
123
133
  }
124
134
  }
125
- const cmdRows = report.byTool.map(([tool, n]) => ({ name: tool, count: n, saved: null, avgPct: null, avgMs: null }));
135
+ const cmdRows = report.byTool.map(([tool, n]) => ({ name: tool, project: '', count: n, saved: null, avgPct: null, avgMs: null }));
126
136
  for (const r of report.rtkGain.byCommand) {
127
- cmdRows.push({ name: r.command, count: r.count, saved: r.saved, avgPct: r.avgPct, avgMs: r.avgMs });
137
+ cmdRows.push({ name: r.command, project: r.project, count: r.count, saved: r.saved, avgPct: r.avgPct, avgMs: r.avgMs });
128
138
  }
129
139
  cmdRows.sort((a, b) => b.count - a.count);
130
140
  if (cmdRows.length) {
131
141
  const g = report.rtkGain;
142
+ const projects = new Set(g.byCommand.map((r) => r.project).filter(Boolean));
132
143
  const scope = g.commands
133
- ? ` COMMAND TOOLS ${fmt(report.byTool.length)} tools · ${fmt(g.commands)} commands · ${fmtShort(g.saved)} saved`
144
+ ? ` COMMAND TOOLS ${fmt(report.byTool.length)} tools · ${fmt(g.commands)} commands · ${fmtShort(g.saved)} saved · ${fmt(projects.size)} projects`
134
145
  : ' COMMAND TOOLS';
135
146
  console.log(scope);
136
147
  const top = cmdRows.reduce((m, r) => Math.max(m, r.count), 1);
137
148
  const crows = cmdRows.slice(0, 15).map((r, idx) => [
138
149
  String(idx + 1),
139
150
  r.name,
151
+ r.project ? path.basename(r.project) : '–',
140
152
  fmt(r.count),
141
153
  r.saved === null ? '–' : fmtShort(r.saved),
142
154
  r.avgPct === null ? '–' : `${r.avgPct.toFixed(1)}%`,
143
155
  r.avgMs === null ? '–' : fmtMs(r.avgMs),
144
156
  bar(r.count / top, 8),
145
157
  ]);
146
- for (const l of table(['#', 'Tool/Command', 'Count', 'Saved', 'Avg%', 'Time', 'Impact'], crows, [true, false, true, true, true, true, false], 30)) {
158
+ for (const l of table(['#', 'Tool/Command', 'Project', 'Count', 'Saved', 'Avg%', 'Time', 'Impact'], crows, [true, false, false, true, true, true, true, false], 30)) {
147
159
  console.log(l);
148
160
  }
149
161
  }
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) {
@@ -177,7 +193,7 @@
177
193
  [/nemotron|nvidia/i, 'NVIDIA', 'nvidia', '#76b900'],
178
194
  [/mistral/i, 'Mistral', 'mistralai', '#ff7000'],
179
195
  [/claude|anthropic/i, 'Anthropic', 'anthropic', '#d97757'],
180
- [/gemini|google/i, 'Google', 'googlegemini', '#8E75B2'],
196
+ [/gemini|google|gemma/i, 'Google', 'google', '#4285F4'],
181
197
  ];
182
198
  function vendorOf(model) {
183
199
  for (var i = 0; i < PROVIDERS.length; i++) {
@@ -205,12 +221,12 @@
205
221
  function dayKey(d) {
206
222
  return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
207
223
  }
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';
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' });
214
230
  }
215
231
  // Per-request generation time + output throughput from the message-level
216
232
  // `duration` (ms). Missing → '–'. Throughput uses output tokens since
@@ -307,15 +323,39 @@
307
323
  function p(n) { return String(n).padStart(2, '0'); }
308
324
  return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
309
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
+
310
345
  function tipRecentHTML(r) {
311
346
  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>';
347
+ var isEst = !costIsMeasured(r);
348
+ var h = '<div class="tt">' + esc(shortName(r.m)) + '</div><div class="tv">' + stampLocal(r.t) + '</div>';
313
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>';
314
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>';
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>';
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>';
317
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>';
318
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>';
319
359
  return h;
320
360
  }
321
361
  // Zone indicators: Lucide face per savings-bento metric. Coarse by design;
@@ -537,18 +577,35 @@
537
577
  });
538
578
  }
539
579
 
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;
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');
546
607
  });
547
- if (!prev) return cur ? 'new' : '-';
548
- var p = Math.round((cur - prev) / prev * 100);
549
- return (p >= 0 ? '+' : '') + p + '%';
550
608
  }
551
- var modelPage = 1, modelPer = 10, recentPage = 1, recentPer = 15;
552
609
  function renderModels() {
553
610
  var byModel = DATA.byModel || {};
554
611
  var tops = topModels(byModel, Object.keys(byModel).length).filter(function(m) { return modelTotal(byModel, m) > 0; });
@@ -556,7 +613,7 @@
556
613
  cards.innerHTML = '';
557
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>';
558
615
  tops.slice(0, 3).forEach(function(m, i) {
559
- var v = vendorOf(m), d = wow(m);
616
+ var v = vendorOf(m);
560
617
  var card = document.createElement('div');
561
618
  card.className = 'modelcard relative p-4 rounded-xl overflow-hidden';
562
619
  if (v.svg) {
@@ -593,13 +650,6 @@
593
650
  var tot = document.createElement('p'); tot.className = 'mono font-bold text-lg shrink-0'; tot.textContent = fmtShort(modelTotal(byModel, m));
594
651
  row.appendChild(badge); row.appendChild(mid); row.appendChild(tot);
595
652
  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
653
  hoverModel(card, m);
604
654
  cards.appendChild(card);
605
655
  });
@@ -662,8 +712,43 @@
662
712
 
663
713
  // Union of session tool calls + RTK-metered commands, sortable + paged.
664
714
  // Session tools were never metered per command: saved / avg / time show – and sort last.
665
- var cmdSort = { key: 'count', dir: -1 }, cmdPage = 1, cmdPer = 15, cmdRows = [];
666
- 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
+ }
667
752
  function sortCmd() {
668
753
  var k = cmdSort.key, d = cmdSort.dir;
669
754
  cmdRows.sort(function(a, b) {
@@ -682,12 +767,14 @@
682
767
  }
683
768
  function renderCmd() {
684
769
  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 };
770
+ var all = (DATA.byTool || []).map(function(r) {
771
+ return { name: r[0], project: '', count: r[1], saved: null, avgPct: null, avgMs: null };
687
772
  });
688
773
  (g.byCommand || []).forEach(function(r) {
689
- 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 });
690
775
  });
776
+ paintProjectFilter(all);
777
+ cmdRows = cmdProject ? all.filter(function(r) { return r.project === cmdProject; }) : all;
691
778
  sortCmd();
692
779
  var pages = Math.max(1, Math.ceil(cmdRows.length / cmdPer));
693
780
  if (cmdPage > pages) cmdPage = pages;
@@ -702,6 +789,7 @@
702
789
  tr.className = 'mrow' + (i < Math.min(cmdRows.length - start, cmdPer) - 1 ? ' rowline' : '');
703
790
  tr.innerHTML = '<td class="text-right pr-3 py-2.5 mono text-xs w-10" style="color: var(--dim)"></td>' +
704
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>' +
705
793
  '<td class="text-right py-2.5 pr-3 font-bold"></td>' +
706
794
  '<td class="text-right py-2.5 pr-3 font-bold"></td>' +
707
795
  '<td class="text-right py-2.5 pr-3" style="color: var(--accent)"></td>' +
@@ -711,11 +799,13 @@
711
799
  tds[0].textContent = String(n + 1).padStart(2, '0');
712
800
  tds[1].textContent = r.name;
713
801
  tds[1].title = r.name;
714
- tds[2].textContent = fmt(r.count);
715
- tds[3].textContent = r.saved === null ? '–' : fmtShort(r.saved);
716
- tds[4].textContent = r.avgPct === null ? '–' : r.avgPct.toFixed(1) + '%';
717
- tds[5].textContent = r.avgMs === null || r.avgMs === undefined ? '–' : fmtMs(r.avgMs);
718
- 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';
719
809
  body.appendChild(tr);
720
810
  });
721
811
  document.getElementById('cmdTable').style.display = cmdRows.length ? '' : 'none';
@@ -792,9 +882,24 @@
792
882
  });
793
883
  });
794
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(); });
795
887
  bindPerPage('section[aria-label="All models"]', function() { return modelPer; }, function(n) { modelPer = n; modelPage = 1; }, renderModels);
796
888
  bindPerPage('section[aria-label="Recent requests"]', function() { return recentPer; }, function(n) { recentPer = n; recentPage = 1; }, renderRecent);
797
889
  }
890
+ function bindRecentTable() {
891
+ if (bindRecentTable.done) return;
892
+ bindRecentTable.done = true;
893
+ Array.prototype.forEach.call(document.querySelectorAll('#recentTable .thsort'), function(btn) {
894
+ btn.addEventListener('click', function() {
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();
900
+ });
901
+ });
902
+ }
798
903
  function fmtMs(ms) {
799
904
  if (ms < 1000) return Math.round(ms) + 'ms';
800
905
  return (ms / 1000).toFixed(1) + 's';
@@ -981,6 +1086,7 @@
981
1086
  renderStrip(t);
982
1087
  bindGraphTabs();
983
1088
  bindCmdTable();
1089
+ bindRecentTable();
984
1090
  renderGraph(d.byDay || {});
985
1091
  renderModels();
986
1092
  renderRecent();
@@ -993,36 +1099,71 @@
993
1099
  }
994
1100
  observe();
995
1101
  }
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
+ }
996
1123
  function renderRecent() {
997
1124
  var body = document.getElementById('recent');
998
1125
  body.innerHTML = '';
999
1126
  var all = DATA.recent || [];
1000
- var recentPages = Math.max(1, Math.ceil(all.length / recentPer));
1127
+ recentRows = all.slice();
1128
+ sortRecent();
1129
+ var recentPages = Math.max(1, Math.ceil(recentRows.length / recentPer));
1001
1130
  if (recentPage > recentPages) recentPage = recentPages;
1002
1131
  var recentStart = (recentPage - 1) * recentPer;
1003
- var rows = all.slice(recentStart, recentStart + recentPer);
1132
+ var rows = recentRows.slice(recentStart, recentStart + recentPer);
1004
1133
  rows.forEach(function(r, i) {
1005
1134
  var v = vendorOf(r.m);
1135
+ var measured = costIsMeasured(r);
1006
1136
  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>';
1137
+ tr.className = 'rrow' + (i < Math.min(recentRows.length - recentStart, recentPer) - 1 ? ' rowline' : '');
1009
1138
  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>' +
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>' +
1011
1143
  '<td class="text-right py-2.5 pr-3 whitespace-nowrap"></td>' +
1012
1144
  '<td class="text-right py-2.5 whitespace-nowrap" style="color: var(--dim)"></td>';
1013
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>';
1014
1147
  tds[0].innerHTML = dot + '<span></span>';
1015
1148
  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);
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);
1021
1162
  hoverRecent(tr, r);
1022
1163
  body.appendChild(tr);
1023
1164
  });
1024
- document.getElementById('recentCount').textContent = all.length ? all.length + ' requests' : '';
1025
- paintPager('recentPages', 'recentRange', recentPage, recentPer, all.length, function(p) { recentPage = p; renderRecent(); });
1165
+ document.getElementById('recentCount').textContent = all.length ? recentRows.length + ' requests' : '';
1166
+ paintPager('recentPages', 'recentRange', recentPage, recentPer, recentRows.length, function(p) { recentPage = p; renderRecent(); });
1026
1167
  document.getElementById('recentTable').style.display = all.length ? '' : 'none';
1027
1168
  document.getElementById('emptyRecent').classList.toggle('hidden', all.length > 0);
1028
1169
  }
@@ -41,6 +41,18 @@
41
41
  --danger: #f87171; --danger-border: rgba(248, 113, 113, .3); --danger-soft: rgba(248, 113, 113, .07);
42
42
  --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.55);
43
43
  }
44
+ html[data-theme="dark"] .modelsSection {
45
+ --panel: #ffffff;
46
+ --ink: #18181b;
47
+ --dim: #52525b;
48
+ --line: rgba(24, 24, 27, .12);
49
+ --track: rgba(24, 24, 27, .08);
50
+ --cell-0: rgba(24, 24, 27, .07);
51
+ --accent-soft: rgba(4, 120, 87, .12);
52
+ --accent: #047857;
53
+ color-scheme: light;
54
+ }
55
+ html[data-theme="dark"] .modelsSection .brandmark.placeholder { background: #fff !important; color: #000 !important; }
44
56
  .ticker { animation: tick 36s linear infinite; will-change: transform; }
45
57
  .ticker:hover { animation-play-state: paused; }
46
58
  @keyframes tick { from { transform: translateX(0); } to { transform: translateX(-50%); } }
@@ -58,6 +70,16 @@
58
70
  .pill { display: inline-flex; align-items: center; gap: 4px; border-radius: 999px; padding: 3px 10px; font-size: 11px; white-space: nowrap; }
59
71
  .pill.good { background: var(--accent-soft); color: var(--accent); }
60
72
  .pill.bad { background: rgba(248, 113, 113, .12); color: #f87171; }
73
+ /* .pill.flat was referenced by the est. / served chips but never defined, so
74
+ they rendered as bare text. Muted fill matches the badge family. */
75
+ .pill.flat { background: var(--track); color: var(--dim); }
76
+ /* shadcn/ui badge variants, used for per-request run status. Neutral by
77
+ default so the destructive (error) badge is the only thing that shouts. */
78
+ .badge { display: inline-flex; align-items: center; border-radius: 999px; padding: 2px 9px; font-size: 11px; line-height: 1.55; white-space: nowrap; border: 1px solid transparent; }
79
+ .badge.secondary { background: var(--track); color: var(--dim); }
80
+ .badge.outline { border-color: var(--line); color: var(--ink); background: transparent; }
81
+ .badge.destructive { background: var(--danger-soft); color: var(--danger); border-color: var(--danger-border); }
82
+ #tip .tnote { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line); font-size: 11px; line-height: 1.5; color: var(--dim); white-space: normal; word-break: break-word; }
61
83
  .zface { display: inline-block; vertical-align: -2px; width: 14px; height: 14px; }
62
84
  .zface.good { color: var(--accent); }
63
85
  .zface.warn { color: #fbbf24; }
@@ -101,7 +123,7 @@
101
123
  .seg button.on { background: var(--accent-soft); color: var(--ink); }
102
124
  .seg button:not(.on):hover { color: var(--ink); }
103
125
  .mrow { transition: background .25s; }
104
- .mrow:hover { box-shadow: var(--shadow-xs); }
126
+ .mrow:hover { background: var(--track); box-shadow: var(--shadow-xs); }
105
127
  .stripcell { transition: background .25s; }
106
128
  .stripcell:hover { box-shadow: var(--shadow-xs); }
107
129
  button, .tilt, .cell, .stripcell { cursor: pointer; }
@@ -122,6 +144,7 @@
122
144
  .cell.l3 { background: var(--cell-3); } .cell.l4 { background: var(--cell-4); }
123
145
  .brandmark { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; flex-shrink: 0; color: #fff; }
124
146
  .brandmark.placeholder { background: transparent !important; border: 1px solid var(--line); color: var(--dim); }
147
+ html[data-theme="dark"] .brandmark.placeholder { background: #fff !important; color: #000 !important; }
125
148
  .brandmark img { width: 18px; height: 18px; }
126
149
  .brandmark svg { width: 18px; height: 18px; display: block; }
127
150
  .brandmark.sm { width: 28px; height: 28px; border-radius: 8px; }
@@ -154,7 +177,7 @@
154
177
  .scrollarea::-webkit-scrollbar-thumb { background: var(--line); border-radius: 99px; }
155
178
  .scrollarea::-webkit-scrollbar-track { background: transparent; }
156
179
  .rrow { transition: background .2s; cursor: pointer; }
157
- .rrow:hover { box-shadow: var(--shadow-xs); }
180
+ .rrow:hover { background: var(--track); box-shadow: var(--shadow-xs); }
158
181
  #recentTable thead th { position: sticky; top: 0; background: var(--panel); z-index: 1; }
159
182
  #tip .tvr { flex-shrink: 0; white-space: nowrap; font-variant-numeric: tabular-nums; }
160
183
  .modelcard { transition: background .25s; }
@@ -193,20 +193,18 @@
193
193
  </section>
194
194
 
195
195
  <!-- Top models: 3 columns -->
196
- <section class="mt-8" aria-label="Top models">
196
+ <section class="mt-8 modelsSection" aria-label="Top models">
197
197
  <div class="flex items-center gap-2 mb-1">
198
198
  <i data-lucide="cpu" class="size-4" style="color: var(--accent)"></i>
199
199
  <h2 class="display font-bold tracking-tight text-xl truncate min-w-0">Top models</h2>
200
200
  <span class="mono text-xs ml-auto" style="color: var(--dim)">usage across sessions</span>
201
201
  </div>
202
- <p class="mono text-xs mb-4" style="color: var(--dim)">week-over-week vs prior 7 days</p>
203
202
  <div class="grid grid-cols-1 md:grid-cols-3 gap-3" id="modelCards"></div>
204
203
  </section>
205
204
 
206
205
 
207
- <!-- Models + recent: twin cards -->
208
- <div class="rise grid grid-cols-12 grid-flow-dense gap-3 mt-8">
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">
206
+ <!-- Models: own full-width section (split out of the old 50/50 twin card) -->
207
+ <section class="rise mt-8 rounded-xl p-5 overflow-hidden flex flex-col modelsSection" style="border: 1px solid var(--line); background: var(--panel)" aria-label="All models">
210
208
  <div class="flex items-center gap-2 mb-1">
211
209
  <i data-lucide="layers" class="size-4" style="color: var(--accent)"></i>
212
210
  <h2 class="display font-bold tracking-tight text-xl truncate min-w-0">Models</h2>
@@ -230,22 +228,29 @@
230
228
  <p class="empty-desc">Model token totals will appear here once sessions report tokens.</p>
231
229
  </div>
232
230
  </section>
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">
231
+
232
+ <!-- Recent requests: full-width, sitting with the other per-request views
233
+ right after Models. When is absolutely stamped, so the column reads as
234
+ a real timeline and sorts honestly against Time. -->
235
+ <section class="rise mt-8 rounded-xl p-5 overflow-hidden flex flex-col" style="border: 1px solid var(--line); background: var(--panel); scroll-margin-top: 90px" aria-label="Recent requests">
234
236
  <div class="flex items-center gap-2 mb-1">
235
237
  <i data-lucide="history" class="size-4" style="color: var(--accent)"></i>
236
238
  <h2 class="display font-bold tracking-tight text-xl truncate min-w-0">Recent requests</h2>
237
239
  <span class="mono text-xs ml-auto truncate shrink-0" style="color: var(--dim)" id="recentCount"></span>
238
240
  </div>
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">
241
+ <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 · local time · sort any column · cost is measured when the host recorded it</p>
242
+ <div class="scrollarea overflow-x-auto" style="max-height: 560px">
241
243
  <table class="w-full mono text-[13px]" id="recentTable">
242
- <colgroup><col><col style="width:120px"><col style="width:110px"><col style="width:80px"></colgroup>
244
+ <colgroup><col><col style="width:96px"><col style="width:96px"><col style="width:120px"><col style="width:116px"><col style="width:120px"><col style="width:158px"></colgroup>
243
245
  <thead>
244
246
  <tr class="text-left text-[11px] uppercase tracking-[0.14em]" style="color: var(--dim)">
245
- <th class="font-normal py-2 pr-3">Model</th>
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>
248
- <th class="font-normal text-right py-2">When</th>
247
+ <th class="font-normal py-2 pr-3" aria-sort="none"><button class="thsort" data-sort="model">Model <span class="arr">⇅</span></button></th>
248
+ <th class="font-normal text-right py-2 pr-3" aria-sort="none"><button class="thsort num" data-sort="input">Input <span class="arr">⇅</span></button></th>
249
+ <th class="font-normal text-right py-2 pr-3" aria-sort="none"><button class="thsort num" data-sort="output">Output <span class="arr">⇅</span></button></th>
250
+ <th class="font-normal text-right py-2 pr-3" aria-sort="none"><button class="thsort num" data-sort="time" title="Elapsed time with the model / output token speed">Time <span class="arr">⇅</span></button></th>
251
+ <th class="font-normal py-2 pr-3" aria-sort="none"><button class="thsort" data-sort="status">Status <span class="arr">⇅</span></button></th>
252
+ <th class="font-normal text-right py-2 pr-3" aria-sort="none"><button class="thsort num" data-sort="cost">Cost <span class="arr">⇅</span></button></th>
253
+ <th class="font-normal text-right py-2" aria-sort="none"><button class="thsort num" data-sort="when" title="Local timestamp: month day, hour:minute:second">When <span class="arr">⇅</span></button></th>
249
254
  </tr>
250
255
  </thead>
251
256
  <tbody id="recent"></tbody>
@@ -260,30 +265,33 @@
260
265
  <span id="recentRange">Showing 0 of 0</span>
261
266
  <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
267
  <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>
268
+ <button data-pp="15" class="px-2.5 py-1" role="button">15</button>
269
+ <button data-pp="25" class="on px-2.5 py-1" role="button">25</button>
265
270
  <button data-pp="50" class="px-2.5 py-1" role="button">50</button>
266
271
  </span>
267
272
  <span class="ml-auto flex items-center gap-1" id="recentPages" role="navigation" aria-label="Recent pages"></span>
268
273
  </div>
269
274
  </section>
270
- </div>
271
275
 
272
276
  <!-- Command tools: own card, header outside like Models -->
273
277
  <section id="tools" class="rise mt-8 rounded-xl p-5 overflow-hidden" style="border: 1px solid var(--line); background: var(--panel); scroll-margin-top: 90px" aria-label="Command tools">
274
278
  <div class="flex items-center gap-2 mb-1">
275
279
  <i data-lucide="terminal" class="size-4" style="color: var(--accent)"></i>
276
280
  <h2 class="display font-bold tracking-tight text-xl truncate min-w-0">Command tools</h2>
277
- <span class="mono text-xs ml-auto truncate min-w-0" style="color: var(--dim)" id="toolsScope">tool calls in sessions</span>
281
+ <div class="flex items-center gap-2 ml-auto min-w-0">
282
+ <select id="cmdProject" class="mono text-xs px-2 py-1.5 rounded-[10px]" style="border: 1px solid var(--line); background: var(--panel); color: var(--ink); max-width: 190px" aria-label="Filter command tools by project"></select>
283
+ <span class="mono text-xs truncate min-w-0" style="color: var(--dim)" id="toolsScope">tool calls in sessions</span>
284
+ </div>
278
285
  </div>
279
286
  <p class="mono text-xs mb-4 truncate" style="color: var(--dim)">count · tokens saved · avg rate · avg time · share of executions</p>
280
287
  <div class="overflow-x-auto">
281
288
  <table class="w-full mono text-[13px]" id="cmdTable">
282
- <colgroup><col style="width:40px"><col><col style="width:90px"><col style="width:90px"><col style="width:90px"><col style="width:90px"><col style="width:140px"></colgroup>
289
+ <colgroup><col style="width:40px"><col><col style="width:120px"><col style="width:90px"><col style="width:90px"><col style="width:90px"><col style="width:90px"><col style="width:140px"></colgroup>
283
290
  <thead>
284
291
  <tr class="text-left text-[11px] uppercase tracking-[0.14em]" style="color: var(--dim)">
285
292
  <th class="font-normal text-right pr-3 py-2 w-10">#</th>
286
293
  <th class="font-normal py-2 pr-3" aria-sort="none"><button class="thsort" data-sort="name">Tool / Command <span class="arr">⇅</span></button></th>
294
+ <th class="font-normal py-2 pr-3" aria-sort="none"><button class="thsort" data-sort="project">Project <span class="arr">⇅</span></button></th>
287
295
  <th class="font-normal text-right py-2 pr-3" aria-sort="descending"><button class="thsort num" data-sort="count">Count <span class="arr">↓</span></button></th>
288
296
  <th class="font-normal text-right py-2 pr-3" aria-sort="none"><button class="thsort num" data-sort="saved">Saved <span class="arr">⇅</span></button></th>
289
297
  <th class="font-normal text-right py-2 pr-3" aria-sort="none"><button class="thsort num" data-sort="avgPct">Avg% <span class="arr">⇅</span></button></th>
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { activeModesSummary, asPromptArray, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, normalizeMode, paintStatusBar, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.js';
3
+ import { activeModesSummary, asPromptArray, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, normalizeMode, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.js';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { readCavemanDefault } from '../shared/plugin-settings.js';
6
6
  const CAVERN_DIR = dirname(fileURLToPath(import.meta.url));
@@ -46,9 +46,8 @@ export default function cavemanSessionExtension(pi) {
46
46
  let isActive = false;
47
47
  let lastCtx = undefined;
48
48
  function syncStatus(ctx) {
49
- if (ctx)
50
- lastCtx = ctx;
51
- const c = ctx || lastCtx;
49
+ lastCtx = paintableCtx(lastCtx, ctx);
50
+ const c = lastCtx;
52
51
  if (!c?.ui?.setStatus)
53
52
  return;
54
53
  // Combo owns the bar when any preset is active; keep ours empty to avoid duplication.
@@ -3,7 +3,7 @@
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { createRequire } from 'node:module';
6
- import { activeModesSummary, asPromptArray, COMBO_LEVELS, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, normalizeComboLevel, reconcileSharedComboEntries, sessionEntries, setSharedComboLevel, setSharedComboListener, setSharedComboMode, systemPromptIncludes, } from '../shared/session-state.js';
6
+ import { activeModesSummary, asPromptArray, COMBO_LEVELS, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, normalizeComboLevel, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboLevel, setSharedComboListener, setSharedComboMode, systemPromptIncludes, } from '../shared/session-state.js';
7
7
  import { readComboDefault, readPonytailDefault } from '../shared/plugin-settings.js';
8
8
  const require = createRequire(import.meta.url);
9
9
  const PONYTAIL_FALLBACK_INTENSITY = {
@@ -31,9 +31,8 @@ export default function comboToggleExtension(pi) {
31
31
  pi.setLabel?.('Combo session toggle (all 3 add-ons)');
32
32
  let lastCtx = undefined;
33
33
  function syncStatus(ctx) {
34
- if (ctx)
35
- lastCtx = ctx;
36
- const c = ctx || lastCtx;
34
+ lastCtx = paintableCtx(lastCtx, ctx);
35
+ const c = lastCtx;
37
36
  if (!c?.ui?.setStatus)
38
37
  return;
39
38
  // Single unified bar replaces the three per-extension bars while a preset is
@@ -1,6 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
- import { activeModesSummary, asPromptArray, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, paintStatusBar, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.js';
3
+ import { activeModesSummary, asPromptArray, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.js';
4
4
  import { readRtkDefault } from '../shared/plugin-settings.js';
5
5
  const DEFAULT_ENABLED = false;
6
6
  function asBoolean(value) {
@@ -27,9 +27,8 @@ export default function rtkSessionExtension(pi) {
27
27
  let isActive = false;
28
28
  let lastCtx = undefined;
29
29
  function syncStatus(ctx) {
30
- if (ctx)
31
- lastCtx = ctx;
32
- const c = ctx || lastCtx;
30
+ lastCtx = paintableCtx(lastCtx, ctx);
31
+ const c = lastCtx;
33
32
  if (!c?.ui?.setStatus)
34
33
  return;
35
34
  // Combo owns the bar when any preset is active; keep ours empty to avoid duplication.
@@ -7,11 +7,25 @@
7
7
  // Caveman and Ponytail have no per-command counters (instruction-following
8
8
  // isn't metered); their gains are bench-measured in BENCHMARK.md and cited
9
9
  // as static figures wherever RTK rows appear.
10
+ //
11
+ // Rows are grouped by (command, project) rather than command alone: RTK's
12
+ // history is machine-wide, so fusing projects made one busy repo's `rtk grep`
13
+ // count as everyone's and let high-saving commands from other projects crowd
14
+ // every other project out of the table entirely.
10
15
  import { execFileSync } from 'node:child_process';
11
16
  import fs from 'node:fs';
12
17
  import os from 'node:os';
13
18
  import path from 'node:path';
14
19
  const EMPTY = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
20
+ // RTK stores the whole command line, and `rtk ls -d` with a long path list
21
+ // runs to tens of KB. The table shows one clipped cell, so bound the string
22
+ // before it crosses the sqlite3 CLI and lands in every /data.json poll.
23
+ const MAX_CMD_CHARS = 200;
24
+ // The query() reader is line- and tab-delimited, and RTK stores heredocs and
25
+ // `node -e` scripts verbatim (~67 rows here), so one logical row can arrive as
26
+ // several lines and derail every row after it. Fold whitespace into spaces
27
+ // first: the stored command keeps its newlines, only the read view flattens.
28
+ const CMD_ONE_LINE = `replace(replace(replace(rtk_cmd, char(10), ' '), char(13), ' '), char(9), ' ')`;
15
29
  export function rtkDbPath() {
16
30
  const override = process.env.TERSIO_RTK_DB;
17
31
  if (override)
@@ -44,8 +58,9 @@ export function readRtkGain(limit = 10, cutoffMs) {
44
58
  ? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
45
59
  : '';
46
60
  const [[commands = '0', saved = '0', input = '0', avgPct = '0', totalMs = '0'] = []] = query(rtkDbPath(), `SELECT COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(SUM(input_tokens),0), COALESCE(AVG(savings_pct),0), COALESCE(SUM(exec_time_ms),0) FROM commands ${where};`);
47
- const byCommand = query(rtkDbPath(), `SELECT rtk_cmd, COUNT(*), SUM(saved_tokens), AVG(savings_pct), AVG(exec_time_ms) FROM commands ${where} GROUP BY rtk_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ${Math.max(1, Math.floor(limit))};`).map(([command, count, savedRow, pct, ms]) => ({
61
+ const byCommand = query(rtkDbPath(), `SELECT substr(${CMD_ONE_LINE}, 1, ${MAX_CMD_CHARS}), COALESCE(project_path, ''), COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(AVG(savings_pct),0), COALESCE(AVG(exec_time_ms),0) FROM commands ${where} GROUP BY rtk_cmd, project_path ORDER BY SUM(saved_tokens) DESC LIMIT ${Math.max(1, Math.floor(limit))};`).map(([command, project, count, savedRow, pct, ms]) => ({
48
62
  command,
63
+ project,
49
64
  count: Number(count) || 0,
50
65
  saved: Number(savedRow) || 0,
51
66
  avgPct: Number(pct) || 0,
@@ -79,6 +79,21 @@ export function paintStatusBar(ui, key, emoji, label, isActive) {
79
79
  const indicator = isActive && theme?.fg ? theme.fg('accent', emoji) : emoji;
80
80
  ui?.setStatus?.(key, theme?.fg ? `${indicator} ${theme.fg('muted', label)}` : `${indicator} ${label}`);
81
81
  }
82
+ // Which context a status bar should remember to paint through.
83
+ //
84
+ // A UI-less event — a headless child session_start, or a top-level
85
+ // session_start that fires before the TUI attaches — must never become the
86
+ // remembered context. Two things went wrong when it did: the old
87
+ // `ctx || lastCtx` fallback only triggered for a *falsy* ctx, so a truthy
88
+ // ctx without `ui` returned early anyway, and remembering it destroyed the one
89
+ // paintable context we had. Every later bare syncStatus() then went mute —
90
+ // and the bare call is the bridge-listener path, which is how a mode change
91
+ // made by a sibling extension reaches this bar. The symptom is a bar frozen on
92
+ // a preset the session no longer has: it shows "combo BALANCED" while caveman
93
+ // and rtk are already off.
94
+ export function paintableCtx(remembered, next) {
95
+ return next?.ui?.setStatus ? next : remembered;
96
+ }
82
97
  // Last-wins scan for a custom session entry; skips entries whose value fails
83
98
  // to parse so a corrupt write never shadows an older valid one.
84
99
  export function lastCustomValue(entries, customType, pick) {
@@ -56,6 +56,36 @@ function zeroBreakdown() {
56
56
  function durOf(v) {
57
57
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
58
58
  }
59
+ // Measured cost for one message. OMP writes usage.cost as
60
+ // { input, output, cacheRead, cacheWrite, total }; a bare number shows up in
61
+ // older fixtures. Anything else means "not recorded" — deliberately not 0, so
62
+ // the dashboard can tell a genuinely free run from an unrecorded one.
63
+ function costOf(usage) {
64
+ const c = usage.cost;
65
+ if (typeof c === 'number')
66
+ return Number.isFinite(c) ? c : undefined;
67
+ if (c && typeof c === 'object') {
68
+ const total = c.total;
69
+ if (typeof total === 'number' && Number.isFinite(total))
70
+ return total;
71
+ }
72
+ return undefined;
73
+ }
74
+ // First line only: real messages carry multi-line provider errors, and this
75
+ // ends up in a tooltip.
76
+ function statusOf(msg) {
77
+ const stop = typeof msg.stopReason === 'string' ? msg.stopReason : '';
78
+ const note = typeof msg.errorMessage === 'string' && msg.errorMessage.trim()
79
+ ? msg.errorMessage.split('\n')[0].trim().slice(0, 180)
80
+ : undefined;
81
+ if (stop === 'error' || msg.isError === true) {
82
+ const code = typeof msg.errorStatus === 'number' && Number.isFinite(msg.errorStatus) ? msg.errorStatus : undefined;
83
+ return { st: 'error', code, note };
84
+ }
85
+ if (stop === 'aborted')
86
+ return { st: 'aborted', note };
87
+ return { st: 'completed', note };
88
+ }
59
89
  function addInto(into, u) {
60
90
  for (const k of ['input', 'output', 'cacheRead', 'cacheWrite']) {
61
91
  const v = u[k];
@@ -104,6 +134,10 @@ function walkJsonl(dir, out, cap) {
104
134
  out.push(full);
105
135
  }
106
136
  }
137
+ // Recent requests are their own full-width table in the dashboard, so this is
138
+ // a payload bound rather than a "few highlights" bound. Rows are small
139
+ // (model, tokens, timing, status), so a few hundred cost little to ship.
140
+ const RECENT_LIMIT = 200;
107
141
  export function importSessionTokens() {
108
142
  const watermark = readResetWatermark();
109
143
  const byModel = {};
@@ -124,7 +158,7 @@ export function importSessionTokens() {
124
158
  let codexProvider = null;
125
159
  const recent = [];
126
160
  // Rows from before the reset watermark (and rows with no usable timestamp,
127
- function ingest(model, usage, ts, durMs) {
161
+ function ingest(model, usage, ts, durMs, run) {
128
162
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
129
163
  if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
130
164
  return false;
@@ -133,8 +167,22 @@ export function importSessionTokens() {
133
167
  addInto(byModel[model], usage);
134
168
  byModelMessages[model] = (byModelMessages[model] ?? 0) + 1;
135
169
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
136
- if (Number.isFinite(ms))
137
- recent.push({ m: model, i: num(usage.input), o: num(usage.output), t: ms, d: durOf(durMs), cr: num(usage.cacheRead), cw: num(usage.cacheWrite) });
170
+ if (Number.isFinite(ms)) {
171
+ const outcome = run ?? { st: 'completed' };
172
+ recent.push({
173
+ m: model,
174
+ i: num(usage.input),
175
+ o: num(usage.output),
176
+ t: ms,
177
+ d: durOf(durMs),
178
+ cr: num(usage.cacheRead),
179
+ cw: num(usage.cacheWrite),
180
+ usd: costOf(usage),
181
+ st: outcome.st,
182
+ code: outcome.code,
183
+ note: outcome.note,
184
+ });
185
+ }
138
186
  const day = ts !== undefined ? dayKey(ts) : null;
139
187
  if (day) {
140
188
  byDay[day] ??= zeroBreakdown();
@@ -184,9 +232,12 @@ export function importSessionTokens() {
184
232
  if (!msg || msg.role !== 'assistant' || !msg.usage)
185
233
  continue;
186
234
  const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
187
- const counted = ingest(model, msg.usage, row.timestamp, msg.duration);
188
- if (counted && typeof msg.usage.cost === 'number' && Number.isFinite(msg.usage.cost))
189
- costMeasured += msg.usage.cost;
235
+ const explicitDur = msg.duration;
236
+ const computedDur = (typeof msg.completedAt === 'number' && typeof msg.timestamp === 'number') ? msg.completedAt - msg.timestamp : undefined;
237
+ const counted = ingest(model, msg.usage, row.timestamp, typeof explicitDur === 'number' ? explicitDur : computedDur, statusOf(msg));
238
+ const measured = costOf(msg.usage);
239
+ if (counted && measured !== undefined)
240
+ costMeasured += measured;
190
241
  if (!counted)
191
242
  continue;
192
243
  for (const part of msg.content ?? []) {
@@ -200,7 +251,7 @@ export function importSessionTokens() {
200
251
  }
201
252
  }
202
253
  recent.sort((a, b) => b.t - a.t);
203
- return { messages, totals, byModel, byDay, byDayModel, byTool, byModelMessages, costMeasured, recent: recent.slice(0, 25) };
254
+ return { messages, totals, byModel, byDay, byDayModel, byTool, byModelMessages, costMeasured, recent: recent.slice(0, RECENT_LIMIT) };
204
255
  }
205
256
  // Lead binary of a shell string: first segment head past `cd` chains and
206
257
  // VAR=x assignments (`cd /x && git status` → `git`). Falls back to `bash`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krtclcdy/tersio",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "✂️ Token-saving OMP add-ons: terse Caveman replies, compact RTK shell output, lean Ponytail code calls — one-command combo presets.",
5
5
  "keywords": [
6
6
  "omp",