@krtclcdy/tersio 2.7.0 → 2.8.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
@@ -82,7 +82,7 @@ Break-even math, balanced preset (caveman full + rtk + ponytail): `⌈overhead
82
82
  | `tersio reinstall` | Fresh install, preserving the Ponytail package |
83
83
  | `tersio doctor` | Check OMP, extension, Ponytail, and RTK health |
84
84
  | `tersio usage` | Ledger-backed usage + savings report |
85
- | `tersio dashboard` | Serve the gain dashboard on localhost (`--open`, `--export <file>`, `--port <n>`) |
85
+ | `tersio gain` | Open the gain dashboard (`--open`, `--export <file>`, `--port <n>`; serves localhost only) |
86
86
  | `tersio uninstall` | Remove extensions, registration, and the Ponytail plugin (`--keep-ponytail` keeps Ponytail; `--remove-rtk` also removes the RTK binary) |
87
87
  | `tersio version` | Print version |
88
88
 
@@ -101,7 +101,7 @@ Everything runs under one root. Bare `/tersio` prints status.
101
101
  | `/tersio status` | Active modes + combo level; state persists, propagates to subagents, and lights the Combo footer indicator. |
102
102
  | `/tersio check` | Add-on version check |
103
103
  | `/tersio update <ponytail\|rtk\|caveman\|all> [--dry-run]` | Update add-ons, preview with dry-run |
104
- | `/tersio gain` | Savings summary; the gain dashboard charts `~/.omp/plugins/tersio-usage.jsonl` (`tersio dashboard` serves on 127.0.0.1, `--export` writes one file). |
104
+ | `/tersio gain` | Savings summary; the gain dashboard charts `~/.omp/plugins/tersio-usage.jsonl` (`tersio gain --open` serves on 127.0.0.1, `--export` writes one file). |
105
105
  | `/tersio usage` | Ledger report for this machine (rows in `~/.omp/plugins/tersio-usage.jsonl`, local only) |
106
106
  | `/tersio help` | This table |
107
107
  | `/caveman`, `/rtk`, `/combo`, `/ai-addons` | Legacy aliases, still work; prefer `/tersio`. |
package/cli/common.js CHANGED
@@ -46,11 +46,11 @@ function flagValue(name) {
46
46
  return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
47
47
  }
48
48
  // --- CLI flags ---
49
- const COMMANDS = new Set(['install', 'update', 'reinstall', 'doctor', 'uninstall', 'usage', 'dashboard', 'version', 'help']);
49
+ const COMMANDS = { install: true, update: true, reinstall: true, doctor: true, uninstall: true, usage: true, gain: true, version: true, help: true };
50
50
  const args = process.argv.slice(2);
51
51
  const commandArg = args.find((arg, index) => !arg.startsWith('-') && args[index - 1] !== '--scope');
52
52
  const command = commandArg?.toLowerCase() || null;
53
- const unknownCommand = command !== null && !COMMANDS.has(command);
53
+ const unknownCommand = command !== null && !COMMANDS[command];
54
54
  const install = command === 'install';
55
55
  const update = command === 'update';
56
56
  const reinstall = command === 'reinstall';
@@ -62,7 +62,7 @@ const yes = args.includes('--yes') || args.includes('-y') || install || update |
62
62
  const verbose = args.includes('--verbose');
63
63
  const doctor = command === 'doctor' || args.includes('--doctor');
64
64
  const usage = command === 'usage' || args.includes('--usage');
65
- const dashboard = command === 'dashboard' || args.includes('--dashboard');
65
+ const gain = command === 'gain';
66
66
  const dashboardPort = Number.parseInt(flagValue('--port') ?? '', 10) || 0;
67
67
  const dashboardOpen = args.includes('--open');
68
68
  const dashboardExport = flagValue('--export') ?? null;
@@ -254,4 +254,4 @@ function relTime(ageMs) {
254
254
  const weeks = Math.floor(days / 7);
255
255
  return `${weeks} week${weeks === 1 ? '' : 's'} ago`;
256
256
  }
257
- export { IS_WINDOWS, RTK_BINARY_NAME, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, BUN_BIN_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_BIN, PACKAGE_VERSION, CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS, RTK_DEFAULTS, COMBO_PRESET_MODES, COMBO_DEFAULTS, parseEnum, flagValue, COMMANDS, commandArg, command, unknownCommand, install, update, reinstall, showVersion, showHelp, applyUpdate, dryRun, yes, verbose, doctor, uninstall, usage, dashboard, dashboardPort, dashboardOpen, dashboardExport, removePonytail, keepPonytail, removeRtk, scopeFlag, comboDefaultFlag, cavemanDefaultFlag, ponytailDefaultFlag, rtkDefaultFlag, profileFlagsGiven, debug, execFileP, execP, writeIfChanged, normalizeExtensionsKey, EXTENSIONS_KEY_RE, writeConfigLines, ensureExtensionInConfig, ensureExtensionAfterConfigEntry, readPonytailConfig, parseJsonObject, parsePonytailConfig, patchPonytailConfig, ensurePonytailConfigValue, readPluginsPackage, SCOPE_MAP, relTime, };
257
+ export { IS_WINDOWS, RTK_BINARY_NAME, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, BUN_BIN_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_BIN, PACKAGE_VERSION, CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS, RTK_DEFAULTS, COMBO_PRESET_MODES, COMBO_DEFAULTS, parseEnum, flagValue, COMMANDS, commandArg, command, unknownCommand, install, update, reinstall, showVersion, showHelp, applyUpdate, dryRun, yes, verbose, doctor, uninstall, usage, gain, dashboardPort, dashboardOpen, dashboardExport, removePonytail, keepPonytail, removeRtk, scopeFlag, comboDefaultFlag, cavemanDefaultFlag, ponytailDefaultFlag, rtkDefaultFlag, profileFlagsGiven, debug, execFileP, execP, writeIfChanged, normalizeExtensionsKey, EXTENSIONS_KEY_RE, writeConfigLines, ensureExtensionInConfig, ensureExtensionAfterConfigEntry, readPonytailConfig, parseJsonObject, parsePonytailConfig, patchPonytailConfig, ensurePonytailConfigValue, readPluginsPackage, SCOPE_MAP, relTime, };
@@ -8,26 +8,24 @@
8
8
  <link rel="icon" type="image/png" href="brand.webp">
9
9
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
10
10
  <script src="https://unpkg.com/lucide@latest"></script>
11
+ <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
12
+ <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
13
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/outfit@5/index.css">
11
14
  <style>
12
15
  :root {
13
16
  --bg: #f4f4f5; --panel: #ffffff; --ink: #18181b; --dim: #52525b;
14
17
  --line: rgba(24, 24, 27, .12); --accent: #047857; --accent-soft: rgba(4, 120, 87, .12);
15
18
  --track: rgba(24, 24, 27, .08); --grid: rgba(4, 120, 87, .07);
16
- --cell-0: rgba(24, 24, 27, .06);
17
- --cell-1: rgba(4, 120, 87, .25); --cell-2: rgba(4, 120, 87, .5);
18
- --cell-3: rgba(4, 120, 87, .75); --cell-4: #047857;
19
19
  }
20
20
  @media (prefers-color-scheme: dark) {
21
21
  :root {
22
22
  --bg: #09090b; --panel: #101014; --ink: #f4f4f5; --dim: #a1a1aa;
23
23
  --line: rgba(255, 255, 255, .09); --accent: #34d399; --accent-soft: rgba(52, 211, 153, .1);
24
24
  --track: rgba(255, 255, 255, .07); --grid: rgba(52, 211, 153, .05);
25
- --cell-0: rgba(255, 255, 255, .06);
26
- --cell-1: rgba(52, 211, 153, .25); --cell-2: rgba(52, 211, 153, .5);
27
- --cell-3: rgba(52, 211, 153, .75); --cell-4: #34d399;
28
25
  }
29
26
  }
30
27
  body { background: var(--bg); color: var(--ink); }
28
+ .display { font-family: "Outfit", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
31
29
  .bg-blueprint {
32
30
  background-image: linear-gradient(var(--grid) 1px, transparent 1px),
33
31
  linear-gradient(90deg, var(--grid) 1px, transparent 1px);
@@ -38,15 +36,10 @@
38
36
  .rowline { border-bottom: 1px solid var(--line); }
39
37
  .bar-fill { background: var(--accent); border-radius: 999px; transform-origin: left; }
40
38
  .bar-track { background: var(--track); border-radius: 999px; }
41
- .bar-dim { background: var(--dim); opacity: .55; border-radius: 999px; transform-origin: bottom; }
42
39
  .live-dot { background: var(--accent); box-shadow: 0 0 0 0 var(--accent-soft); animation: ping 2s cubic-bezier(0, 0, .2, 1) infinite; }
43
40
  @keyframes ping { 75%, 100% { box-shadow: 0 0 0 9px transparent; } }
44
41
  .rise { opacity: 0; transform: translateY(26px); }
45
42
  .rise.in { opacity: 1; transform: none; transition: opacity .7s cubic-bezier(.16, 1, .3, 1), transform .7s cubic-bezier(.16, 1, .3, 1); }
46
- .grow { transform: scaleX(0); transition: transform 1.1s cubic-bezier(.16, 1, .3, 1); }
47
- .grow.in { transform: scaleX(1); }
48
- .growY { transform: scaleY(0); transition: transform .9s cubic-bezier(.16, 1, .3, 1); }
49
- .growY.in { transform: scaleY(1); }
50
43
  .tilt { transition: transform .25s cubic-bezier(.16, 1, .3, 1), background .25s; }
51
44
  .tilt:hover { transform: translateX(6px); background: var(--accent-soft); }
52
45
  .btn-push { transition: transform .12s, background .2s; }
@@ -54,9 +47,12 @@
54
47
  .btn-push:active { transform: scale(.96); }
55
48
  a.social { color: var(--ink); }
56
49
  a.social:hover { color: var(--accent); }
57
- .cell { width: 11px; height: 11px; border-radius: 2.5px; background: var(--cell-0); }
58
- .cell.l1 { background: var(--cell-1); } .cell.l2 { background: var(--cell-2); }
59
- .cell.l3 { background: var(--cell-3); } .cell.l4 { background: var(--cell-4); }
50
+ .seg button { color: var(--dim); border-radius: 8px; transition: background .2s, color .2s; }
51
+ .seg button.on { background: var(--accent-soft); color: var(--ink); }
52
+ .seg button:not(.on):hover { color: var(--ink); }
53
+ .mbar { display: flex; flex-direction: column-reverse; border-radius: 6px 6px 2px 2px; overflow: hidden; min-height: 3px; background: var(--track); }
54
+ .mbar > span { display: block; width: 100%; }
55
+ .monogram { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; font-weight: 700; font-size: 13px; color: #fff; flex-shrink: 0; }
60
56
  .dock {
61
57
  position: sticky; top: 12px; z-index: 50;
62
58
  background: color-mix(in srgb, var(--panel) 84%, transparent);
@@ -64,25 +60,18 @@
64
60
  border: 1px solid var(--line); border-radius: 20px;
65
61
  box-shadow: 0 12px 40px rgba(0, 0, 0, .25);
66
62
  }
67
- .dock a.docklink { transition: transform .22s cubic-bezier(.34, 1.56, .64, 1), background .2s; border-radius: 12px; }
68
- .dock a.docklink:hover { transform: scale(1.28) translateY(-3px); background: var(--accent-soft); }
69
- .dock a.docklink:active { transform: scale(1.05); }
70
63
  .dock img.brand { border-radius: 10px; }
71
- @media (prefers-reduced-motion: reduce) {
72
- .dock a.docklink { transition: none; }
73
- .dock a.docklink:hover { transform: none; }
74
- }
75
64
  @media (prefers-reduced-motion: reduce) {
76
65
  .rise { opacity: 1; transform: none; }
77
- .grow, .growY { transform: none; transition: none; }
78
66
  .live-dot { animation: none; }
79
- .tilt, .btn-push { transition: none; }
67
+ .tilt, .btn-push, .seg button { transition: none; }
80
68
  }
81
69
  </style>
82
70
  </head>
83
71
  <body class="min-h-[100dvh] antialiased">
84
72
  <div class="bg-blueprint pointer-events-none fixed inset-0" aria-hidden="true"></div>
85
73
 
74
+ <main class="overflow-x-hidden w-full max-w-full">
86
75
  <div class="relative mx-auto max-w-7xl px-4 sm:px-6 pb-16">
87
76
 
88
77
  <!-- Floating dock navbar -->
@@ -98,94 +87,99 @@
98
87
  <span class="live-dot inline-block size-2 rounded-full"></span>
99
88
  <span id="clock">--:--</span>
100
89
  </span>
101
- <button id="reload" class="btn-push mono flex shrink-0 items-center gap-2 text-xs pl-2.5 pr-3 py-2 rounded-xl" style="border: 1px solid var(--line)" aria-label="Reload data">
90
+ <button id="reload" class="btn-push mono flex shrink-0 items-center gap-2 text-xs pl-2.5 pr-3 py-2 rounded-xl" style="border: 1px solid var(--line); color: var(--ink)" aria-label="Reload data">
102
91
  <i data-lucide="refresh-cw" class="size-3.5"></i><span class="hidden md:inline">Reload</span>
103
92
  </button>
104
93
  </header>
105
94
 
106
- <!-- Hero: asymmetric 7/5 -->
107
- <section class="grid grid-cols-1 lg:grid-cols-12 gap-8 pt-10 pb-8 items-end">
108
- <div class="lg:col-span-7 rise">
109
- <p class="mono text-[11px] uppercase tracking-[0.22em] mb-3" style="color: var(--accent)">Live token feed</p>
110
- <p class="mono font-bold tracking-tighter leading-none text-7xl md:text-8xl" id="total">0</p>
111
- <p class="mt-3 text-base max-w-[52ch]" style="color: var(--dim)">session tokens parsed from OMP transcripts. Input, output, cache read, cache write, USD cost, CO2.</p>
95
+ <!-- Hero: centered total -->
96
+ <section class="mx-auto max-w-6xl text-center pt-14 pb-10">
97
+ <p class="hero-in mono text-[11px] uppercase tracking-[0.22em] mb-4" style="color: var(--accent)">Live token feed</p>
98
+ <p class="hero-in mono font-bold tracking-tighter leading-none text-6xl md:text-7xl" id="total">0</p>
99
+ <p class="hero-in mono mt-4 text-sm max-w-[62ch] mx-auto" style="color: var(--dim)" id="herosub">session tokens parsed from OMP transcripts. Input, output, cache read, cache write, USD cost, CO2.</p>
100
+ </section>
101
+
102
+ <!-- Savings bento: 3+3+2+2+2 -->
103
+ <section class="grid grid-cols-12 grid-flow-dense gap-3" aria-label="Savings">
104
+ <div class="rise col-span-6 md:col-span-3 rounded-xl p-5" style="border: 1px solid var(--line); background: var(--panel)">
105
+ <p class="mono text-[11px] uppercase tracking-[0.14em]" style="color: var(--dim)">usd cost</p>
106
+ <p class="mono font-bold text-3xl mt-1" id="usd">-</p>
107
+ <p class="mono text-[11px] mt-1" style="color: var(--dim)" id="usdNote">per-model price table</p>
108
+ </div>
109
+ <div class="rise col-span-6 md:col-span-3 rounded-xl p-5" style="border: 1px solid var(--line); background: var(--panel)">
110
+ <p class="mono text-[11px] uppercase tracking-[0.14em]" style="color: var(--dim)">saved by cache</p>
111
+ <p class="mono font-bold text-3xl mt-1" style="color: var(--accent)" id="saved">-</p>
112
+ <p class="mono text-[11px] mt-1" style="color: var(--dim)">est. vs full input price</p>
113
+ </div>
114
+ <div class="rise col-span-4 md:col-span-2 rounded-xl p-5" style="border: 1px solid var(--line); background: var(--panel)">
115
+ <p class="mono text-[11px] uppercase tracking-[0.14em]" style="color: var(--dim)">co2</p>
116
+ <p class="mono font-bold text-3xl mt-1" id="co2">-</p>
117
+ <p class="mono text-[11px] mt-1" style="color: var(--dim)">est. from output</p>
112
118
  </div>
113
- <div class="lg:col-span-5 rise" style="transition-delay: .12s">
114
- <dl class="mono text-sm">
115
- <div class="flex justify-between gap-4 py-2 rowline"><dt style="color: var(--dim)">usd cost</dt><dd id="usd">-</dd></div>
116
- <div class="flex justify-between gap-4 py-2 rowline"><dt style="color: var(--dim)">co2</dt><dd id="co2">-</dd></div>
117
- <div class="flex justify-between gap-4 py-2"><dt style="color: var(--dim)">assistant msgs</dt><dd id="msgs">0</dd></div>
118
- </dl>
119
+ <div class="rise col-span-4 md:col-span-2 rounded-xl p-5" style="border: 1px solid var(--line); background: var(--panel)">
120
+ <p class="mono text-[11px] uppercase tracking-[0.14em]" style="color: var(--dim)">cache share</p>
121
+ <p class="mono font-bold text-3xl mt-1" id="cacheShare">-</p>
122
+ <p class="mono text-[11px] mt-1" style="color: var(--dim)">of tokens cached</p>
123
+ </div>
124
+ <div class="rise col-span-4 md:col-span-2 rounded-xl p-5" style="border: 1px solid var(--line); background: var(--panel)">
125
+ <p class="mono text-[11px] uppercase tracking-[0.14em]" style="color: var(--dim)">assistant msgs</p>
126
+ <p class="mono font-bold text-3xl mt-1" id="msgs">0</p>
127
+ <p class="mono text-[11px] mt-1" style="color: var(--dim)" id="msgNote">sessions parsed</p>
119
128
  </div>
120
129
  </section>
121
130
 
122
- <!-- Token strip: plain stats, dividers, no cards -->
123
- <section class="rise grid grid-cols-2 sm:grid-cols-4 rounded-xl overflow-hidden" style="border: 1px solid var(--line); background: var(--panel)" id="tokens" aria-label="Tokens by bucket"></section>
131
+ <!-- Token strip: nonzero buckets only -->
132
+ <section class="rise mt-3 flex rounded-xl overflow-hidden" style="border: 1px solid var(--line); background: var(--panel)" id="tokens" aria-label="Tokens by bucket"></section>
124
133
 
125
- <!-- Contribution graph, GitHub style -->
126
- <section id="activity" 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="Activity graph">
127
- <div class="flex items-center gap-2 mb-1">
128
- <i data-lucide="calendar-days" class="size-4" style="color: var(--accent)"></i>
129
- <h2 class="font-bold tracking-tight text-xl">Activity</h2>
130
- <span class="mono text-xs ml-auto" style="color: var(--dim)" id="graphRange"></span>
131
- </div>
132
- <p class="mono text-xs mb-4" style="color: var(--dim)">daily session tokens / last 26 weeks / darker means more</p>
133
- <div class="overflow-x-auto pb-1">
134
- <div class="flex gap-[3px] w-max" id="graph"></div>
135
- </div>
136
- <div class="mono text-[11px] mt-3 flex items-center gap-1.5" style="color: var(--dim)">
137
- <span>less</span>
138
- <span class="cell"></span><span class="cell l1"></span><span class="cell l2"></span><span class="cell l3"></span><span class="cell l4"></span>
139
- <span>more</span>
134
+ <!-- Token activity: stacked per-model bars + range toggle -->
135
+ <section id="activity" class="rise mt-8 rounded-xl p-5" style="border: 1px solid var(--line); background: var(--panel); scroll-margin-top: 90px" aria-label="Token activity">
136
+ <div class="flex flex-wrap items-center gap-3 mb-1">
137
+ <i data-lucide="chart-column" class="size-4" style="color: var(--accent)"></i>
138
+ <h2 class="display font-bold tracking-tight text-xl">Token activity</h2>
139
+ <div class="seg mono text-xs flex items-center gap-1 ml-auto p-1 rounded-[10px]" style="border: 1px solid var(--line)" role="tablist" aria-label="Range">
140
+ <button data-range="daily" class="on px-3 py-1.5" role="tab">Daily</button>
141
+ <button data-range="weekly" class="px-3 py-1.5" role="tab">Weekly</button>
142
+ <button data-range="cumulative" class="px-3 py-1.5" role="tab">Cumulative</button>
143
+ </div>
140
144
  </div>
145
+ <p class="mono text-xs mb-4" style="color: var(--dim)" id="rangeNote">stacked by model / last 60 days</p>
146
+ <div class="flex items-end gap-[3px] h-56" id="bars"></div>
147
+ <div class="flex gap-[3px] mt-2 mono text-[10px]" style="color: var(--dim)" id="barDays"></div>
148
+ <div class="flex flex-wrap gap-x-4 gap-y-1.5 mt-4 mono text-[11px]" style="color: var(--dim)" id="legend"></div>
141
149
  </section>
142
150
 
143
- <!-- Main: 8/4 split -->
144
- <main class="grid grid-cols-1 lg:grid-cols-12 gap-8 mt-8">
151
+ <!-- Top models: 3 cards -->
152
+ <section class="mt-8" aria-label="Top models">
153
+ <div class="flex items-center gap-2 mb-1">
154
+ <i data-lucide="cpu" class="size-4" style="color: var(--accent)"></i>
155
+ <h2 class="display font-bold tracking-tight text-xl">Top models</h2>
156
+ <span class="mono text-xs ml-auto" style="color: var(--dim)">usage across sessions</span>
157
+ </div>
158
+ <p class="mono text-xs mb-4" style="color: var(--dim)">week-over-week vs prior 7 days</p>
159
+ <div class="grid grid-cols-1 md:grid-cols-12 grid-flow-dense gap-3" id="modelCards"></div>
160
+ </section>
145
161
 
146
- <div class="lg:col-span-8 flex flex-col gap-8">
147
- <section id="daily" class="rise" style="scroll-margin-top: 90px" aria-label="Daily chart">
162
+ <!-- Models list + top tools: 8 + 4 -->
163
+ <main class="grid grid-cols-1 lg:grid-cols-12 grid-flow-dense gap-8 mt-8">
164
+ <div class="lg:col-span-8">
165
+ <section class="rise" aria-label="All models">
148
166
  <div class="flex items-center gap-2 mb-1">
149
- <i data-lucide="chart-column" class="size-4" style="color: var(--accent)"></i>
150
- <h2 class="font-bold tracking-tight text-xl">Daily input vs output</h2>
151
- </div>
152
- <p class="mono text-xs mb-4" style="color: var(--dim)">last 14 days / tall bar input, short bar output</p>
153
- <div class="rounded-xl p-5" style="border: 1px solid var(--line); background: var(--panel)">
154
- <div class="flex items-end gap-2 h-36" id="chart"></div>
155
- <div class="flex gap-2 mt-2 mono text-[10px]" style="color: var(--dim)" id="chartDays"></div>
167
+ <i data-lucide="layers" class="size-4" style="color: var(--accent)"></i>
168
+ <h2 class="display font-bold tracking-tight text-xl">Models</h2>
156
169
  </div>
170
+ <p class="mono text-xs mb-4" style="color: var(--dim)">ranked by tokens / top 8</p>
171
+ <ol id="models" class="rounded-xl overflow-hidden" style="border: 1px solid var(--line); background: var(--panel)"></ol>
157
172
  </section>
158
-
159
- <section id="commands" class="rise" style="scroll-margin-top: 90px" aria-label="Top commands">
173
+ </div>
174
+ <div class="lg:col-span-4">
175
+ <section id="tools" class="rise rounded-xl p-5" style="border: 1px solid var(--line); background: var(--panel); scroll-margin-top: 90px" aria-label="Top tools">
160
176
  <div class="flex items-center gap-2 mb-1">
161
177
  <i data-lucide="terminal" class="size-4" style="color: var(--accent)"></i>
162
- <h2 class="font-bold tracking-tight text-xl">Top commands</h2>
163
- </div>
164
- <p class="mono text-xs mb-4" style="color: var(--dim)">ranked by ledger count / top 10</p>
165
- <ol id="top" class="rounded-xl overflow-hidden" style="border: 1px solid var(--line); background: var(--panel)"></ol>
166
- <p id="emptyTop" class="mono text-sm py-8 text-center hidden" style="color: var(--dim)">No ledger rows yet. Run tersio commands to record activity.</p>
167
- </section>
168
- </div>
169
-
170
- <div class="lg:col-span-4 flex flex-col gap-8">
171
- <section id="savings" class="rise rounded-xl p-5" style="border: 1px solid var(--line); background: var(--panel); transition-delay: .1s; scroll-margin-top: 90px" aria-label="Savings">
172
- <div class="flex items-center gap-2 mb-3">
173
- <i data-lucide="gauge" class="size-4" style="color: var(--accent)"></i>
174
- <h2 class="font-bold tracking-tight text-xl">Savings</h2>
175
- </div>
176
- <ul class="mono text-[13px] leading-relaxed space-y-2">
177
- <li class="flex gap-2"><i data-lucide="circle-dollar-sign" class="size-4 shrink-0 mt-0.5" style="color: var(--accent)"></i><span id="usdLine">USD from per-model price table.</span></li>
178
- <li class="flex gap-2"><i data-lucide="leaf" class="size-4 shrink-0 mt-0.5" style="color: var(--accent)"></i><span id="co2Line">CO2 ~est. from output tokens.</span></li>
179
- <li class="flex gap-2"><i data-lucide="database" class="size-4 shrink-0 mt-0.5" style="color: var(--accent)"></i><span id="cacheLine">Cache share loads with session data.</span></li>
180
- </ul>
181
- </section>
182
-
183
- <section class="rise rounded-xl p-5" style="border: 1px solid var(--line); background: var(--panel); transition-delay: .18s" aria-label="Models">
184
- <div class="flex items-center gap-2 mb-3">
185
- <i data-lucide="cpu" class="size-4" style="color: var(--accent)"></i>
186
- <h2 class="font-bold tracking-tight text-xl">Models</h2>
178
+ <h2 class="display font-bold tracking-tight text-xl">Top tools</h2>
187
179
  </div>
188
- <ol id="models" class="mono text-[13px] space-y-2" style="scroll-margin-top: 90px"></ol>
180
+ <p class="mono text-xs mb-4" style="color: var(--dim)">tool calls in sessions</p>
181
+ <ol id="top" class="mono text-[13px] space-y-3"></ol>
182
+ <p id="emptyTop" class="mono text-sm py-6 text-center hidden" style="color: var(--dim)">No tool calls yet.</p>
189
183
  </section>
190
184
  </div>
191
185
  </main>
@@ -200,24 +194,29 @@
200
194
  <a id="rawlink" href="data.json" class="underline underline-offset-2">raw data.json</a>
201
195
  </footer>
202
196
  </div>
197
+ </main>
203
198
 
204
199
  <script>
205
200
  (function () {
206
201
  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
202
+ var hasGsap = !reduce && typeof window.gsap !== 'undefined';
203
+ var hasST = hasGsap && typeof window.ScrollTrigger !== 'undefined';
204
+ if (hasST) gsap.registerPlugin(ScrollTrigger);
207
205
 
208
206
  function fmt(n) { return Number(n).toLocaleString('en-US'); }
207
+ function fmtShort(n) {
208
+ if (n >= 1e12) return (n / 1e12).toFixed(1) + 'T';
209
+ if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
210
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
211
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
212
+ return String(Math.round(n));
213
+ }
209
214
 
210
215
  function countUp(el, target, money) {
211
216
  function text(v) { return money ? '$' + v.toFixed(2) : fmt(Math.round(v)); }
212
- if (reduce || target <= 0) { el.textContent = text(target); return; }
213
- var t0 = null, dur = 900;
214
- function step(t) {
215
- if (!t0) t0 = t;
216
- var p = Math.min((t - t0) / dur, 1), e = 1 - Math.pow(1 - p, 3);
217
- el.textContent = text(target * e);
218
- if (p < 1) requestAnimationFrame(step);
219
- }
220
- requestAnimationFrame(step);
217
+ if (!hasGsap || target <= 0) { el.textContent = text(target); return; }
218
+ var o = { v: 0 };
219
+ gsap.to(o, { v: target, duration: 1.1, ease: 'power3.out', onUpdate: function () { el.textContent = text(o.v); } });
221
220
  }
222
221
 
223
222
  function tick() {
@@ -226,86 +225,257 @@
226
225
  }
227
226
  tick(); setInterval(tick, 1000);
228
227
 
228
+ var VENDORS = [
229
+ [/glm|z-ai|zhipu/i, 'Zhipu', 'Z', '#3b82f6'],
230
+ [/muse/i, 'Meta', 'M', '#0082fb'],
231
+ [/qwen|qwq/i, 'Alibaba', 'Q', '#7c3aed'],
232
+ [/deepseek/i, 'DeepSeek', 'D', '#4d6bfe'],
233
+ [/mimo/i, 'Xiaomi', 'Mi', '#ff6900'],
234
+ [/kimi|moonshot/i, 'Moonshot', 'K', '#a855f7'],
235
+ [/minimax/i, 'MiniMax', 'M', '#e11d48'],
236
+ [/nemotron|nvidia/i, 'NVIDIA', 'N', '#76b900'],
237
+ [/gpt|openai|codex/i, 'OpenAI', 'O', '#10a37f'],
238
+ [/claude|anthropic/i, 'Anthropic', 'A', '#d97757'],
239
+ [/gemini|google/i, 'Google', 'G', '#4285f4'],
240
+ [/grok|xai/i, 'xAI', 'X', '#71717a']
241
+ ];
242
+ function vendorOf(model) {
243
+ for (var i = 0; i < VENDORS.length; i++) {
244
+ if (VENDORS[i][0].test(model)) return { name: VENDORS[i][1], mark: VENDORS[i][2], color: VENDORS[i][3] };
245
+ }
246
+ return { name: 'Other', mark: (model || '?').charAt(0).toUpperCase(), color: '#71717a' };
247
+ }
248
+ var PALETTE = ['#34d399', '#818cf8', '#22d3ee', '#fbbf24', '#f472b6', '#a78bfa', '#fb923c', '#2dd4bf'];
249
+
229
250
  function dayKey(d) {
230
251
  return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
231
252
  }
253
+ function modelTotal(byModel, m) {
254
+ var b = byModel[m] || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
255
+ return b.input + b.output + b.cacheRead + b.cacheWrite;
256
+ }
232
257
 
233
- function renderGraph(byDay) {
234
- var g = document.getElementById('graph');
235
- g.innerHTML = '';
236
- var today = new Date(); today.setHours(0, 0, 0, 0);
237
- var start = new Date(today); start.setDate(start.getDate() - (26 * 7 - 1) - today.getDay());
238
- var max = 1, sums = {};
239
- Object.keys(byDay).forEach(function (day) {
240
- var b = byDay[day];
241
- sums[day] = b.input + b.output + b.cacheRead + b.cacheWrite;
242
- if (sums[day] > max) max = sums[day];
243
- });
244
- var weeks = 0;
245
- for (var w = 0; w < 26; w++) {
258
+ var DATA = null, RANGE = 'daily';
259
+
260
+ function topModels(byModel, n) {
261
+ return Object.keys(byModel).sort(function (a, b) { return modelTotal(byModel, b) - modelTotal(byModel, a); }).slice(0, n);
262
+ }
263
+
264
+ function allDays() {
265
+ var set = {};
266
+ Object.keys(DATA.byDay || {}).forEach(function (d) { set[d] = 1; });
267
+ Object.keys(DATA.byDayModel || {}).forEach(function (d) { set[d] = 1; });
268
+ return Object.keys(set).sort();
269
+ }
270
+
271
+ // Buckets: [{ key, label, total, segs: [[model, v]] }]
272
+ function buckets() {
273
+ var days = allDays(), tops = topModels(DATA.byModel || {}, 8), out = [];
274
+ if (RANGE === 'daily') {
275
+ var last = days.slice(-60);
276
+ last.forEach(function (d) {
277
+ var per = (DATA.byDayModel || {})[d] || {}, segs = [], total = 0, other = 0;
278
+ tops.forEach(function (m) { var v = per[m] || 0; if (v) { segs.push([m, v]); total += v; } });
279
+ Object.keys(per).forEach(function (m) { if (tops.indexOf(m) < 0) other += per[m]; });
280
+ if (other) { segs.push(['other', other]); total += other; }
281
+ var dt = new Date(d + 'T12:00:00');
282
+ out.push({ key: d, label: isNaN(dt) ? '' : String(dt.getDate()), total: total, segs: segs });
283
+ });
284
+ document.getElementById('rangeNote').textContent = 'stacked by model / last 60 days';
285
+ } else if (RANGE === 'weekly') {
286
+ var weeks = {}, order = [];
287
+ days.forEach(function (d) {
288
+ var dt = new Date(d + 'T12:00:00'); if (isNaN(dt)) return;
289
+ var monday = new Date(dt); monday.setDate(dt.getDate() - ((dt.getDay() + 6) % 7));
290
+ var k = dayKey(monday);
291
+ if (!weeks[k]) { weeks[k] = {}; order.push(k); }
292
+ var per = (DATA.byDayModel || {})[d] || {};
293
+ Object.keys(per).forEach(function (m) { weeks[k][m] = (weeks[k][m] || 0) + per[m]; });
294
+ });
295
+ order.slice(-26).forEach(function (k) {
296
+ var per = weeks[k], segs = [], total = 0, other = 0;
297
+ tops.forEach(function (m) { var v = per[m] || 0; if (v) { segs.push([m, v]); total += v; } });
298
+ Object.keys(per).forEach(function (m) { if (tops.indexOf(m) < 0) other += per[m]; });
299
+ if (other) { segs.push(['other', other]); total += other; }
300
+ var dt = new Date(k + 'T12:00:00');
301
+ out.push({ key: k, label: isNaN(dt) ? '' : (dt.toLocaleString('en-US', { month: 'short' }) + ' ' + dt.getDate()), total: total, segs: segs });
302
+ });
303
+ document.getElementById('rangeNote').textContent = 'stacked by model / last 26 weeks';
304
+ } else {
305
+ var run = {}, cum = 0;
306
+ days.forEach(function (d) {
307
+ var per = (DATA.byDayModel || {})[d] || {}, segs = [], other = 0;
308
+ tops.forEach(function (m) {
309
+ var v = per[m] || 0;
310
+ run[m] = (run[m] || 0) + v;
311
+ if (run[m]) segs.push([m, run[m]]);
312
+ });
313
+ Object.keys(per).forEach(function (m) { if (tops.indexOf(m) < 0) { run[m] = (run[m] || 0) + per[m]; } });
314
+ other = Object.keys(run).reduce(function (a, m) { return tops.indexOf(m) < 0 ? a + run[m] : a; }, 0);
315
+ var tot = segs.reduce(function (a, s) { return a + s[1]; }, 0) + other;
316
+ if (other) segs.push(['other', other]);
317
+ cum = tot;
318
+ var dt = new Date(d + 'T12:00:00');
319
+ out.push({ key: d, label: isNaN(dt) ? '' : String(dt.getDate()), total: tot, segs: segs });
320
+ });
321
+ out = out.slice(-60);
322
+ document.getElementById('rangeNote').textContent = 'running total by model / all time';
323
+ }
324
+ return { list: out, tops: tops };
325
+ }
326
+
327
+ function renderBars() {
328
+ var b = buckets(), wrap = document.getElementById('bars'), axis = document.getElementById('barDays');
329
+ wrap.innerHTML = ''; axis.innerHTML = '';
330
+ var max = 1;
331
+ b.list.forEach(function (r) { max = Math.max(max, r.total); });
332
+ b.list.forEach(function (r) {
246
333
  var col = document.createElement('div');
247
- col.className = 'flex flex-col gap-[3px]';
248
- for (var i = 0; i < 7; i++) {
249
- var dt = new Date(start); dt.setDate(dt.getDate() + weeks * 7 + i);
250
- var key = dayKey(dt), v = sums[key] || 0, lvl = 0;
251
- if (v > 0) lvl = Math.min(4, 1 + Math.floor((v / max) * 3.99));
252
- var cell = document.createElement('span');
253
- cell.className = 'cell' + (lvl ? ' l' + lvl : '');
254
- cell.title = key + ': ' + fmt(v) + ' tokens';
255
- col.appendChild(cell);
334
+ col.className = 'flex-1 flex items-end h-full';
335
+ col.title = r.key + ': ' + fmt(r.total) + ' tokens' +
336
+ (r.segs.length ? '\n' + r.segs.map(function (s) { return s[0] + ' ' + fmt(s[1]); }).join('\n') : '');
337
+ var bar = document.createElement('div');
338
+ bar.className = 'mbar w-full';
339
+ bar.style.height = Math.max(2, Math.round(r.total / max * 100)) + '%';
340
+ var order = r.segs.slice().sort(function (x, y) { return y[1] - x[1]; });
341
+ order.forEach(function (s) {
342
+ var seg = document.createElement('span');
343
+ var ci = s[0] === 'other' ? -1 : b.tops.indexOf(s[0]);
344
+ seg.style.background = ci < 0 ? 'var(--dim)' : PALETTE[ci % PALETTE.length];
345
+ seg.style.height = Math.max(1, Math.round(s[1] / r.total * 100)) + '%';
346
+ bar.appendChild(seg);
347
+ });
348
+ col.appendChild(bar); wrap.appendChild(col);
349
+ var lb = document.createElement('span');
350
+ lb.className = 'flex-1 text-center truncate'; lb.textContent = r.label;
351
+ axis.appendChild(lb);
352
+ });
353
+ var legend = document.getElementById('legend');
354
+ legend.innerHTML = '';
355
+ b.tops.slice(0, 8).forEach(function (m, i) {
356
+ var s = document.createElement('span');
357
+ s.className = 'flex items-center gap-1.5';
358
+ var dot = document.createElement('span');
359
+ dot.style.cssText = 'width:9px;height:9px;border-radius:3px;background:' + PALETTE[i % PALETTE.length] + ';display:inline-block';
360
+ s.appendChild(dot);
361
+ s.appendChild(document.createTextNode(m));
362
+ legend.appendChild(s);
363
+ });
364
+ if (hasGsap) {
365
+ gsap.from('#bars > div', { scaleY: 0.2, opacity: 0.2, transformOrigin: 'bottom', duration: 0.5, ease: 'power2.out', stagger: 0.008, overwrite: true });
366
+ if (hasST) {
367
+ gsap.from('#modelCards > div', {
368
+ scale: 0.92, opacity: 0.2, duration: 0.6, ease: 'power2.out', stagger: 0.08, overwrite: true,
369
+ scrollTrigger: { trigger: '#modelCards', start: 'top 88%' }
370
+ });
256
371
  }
257
- weeks++;
258
- g.appendChild(col);
259
372
  }
260
- document.getElementById('graphRange').textContent =
261
- dayKey(start) + ' to ' + dayKey(today);
262
373
  }
263
374
 
264
- function renderChart(byDay) {
265
- var chart = document.getElementById('chart'), days = document.getElementById('chartDays');
266
- chart.innerHTML = ''; days.innerHTML = '';
267
- var list = [], today = new Date(); today.setHours(0, 0, 0, 0);
268
- for (var i = 13; i >= 0; i--) {
269
- var dt = new Date(today); dt.setDate(dt.getDate() - i);
270
- list.push({ key: dayKey(dt), label: String(dt.getDate()), b: byDay[dayKey(dt)] || { input: 0, output: 0 } });
271
- }
375
+ function wow(model) {
376
+ var days = allDays().slice(-14), cur = 0, prev = 0;
377
+ days.forEach(function (d, i) {
378
+ var v = ((DATA.byDayModel || {})[d] || {})[model] || 0;
379
+ if (i < days.length - 7) prev += v; else cur += v;
380
+ });
381
+ if (!prev) return cur ? 'new' : '—';
382
+ var p = Math.round((cur - prev) / prev * 100);
383
+ return (p >= 0 ? '+' : '') + p + '%';
384
+ }
385
+
386
+ function renderModels() {
387
+ var byModel = DATA.byModel || {}, tops = topModels(byModel, 8);
388
+ var cards = document.getElementById('modelCards');
389
+ cards.innerHTML = '';
390
+ tops.slice(0, 3).forEach(function (m, i) {
391
+ var v = vendorOf(m), d = wow(m);
392
+ var card = document.createElement('div');
393
+ card.className = 'rise md:col-span-4 rounded-xl p-5 overflow-hidden relative';
394
+ card.style.cssText = 'border: 1px solid var(--line); background: var(--panel)';
395
+ var ghost = document.createElement('span');
396
+ ghost.className = 'display pointer-events-none select-none absolute -right-2 -bottom-7 font-bold';
397
+ ghost.style.cssText = 'font-size: 120px; line-height: 1; opacity: .07';
398
+ ghost.textContent = v.mark;
399
+ var head = document.createElement('p');
400
+ head.className = 'mono text-xs mb-3'; head.style.color = 'var(--dim)';
401
+ head.textContent = '0' + (i + 1);
402
+ var row = document.createElement('div');
403
+ row.className = 'flex items-center gap-3';
404
+ var badge = document.createElement('span');
405
+ badge.className = 'monogram'; badge.style.background = v.color; badge.textContent = v.mark;
406
+ var mid = document.createElement('div'); mid.className = 'min-w-0 flex-1';
407
+ var nm = document.createElement('p'); nm.className = 'mono text-sm font-bold truncate'; nm.textContent = m;
408
+ var vn = document.createElement('p'); vn.className = 'mono text-xs truncate'; vn.style.color = 'var(--dim)'; vn.textContent = v.name;
409
+ mid.appendChild(nm); mid.appendChild(vn);
410
+ var tot = document.createElement('p'); tot.className = 'mono font-bold text-lg shrink-0'; tot.textContent = fmtShort(modelTotal(byModel, m));
411
+ row.appendChild(badge); row.appendChild(mid); row.appendChild(tot);
412
+ var delta = document.createElement('p');
413
+ delta.className = 'mono text-xs mt-3';
414
+ delta.style.color = d.charAt(0) === '-' ? '#f87171' : 'var(--accent)';
415
+ delta.textContent = d;
416
+ card.appendChild(ghost); card.appendChild(head); card.appendChild(row); card.appendChild(delta);
417
+ cards.appendChild(card);
418
+ });
419
+ var ol = document.getElementById('models');
420
+ ol.innerHTML = '';
272
421
  var max = 1;
273
- list.forEach(function (d) { max = Math.max(max, d.b.input, d.b.output); });
274
- list.forEach(function (d, i) {
275
- var wrap = document.createElement('div');
276
- wrap.className = 'flex-1 flex items-end justify-center gap-1 h-full';
277
- wrap.title = d.key + ': in ' + fmt(d.b.input) + ' / out ' + fmt(d.b.output);
278
- var bi = document.createElement('div');
279
- bi.className = 'bar-fill growY w-3';
280
- bi.style.height = Math.max(2, Math.round(d.b.input / max * 100)) + '%';
281
- bi.style.transitionDelay = reduce ? '' : (i * 40) + 'ms';
282
- var bo = document.createElement('div');
283
- bo.className = 'bar-dim growY w-3';
284
- bo.style.height = Math.max(2, Math.round(d.b.output / max * 100)) + '%';
285
- bo.style.transitionDelay = reduce ? '' : (i * 40 + 60) + 'ms';
286
- wrap.appendChild(bi); wrap.appendChild(bo); chart.appendChild(wrap);
287
- var lb = document.createElement('span');
288
- lb.className = 'flex-1 text-center'; lb.textContent = d.label;
289
- days.appendChild(lb);
422
+ tops.forEach(function (m) { max = Math.max(max, modelTotal(byModel, m)); });
423
+ tops.forEach(function (m, i) {
424
+ var v = vendorOf(m);
425
+ var li = document.createElement('li');
426
+ li.className = 'tilt flex items-center gap-3 px-4 py-3' + (i < tops.length - 1 ? ' rowline' : '');
427
+ var badge = document.createElement('span');
428
+ badge.className = 'monogram'; badge.style.cssText = 'width:28px;height:28px;font-size:11px;border-radius:8px;background:' + v.color;
429
+ badge.textContent = v.mark;
430
+ badge.title = v.name;
431
+ var mid = document.createElement('div'); mid.className = 'min-w-0 flex-1';
432
+ var nm = document.createElement('p'); nm.className = 'mono text-sm truncate'; nm.textContent = m;
433
+ var track = document.createElement('div'); track.className = 'bar-track mt-1.5 h-1.5 overflow-hidden';
434
+ var fill = document.createElement('div'); fill.className = 'bar-fill h-full';
435
+ fill.style.width = Math.max(2, Math.round(modelTotal(byModel, m) / max * 100)) + '%';
436
+ track.appendChild(fill); mid.appendChild(nm); mid.appendChild(track);
437
+ var n = document.createElement('span'); n.className = 'mono font-bold shrink-0'; n.textContent = fmt(modelTotal(byModel, m));
438
+ li.appendChild(badge); li.appendChild(mid); li.appendChild(n);
439
+ ol.appendChild(li);
290
440
  });
441
+ if (!tops.length) ol.innerHTML = '<li class="mono text-sm px-4 py-6" style="color: var(--dim)">no session tokens yet</li>';
291
442
  }
292
443
 
293
- function render(d) {
294
- var t = d.tokens || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
295
- var total = t.input + t.output + t.cacheRead + t.cacheWrite;
296
- document.getElementById('meta').textContent =
297
- fmt(total) + ' session tokens / ' + d.total + ' ledger rows';
298
- countUp(document.getElementById('total'), total);
299
- countUp(document.getElementById('usd'), d.usd || 0, true);
300
- document.getElementById('co2').textContent = '~' + (d.co2g || 0).toFixed(1) + 'g (est.)';
301
- document.getElementById('msgs').textContent = fmt(d.messages || 0);
444
+ function renderTools() {
445
+ var ol = document.getElementById('top');
446
+ ol.innerHTML = '';
447
+ var tools = DATA.byTool || [], max = 1;
448
+ tools.forEach(function (r) { max = Math.max(max, r[1]); });
449
+ tools.forEach(function (r) {
450
+ var li = document.createElement('li');
451
+ var top = document.createElement('div');
452
+ top.className = 'flex justify-between gap-2';
453
+ var nm = document.createElement('span'); nm.className = 'truncate'; nm.textContent = r[0];
454
+ var n = document.createElement('span'); n.className = 'font-bold'; n.textContent = fmt(r[1]);
455
+ top.appendChild(nm); top.appendChild(n);
456
+ var track = document.createElement('div'); track.className = 'bar-track mt-1 h-1 overflow-hidden';
457
+ var fill = document.createElement('div'); fill.className = 'bar-fill h-full';
458
+ fill.style.width = Math.max(2, Math.round(r[1] / max * 100)) + '%';
459
+ track.appendChild(fill);
460
+ li.appendChild(top); li.appendChild(track);
461
+ ol.appendChild(li);
462
+ });
463
+ document.getElementById('emptyTop').classList.toggle('hidden', tools.length > 0);
464
+ }
302
465
 
303
- var buckets = [['input', t.input, 'arrow-down-to-line'], ['output', t.output, 'arrow-up-from-line'], ['cache read', t.cacheRead, 'hard-drive-download'], ['cache write', t.cacheWrite, 'hard-drive-upload']];
466
+ function renderStrip(t) {
467
+ var defs = [
468
+ ['input', t.input, 'arrow-down-to-line'],
469
+ ['output', t.output, 'arrow-up-from-line'],
470
+ ['cache read', t.cacheRead, 'hard-drive-download'],
471
+ ['cache write', t.cacheWrite, 'hard-drive-upload']
472
+ ].filter(function (b) { return b[1] > 0; });
473
+ if (!defs.length) defs = [['input', 0, 'arrow-down-to-line'], ['output', 0, 'arrow-up-from-line']];
304
474
  var strip = document.getElementById('tokens');
305
475
  strip.innerHTML = '';
306
- buckets.forEach(function (b, i) {
476
+ defs.forEach(function (b, i) {
307
477
  var cell = document.createElement('div');
308
- cell.className = 'tilt px-4 py-4' + (i > 0 ? ' sm:border-l' : '');
478
+ cell.className = 'tilt flex-1 px-4 py-4' + (i > 0 ? ' sm:border-l' : '');
309
479
  cell.style.borderColor = 'var(--line)';
310
480
  cell.innerHTML = '<div class="flex items-center gap-2 mono text-[11px] uppercase tracking-[0.14em]" style="color: var(--dim)">' +
311
481
  '<i data-lucide="' + b[2] + '" class="size-3.5"></i><span></span></div>' +
@@ -314,63 +484,60 @@
314
484
  cell.querySelector('p').textContent = fmt(b[1]);
315
485
  strip.appendChild(cell);
316
486
  });
487
+ }
317
488
 
318
- renderGraph(d.byDay || {});
319
- renderChart(d.byDay || {});
320
-
321
- var cacheShare = total ? Math.round((t.cacheRead + t.cacheWrite) / total * 100) : 0;
322
- document.getElementById('usdLine').textContent =
323
- '$' + (d.usd || 0).toFixed(2) + (d.priced ? ' measured (price table).' : ' incl. default pricing.');
324
- document.getElementById('co2Line').textContent =
325
- '~' + (d.co2g || 0).toFixed(1) + 'g CO2 est. from output tokens.';
326
- document.getElementById('cacheLine').textContent =
327
- cacheShare + '% of tokens served from cache.';
489
+ function money(v) { return '$' + v.toFixed(2); }
328
490
 
329
- var models = document.getElementById('models');
330
- models.innerHTML = '';
331
- Object.entries(d.byModel || {}).sort(function (a, b) {
332
- return (b[1].input + b[1].output) - (a[1].input + a[1].output);
333
- }).slice(0, 5).forEach(function (row) {
334
- var li = document.createElement('li');
335
- li.className = 'flex justify-between gap-2';
336
- var name = document.createElement('span'); name.className = 'truncate'; name.textContent = row[0];
337
- var n = document.createElement('span'); n.textContent = fmt(row[1].input + row[1].output);
338
- li.appendChild(name); li.appendChild(n); models.appendChild(li);
491
+ function render(d) {
492
+ DATA = d;
493
+ var t = d.tokens || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
494
+ var total = t.input + t.output + t.cacheRead + t.cacheWrite;
495
+ document.getElementById('meta').textContent = fmt(total) + ' session tokens / ' + d.total + ' ledger rows';
496
+ var sub = document.getElementById('herosub');
497
+ sub.innerHTML = '';
498
+ ('input ' + fmt(t.input) + ' · output ' + fmt(t.output) + ' · cache read ' + fmt(t.cacheRead) +
499
+ (t.cacheWrite ? ' · cache write ' + fmt(t.cacheWrite) : '') + ' · ' + money(d.usd || 0)).split(' ').forEach(function (w) {
500
+ var s = document.createElement('span'); s.textContent = w + ' ';
501
+ sub.appendChild(s);
339
502
  });
340
- if (!models.children.length) {
341
- models.innerHTML = '<li style="color: var(--dim)">no session tokens yet</li>';
503
+ var totalEl = document.getElementById('total');
504
+ if (hasGsap) {
505
+ var o = { v: 0 };
506
+ gsap.to(o, { v: total, duration: 1.2, ease: 'power3.out', onUpdate: function () { totalEl.textContent = fmt(o.v); } });
507
+ } else {
508
+ totalEl.textContent = fmt(total);
342
509
  }
510
+ countUp(document.getElementById('usd'), d.usd || 0, true);
511
+ document.getElementById('usdNote').textContent = d.priced ? 'per-model price table' : 'incl. default pricing';
512
+ countUp(document.getElementById('saved'), d.savedUsd || 0, true);
513
+ document.getElementById('co2').textContent = '~' + (d.co2g || 0).toFixed(1) + 'g';
514
+ var share = total ? Math.round((t.cacheRead + t.cacheWrite) / total * 100) : 0;
515
+ document.getElementById('cacheShare').textContent = share + '%';
516
+ document.getElementById('msgs').textContent = fmt(d.messages || 0);
343
517
 
344
- var ol = document.getElementById('top');
345
- ol.innerHTML = '';
346
- var max = 1;
347
- (d.byDetail || []).forEach(function (row) { if (row[1] > max) max = row[1]; });
348
- (d.byDetail || []).forEach(function (row, i) {
349
- var li = document.createElement('li');
350
- li.className = 'tilt flex items-center gap-3 px-4 py-3' + (i < d.byDetail.length - 1 ? ' rowline' : '');
351
- var rank = document.createElement('span');
352
- rank.className = 'mono text-xs w-6 shrink-0'; rank.style.color = 'var(--dim)';
353
- rank.textContent = String(i + 1).padStart(2, '0');
354
- var mid = document.createElement('div'); mid.className = 'min-w-0 flex-1';
355
- var name = document.createElement('p'); name.className = 'mono text-sm truncate'; name.textContent = row[0];
356
- var track = document.createElement('div'); track.className = 'bar-track mt-1.5 h-1.5 overflow-hidden';
357
- var fill = document.createElement('div'); fill.className = 'bar-fill grow h-full';
358
- fill.style.width = Math.max(2, Math.round(row[1] / max * 100)) + '%';
359
- track.appendChild(fill); mid.appendChild(name); mid.appendChild(track);
360
- var n = document.createElement('span'); n.className = 'mono font-bold text-lg shrink-0'; n.textContent = String(row[1]);
361
- li.appendChild(rank); li.appendChild(mid); li.appendChild(n);
362
- ol.appendChild(li);
363
- });
364
- document.getElementById('emptyTop').classList.toggle('hidden', (d.total || 0) > 0);
365
- ol.classList.toggle('hidden', (d.total || 0) === 0);
518
+ renderStrip(t);
519
+ renderBars();
520
+ renderModels();
521
+ renderTools();
366
522
 
367
523
  if (window.lucide) lucide.createIcons();
368
- observe();
524
+ if (hasGsap) {
525
+ gsap.from('.hero-in', { y: 26, opacity: 0, duration: 0.8, ease: 'power3.out', stagger: 0.08 });
526
+ // Scrub: hero sub words fade in sequence on scroll.
527
+ if (hasST) {
528
+ gsap.fromTo('#herosub span', { opacity: 0.15 }, {
529
+ opacity: 1, ease: 'none', stagger: 0.05,
530
+ scrollTrigger: { trigger: '#herosub', start: 'top 92%', end: 'top 55%', scrub: true }
531
+ });
532
+ }
533
+ } else {
534
+ observe();
535
+ }
369
536
  }
370
537
 
371
538
  var seen = new WeakSet();
372
539
  function observe() {
373
- var els = document.querySelectorAll('.rise, .grow, .growY');
540
+ var els = document.querySelectorAll('.rise');
374
541
  if (!('IntersectionObserver' in window) || reduce) {
375
542
  els.forEach(function (el) { el.classList.add('in'); });
376
543
  return;
@@ -385,10 +552,19 @@
385
552
  els.forEach(function (el) { if (!seen.has(el)) io.observe(el); });
386
553
  }
387
554
 
555
+ document.querySelectorAll('.seg button').forEach(function (btn) {
556
+ btn.addEventListener('click', function () {
557
+ document.querySelectorAll('.seg button').forEach(function (b) { b.classList.remove('on'); });
558
+ btn.classList.add('on');
559
+ RANGE = btn.getAttribute('data-range');
560
+ if (DATA) renderBars();
561
+ });
562
+ });
563
+
388
564
  function load() {
389
565
  document.getElementById('meta').textContent = 'connecting to ledger';
390
566
  fetch('data.json').then(function (r) { return r.json(); }).then(render).catch(function () {
391
- document.getElementById('meta').textContent = 'No live data. Serve with: tersio dashboard';
567
+ document.getElementById('meta').textContent = 'No live data. Open with: tersio gain --open';
392
568
  document.getElementById('rawlink').style.display = 'none';
393
569
  document.getElementById('emptyTop').classList.remove('hidden');
394
570
  if (window.lucide) lucide.createIcons();
package/cli/dashboard.js CHANGED
@@ -1,4 +1,4 @@
1
- // cli/dashboard.ts — localhost gain dashboard. Serves the single plain-HTML
1
+ // cli/dashboard.ts — gain dashboard. Serves the single plain-HTML
2
2
  // template (no deps, no build) plus a /data.json endpoint from the ledger.
3
3
  // Binds 127.0.0.1 only; --export writes a file://-ready file instead.
4
4
  // Note: no Promise.withResolvers here — engines still allow Node 20.12,
@@ -35,7 +35,7 @@ async function runDashboard(options) {
35
35
  .replace('href="brand.webp"', `href="${await faviconDataUri()}"`)
36
36
  .replace('src="brand.webp"', `src="${await faviconDataUri()}"`);
37
37
  await fs.writeFile(options.exportFile, inline, 'utf8');
38
- console.log(`[ok] dashboard exported → ${options.exportFile}`);
38
+ console.log(`[ok] gain exported → ${options.exportFile}`);
39
39
  return;
40
40
  }
41
41
  const html = await withInteractiveSpinner('Loading dashboard template', templateHtml);
@@ -64,7 +64,7 @@ async function runDashboard(options) {
64
64
  const address = server.address();
65
65
  const port = typeof address === 'object' && address ? address.port : options.port;
66
66
  const url = `http://127.0.0.1:${port}`;
