@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 +18 -2
- package/cli/usage.js +18 -6
- package/dashboard/app.js +193 -52
- package/dashboard/styles.css +25 -2
- package/dashboard/template.html +26 -18
- package/extensions/caveman-session/index.js +3 -4
- package/extensions/combo-toggle/index.js +3 -4
- package/extensions/rtk-session/index.js +3 -4
- package/extensions/shared/rtk-gain.js +16 -1
- package/extensions/shared/session-state.js +15 -0
- package/extensions/shared/usage-ledger.js +58 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-

|
|
2
2
|
|
|
3
3
|
# ✂️ Tersio — Token-saving OMP Add-ons
|
|
4
4
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
[](https://github.com/KurutoDenzeru/tersio/actions)
|
|
9
9
|
[](./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
|
+

|
|
90
|
+
|
|
91
|
+
**Top models and recent requests.** Per-model cost breakdown plus the last requests with elapsed time and token speed.
|
|
92
|
+
|
|
93
|
+

|
|
94
|
+
|
|
95
|
+
**Command tools.** Token savings, average rate, and timing per shell tool.
|
|
96
|
+
|
|
97
|
+

|
|
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
|
-
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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', '
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
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">' +
|
|
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">' +
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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)
|
|
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
|
-
|
|
666
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
715
|
-
tds[
|
|
716
|
-
tds[
|
|
717
|
-
tds[
|
|
718
|
-
tds[
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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].
|
|
1017
|
-
tds[
|
|
1018
|
-
tds[
|
|
1019
|
-
tds[
|
|
1020
|
-
tds[
|
|
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 ?
|
|
1025
|
-
paintPager('recentPages', 'recentRange', recentPage, recentPer,
|
|
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
|
}
|
package/dashboard/styles.css
CHANGED
|
@@ -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; }
|
package/dashboard/template.html
CHANGED
|
@@ -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
|
|
208
|
-
|
|
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
|
-
|
|
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 ·
|
|
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:
|
|
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
|
|
246
|
-
<th class="font-normal text-right py-2 pr-3">
|
|
247
|
-
<th class="font-normal text-right py-2 pr-3"
|
|
248
|
-
<th class="font-normal text-right py-2">
|
|
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="
|
|
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
|
-
<
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
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,
|
|
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