claude-finops 0.11.0 → 0.12.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
@@ -318,7 +318,7 @@ agents listed under Multi-agent.
318
318
  server binds to `127.0.0.1` only, but treat the database and any export you
319
319
  generate as sensitive.
320
320
 
321
- **Hide prompts** in the top bar hides prompt text and session titles as dots,
321
+ **Hide prompts** (the eye icon in the top bar) hides prompt text and session titles as dots,
322
322
  for when you share your screen. Session titles fall back to their short id. The
323
323
  setting is remembered in that browser. It only changes what the page shows: the
324
324
  database and exports still contain the full text.
@@ -364,7 +364,8 @@ files on Windows.
364
364
 
365
365
  ## Actions (these change your machine, and only when you click)
366
366
 
367
- - **⟳ Sync** (top bar) re-reads `~/.claude` and swaps in fresh data without restarting.
367
+ - **Sync** (top bar, database icon) re-reads `~/.claude` and swaps in fresh data without restarting.
368
+ The circular-arrow **Reload** next to it only redraws the page from the data already loaded.
368
369
  - **Free models** adds a launcher such as `claude-qwen` in `~/.local/bin` that runs Claude Code
369
370
  on a free model (Ollama locally, or OpenRouter). It lists every step first, and asks before it
370
371
  installs Ollama or downloads a model. Your normal `claude` is unchanged. Edit the list in
@@ -378,7 +379,7 @@ files on Windows.
378
379
  - **Hand over / split** (Running sessions) writes a brief of the session to
379
380
  `~/.claude/finops-handoffs/` and opens fresh session(s) in new terminals, one per sub-task if
380
381
  you list them; it can close the old session (resumable).
381
- - **☰ Recent** (top bar, or press `R`) opens a side panel on any page: running sessions first,
382
+ - **Recent** (clock icon in the top bar, or press `R`) opens a side panel on any page: running sessions first,
382
383
  with Resume, Compact and Stop (Interrupt), then past sessions newest first. Click a session to
383
384
  list its prompts, and click a prompt for its full details. Search matches your prompt text.
384
385
  It covers all dates but keeps the agent and project filters. Close and Force kill stay on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-finops",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Local FinOps dashboard for Claude Code: what you used, what it cost, why it cost that much, and what to change. Reads your own transcripts, no API key, no data leaves the machine.",