67
- console.log(`[ok] dashboard live → ${url} (Ctrl-C to stop)`);
67
+ console.log(`[ok] gain live → ${url} (Ctrl-C to stop)`);
68
68
  if (options.open)
69
69
  openBrowser(url);
70
70
  });
package/cli/install.js CHANGED
@@ -3,7 +3,7 @@ import { promises as fs } from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { BUN_BIN_DIR, COMBO_PRESET_MODES, EXTENSIONS_KEY_RE, HOME, IS_WINDOWS, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, SCOPE_MAP, applyUpdate, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, install, ponytailDefaultFlag, profileFlagsGiven, reinstall, rtkDefaultFlag, scopeFlag, verbose, yes, dashboardExport, dashboardOpen, dashboardPort, debug, ensureExtensionAfterConfigEntry, ensureExtensionInConfig, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
6
+ import { BUN_BIN_DIR, COMBO_PRESET_MODES, EXTENSIONS_KEY_RE, HOME, IS_WINDOWS, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, SCOPE_MAP, applyUpdate, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, install, ponytailDefaultFlag, profileFlagsGiven, reinstall, rtkDefaultFlag, scopeFlag, verbose, yes, dashboardExport, dashboardPort, debug, ensureExtensionAfterConfigEntry, ensureExtensionInConfig, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
7
7
  import { ask, askInteractiveChoice, askInteractiveConfirm, closeRL, execNetwork, tty, withInteractiveSpinner, } from './interactive.js';
8
8
  import { checkForUpdate, runLatestUpdate } from './update.js';
9
9
  import { runUninstall } from './uninstall.js';
@@ -571,7 +571,6 @@ async function runCommandMenu() {
571
571
  { value: 'doctor', label: 'Doctor', hint: 'verify the installation' },
572
572
  { value: 'usage', label: 'Usage', hint: 'token usage and savings report' },
573
573
  { value: 'gain', label: 'Gain dashboard', hint: 'open the report in your browser' },
574
- { value: 'dashboard', label: 'Serve dashboard', hint: 'localhost server, no browser' },
575
574
  { value: 'uninstall', label: 'Uninstall', hint: 'remove tersio' },
576
575
  ], 'install');
577
576
  if (choice.status !== 'selected') {
@@ -601,11 +600,7 @@ async function runCommandMenu() {
601
600
  closeRL();
602
601
  break;
603
602
  case 'gain':
604
- await runDashboard({ port: dashboardPort, open: true, exportFile: null });
605
- closeRL();
606
- break;
607
- case 'dashboard':
608
- await runDashboard({ port: dashboardPort, open: dashboardOpen, exportFile: dashboardExport });
603
+ await runDashboard({ port: dashboardPort, open: true, exportFile: dashboardExport });
609
604
  closeRL();
610
605
  break;
611
606
  case 'uninstall':
package/cli/usage.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // cli/usage.ts — ledger + session-token usage report, tokscale-style.
2
- import { co2Grams, importSessionTokens, readUsage, usdCost, } from '../extensions/shared/usage-ledger.js';
2
+ import { co2Grams, importSessionTokens, priceFor, readUsage, usdCost, } from '../extensions/shared/usage-ledger.js';
3
3
  import { withInteractiveSpinner } from './interactive.js';
4
4
  export function summarizeUsage(rows) {
5
5
  const byKind = {};
@@ -12,12 +12,15 @@ export function summarizeUsage(rows) {
12
12
  const session = importSessionTokens();
13
13
  let usd = 0;
14
14
  let priced = true;
15
+ let savedUsd = 0;
15
16
  for (const [model, t] of Object.entries(session.byModel)) {
16
17
  const c = usdCost(t, model);
17
18
  usd += c.usd;
18
19
  if (!c.priced)
19
20
  priced = false;
21
+ savedUsd += (t.cacheRead / 1e6) * priceFor(model).price.input;
20
22
  }
23
+ const byTool = Object.entries(session.byTool).sort((a, b) => b[1] - a[1]).slice(0, 10);
21
24
  return {
22
25
  total: rows.length,
23
26
  byKind,
@@ -28,8 +31,12 @@ export function summarizeUsage(rows) {
28
31
  tokens: session.totals,
29
32
  byModel: session.byModel,
30
33
  byDay: session.byDay,
34
+ byDayModel: session.byDayModel,
35
+ byTool,
31
36
  usd,
32
37
  priced,
38
+ savedUsd,
39
+ costMeasured: session.costMeasured,
33
40
  co2g: co2Grams(session.totals.output),
34
41
  };
35
42
  }
@@ -44,7 +51,7 @@ function printReport(report) {
44
51
  }
45
52
  const t = report.tokens;
46
53
  console.log(` Tokens input ${fmt(t.input)} · output ${fmt(t.output)} · cache read ${fmt(t.cacheRead)} · cache write ${fmt(t.cacheWrite)} (${report.messages} assistant messages)`);
47
- console.log(` Cost $${report.usd.toFixed(2)}${report.priced ? '' : ' (includes default pricing for unknown models)'} · CO2 ~${report.co2g.toFixed(1)}g (est.)`);
54
+ console.log(` Cost $${report.usd.toFixed(2)}${report.priced ? '' : ' (includes default pricing for unknown models)'} · ~$${report.savedUsd.toFixed(2)} saved by cache (est.) · CO2 ~${report.co2g.toFixed(1)}g (est.)`);
48
55
  const models = Object.entries(report.byModel).sort((a, b) => (b[1].input + b[1].output) - (a[1].input + a[1].output)).slice(0, 5);
49
56
  if (models.length) {
50
57
  console.log(' By model');
@@ -52,6 +59,12 @@ function printReport(report) {
52
59
  console.log(` ${model}: in ${fmt(b.input)} · out ${fmt(b.output)} · cache r/w ${fmt(b.cacheRead)}/${fmt(b.cacheWrite)}`);
53
60
  }
54
61
  }
62
+ if (report.byTool.length) {
63
+ console.log(' Top tools');
64
+ for (const [tool, n] of report.byTool) {
65
+ console.log(` ${n}× ${tool}`);
66
+ }
67
+ }
55
68
  if (report.total) {
56
69
  console.log(' Activity');
57
70
  for (const [kind, n] of Object.entries(report.byKind)) {
@@ -61,7 +74,6 @@ function printReport(report) {
61
74
  for (const [detail, n] of report.byDetail) {
62
75
  console.log(` ${n}× ${detail}`);
63
76
  }
64
- console.log(` Total rows: ${report.total} · last write: ${report.lastWrite}`);
65
77
  }
66
78
  }
67
79
  async function runUsage() {
@@ -64,8 +64,11 @@ function walkJsonl(dir, out, cap) {
64
64
  export function importSessionTokens() {
65
65
  const byModel = {};
66
66
  const byDay = {};
67
+ const byDayModel = {};
68
+ const byTool = {};
67
69
  const totals = zeroBreakdown();
68
70
  let messages = 0;
71
+ let costMeasured = 0;
69
72
  const files = [];
70
73
  walkJsonl(sessionsDir(), files, 2000);
71
74
  for (const file of files) {
@@ -92,13 +95,44 @@ export function importSessionTokens() {
92
95
  if (day) {
93
96
  byDay[day] ??= zeroBreakdown();
94
97
  addInto(byDay[day], msg.usage);
98
+ const n = msg.usage;
99
+ const sum = ['input', 'output', 'cacheRead', 'cacheWrite'].reduce((a, k) => a + (typeof n[k] === 'number' && Number.isFinite(n[k]) ? Math.floor(n[k]) : 0), 0);
100
+ if (sum > 0) {
101
+ byDayModel[day] ??= {};
102
+ byDayModel[day][model] = (byDayModel[day][model] ?? 0) + sum;
103
+ }
104
+ }
105
+ if (typeof msg.usage.cost === 'number' && Number.isFinite(msg.usage.cost))
106
+ costMeasured += msg.usage.cost;
107
+ for (const part of msg.content ?? []) {
108
+ if (part?.type === 'toolCall' && typeof part.name === 'string' && part.name) {
109
+ const key = part.name === 'bash' ? `bash:${leadBinary(part.arguments?.command)}` : part.name;
110
+ byTool[key] = (byTool[key] ?? 0) + 1;
111
+ }
95
112
  }
96
113
  messages += 1;
97
114
  }
98
115
  catch { /* skip corrupt lines */ }
99
116
  }
100
117
  }
101
- return { messages, totals, byModel, byDay };
118
+ return { messages, totals, byModel, byDay, byDayModel, byTool, costMeasured };
119
+ }
120
+ // Lead binary of a shell string: first segment head past `cd` chains and
121
+ // VAR=x assignments (`cd /x && git status` → `git`). Falls back to `bash`.
122
+ const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
123
+ function leadBinary(command) {
124
+ if (typeof command !== 'string' || !command.trim())
125
+ return 'bash';
126
+ for (const seg of command.split(/&&|;|\n|\|(?!\|)/)) {
127
+ const toks = seg.trim().split(/\s+/).filter(Boolean);
128
+ let i = 0;
129
+ while (i < toks.length && (toks[i] === 'sudo' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])))
130
+ i++;
131
+ const head = (toks[i] ?? '').replace(/^['"]|['"]$/g, '').split('/').pop() ?? '';
132
+ if (head && !SKIP_HEADS.includes(head))
133
+ return head;
134
+ }
135
+ return 'bash';
102
136
  }
103
137
  const PRICE_TABLE = [
104
138
  { match: ':free', price: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } },
@@ -43,7 +43,7 @@ function usageSummary() {
43
43
  async function openGainDashboard(pi, ctx) {
44
44
  const file = path.join(os.tmpdir(), `tersio-gain-${Date.now()}.html`);
45
45
  try {
46
- const exported = await pi.exec?.('tersio', ['dashboard', '--export', file], { cwd: ctx?.cwd });
46
+ const exported = await pi.exec?.('tersio', ['gain', '--export', file], { cwd: ctx?.cwd });
47
47
  if (!exported || exported.code !== 0)
48
48
  throw new Error((exported?.stderr || 'export failed').trim());
49
49
  const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
@@ -51,7 +51,7 @@ async function openGainDashboard(pi, ctx) {
51
51
  ctx?.ui?.notify?.(`tersio gain: dashboard opened in your browser.`, 'info');
52
52
  }
53
53
  catch (e) {
54
- ctx?.ui?.notify?.(`tersio gain: could not open dashboard (${e.message}). Run 'tersio dashboard --open' in a shell.`, 'warning');
54
+ ctx?.ui?.notify?.(`tersio gain: could not open dashboard (${e.message}). Run 'tersio gain --open' in a shell.`, 'warning');
55
55
  }
56
56
  }
57
57
  export default function tersioCommandsExtension(pi) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krtclcdy/tersio",
3
- "version": "2.7.0",
3
+ "version": "2.8.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",
package/tersio.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // tersio.ts — Install Tersio (caveman/rtk/ponytail) add-ons on any OMP device.
3
3
  // Usage: node tersio.js [install|update|reinstall|doctor|uninstall|version|help] [options]
4
4
  // Requires: node/npm and omp CLI
5
- import { PACKAGE_BIN, PACKAGE_VERSION, applyUpdate, commandArg, dashboard, dashboardExport, dashboardOpen, dashboardPort, doctor, showHelp, showVersion, uninstall, unknownCommand, update, usage, } from './cli/common.js';
5
+ import { PACKAGE_BIN, PACKAGE_VERSION, applyUpdate, commandArg, gain, dashboardExport, dashboardOpen, dashboardPort, doctor, showHelp, showVersion, uninstall, unknownCommand, update, usage, } from './cli/common.js';
6
6
  import { closeRL } from './cli/interactive.js';
7
7
  import { runInstall } from './cli/install.js';
8
8
  import { runDoctor } from './cli/doctor.js';
@@ -19,7 +19,7 @@ Commands:
19
19
  reinstall Clean and reinstall the user-scope add-ons
20
20
  doctor Check the current installation
21
21
  usage Ledger-backed usage + savings report
22
- dashboard Serve the gain dashboard (localhost only)
22
+ gain Open the gain dashboard (localhost only)
23
23
  uninstall Remove the managed extensions
24
24
  version Print the package version
25
25
  help Show this help
@@ -35,9 +35,9 @@ Options:
35
35
  --yes, -y
36
36
  --dry-run
37
37
  --verbose
38
- --port <n> (dashboard: pin port, default ephemeral)
39
- --open (dashboard: open browser)
40
- --export <file> (dashboard: write HTML file instead of serving)
38
+ --port <n> (gain: pin port, default ephemeral)
39
+ --open (gain: open browser)
40
+ --export <file> (gain: write HTML file instead of serving)
41
41
  --version, -v
42
42
  --help, -h`);
43
43
  }
@@ -74,7 +74,7 @@ async function main() {
74
74
  closeRL();
75
75
  return;
76
76
  }
77
- if (dashboard) {
77
+ if (gain) {
78
78
  await runDashboard({ port: dashboardPort, open: dashboardOpen, exportFile: dashboardExport });
79
79
  closeRL();
80
80
  return;