5
5
  "bin": {
6
6
  "claude-finops": "bin/claude-finops.js"
package/web/app.js CHANGED
@@ -91,7 +91,7 @@ const vsYesterday = o => {
91
91
  };
92
92
  function kpi(label, value, detail, opts = {}) {
93
93
  const na = value == null;
94
- return `<div class="kpi${na ? ' na' : ''}">
94
+ return `<div class="kpi${na ? ' na' : ''}${opts.tone && !na ? ' tone-' + opts.tone : ''}"${opts.title ? ` title="${esc(opts.title)}"` : ''}>
95
95
  <div class="l">${esc(label)}${opts.badge ? ' ' + opts.badge : ''}</div>
96
96
  <div class="v${opts.small ? ' sm' : ''}">${na ? esc(naLabel()) : value}</div>
97
97
  ${detail ? `<div class="d">${detail}</div>` : ''}</div>`;
@@ -161,6 +161,31 @@ const NAV = [
161
161
  ]],
162
162
  ];
163
163
 
164
+ // Toolbar icons: 24px line icons drawn in currentColor, so they follow the theme.
165
+ const toolIcon = d => `<svg viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
166
+ const ICON = {
167
+ tour: toolIcon('<circle cx="12" cy="12" r="10"/><path d="m16.2 7.8-2.1 6.3-6.3 2.1 2.1-6.3z"/>'),
168
+ recent: toolIcon('<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/>'),
169
+ eye: toolIcon('<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>'),
170
+ eyeOff: toolIcon('<path d="M10.7 5.1A10.4 10.4 0 0 1 12 5c6.4 0 10 7 10 7a18 18 0 0 1-2.2 3.2"/><path d="M6.6 6.6A17.6 17.6 0 0 0 2 12s3.6 7 10 7a9.7 9.7 0 0 0 5.4-1.6"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/><path d="m2 2 20 20"/>'),
171
+ moon: toolIcon('<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>'),
172
+ sun: toolIcon('<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>'),
173
+ reload: toolIcon('<path d="M21 12a9 9 0 1 1-2.6-6.4L21 8"/><path d="M21 3v5h-5"/>'),
174
+ // Sync pulls transcripts into the warehouse: a database with an arrow going in.
175
+ sync: toolIcon('<ellipse cx="11" cy="5" rx="7" ry="3"/><path d="M4 5v6c0 1.66 3.13 3 7 3"/><path d="M4 11v6c0 1.66 3.13 3 7 3"/><path d="M18 5v5"/><path d="M19 13v8"/><path d="m16 18 3 3 3-3"/>'),
176
+ };
177
+
178
+ // The theme button shows the theme it switches to: a moon in light mode, a sun in dark.
179
+ function themeLabel() {
180
+ const b = $('#theme'); if (!b) return;
181
+ const cur = document.documentElement.getAttribute('data-theme')
182
+ || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
183
+ const to = cur === 'dark' ? 'light' : 'dark';
184
+ b.innerHTML = to === 'dark' ? ICON.moon : ICON.sun;
185
+ b.title = `Switch to ${to} theme`;
186
+ b.setAttribute('aria-label', b.title);
187
+ }
188
+
164
189
  function shell() {
165
190
  document.body.innerHTML = `<div class="app">
166
191
  <aside class="sidebar">
@@ -175,18 +200,21 @@ function shell() {
175
200
  <div class="main">
176
201
  <header class="topbar">
177
202
  <div class="r1">
178
- <h1 id="ttl">Executive overview</h1>
179
- <div class="crumbs" id="crumbs"></div>
203
+ <div class="ttl-block"><h1 id="ttl">Executive overview</h1>
204
+ <div class="crumbs" id="crumbs"></div></div>
205
+ <div class="scope" id="scope" aria-label="Totals for the current filters"></div>
180
206
  <span class="spacer"></span>
181
207
  <div class="search"><span class="mag">⌕</span>
182
- <input id="gsearch" placeholder="Search prompts, sessions, models, dates…"></div>
208
+ <input id="gsearch" placeholder="Search prompts, sessions, models…"></div>
183
209
  <button class="iconbtn upd" id="upd" hidden></button>
184
- <button class="iconbtn" id="tour-btn" title="Walk through this dashboard">? Tour</button>
185
- <button class="iconbtn" id="recent-btn" title="Past sessions and prompts, from any page (R)">☰ Recent</button>
186
- <button class="iconbtn" id="mask"></button>
187
- <button class="iconbtn" id="theme" title="Toggle theme">◐</button>
188
- <button class="iconbtn" id="refresh" title="Reload data">↻</button>
189
- <button class="act" id="sync" title="Re-read every agent's local data so the dashboard is current">⟳ Sync</button>
210
+ <div class="tools">
211
+ <button class="iconbtn" id="tour-btn" title="Walk through this dashboard">${ICON.tour}Tour</button>
212
+ <button class="iconbtn sq" id="recent-btn" title="Recent sessions and prompts, from any page (R)" aria-label="Recent">${ICON.recent}</button>
213
+ <button class="iconbtn sq" id="mask"></button>
214
+ <button class="iconbtn sq" id="theme"></button>
215
+ <button class="iconbtn sq" id="refresh" title="Reload this page's numbers from the warehouse. To pick up new sessions, use Sync" aria-label="Reload view">${ICON.reload}</button>
216
+ </div>
217
+ <button class="act sync" id="sync" title="Re-read every agent's transcripts from disk into the warehouse">${ICON.sync}Sync</button>
190
218
  </div>
191
219
  <div class="filters" id="filters"></div>
192
220
  </header>
@@ -202,8 +230,10 @@ function shell() {
202
230
  : (matchMedia('(prefers-color-scheme: dark)').matches ? 'light' : 'dark');
203
231
  document.documentElement.setAttribute('data-theme', next);
204
232
  localStorage.setItem('finops-theme', next);
233
+ themeLabel();
205
234
  render();
206
235
  };
236
+ themeLabel();
207
237
  $('#refresh').onclick = () => { bust(); render(); };
208
238
  maskLabel();
209
239
  $('#mask').onclick = () => {
@@ -222,11 +252,73 @@ function shell() {
222
252
  else if (S.view === 'search') go('overview'); }, 260); };
223
253
  }
224
254
 
255
+ // The page loader: a skeleton in the shape of a dashboard (KPI row, two charts, a table)
256
+ // so the layout doesn't jump when data lands, plus a status line that says what is
257
+ // happening and, on a slow query, how long it has taken.
258
+ const LOADER_STEPS = ['Reading the warehouse', 'Pricing every request', 'Adding up tokens and cache',
259
+ 'Grouping by session and project', 'Drawing the charts'];
260
+ const LOADER_TIPS = ['Press R anywhere to open Recent sessions.',
261
+ 'Click any row in a table to open its details.',
262
+ 'The eye button hides prompt text before you share your screen.',
263
+ '"Actual" numbers come from transcripts; "Estimated" ones use list prices.',
264
+ 'Tour walks you through whichever dashboard you are on.'];
265
+ function pageLoader() {
266
+ const bone = (w, h = 10) => `<i class="bone" style="width:${w};height:${h}px"></i>`;
267
+ const bars = [38, 62, 45, 80, 56, 90, 70, 48, 66, 84, 58, 74]
268
+ .map((v, i) => `<i style="height:${v}%;animation-delay:${i * 70}ms"></i>`).join('');
269
+ return `<div class="pl" role="status" aria-live="polite">
270
+ <div class="pl-status"><span class="pl-mark"><i></i><i></i><i></i></span>
271
+ <span class="pl-msg">${LOADER_STEPS[0]}…</span><span class="pl-time"></span></div>
272
+ <div class="grid g4">${Array.from({length: 4}, () =>
273
+ `<div class="kpi skel">${bone('55%', 8)}${bone('45%', 22)}${bone('70%', 8)}</div>`).join('')}</div>
274
+ <div class="grid g2">
275
+ <div class="card skel"><div class="body">${bone('35%', 10)}<div class="pl-bars">${bars}</div></div></div>
276
+ <div class="card skel"><div class="body">${bone('30%', 10)}<div class="pl-bars alt">${bars}</div></div></div>
277
+ </div>
278
+ <div class="card skel"><div class="body">${bone('25%', 10)}
279
+ ${Array.from({length: 4}, (_, i) => `<div class="pl-row">${bone(`${60 - i * 8}%`)}${bone('12%')}${bone('9%')}</div>`).join('')}
280
+ </div></div>
281
+ <div class="pl-tip">Tip: ${esc(LOADER_TIPS[Math.floor(Math.random() * LOADER_TIPS.length)])}</div>
282
+ </div>`;
283
+ }
284
+ function loaderTicker(page) {
285
+ const t0 = Date.now();
286
+ let i = 0;
287
+ const id = setInterval(() => {
288
+ const msg = page.querySelector('.pl-msg');
289
+ if (!msg || !page.isConnected) return clearInterval(id); // the view has rendered
290
+ i = Math.min(i + 1, LOADER_STEPS.length - 1);
291
+ msg.textContent = LOADER_STEPS[i] + '…';
292
+ const s = Math.round((Date.now() - t0) / 1000);
293
+ if (s >= 3) page.querySelector('.pl-time').textContent = `${s}s · large ranges take a little longer`;
294
+ }, 1100);
295
+ }
296
+
297
+ // Headline totals for whatever the filters select, on every page, so a number
298
+ // on the page can always be read against the whole it came from.
299
+ async function scopeStrip() {
300
+ const el = $('#scope'); if (!el) return;
301
+ const key = qs();
302
+ el.dataset.key = key;
303
+ let o;
304
+ try { o = await api('overview'); } catch { el.innerHTML = ''; return; }
305
+ if (el.dataset.key !== key) return; // the filters moved on while this loaded
306
+ const cell = (l, v, t) => `<div class="sc"${t ? ` title="${esc(t)}"` : ''}><span>${l}</span><b>${v}</b></div>`;
307
+ el.innerHTML =
308
+ cell('Est. spend', fmtUSD(o.est_cost_usd), 'Estimated at list prices for the current filters') +
309
+ cell('Sessions', fmtInt(o.sessions)) +
310
+ cell('Prompts', fmtInt(o.prompts)) +
311
+ cell('Requests', fmtNum(o.requests), fmtInt(o.requests) + ' model requests') +
312
+ cell('Active days', fmtInt(o.active_days));
313
+ }
314
+
225
315
  function maskLabel() {
226
316
  const b = $('#mask');
227
- b.textContent = MASKED ? '◌ Show prompts' : '◉ Hide prompts';
317
+ // The icon shows the current state: an open eye while prompts are visible.
318
+ b.innerHTML = MASKED ? ICON.eyeOff : ICON.eye;
228
319
  b.title = MASKED ? 'Prompt text is hidden. Click to show it'
229
320
  : 'Hide prompt text and session titles, e.g. before sharing your screen';
321
+ b.setAttribute('aria-label', MASKED ? 'Show prompts' : 'Hide prompts');
230
322
  b.setAttribute('aria-pressed', MASKED);
231
323
  b.classList.toggle('on', MASKED);
232
324
  }
@@ -275,14 +367,13 @@ function filterBar() {
275
367
  const nSel = a => a.length ? `<span class="n">${a.length}</span>` : '';
276
368
  const ag = o.agents || [];
277
369
  $('#filters').innerHTML = `
278
- ${ag.length > 1 ? `<span class="note" style="margin-right:2px">Agent</span>
279
- ${ag.map(a => `<button class="chip ${f.agents.includes(a.id) ? 'on' : ''}" data-agent="${esc(a.id)}"
280
- title="${esc(a.note)}${a.requests ? '' : ' (no usage recorded)'}">${esc(a.name)}${a.requests ? '' : ' ·'}</button>`).join('')}
281
- <button class="chip ${f.agents.length === ag.length ? 'on' : ''}" data-agent="*" title="Every agent together">All</button>
282
- <span class="divider"></span>` : ''}
283
- ${RANGES.map(([k, l]) => `<button class="chip ${S.range === k ? 'on' : ''}"
284
- data-range="${k}">${l}</button>`).join('')}
285
- <span class="divider"></span>
370
+ ${ag.length > 1 ? `<div class="seg" role="group" aria-label="Agent"><span class="seg-l">Agent</span>
371
+ ${ag.map(a => `<button class="${f.agents.includes(a.id) ? 'on' : ''}" data-agent="${esc(a.id)}"
372
+ title="${esc(a.note)}${a.requests ? '' : ' (no usage recorded)'}. Cmd/Shift-click to combine">${esc(a.name)}${a.requests ? '' : ' ·'}</button>`).join('')}
373
+ <button class="${f.agents.length === ag.length ? 'on' : ''}" data-agent="*" title="Every agent together">All</button></div>` : ''}
374
+ <div class="seg" role="group" aria-label="Date range"><span class="seg-l">Range</span>
375
+ ${RANGES.map(([k, l]) => `<button class="${S.range === k ? 'on' : ''}"
376
+ data-range="${k}">${l}</button>`).join('')}</div>
286
377
  <div class="chipsel"><button class="chip ${f.models.length ? 'on' : ''}" data-pop="models">
287
378
  Model ${nSel(f.models)} ▾</button></div>
288
379
  <div class="chipsel"><button class="chip ${f.projects.length ? 'on' : ''}" data-pop="projects">
@@ -291,13 +382,14 @@ function filterBar() {
291
382
  Category ${nSel(f.categories)} ▾</button></div>
292
383
  <div class="chipsel"><button class="chip ${(f.min_cost || f.min_tokens) ? 'on' : ''}"
293
384
  data-pop="thresholds">Thresholds ▾</button></div>
294
- <button class="chip ${f.include_sandbox ? '' : 'on'}" id="sbx">
295
- ${f.include_sandbox ? 'Including' : 'Excluding'} sandbox agents</button>
385
+ <button class="chip toggle ${f.include_sandbox ? '' : 'on'}" id="sbx" role="switch" aria-checked="${!f.include_sandbox}"
386
+ title="Sandbox agents are throwaway projects. ${f.include_sandbox ? 'They are included; click to leave them out' : 'They are left out; click to include them'}">
387
+ <i></i>Hide sandbox</button>
296
388
  ${(f.models.length || f.projects.length || f.categories.length || f.min_cost || f.min_tokens)
297
- ? '<button class="chip" id="clr">Clear ✕</button>' : ''}
389
+ ? '<button class="chip clr" id="clr">Clear filters ✕</button>' : ''}
298
390
  <span class="spacer"></span>
299
- <span class="note">${o.date_range.first} → ${o.date_range.last} ·
300
- ${esc(o.meta.transcript_files)} transcripts · built ${esc((o.meta.built_at||'').slice(0,16).replace('T',' '))}</span>`;
391
+ <span class="coverage" title="Data in the warehouse · last rebuilt ${esc((o.meta.built_at||'').slice(0,16).replace('T',' '))}">
392
+ ${esc(o.meta.transcript_files)} transcripts · since ${esc(o.date_range.first)}</span>`;
301
393
 
302
394
  $('#filters').querySelectorAll('[data-range]').forEach(b =>
303
395
  b.onclick = () => { if (b.dataset.range === 'custom') return openCustom(b);
@@ -1450,6 +1542,12 @@ VIEWS.hygiene = async (page) => {
1450
1542
  const T = hy.thresholds.map(String), kT = String(hy.rank_threshold);
1451
1543
  const K = t => `${Math.round(+t / 1000)}K`;
1452
1544
  const ab = t => hy.above[t];
1545
+ // Colour grade by how much of the spend went to re-sending a large prefix. The bands are
1546
+ // a reading aid, not a verdict: long agentic work legitimately runs at high context.
1547
+ const grade = (v, bands) => v == null ? null
1548
+ : v < bands[0] ? 'good' : v < bands[1] ? 'warning' : v < bands[2] ? 'serious' : 'critical';
1549
+ const SHARE = [25, 50, 75], FIT = [5, 15, 30];
1550
+ const bandTip = b => `Green under ${b[0]}%, amber ${b[0]}–${b[1]}%, orange ${b[1]}–${b[2]}%, red ${b[2]}% and up`;
1453
1551
  page.innerHTML = `
1454
1552
  <div class="note" style="margin:0 0 10px">Everything on this page is observed from your transcripts.
1455
1553
  It shows where spend sat while a large prefix was being re-sent on every turn. It does
@@ -1459,16 +1557,18 @@ VIEWS.hygiene = async (page) => {
1459
1557
  recorded and also counts.</div>
1460
1558
  <div class="grid g4">
1461
1559
  ${T.map(t => kpi(`Spend in requests ≥ ${K(t)} context`, fmtPct(ab(t).share_pct),
1462
- `${fmtUSD(ab(t).cost_usd)} · ${fmtInt(ab(t).requests)} requests`, {badge: BADGE.actual})).join('')}
1560
+ `${fmtUSD(ab(t).cost_usd)} · ${fmtInt(ab(t).requests)} requests`,
1561
+ {badge: BADGE.actual, tone: grade(ab(t).share_pct, SHARE), title: bandTip(SHARE)})).join('')}
1463
1562
  ${T.map(t => kpi(`Spend after a session first crossed ${K(t)}`, fmtPct(ab(t).share_after_first_cross_pct),
1464
- `${fmtInt(ab(t).sessions)} of ${fmtInt(hy.sessions)} sessions crossed it`, {badge: BADGE.actual})).join('')}
1563
+ `${fmtInt(ab(t).sessions)} of ${fmtInt(hy.sessions)} sessions crossed it`,
1564
+ {badge: BADGE.actual, tone: grade(ab(t).share_after_first_cross_pct, SHARE), title: bandTip(SHARE)})).join('')}
1465
1565
  </div>
1466
1566
  <div class="grid g3">
1467
1567
  ${kpi('Spend near or over the context window', fmtPct(fit.near_or_over_cost_pct),
1468
1568
  `≥ ${fit.threshold_pct}% of the window in use · ${fmtInt(fit.near_requests + fit.over_requests)} requests`,
1469
- {badge: BADGE.actual})}
1470
- ${kpi('Sessions in range', fmtInt(hy.sessions), `${fmtInt(hy.requests)} main-thread requests`, {badge: BADGE.actual})}
1471
- ${kpi('Spend in range', fmtUSD(hy.cost_usd), 'subagent turns excluded (own prefix)', {badge: BADGE.estimated})}
1569
+ {badge: BADGE.actual, tone: grade(fit.near_or_over_cost_pct, FIT), title: bandTip(FIT)})}
1570
+ ${kpi('Sessions in range', fmtInt(hy.sessions), `${fmtInt(hy.requests)} main-thread requests`, {badge: BADGE.actual, tone: 'info'})}
1571
+ ${kpi('Spend in range', fmtUSD(hy.cost_usd), 'subagent turns excluded (own prefix)', {badge: BADGE.estimated, tone: 'info'})}
1472
1572
  </div>
1473
1573
  ${card('Context per request, most expensive session after ' + K(kT), '<div class="chart" id="hy-traj"></div>',
1474
1574
  {badge: BADGE.actual, hint: 'prompt-side tokens on each request, in order · click a row below to change session'})}
@@ -2608,7 +2708,9 @@ async function render() {
2608
2708
  if (!f.include_sandbox) bits.push('excl. sandbox');
2609
2709
  $('#crumbs').innerHTML = bits.join(' <span style="opacity:.4">·</span> ');
2610
2710
  filterBar();
2611
- page.innerHTML = '<div class="loading">Computing…</div>';
2711
+ scopeStrip();
2712
+ page.innerHTML = pageLoader();
2713
+ loaderTicker(page);
2612
2714
  updateNavBadges();
2613
2715
  try {
2614
2716
  await (VIEWS[S.view] || VIEWS.overview)(page);
@@ -2892,12 +2994,13 @@ async function syncLabel() {
2892
2994
  const b = $('#sync'); if (!b) return;
2893
2995
  const d = await fetch('/api/sync').then(r => r.json()).catch(() => ({}));
2894
2996
  if (d.job && d.job.state === 'running') return runSync();
2895
- b.title = `Last synced ${ago(d.built_at)}. Re-read every agent's local data so the dashboard is current`;
2896
- b.textContent = `⟳ Sync · ${ago(d.built_at)}`;
2997
+ b.title = `Last synced ${ago(d.built_at)}. Re-read every agent's transcripts from disk into the warehouse`;
2998
+ b.classList.remove('busy');
2999
+ b.innerHTML = `${ICON.sync}Sync <span class="ago">· ${ago(d.built_at)}</span>`;
2897
3000
  }
2898
3001
  async function runSync() {
2899
3002
  const b = $('#sync');
2900
- b.disabled = true; b.textContent = '⟳ Syncing…';
3003
+ b.disabled = true; b.classList.add('busy'); b.innerHTML = `${ICON.sync}Syncing…`;
2901
3004
  try {
2902
3005
  const {job} = await doAction('sync');
2903
3006
  const j = await followJob(job);
@@ -3305,7 +3408,15 @@ const TOURS = {
3305
3408
  {el: 'card:MCP servers', t: 'MCP servers', see: 'Each configured server, whether it was ever called, and its context cost.', get: 'Tool definitions loaded on every request for nothing.', act: 'Remove servers that are configured but never called.'},
3306
3409
  {el: 'card:Connectors', t: 'Connectors', see: 'claude.ai connectors and their share of context.', get: 'The same check for connectors as for MCP servers.', act: 'Disconnect what you don\'t use from this machine.'}],
3307
3410
  hygiene: [
3308
- {el: 'kpis', t: 'Where spend sat', see: 'The share of spend in requests above each context size, and the share that came after a session first crossed it.', get: 'How much of the bill was re-sending a large prefix.', act: 'A high "after crossing" share means the expensive part of a session was its tail.'},
3411
+ {el: 'kpis', t: 'Where spend sat', see: 'Headline shares of your spend, colour-graded: green is low, amber and orange are rising, red means most of the bill sat there. Hover a card for its bands.', get: 'How much of the bill was re-sending a large prefix.', act: 'Walk through each card with Next; the colour is a reading aid, not a verdict.'},
3412
+ {el: 'kpi:Spend in requests ≥ 100K', t: 'Spend in requests ≥ 100K context', see: 'The share of spend on requests whose prompt side (context re-sent to the model) was 100K tokens or more.', get: 'How much you paid while carrying a big conversation. Every turn re-sends it, so large context costs on every request.', act: 'Red here with a red "after crossing" card means sessions keep going long after they got big.'},
3413
+ {el: 'kpi:Spend in requests ≥ 150K', t: 'Spend in requests ≥ 150K context', see: 'The same share, but for requests at 150K tokens or more.', get: 'The heaviest tier: close to where auto-compaction kicks in.', act: 'Compare with the 100K card: a small gap means sessions that pass 100K usually keep growing to 150K.'},
3414
+ {el: 'kpi:Spend after a session first crossed 100K', t: 'Spend after first crossing 100K', see: 'The share of spend that came after a session first reached 100K, even if a later /compact brought it back down. The detail line counts how many sessions ever crossed it.', get: 'How much of your bill is the long tail of big sessions.', act: 'If this is much higher than the "requests ≥ 100K" card, compaction is working but sessions still run long afterwards.'},
3415
+ {el: 'kpi:Spend after a session first crossed 150K', t: 'Spend after first crossing 150K', see: 'The same, measured from the first time a session reached 150K.', get: 'Spend in sessions that went all the way to the heavy end.', act: 'Few sessions but a high share? Open them in the ranked list below.'},
3416
+ {el: 'kpi:Spend near or over the context window', t: 'Near or over the context window', see: 'The share of spend on requests using 90% or more of the model\'s context window, or going over it.', get: 'Turns that risk truncation or a forced compaction mid-task.', act: 'Anything above amber is worth a look: a /compact or fresh session before you hit the wall keeps the answer quality up.'},
3417
+ {el: 'kpi:Sessions in range', t: 'Sessions in range', see: 'How many sessions and main-thread requests the page is based on (blue: context, not graded).', get: 'The sample size behind the percentages.', act: 'Widen the date range if this is small; a handful of sessions makes the shares jumpy.'},
3418
+ {el: 'kpi:Spend in range', t: 'Spend in range', see: 'Total estimated main-thread spend the shares are taken from. Subagent turns are left out because they run on their own prefix.', get: 'The dollar base: multiply any share above by this to get dollars.', act: 'Marked Estimated because it uses list prices, not your invoice.'},
3419
+ {el: 'card:Context per request', t: 'Context per request', see: 'Prompt-side tokens on every request of one session, in order.', get: 'Where a session grew, and where compaction dropped it back.', act: 'Click a row in the ranked list below to switch session.'},
3309
3420
  {el: 'card:Sessions ranked', t: 'Sessions', see: 'Each session\'s context trajectory and what it spent after crossing the threshold.', get: 'The sessions where a fresh start would have mattered most.', act: 'Click a row for its trajectory; alt-click to open the session.'}],
3310
3421
  waste: [
3311
3422
  {el: 'kpis', t: 'Waste headlines', see: 'Estimated excess, exposed spend, and how many prompts, sessions and rules are involved.', get: 'An honest estimate of avoidable spend.', act: '<b>Exposed</b> is what flagged work cost in total; <b>excess</b> is how much more than a fair baseline.'},
@@ -3363,6 +3474,13 @@ function tourTarget(el) {
3363
3474
  const page = $('#page');
3364
3475
  if (!el) return null;
3365
3476
  if (el === 'kpis') return page?.querySelector('.kpi')?.closest('.grid') || null;
3477
+ // 'kpi:Label start' — one KPI card, matched by the start of its label (badge text ignored).
3478
+ if (el.startsWith('kpi:')) {
3479
+ const want = el.slice(4).trim().toLowerCase();
3480
+ for (const k of page?.querySelectorAll('.kpi') || [])
3481
+ if ((k.querySelector('.l')?.firstChild?.textContent || '').trim().toLowerCase().startsWith(want)) return k;
3482
+ return null;
3483
+ }
3366
3484
  const m = /^card(\d+)$/.exec(el);
3367
3485
  if (m) return page?.querySelectorAll('.card')[+m[1]] || null;
3368
3486
  // 'card:Some title' — match a card by the start of its heading, so a step keeps
package/web/styles.css CHANGED
@@ -101,14 +101,28 @@ button, input, select { font: inherit; color: inherit; }
101
101
 
102
102
  /* ---------- header ---------- */
103
103
  .topbar { position:sticky; top:0; z-index:40; background: var(--surface);
104
- border-bottom:1px solid var(--border); padding:10px 20px; }
105
- .topbar .r1 { display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
106
- .topbar h1 { font-size:16px; margin:0; font-weight:640; letter-spacing:-0.01em; }
107
- .crumbs { color:var(--muted); font-size:11.5px; display:flex; gap:6px; align-items:center; }
108
- .crumbs b { color: var(--text-2); font-weight:560; }
104
+ border-bottom:1px solid var(--border); padding:10px 20px 9px; }
105
+ .topbar .r1 { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
106
+ .ttl-block { display:flex; flex-direction:column; gap:1px; min-width:0; }
107
+ .topbar h1 { font-size:16px; margin:0; font-weight:640; letter-spacing:-0.01em; line-height:1.2; }
108
+ .crumbs { color:var(--muted); font-size:11px; display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
109
+ .crumbs b { color: var(--text-2); font-weight:560; font-variant-numeric: tabular-nums; }
109
110
  .spacer { flex:1; }
111
+
112
+ /* Headline totals for the current filters. */
113
+ .scope { display:flex; align-items:stretch; margin-left:6px; padding-left:14px;
114
+ border-left:1px solid var(--border); min-height:30px; }
115
+ .scope:empty { display:none; }
116
+ .scope .sc { display:flex; flex-direction:column; justify-content:center; padding:0 12px; gap:1px;
117
+ border-right:1px solid var(--border); }
118
+ .scope .sc:first-child { padding-left:0; }
119
+ .scope .sc:last-child { border-right:0; }
120
+ .scope .sc span { font-size:9px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted);
121
+ font-weight:620; white-space:nowrap; }
122
+ .scope .sc b { font-size:13.5px; font-weight:650; font-variant-numeric: tabular-nums; white-space:nowrap; }
123
+
110
124
  .search { position:relative; }
111
- .search input { width:270px; padding:6px 10px 6px 28px; border-radius:8px; font-size:12.5px;
125
+ .search input { width:230px; padding:6px 10px 6px 28px; border-radius:8px; font-size:12.5px;
112
126
  border:1px solid var(--border); background:var(--surface-2); }
113
127
  .search input:focus { outline:2px solid var(--s1); outline-offset:-1px; border-color:transparent; }
114
128
  .search .mag { position:absolute; left:9px; top:50%; transform:translateY(-50%);
@@ -116,9 +130,48 @@ button, input, select { font: inherit; color: inherit; }
116
130
  .iconbtn { border:1px solid var(--border); background:var(--surface-2); border-radius:8px;
117
131
  padding:6px 10px; cursor:pointer; font-size:12px; color:var(--text-2); }
118
132
  .iconbtn:hover { background: var(--surface-3); color:var(--text); }
133
+ /* One joined toolbar instead of five loose buttons. */
134
+ .tools { display:inline-flex; border:1px solid var(--border); border-radius:8px; overflow:hidden;
135
+ background:var(--surface-2); }
136
+ .tools .iconbtn { border:0; border-radius:0; border-right:1px solid var(--border); background:transparent;
137
+ height:30px; line-height:1; }
138
+ .tools .iconbtn:last-child { border-right:0; }
139
+ .tools .iconbtn.sq { width:32px; padding:0; font-size:13px; }
140
+ .tools .iconbtn { display:inline-flex; align-items:center; justify-content:center; gap:6px; }
141
+ .tools svg { width:15px; height:15px; fill:none; stroke:currentColor; stroke-width:1.9;
142
+ stroke-linecap:round; stroke-linejoin:round; flex:none; }
143
+ .tools .iconbtn.on { background: var(--surface-3); color:var(--s1); box-shadow: inset 0 -2px 0 var(--s1); }
144
+ .act.sync { height:30px; white-space:nowrap; display:inline-flex; align-items:center; gap:6px; }
145
+ .act.sync svg { width:15px; height:15px; fill:none; stroke:currentColor; stroke-width:1.9;
146
+ stroke-linecap:round; stroke-linejoin:round; flex:none; }
147
+ .act.sync .ago { color:var(--muted); font-weight:450; }
148
+ /* While syncing, the arrow into the database pulses so the button reads as working. */
149
+ .act.sync.busy svg path:nth-last-child(-n+2) { animation: sync-in 1s ease-in-out infinite; }
150
+ @keyframes sync-in { 0%,100% { opacity:1; transform:translateY(0); } 50% { opacity:.35; transform:translateY(-2px); } }
151
+ @media (prefers-reduced-motion: reduce) { .act.sync.busy svg path { animation:none; } }
119
152
 
120
153
  /* ---------- filter bar ---------- */
121
- .filters { display:flex; gap:7px; align-items:center; flex-wrap:wrap; margin-top:9px; }
154
+ .filters { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:9px; }
155
+ /* Segmented control: a labelled group of mutually exclusive choices. */
156
+ .seg { display:inline-flex; align-items:center; border:1px solid var(--border); border-radius:8px;
157
+ background:var(--surface-2); padding:2px; gap:1px; }
158
+ .seg .seg-l { font-size:9.5px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted);
159
+ font-weight:620; padding:0 7px 0 6px; }
160
+ .seg button { border:0; background:transparent; border-radius:6px; padding:3px 9px; font-size:11.5px;
161
+ color:var(--text-2); cursor:pointer; white-space:nowrap; }
162
+ .seg button:hover { background:var(--surface-3); color:var(--text); }
163
+ .seg button.on { background:var(--s1); color:#fff; font-weight:550; }
164
+ .chip.toggle i { width:22px; height:12px; border-radius:99px; background:var(--border-strong);
165
+ position:relative; display:inline-block; transition:background .15s; }
166
+ .chip.toggle i::after { content:''; position:absolute; top:2px; left:2px; width:8px; height:8px;
167
+ border-radius:50%; background:#fff; transition:left .15s; }
168
+ .chip.toggle.on { background:var(--surface-2); border-color:var(--border); color:var(--text); font-weight:500; }
169
+ .chip.toggle.on i { background:var(--s1); }
170
+ .chip.toggle.on i::after { left:12px; }
171
+ .chip.clr { color:var(--critical-ink); border-color:color-mix(in srgb, var(--critical) 35%, var(--border)); }
172
+ .coverage { font-size:10.5px; color:var(--muted); white-space:nowrap; font-variant-numeric: tabular-nums; }
173
+ @media (max-width: 1450px) { .scope .sc:nth-child(n+4) { display:none; } }
174
+ @media (max-width: 1150px) { .scope, .coverage { display:none; } }
122
175
  .chipsel { position:relative; }
123
176
  .chip { border:1px solid var(--border); background:var(--surface-2); border-radius:99px;
124
177
  padding:4px 11px; font-size:11.5px; cursor:pointer; color:var(--text-2);
@@ -194,6 +247,17 @@ button, input, select { font: inherit; color: inherit; }
194
247
  .kpi .d { font-size:11px; color:var(--text-2); }
195
248
  .kpi .d .up { color:var(--critical-ink); font-weight:600; }
196
249
  .kpi .d .down { color:var(--good-ink); font-weight:600; }
250
+ /* Colour-graded KPI: a tinted surface, a left stripe and a tinted value. */
251
+ .kpi[class*="tone-"] { border-left-width:4px; }
252
+ .kpi.tone-good { border-left-color:var(--good); background:color-mix(in srgb, var(--good) 6%, var(--surface)); }
253
+ .kpi.tone-warning { border-left-color:var(--warning); background:color-mix(in srgb, var(--warning) 8%, var(--surface)); }
254
+ .kpi.tone-serious { border-left-color:var(--serious); background:color-mix(in srgb, var(--serious) 8%, var(--surface)); }
255
+ .kpi.tone-critical { border-left-color:var(--critical); background:color-mix(in srgb, var(--critical) 7%, var(--surface)); }
256
+ .kpi.tone-info { border-left-color:var(--s1); }
257
+ .kpi.tone-good .v { color:var(--good-ink); }
258
+ .kpi.tone-warning .v { color:var(--warning-ink); }
259
+ .kpi.tone-serious .v { color:var(--serious-ink); }
260
+ .kpi.tone-critical .v { color:var(--critical-ink); }
197
261
  .kpi.na .v { font-size:12px; font-weight:500; color:var(--muted); font-style:italic; }
198
262
 
199
263
  /* ---------- table ---------- */
@@ -284,7 +348,47 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
284
348
  .pill { display:inline-block; font-size:10.5px; padding:1px 7px; border-radius:99px;
285
349
  background:var(--surface-3); color:var(--text-2); border:1px solid var(--border); }
286
350
  .empty { padding:26px 10px; text-align:center; color:var(--muted); font-size:12px; }
287
- .loading { padding:40px; text-align:center; color:var(--muted); font-size:12px; }
351
+ .loading { padding:40px; text-align:center; color:var(--muted); font-size:12px;
352
+ display:flex; align-items:center; justify-content:center; gap:9px; }
353
+ .loading::before { content:''; width:14px; height:14px; border-radius:50%; flex:none;
354
+ border:2px solid color-mix(in srgb, var(--s1) 22%, transparent); border-top-color:var(--s1);
355
+ animation: spin .8s linear infinite; }
356
+ @keyframes spin { to { transform: rotate(360deg); } }
357
+
358
+ /* ---------- page loader: dashboard-shaped skeleton + live status ---------- */
359
+ .pl { display:flex; flex-direction:column; gap:12px; }
360
+ .pl-status { display:flex; align-items:center; gap:10px; align-self:flex-start; padding:6px 12px 6px 8px;
361
+ border:1px solid color-mix(in srgb, var(--s1) 30%, var(--border)); border-radius:99px;
362
+ background:color-mix(in srgb, var(--s1) 7%, var(--surface)); font-size:12px; color:var(--text-2); }
363
+ .pl-mark { display:inline-flex; align-items:flex-end; gap:2px; height:14px; width:16px; }
364
+ .pl-mark i { width:4px; border-radius:1.5px; background:var(--s1); animation: pl-eq 0.9s ease-in-out infinite; }
365
+ .pl-mark i:nth-child(2) { background:var(--s2); animation-delay:.15s; }
366
+ .pl-mark i:nth-child(3) { background:var(--s3); animation-delay:.3s; }
367
+ @keyframes pl-eq { 0%,100% { height:30%; } 50% { height:100%; } }
368
+ .pl-msg { font-weight:560; color:var(--text); }
369
+ .pl-time { color:var(--muted); font-size:11px; font-variant-numeric: tabular-nums; }
370
+ .pl-time:empty { display:none; }
371
+ .pl-tip { align-self:center; font-size:11.5px; color:var(--muted); padding:4px 0 8px; }
372
+ .skel { box-shadow:none; gap:8px; }
373
+ .kpi.skel { gap:9px; }
374
+ .bone { display:block; border-radius:5px; background:var(--surface-3); position:relative; overflow:hidden; }
375
+ .bone::after, .pl-bars i::after { content:''; position:absolute; inset:0; transform:translateX(-100%);
376
+ background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--surface) 70%, transparent), transparent);
377
+ animation: shimmer 1.4s ease-in-out infinite; }
378
+ @keyframes shimmer { to { transform:translateX(100%); } }
379
+ .pl-bars { display:flex; align-items:flex-end; gap:6px; height:130px; margin-top:14px;
380
+ border-bottom:1px solid var(--grid); }
381
+ .pl-bars i { flex:1; border-radius:4px 4px 0 0; position:relative; overflow:hidden;
382
+ background:color-mix(in srgb, var(--s1) 16%, var(--surface-3));
383
+ animation: pl-grow 1.6s ease-in-out infinite alternate; transform-origin:bottom; }
384
+ .pl-bars.alt i { background:color-mix(in srgb, var(--s3) 16%, var(--surface-3)); }
385
+ @keyframes pl-grow { from { transform:scaleY(.55); } to { transform:scaleY(1); } }
386
+ .pl-row { display:flex; justify-content:space-between; gap:16px; padding:9px 0; border-bottom:1px solid var(--grid); }
387
+ .pl-row:last-child { border-bottom:0; }
388
+ @media (prefers-reduced-motion: reduce) {
389
+ .bone::after, .pl-bars i::after, .pl-bars i, .pl-mark i, .loading::before { animation:none; }
390
+ .pl-mark i { height:70%; }
391
+ }
288
392
  .tabs { display:flex; gap:3px; border-bottom:1px solid var(--border); margin-bottom:2px;
289
393
  overflow-x:auto; }
290
394
  .tabs button { background:none; border:none; padding:7px 12px; font-size:12px; cursor:pointer;