claude-finops 0.10.1 → 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,6 +318,11 @@ 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** (the eye icon in the top bar) hides prompt text and session titles as dots,
322
+ for when you share your screen. Session titles fall back to their short id. The
323
+ setting is remembered in that browser. It only changes what the page shows: the
324
+ database and exports still contain the full text.
325
+
321
326
  The app makes exactly one outbound request of its own: once a day it asks
322
327
  `registry.npmjs.org` what the latest `claude-finops` version is, so it can tell
323
328
  you when an upgrade is out (npm has no way to push one at you). It sends nothing
@@ -359,7 +364,8 @@ files on Windows.
359
364
 
360
365
  ## Actions (these change your machine, and only when you click)
361
366
 
362
- - **⟳ 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.
363
369
  - **Free models** adds a launcher such as `claude-qwen` in `~/.local/bin` that runs Claude Code
364
370
  on a free model (Ollama locally, or OpenRouter). It lists every step first, and asks before it
365
371
  installs Ollama or downloads a model. Your normal `claude` is unchanged. Edit the list in
@@ -367,12 +373,17 @@ files on Windows.
367
373
  - **Skills & MCP** suggests MCP servers and skills from work you repeat, with the evidence.
368
374
  "Add" runs `claude mcp add -s user …`; "Create skill" writes `~/.claude/skills/<name>/SKILL.md`.
369
375
  The strongest skill suggestions also appear in **⚡ Act now** on the overview.
370
- - **Compact** (Running sessions, Act now, session rows) types `/compact` into that session's
376
+ - **Compact** (Running sessions, Recent, Act now, session rows) types `/compact` into that session's
371
377
  terminal: tmux, Terminal.app, iTerm2 or the Windows console. Anywhere else it copies the
372
378
  command for you to paste. It asks you to click twice.
373
379
  - **Hand over / split** (Running sessions) writes a brief of the session to
374
380
  `~/.claude/finops-handoffs/` and opens fresh session(s) in new terminals, one per sub-task if
375
381
  you list them; it can close the old session (resumable).
382
+ - **Recent** (clock icon in the top bar, or press `R`) opens a side panel on any page: running sessions first,
383
+ with Resume, Compact and Stop (Interrupt), then past sessions newest first. Click a session to
384
+ list its prompts, and click a prompt for its full details. Search matches your prompt text.
385
+ It covers all dates but keeps the agent and project filters. Close and Force kill stay on
386
+ Running sessions.
376
387
  - **Interrupt / Close / Force kill** (Running sessions) send the session's process a signal.
377
388
  Each asks you to click twice. Interrupt is macOS/Linux only: on Windows there is no way to
378
389
  interrupt one console process without hitting the others in its window.
package/finops/act_now.py CHANGED
@@ -33,6 +33,8 @@ def build(live, skills, memory, statusline):
33
33
  "kind": "session", "severity": x["severity"], "pid": x.get("pid"),
34
34
  "session_id": x.get("session_id"), "hosts_dashboard": bool(x.get("hosts_dashboard")),
35
35
  "title": f"{x.get('name') or 'A session'} is carrying {_k(x['context'])} tokens of context",
36
+ # the part of the title that comes from the user's prompts, for the privacy mask
37
+ "private": x.get("name"),
36
38
  "detail": f"{x.get('project') or ''} · every message re-reads all of it. "
37
39
  + ("Hand it over to a fresh session." if x["severity"] == "high"
38
40
  else "Compact it at the next break."),
@@ -52,6 +54,7 @@ def build(live, skills, memory, statusline):
52
54
  items.append({
53
55
  "kind": "memory",
54
56
  "title": f"You keep re-typing instructions about: {m['text']}",
57
+ "private": m["text"],
55
58
  "detail": f"Seen in {m['sessions']} sessions. Put it in {m['target']} once instead.",
56
59
  })
57
60
  if statusline is None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-finops",
3
- "version": "0.10.1",
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
@@ -21,6 +21,15 @@ const h = (html) => { const t = document.createElement('template');
21
21
  const esc = s => String(s ?? '').replace(/[&<>"']/g, c =>
22
22
  ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
23
23
 
24
+ /* ---------- privacy mask ----------
25
+ For screen-sharing: prompt text and session titles (written from your prompts)
26
+ render as dots. Only the page changes; the warehouse and exports are untouched. */
27
+ const MASK_KEY = 'finops-mask-prompts';
28
+ let MASKED = false;
29
+ try { MASKED = localStorage.getItem(MASK_KEY) === '1'; } catch (_) {}
30
+ const pt = t => MASKED && t ? String(t).replace(/\S/g, '•') : t;
31
+ const stitle = x => (!MASKED && x.title) || shortId(x.session_id);
32
+
24
33
  const qs = () => {
25
34
  const f = S.filter, p = new URLSearchParams();
26
35
  if (f.start) p.set('start', f.start);
@@ -82,7 +91,7 @@ const vsYesterday = o => {
82
91
  };
83
92
  function kpi(label, value, detail, opts = {}) {
84
93
  const na = value == null;
85
- 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)}"` : ''}>
86
95
  <div class="l">${esc(label)}${opts.badge ? ' ' + opts.badge : ''}</div>
87
96
  <div class="v${opts.small ? ' sm' : ''}">${na ? esc(naLabel()) : value}</div>
88
97
  ${detail ? `<div class="d">${detail}</div>` : ''}</div>`;
@@ -152,6 +161,31 @@ const NAV = [
152
161
  ]],
153
162
  ];
154
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
+
155
189
  function shell() {
156
190
  document.body.innerHTML = `<div class="app">
157
191
  <aside class="sidebar">
@@ -166,16 +200,21 @@ function shell() {
166
200
  <div class="main">
167
201
  <header class="topbar">
168
202
  <div class="r1">
169
- <h1 id="ttl">Executive overview</h1>
170
- <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>
171
206
  <span class="spacer"></span>
172
207
  <div class="search"><span class="mag">⌕</span>
173
- <input id="gsearch" placeholder="Search prompts, sessions, models, dates…"></div>
208
+ <input id="gsearch" placeholder="Search prompts, sessions, models…"></div>
174
209
  <button class="iconbtn upd" id="upd" hidden></button>
175
- <button class="iconbtn" id="tour-btn" title="Walk through this dashboard">? Tour</button>
176
- <button class="iconbtn" id="theme" title="Toggle theme">◐</button>
177
- <button class="iconbtn" id="refresh" title="Reload data">↻</button>
178
- <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>
179
218
  </div>
180
219
  <div class="filters" id="filters"></div>
181
220
  </header>
@@ -191,9 +230,19 @@ function shell() {
191
230
  : (matchMedia('(prefers-color-scheme: dark)').matches ? 'light' : 'dark');
192
231
  document.documentElement.setAttribute('data-theme', next);
193
232
  localStorage.setItem('finops-theme', next);
233
+ themeLabel();
194
234
  render();
195
235
  };
236
+ themeLabel();
196
237
  $('#refresh').onclick = () => { bust(); render(); };
238
+ maskLabel();
239
+ $('#mask').onclick = () => {
240
+ MASKED = !MASKED;
241
+ try { localStorage.setItem(MASK_KEY, MASKED ? '1' : '0'); } catch (_) {}
242
+ maskLabel(); closeDrawer(); render();
243
+ if (RECENT.open) { RECENT.offset = 0; renderRecent(); }
244
+ };
245
+ $('#recent-btn').onclick = () => toggleRecent();
197
246
  updateChip();
198
247
  $('#sync').onclick = runSync;
199
248
  syncLabel();
@@ -203,6 +252,77 @@ function shell() {
203
252
  else if (S.view === 'search') go('overview'); }, 260); };
204
253
  }
205
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
+
315
+ function maskLabel() {
316
+ const b = $('#mask');
317
+ // The icon shows the current state: an open eye while prompts are visible.
318
+ b.innerHTML = MASKED ? ICON.eyeOff : ICON.eye;
319
+ b.title = MASKED ? 'Prompt text is hidden. Click to show it'
320
+ : 'Hide prompt text and session titles, e.g. before sharing your screen';
321
+ b.setAttribute('aria-label', MASKED ? 'Show prompts' : 'Hide prompts');
322
+ b.setAttribute('aria-pressed', MASKED);
323
+ b.classList.toggle('on', MASKED);
324
+ }
325
+
206
326
  /* ---------- update notice ----------
207
327
  npm cannot push a new release at anyone, so the server asks the registry once
208
328
  a day and we surface the answer here. Silent when you are current, when the
@@ -247,14 +367,13 @@ function filterBar() {
247
367
  const nSel = a => a.length ? `<span class="n">${a.length}</span>` : '';
248
368
  const ag = o.agents || [];
249
369
  $('#filters').innerHTML = `
250
- ${ag.length > 1 ? `<span class="note" style="margin-right:2px">Agent</span>
251
- ${ag.map(a => `<button class="chip ${f.agents.includes(a.id) ? 'on' : ''}" data-agent="${esc(a.id)}"
252
- title="${esc(a.note)}${a.requests ? '' : ' (no usage recorded)'}">${esc(a.name)}${a.requests ? '' : ' ·'}</button>`).join('')}
253
- <button class="chip ${f.agents.length === ag.length ? 'on' : ''}" data-agent="*" title="Every agent together">All</button>
254
- <span class="divider"></span>` : ''}
255
- ${RANGES.map(([k, l]) => `<button class="chip ${S.range === k ? 'on' : ''}"
256
- data-range="${k}">${l}</button>`).join('')}
257
- <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>
258
377
  <div class="chipsel"><button class="chip ${f.models.length ? 'on' : ''}" data-pop="models">
259
378
  Model ${nSel(f.models)} ▾</button></div>
260
379
  <div class="chipsel"><button class="chip ${f.projects.length ? 'on' : ''}" data-pop="projects">
@@ -263,13 +382,14 @@ function filterBar() {
263
382
  Category ${nSel(f.categories)} ▾</button></div>
264
383
  <div class="chipsel"><button class="chip ${(f.min_cost || f.min_tokens) ? 'on' : ''}"
265
384
  data-pop="thresholds">Thresholds ▾</button></div>
266
- <button class="chip ${f.include_sandbox ? '' : 'on'}" id="sbx">
267
- ${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>
268
388
  ${(f.models.length || f.projects.length || f.categories.length || f.min_cost || f.min_tokens)
269
- ? '<button class="chip" id="clr">Clear ✕</button>' : ''}
389
+ ? '<button class="chip clr" id="clr">Clear filters ✕</button>' : ''}
270
390
  <span class="spacer"></span>
271
- <span class="note">${o.date_range.first} → ${o.date_range.last} ·
272
- ${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>`;
273
393
 
274
394
  $('#filters').querySelectorAll('[data-range]').forEach(b =>
275
395
  b.onclick = () => { if (b.dataset.range === 'custom') return openCustom(b);
@@ -367,7 +487,13 @@ function drawer(title, bodyHtml, sub) {
367
487
  document.body.append(scrim, d);
368
488
  return d;
369
489
  }
370
- document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDrawer(); });
490
+ document.addEventListener('keydown', e => {
491
+ // Esc closes the top-most layer: a detail drawer first, then the Recent panel.
492
+ if (e.key === 'Escape') { if ($('.drawer')) closeDrawer(); else if (RECENT.open) toggleRecent(false); return; }
493
+ const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable;
494
+ if (!typing && !e.metaKey && !e.ctrlKey && !e.altKey && e.key.toLowerCase() === 'r' && !$('.drawer'))
495
+ toggleRecent();
496
+ });
371
497
 
372
498
  async function openPrompt(id) {
373
499
  const d = drawer('Prompt detail', '<div class="loading">Loading…</div>');
@@ -379,11 +505,11 @@ async function openPrompt(id) {
379
505
  ${kpi('Output tokens', fmtInt(p.output_tokens))}
380
506
  ${kpi('Peak context', fmtInt(p.max_context_tokens))}
381
507
  </div>
382
- ${card('Prompt', `<div class="prompt-text">${esc(p.text)}</div>
508
+ ${card('Prompt', `<div class="prompt-text">${esc(pt(p.text))}</div>
383
509
  <div class="mt" style="margin-top:8px;display:flex;gap:14px;flex-wrap:wrap;font-size:11px;color:var(--muted)">
384
510
  <span>${esc(p.ts || '')}</span><span class="pill">${esc(p.category)}</span>
385
511
  <span>confidence ${(100*(p.category_confidence||0)).toFixed(0)}%</span>
386
- ${(p.category_evidence||[]).length ? `<span>matched: ${esc(p.category_evidence.join(', '))}</span>` : ''}
512
+ ${(p.category_evidence||[]).length ? `<span>matched: ${esc(pt(p.category_evidence.join(', ')))}</span>` : ''}
387
513
  <span>${fmtInt(p.char_len)} chars · ${fmtInt(p.word_len)} words</span>
388
514
  ${p.source ? `<span class="pill">${esc(p.source)}</span>` : ''}
389
515
  </div>`, {badge: BADGE.actual})}
@@ -411,7 +537,7 @@ async function openPrompt(id) {
411
537
  : '<div class="na">No files touched</div>', {badge: BADGE.actual})}
412
538
  </div>
413
539
  ${card('Context', `<dl class="kv">
414
- <dt>Session</dt><dd><a data-sess="${esc(p.session_id)}">${esc(p.session_title || shortId(p.session_id))}</a></dd>
540
+ <dt>Session</dt><dd><a data-sess="${esc(p.session_id)}">${esc((!MASKED && p.session_title) || shortId(p.session_id))}</a></dd>
415
541
  <dt>Project</dt><dd>${esc(p.project)}</dd>
416
542
  <dt>Git branch</dt><dd>${p.git_branch ? esc(p.git_branch) : NA()}</dd>
417
543
  <dt>Models</dt><dd>${esc(p.models || '—')}</dd>
@@ -443,7 +569,7 @@ async function openSession(id) {
443
569
  hint: 'context tokens actual, cost estimated'})}
444
570
  ${card('Prompts in this session', table([
445
571
  {h: 'Time', f: r => `<span class="mono">${esc((r.ts||'').slice(11,16))}</span>`},
446
- {h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
572
+ {h: 'Prompt', trunc: 1, title: r => pt(r.preview), f: r => esc(pt(r.preview))},
447
573
  {h: 'Category', f: r => `<span class="pill">${esc(r.category)}</span>`},
448
574
  {h: 'Tools', num: 1, f: r => fmtInt(r.tool_calls)},
449
575
  {h: 'Peak ctx', num: 1, f: r => fmtInt(r.max_context)},
@@ -462,7 +588,7 @@ async function openSession(id) {
462
588
  ${s.resume ? `<dt>Resume</dt><dd><span class="mono">${esc(s.resume)}</span>
463
589
  <button class="btn pb-copy" id="sess-resume" data-copy="${esc(s.resume)}">Copy</button></dd>` : ''}
464
590
  <dt>Session ID</dt><dd class="mono">${esc(s.id)}</dd>
465
- <dt>Title</dt><dd>${s.title ? esc(s.title) : NA()}</dd>
591
+ <dt>Title</dt><dd>${s.title ? esc(pt(s.title)) : NA()}</dd>
466
592
  <dt>Project</dt><dd>${esc(s.project)}</dd>
467
593
  <dt>Git branch</dt><dd>${s.git_branch ? esc(s.git_branch) : NA()}</dd>
468
594
  <dt>CLI version</dt><dd>${s.cli_version ? esc(s.cli_version) : NA()}</dd>
@@ -650,7 +776,7 @@ VIEWS.overview = async (page) => {
650
776
  const lp = leaderboards.most_expensive.slice(0, 10);
651
777
  $('#topprompts', page).innerHTML = table([
652
778
  {h: '#', num: 1, f: (r) => lp.indexOf(r) + 1},
653
- {h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
779
+ {h: 'Prompt', trunc: 1, title: r => pt(r.preview), f: r => esc(pt(r.preview))},
654
780
  {h: 'Category', f: r => `<span class="pill">${esc(r.category)}</span>`},
655
781
  {h: 'Tokens', num: 1, f: r => fmtNum(r.ptokens)},
656
782
  {h: 'Est. cost', num: 1, f: r => fmtUSD(r.pcost)},
@@ -660,7 +786,7 @@ VIEWS.overview = async (page) => {
660
786
  const ls = (await api('sessions', '&limit=10&order=cost')).rows;
661
787
  $('#topsessions', page).innerHTML = table([
662
788
  {h: 'Session', trunc: 1, title: r => r.session_id,
663
- f: r => esc(r.title || shortId(r.session_id))},
789
+ f: r => esc(stitle(r))},
664
790
  {h: 'Project', f: r => esc(r.project)},
665
791
  {h: 'Prompts', num: 1, f: r => fmtInt(r.prompts)},
666
792
  {h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
@@ -711,7 +837,7 @@ function actNowStrip(items) {
711
837
  <span class="note">one click each · nothing changes until you click</span></div>
712
838
  ${items.map((it, i) => `<div class="actnow-it" data-i="${i}">
713
839
  <span class="ai">${ACT_ICON[it.kind] || '•'}</span>
714
- <div class="ft"><b>${codeTicks(it.title)}</b><div class="note">${esc(it.detail)}</div></div>
840
+ <div class="ft"><b>${codeTicks(MASKED && it.private ? it.title.split(it.private).join(pt(it.private)) : it.title)}</b><div class="note">${esc(it.detail)}</div></div>
715
841
  <div class="live-actions">${btn(it)}<span class="live-msg"></span></div></div>`).join('')}</section>`;
716
842
  }
717
843
  function wireActNow(root, items) {
@@ -1201,13 +1327,13 @@ VIEWS.sessions = async (page) => {
1201
1327
  </div>`, {badge: BADGE.estimated, hint: 'click a row to open the session'})}
1202
1328
  <div class="grid g2">
1203
1329
  ${card('Lowest output yield', table([
1204
- {h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
1330
+ {h: 'Session', trunc: 1, f: r => esc(stitle(r))},
1205
1331
  {h: 'Output share', num: 1, f: r => fmtPct(r.output_ratio * 100, 2)},
1206
1332
  {h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
1207
1333
  {h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
1208
1334
  ], eff.low_efficiency_sessions, {onRow: 1}), {badge: BADGE.estimated, hint: 'low efficiency'})}
1209
1335
  ${card('Highest output yield', table([
1210
- {h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
1336
+ {h: 'Session', trunc: 1, f: r => esc(stitle(r))},
1211
1337
  {h: 'Output share', num: 1, f: r => fmtPct(r.output_ratio * 100, 2)},
1212
1338
  {h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
1213
1339
  {h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
@@ -1219,7 +1345,7 @@ VIEWS.sessions = async (page) => {
1219
1345
  if (sessMore) sessMore.onclick = () => { sp.offset += sp.limit; bust(); render(); };
1220
1346
  $('#st', page).innerHTML = table([
1221
1347
  {h: 'Session', trunc: 1, title: r => r.session_id,
1222
- f: r => `${r.tokens > avgTok * 3 ? '🔴 ' : ''}${esc(r.title || shortId(r.session_id))}
1348
+ f: r => `${r.tokens > avgTok * 3 ? '🔴 ' : ''}${esc(stitle(r))}
1223
1349
  <div class="sub mono">${esc(shortId(r.session_id))}</div>`},
1224
1350
  {h: '', f: r => rowActs(r, live)},
1225
1351
  {h: 'Project', f: r => esc(r.project)},
@@ -1249,7 +1375,7 @@ VIEWS.sessions = async (page) => {
1249
1375
  wireTable(effCards[1], eff.high_efficiency_sessions, r => openSession(r.session_id));
1250
1376
  addChart(page, 'Top sessions by estimated cost', el => C.barsH(el, {
1251
1377
  rows: [...rows].sort((a, b) => b.cost - a.cost).slice(0, 12),
1252
- label: r => clip(r.title || shortId(r.session_id), 42), value: r => r.cost,
1378
+ label: r => clip(stitle(r), 42), value: r => r.cost,
1253
1379
  sub: r => `<div class="row"><span class="k">Project</span><span class="v">${esc(r.project)}</span></div>
1254
1380
  <div class="row"><span class="k">Tokens</span><span class="v">${fmtNum(r.tokens)}</span></div>
1255
1381
  <div class="row"><span class="k">Prompts</span><span class="v">${fmtInt(r.prompts)}</span></div>`,
@@ -1289,12 +1415,12 @@ VIEWS.prompts = async (page) => {
1289
1415
  if (promptMore) promptMore.onclick = () => { pp.offset += pp.limit; bust(); render(); };
1290
1416
  $('#pt', page).innerHTML = table([
1291
1417
  {h: 'When', f: r => `<span class="mono">${esc((r.ts || '').slice(0, 16).replace('T', ' '))}</span>`},
1292
- {h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
1418
+ {h: 'Prompt', trunc: 1, title: r => pt(r.preview), f: r => esc(pt(r.preview))},
1293
1419
  {h: 'Category', f: r => `<span class="pill" title="confidence ${(100*(r.category_confidence||0)).toFixed(0)}%${
1294
1420
  r.category_evidence?.length ? ' · matched: ' + esc(r.category_evidence.join(', ')) : ''}">${esc(r.category)}</span>`},
1295
1421
  {h: 'Project', f: r => esc(r.project)},
1296
1422
  {h: 'Session', trunc: 1, title: r => r.session_id,
1297
- f: r => `<span class="mono sub">${esc(r.session_title || shortId(r.session_id))}</span>`},
1423
+ f: r => `<span class="mono sub">${esc((!MASKED && r.session_title) || shortId(r.session_id))}</span>`},
1298
1424
  {h: 'Models', f: r => (r.models || '').split(',').map(m =>
1299
1425
  `<span class="swatch" title="${esc(modelName(m))}" style="background:${modelColor(m)}"></span>`).join('')},
1300
1426
  {h: 'Chars', num: 1, f: r => fmtInt(r.char_len)},
@@ -1334,7 +1460,7 @@ VIEWS.rankings = async (page) => {
1334
1460
  S.rankTab = b.dataset.rt; render(); });
1335
1461
  const cols = isSess ? [
1336
1462
  {h: '#', num: 1, f: r => rows.indexOf(r) + 1},
1337
- {h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
1463
+ {h: 'Session', trunc: 1, f: r => esc(stitle(r))},
1338
1464
  {h: '', f: r => rowActs(r, live)},
1339
1465
  {h: 'Project', f: r => esc(r.project)},
1340
1466
  {h: 'Duration', num: 1, f: r => dur(r.duration_s)},
@@ -1345,7 +1471,7 @@ VIEWS.rankings = async (page) => {
1345
1471
  {h: 'Start', f: r => `<span class="mono">${esc((r.started_at || '').slice(0, 16).replace('T', ' '))}</span>`},
1346
1472
  ] : [
1347
1473
  {h: '#', num: 1, f: r => rows.indexOf(r) + 1},
1348
- {h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
1474
+ {h: 'Prompt', trunc: 1, title: r => pt(r.preview), f: r => esc(pt(r.preview))},
1349
1475
  {h: 'Category', f: r => `<span class="pill">${esc(r.category)}</span>`},
1350
1476
  {h: 'Models', f: r => (r.models || '').split(',').map(m =>
1351
1477
  `<span class="swatch" title="${esc(modelName(m))}" style="background:${modelColor(m)}"></span>`).join('')},
@@ -1353,7 +1479,7 @@ VIEWS.rankings = async (page) => {
1353
1479
  {h: 'Output', num: 1, f: r => fmtNum(r.output_tokens)},
1354
1480
  {h: 'Tok eff', num: 1, f: r => fmtPct(r.efficiency * 100, 2)},
1355
1481
  {h: 'Est. cost', num: 1, f: r => fmtUSD(r.pcost)},
1356
- {h: 'Session', trunc: 1, f: r => esc(r.session_title || shortId(r.session_id))},
1482
+ {h: 'Session', trunc: 1, f: r => esc((!MASKED && r.session_title) || shortId(r.session_id))},
1357
1483
  {h: '', f: r => rowActs(r, live)},
1358
1484
  {h: 'Date', f: r => esc(r.day)},
1359
1485
  ];
@@ -1361,7 +1487,7 @@ VIEWS.rankings = async (page) => {
1361
1487
  wireTable($('#rt', page), rows, r => isSess ? openSession(r.session_id) : openPrompt(r.prompt_id));
1362
1488
  wireRowActs($('#rt', page));
1363
1489
  addChart(page, 'Top 12 prompts by estimated cost', el => C.barsH(el, {
1364
- rows: (lb.most_expensive || []).slice(0, 12), label: r => clip(r.preview, 46), value: r => r.pcost,
1490
+ rows: (lb.most_expensive || []).slice(0, 12), label: r => clip(pt(r.preview), 46), value: r => r.pcost,
1365
1491
  sub: r => `<div class="row"><span class="k">Project</span><span class="v">${esc(r.project)}</span></div>
1366
1492
  <div class="row"><span class="k">Tokens</span><span class="v">${fmtNum(r.ptokens)}</span></div>`}),
1367
1493
  {badge: BADGE.estimated, after: '.nothing'});
@@ -1416,6 +1542,12 @@ VIEWS.hygiene = async (page) => {
1416
1542
  const T = hy.thresholds.map(String), kT = String(hy.rank_threshold);
1417
1543
  const K = t => `${Math.round(+t / 1000)}K`;
1418
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`;
1419
1551
  page.innerHTML = `
1420
1552
  <div class="note" style="margin:0 0 10px">Everything on this page is observed from your transcripts.
1421
1553
  It shows where spend sat while a large prefix was being re-sent on every turn. It does
@@ -1425,16 +1557,18 @@ VIEWS.hygiene = async (page) => {
1425
1557
  recorded and also counts.</div>
1426
1558
  <div class="grid g4">
1427
1559
  ${T.map(t => kpi(`Spend in requests ≥ ${K(t)} context`, fmtPct(ab(t).share_pct),
1428
- `${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('')}
1429
1562
  ${T.map(t => kpi(`Spend after a session first crossed ${K(t)}`, fmtPct(ab(t).share_after_first_cross_pct),
1430
- `${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('')}
1431
1565
  </div>
1432
1566
  <div class="grid g3">
1433
1567
  ${kpi('Spend near or over the context window', fmtPct(fit.near_or_over_cost_pct),
1434
1568
  `≥ ${fit.threshold_pct}% of the window in use · ${fmtInt(fit.near_requests + fit.over_requests)} requests`,
1435
- {badge: BADGE.actual})}
1436
- ${kpi('Sessions in range', fmtInt(hy.sessions), `${fmtInt(hy.requests)} main-thread requests`, {badge: BADGE.actual})}
1437
- ${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'})}
1438
1572
  </div>
1439
1573
  ${card('Context per request, most expensive session after ' + K(kT), '<div class="chart" id="hy-traj"></div>',
1440
1574
  {badge: BADGE.actual, hint: 'prompt-side tokens on each request, in order · click a row below to change session'})}
@@ -1448,13 +1582,13 @@ VIEWS.hygiene = async (page) => {
1448
1582
  series: [{key: 'ctx', label: 'Context tokens', color: seriesVar(0)}],
1449
1583
  xLabel: v => `#${v}`});
1450
1584
  $('#hy-traj', page).insertAdjacentHTML('beforeend',
1451
- `<div class="note" style="margin-top:6px">${esc(s.title || shortId(s.session_id))} — ${fmtInt(s.requests)} requests,
1585
+ `<div class="note" style="margin-top:6px">${esc(stitle(s))} — ${fmtInt(s.requests)} requests,
1452
1586
  ${fmtUSD(s.cost_usd)}. Crossed ${K(kT)} at request #${s.ever_crossed[kT] ? s.first_cross_idx[kT] + 1 : '—'};
1453
1587
  ${fmtPct(s.cost_after_pct[kT])} of its spend came after that.</div>`);
1454
1588
  };
1455
1589
  const rows = hy.sessions_ranked;
1456
1590
  $('#hy-sess', page).innerHTML = table([
1457
- {h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
1591
+ {h: 'Session', trunc: 1, f: r => esc(stitle(r))},
1458
1592
  {h: 'Project', trunc: 1, f: r => esc(r.project || '')},
1459
1593
  {h: 'Context', f: r => `<span class="spk" data-i="${rows.indexOf(r)}" style="display:inline-block;width:110px"></span>`},
1460
1594
  {h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
@@ -1599,7 +1733,7 @@ VIEWS.context = async (page) => {
1599
1733
  {k: 'With caching (your actual usage)', v: ca.cost_with_cache}],
1600
1734
  label: r => r.k, value: r => r.v, color: (r, i) => i ? seriesVar(2) : seriesVar(1), height: 60});
1601
1735
  $('#hs', page).innerHTML = table([
1602
- {h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
1736
+ {h: 'Session', trunc: 1, f: r => esc(stitle(r))},
1603
1737
  {h: 'Project', f: r => esc(r.project)},
1604
1738
  {h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
1605
1739
  {h: 'Avg context', num: 1, f: r => fmtNum(r.avg_context)},
@@ -1653,14 +1787,14 @@ VIEWS.waste = async (page) => {
1653
1787
  const f = all.find(x => x.kind === host.dataset.kind);
1654
1788
  const isSess = !!f.evidence[0]?.session_id && !f.evidence[0]?.prompt_id;
1655
1789
  const cols = isSess ? [
1656
- {h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
1790
+ {h: 'Session', trunc: 1, f: r => esc(stitle(r))},
1657
1791
  {h: 'Project', f: r => esc(r.project || '—')},
1658
1792
  {h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
1659
1793
  {h: 'Tokens', num: 1, f: r => fmtNum(r.tokens ?? r.reads)},
1660
1794
  {h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
1661
1795
  {h: 'Est. excess', num: 1, f: r => fmtUSD(r.excess)},
1662
1796
  ] : [
1663
- {h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
1797
+ {h: 'Prompt', trunc: 1, title: r => pt(r.preview), f: r => esc(pt(r.preview))},
1664
1798
  {h: 'Detail', f: r => r.n ? `repeated ${r.n}×` : r.char_len ? fmtInt(r.char_len) + ' chars'
1665
1799
  : r.tools ? fmtInt(r.tools) + ' tool calls'
1666
1800
  : r.out_tokens != null ? fmtInt(r.out_tokens) + ' output tokens' : '—'},
@@ -1719,7 +1853,7 @@ VIEWS.attribution = async (page) => {
1719
1853
  ${b.configured_unused_mcp.length ? `<div class="note">Configured but never called: <b>${esc(b.configured_unused_mcp.join(', '))}</b>. Their tool definitions still load into every session. Remove them with <code>claude mcp remove &lt;name&gt;</code>.</div>` : ''}`;
1720
1854
  const put = (id, cols, rows, onRow) => { const el = $(id, page); el.innerHTML = table(cols, rows, {onRow: !!onRow}); wireTable(el, rows, onRow); };
1721
1855
  put('#at-sess', [
1722
- {h: 'Session', trunc: 1, title: r => r.session_id, f: r => esc(r.title || shortId(r.session_id))},
1856
+ {h: 'Session', trunc: 1, title: r => r.session_id, f: r => esc(stitle(r))},
1723
1857
  {h: 'Project', f: r => esc(r.project)},
1724
1858
  {h: 'Main agent', num: 1, f: r => fmtNum(r.main_tokens)},
1725
1859
  {h: 'Subagents', num: 1, f: r => r.sub_tokens ? `${fmtNum(r.sub_tokens)} (${r.subagents})` : '—'},
@@ -1822,7 +1956,7 @@ VIEWS.live = async (page) => {
1822
1956
  <div class="item sev-${sevOf(x)}" data-i="${i}">
1823
1957
  <div class="hd">${x.status === 'busy' ? '<span class="live-dot"></span>' : '<span class="idle-dot"></span>'}
1824
1958
  <span class="pill">${esc(agentName(x.agent))}</span>
1825
- ${esc(x.name || shortId(x.session_id))} <span class="note">· ${esc(x.project)}${x.pid ? ` · pid ${x.pid}` : ''}${x.model ? ` · ${esc(x.model)}` : ''}</span>
1959
+ ${esc((!MASKED && x.name) || shortId(x.session_id))} <span class="note">· ${esc(x.project)}${x.pid ? ` · pid ${x.pid}` : ''}${x.model ? ` · ${esc(x.model)}` : ''}</span>
1826
1960
  ${x.hosts_dashboard ? '<span class="status high">runs this dashboard</span>' : ''}
1827
1961
  <span class="spacer"></span>
1828
1962
  <span style="font-variant-numeric:tabular-nums">${x.context == null ? 'context not recorded' : fmtNum(x.context) + ' context'} · ${fmtInt(x.steps)} steps${x.est_cost_usd == null ? '' : ' · ' + fmtUSD(x.est_cost_usd)}</span></div>
@@ -1958,7 +2092,7 @@ VIEWS.diagnose = async (page) => {
1958
2092
  <div id="dx-live"></div>${card('Running now', d.live_sessions.length ? `<div class="stack">${d.live_sessions.map(x => `
1959
2093
  <div class="item sev-${x.severity === 'ok' ? 'low' : x.severity}">
1960
2094
  <div class="hd">${x.severity === 'high' ? '🔴' : x.severity === 'medium' ? '🟡' : '🟢'}
1961
- ${esc(x.title || shortId(x.session_id))} <span class="note">· ${esc(x.project)}</span><span class="spacer"></span>
2095
+ ${esc(stitle(x))} <span class="note">· ${esc(x.project)}</span><span class="spacer"></span>
1962
2096
  <span style="font-variant-numeric:tabular-nums">${fmtNum(x.context)} context · ${fmtInt(x.steps)} steps</span></div>
1963
2097
  <div class="dt">${esc(x.advice)} <span class="note">Last write ${x.idle_min} min ago; context grew
1964
2098
  ${fmtNum(x.start_context)} → ${fmtNum(x.context)}.</span></div>${pb(x.playbook)}</div>`).join('')}</div>`
@@ -1966,7 +2100,7 @@ VIEWS.diagnose = async (page) => {
1966
2100
  {badge: BADGE.actual, hint: 'Read live from transcripts on each load'})}
1967
2101
  <div id="dx-past"></div>${card('Past sessions that carried too much context', `<div class="stack">${d.session_health.map(x => `
1968
2102
  <div class="item sev-${x.peak >= 300000 ? 'high' : 'medium'}">
1969
- <div class="hd"><a href="#" class="sess-link" data-sess="${esc(x.session_id)}">${esc(x.title || shortId(x.session_id))}</a>
2103
+ <div class="hd"><a href="#" class="sess-link" data-sess="${esc(x.session_id)}">${esc(stitle(x))}</a>
1970
2104
  <span class="note">· ${esc(x.project)}</span><span class="spacer"></span>
1971
2105
  <span style="font-variant-numeric:tabular-nums">peak ${fmtNum(x.peak)} · ${fmtUSD(x.cost)} ·
1972
2106
  ~${fmtNum(x.tokens_above_100k)} tokens re-read above 100K (not a saving)</span></div>
@@ -1976,12 +2110,12 @@ VIEWS.diagnose = async (page) => {
1976
2110
  <div id="dx-mem"></div>${card(`Add to ${md}${isCl ? ' / memory' : ''} (from your past prompts)`, `<div class="stack">${d.memory_suggestions.map(x => `
1977
2111
  <div class="item sev-${x.kind === 'security' ? 'high' : x.already_saved ? 'low' : 'medium'}">
1978
2112
  <div class="hd">${x.kind === 'security' ? '🔐' : x.kind === 'template' ? '⚙' : x.kind === 'reference' ? '🔗' : '📝'}
1979
- ${esc(x.text.slice(0, 140))}${x.already_saved ? ' <span class="na">already saved</span>' : ''}
2113
+ ${esc(pt(x.text.slice(0, 140)))}${x.already_saved ? ' <span class="na">already saved</span>' : ''}
1980
2114
  <span class="spacer"></span><span class="note">${fmtInt(x.sessions)} sessions</span></div>
1981
2115
  <div class="dt">${esc(x.why)}</div>
1982
2116
  <div class="dt"><b>Put it in:</b> ${esc(x.target)}</div>
1983
2117
  ${(x.examples || []).length ? `<details><summary style="cursor:pointer;font-size:11.5px">Examples</summary>
1984
- ${x.examples.map(e => `<div class="dt note">“${esc(e)}”</div>`).join('')}</details>` : ''}${pb(x.playbook)}</div>`).join('')
2118
+ ${x.examples.map(e => `<div class="dt note">“${esc(pt(e))}”</div>`).join('')}</details>` : ''}${pb(x.playbook)}</div>`).join('')
1985
2119
  || '<div class="empty">No repeated instructions found</div>'}</div>`,
1986
2120
  {badge: BADGE.recommendation, hint: 'Mined from non-sandbox prompts; secrets masked'})}
1987
2121
  ${card('Why consumption is high', `<div class="stack">${d.drivers.map(x => `
@@ -2325,7 +2459,7 @@ VIEWS.search = async (page) => {
2325
2459
  if (r.prompts.length) {
2326
2460
  $('#sp', page).innerHTML = table([
2327
2461
  {h: 'When', f: x => `<span class="mono">${esc((x.ts || '').slice(0, 16).replace('T', ' '))}</span>`},
2328
- {h: 'Prompt', trunc: 1, title: x => x.preview, f: x => esc(x.preview)},
2462
+ {h: 'Prompt', trunc: 1, title: x => pt(x.preview), f: x => esc(pt(x.preview))},
2329
2463
  {h: 'Category', f: x => `<span class="pill">${esc(x.category)}</span>`},
2330
2464
  {h: 'Tokens', num: 1, f: x => fmtNum(x.billable_tokens)},
2331
2465
  {h: 'Est. cost', num: 1, f: x => fmtUSD(x.est_cost_usd)},
@@ -2334,7 +2468,7 @@ VIEWS.search = async (page) => {
2334
2468
  }
2335
2469
  if (r.sessions.length) {
2336
2470
  $('#ss', page).innerHTML = table([
2337
- {h: 'Session', trunc: 1, f: x => esc(x.title || shortId(x.session_id))},
2471
+ {h: 'Session', trunc: 1, f: x => esc(stitle(x))},
2338
2472
  {h: 'Project', f: x => esc(x.project)},
2339
2473
  {h: 'Branch', f: x => x.git_branch ? `<span class="mono">${esc(x.git_branch)}</span>` : '—'},
2340
2474
  {h: 'Tokens', num: 1, f: x => fmtNum(x.billable_tokens)},
@@ -2574,7 +2708,9 @@ async function render() {
2574
2708
  if (!f.include_sandbox) bits.push('excl. sandbox');
2575
2709
  $('#crumbs').innerHTML = bits.join(' <span style="opacity:.4">·</span> ');
2576
2710
  filterBar();
2577
- page.innerHTML = '<div class="loading">Computing…</div>';
2711
+ scopeStrip();
2712
+ page.innerHTML = pageLoader();
2713
+ loaderTicker(page);
2578
2714
  updateNavBadges();
2579
2715
  try {
2580
2716
  await (VIEWS[S.view] || VIEWS.overview)(page);
@@ -2585,6 +2721,154 @@ async function render() {
2585
2721
  }
2586
2722
  }
2587
2723
 
2724
+ /* ---------- Recent panel ----------
2725
+ Past sessions and their prompts from any page, without leaving it. Resume and
2726
+ stop act on sessions, so the list is grouped by session; running ones sit on
2727
+ top. Close and Force kill stay on Running sessions, one click away from here. */
2728
+ const RECENT = {open: false, offset: 0, limit: 30, q: '', open_ids: new Set()};
2729
+ // Recent means all time: keep agent/project filters, drop the date range and thresholds.
2730
+ const recentQs = () => { const p = new URLSearchParams(qs());
2731
+ ['start', 'end', 'min_cost', 'min_tokens'].forEach(k => p.delete(k)); return p.toString(); };
2732
+ const fmtWhen = iso => iso ? `${ago(iso)} · ${esc(iso.slice(0, 16).replace('T', ' '))}` : '';
2733
+
2734
+ function toggleRecent(force) {
2735
+ RECENT.open = force ?? !RECENT.open;
2736
+ document.querySelectorAll('.recent,.recent-scrim').forEach(e => e.remove());
2737
+ $('#recent-btn')?.classList.toggle('on', RECENT.open);
2738
+ if (!RECENT.open) return;
2739
+ const scrim = h('<div class="recent-scrim"></div>');
2740
+ const p = h(`<aside class="recent" aria-label="Recent sessions"><header>
2741
+ <h2>Recent</h2><span class="note">all time · agent &amp; project filters apply</span>
2742
+ <span class="spacer"></span><button class="iconbtn" data-x>Close ✕</button></header>
2743
+ <div class="recent-search"><span class="mag">⌕</span>
2744
+ <input id="recent-q" placeholder="Search your prompts…" value="${esc(RECENT.q)}"></div>
2745
+ <div class="content"><div class="loading">Loading…</div></div></aside>`);
2746
+ scrim.onclick = () => toggleRecent(false);
2747
+ p.querySelector('[data-x]').onclick = () => toggleRecent(false);
2748
+ let t;
2749
+ p.querySelector('#recent-q').oninput = e => { clearTimeout(t);
2750
+ t = setTimeout(() => { RECENT.q = e.target.value.trim(); RECENT.offset = 0; renderRecent(); }, 260); };
2751
+ document.body.append(scrim, p);
2752
+ RECENT.offset = 0;
2753
+ renderRecent();
2754
+ }
2755
+
2756
+ async function renderRecent() {
2757
+ const box = $('.recent .content');
2758
+ if (!box) return;
2759
+ const more = RECENT.offset > 0;
2760
+ const [live, res] = await Promise.all([
2761
+ more || RECENT.q ? null : fetch('/api/live?agents=' + encodeURIComponent(S.filter.agents.join(',')))
2762
+ .then(r => r.json()).catch(() => ({sessions: []})),
2763
+ RECENT.q
2764
+ ? fetch(`/api/prompts?${recentQs()}&order=recent&limit=${RECENT.limit}&offset=${RECENT.offset}&q=${encodeURIComponent(RECENT.q)}`).then(r => r.json())
2765
+ : fetch(`/api/sessions?${recentQs()}&order=recent&limit=${RECENT.limit}&offset=${RECENT.offset}`).then(r => r.json())]);
2766
+ if (!$('.recent')) return; // closed while loading
2767
+ const rows = res.rows || [];
2768
+ const liveIds = new Set(((live || {}).sessions || RECENT.live || []).map(x => x.session_id));
2769
+ if (live) RECENT.live = live.sessions || [];
2770
+ const html = RECENT.q ? recentPrompts(rows) : recentSessions(rows, liveIds);
2771
+ const shown = RECENT.offset + rows.length;
2772
+ const moreBtn = shown < (res.total || 0)
2773
+ ? `<button class="btn pb-copy recent-more">Load more (${fmtInt(shown)} of ${fmtInt(res.total)})</button>` : '';
2774
+ if (more) { box.querySelector('.recent-more')?.remove(); box.insertAdjacentHTML('beforeend', html + moreBtn); }
2775
+ else box.innerHTML = (RECENT.q ? '' : recentLive(RECENT.live || [])) + html + moreBtn
2776
+ || '<div class="empty">Nothing found</div>';
2777
+ if (!rows.length && !more && RECENT.q) box.innerHTML = '<div class="empty">No prompt matches</div>';
2778
+ wireRecent(box);
2779
+ }
2780
+
2781
+ const recentLive = list => !list.length ? '' : `<div class="recent-hd">Running now
2782
+ <span class="spacer"></span><a href="#" data-go-live>Close or kill ›</a></div>
2783
+ ${list.map((x, i) => `<div class="item recent-it sev-${x.severity === 'high' ? 'high' : x.severity === 'medium' ? 'medium' : 'low'}" data-live="${i}">
2784
+ <div class="hd"><span class="live-dot ${x.status === 'busy' ? 'busy' : ''}" title="${esc(x.status)}"></span>
2785
+ <span class="recent-title">${esc((!MASKED && x.name) || shortId(x.session_id))}</span>
2786
+ <span class="spacer"></span><span class="note">${esc(x.status || '')}</span></div>
2787
+ <div class="mt"><span>${esc(x.project || '')}</span>
2788
+ ${x.context ? `<span>${fmtNum(x.context)} context</span>` : ''}
2789
+ ${x.est_cost_usd != null ? `<span>${fmtUSD(x.est_cost_usd)}</span>` : ''}</div>
2790
+ <div class="live-actions">
2791
+ ${x.resume ? `<button class="act ghost" data-resume="${esc(x.resume)}" title="Copy: ${esc(x.resume)}">⧉ Resume</button>` : ''}
2792
+ ${x.signalable && x.agent === 'claude' ? '<button class="act" data-la="compact" title="Types /compact into that session\'s terminal">🗜 Compact</button>' : ''}
2793
+ ${x.signalable ? `<button class="act warn" data-la="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : 'title="Stops the current turn, like pressing Esc"'}>⏸ Stop</button>` : ''}
2794
+ ${x.session_id ? `<button class="act ghost" data-open-sess="${esc(x.session_id)}">Details</button>` : ''}
2795
+ <span class="live-msg"></span></div></div>`).join('')}
2796
+ <div class="recent-hd">Past sessions</div>`;
2797
+
2798
+ const recentSessions = (rows, liveIds) => rows.map(r => `
2799
+ <div class="item recent-it clickable${RECENT.open_ids.has(r.session_id) ? ' open' : ''}" data-sess="${esc(r.session_id)}">
2800
+ <div class="hd">${liveIds.has(r.session_id) ? '<span class="live-dot" title="Running now"></span>' : ''}
2801
+ <span class="recent-title">${esc(stitle(r))}</span><span class="spacer"></span>
2802
+ <span class="note">${fmtUSD(r.cost)}</span></div>
2803
+ <div class="mt"><span>${esc(r.project || '')}</span><span>${fmtInt(r.prompts)} prompts</span>
2804
+ <span>${fmtWhen(r.started_at)}</span></div>
2805
+ <div class="live-actions">
2806
+ ${r.resume ? `<button class="act ghost" data-resume="${esc(r.resume)}" title="Copy: ${esc(r.resume)}">⧉ Resume</button>` : ''}
2807
+ <button class="act ghost" data-open-sess="${esc(r.session_id)}">Details</button>
2808
+ <span class="spacer"></span><span class="note recent-caret">${RECENT.open_ids.has(r.session_id) ? '▾' : '▸'} prompts</span></div>
2809
+ <div class="recent-prompts"${RECENT.open_ids.has(r.session_id) ? '' : ' hidden'}></div></div>`).join('')
2810
+ || '<div class="empty">No sessions for these filters</div>';
2811
+
2812
+ const promptLine = x => `<a href="#" class="recent-p" data-prompt="${x.prompt_id}" title="${esc(pt(x.preview))}">
2813
+ <span class="mono sub">${esc((x.ts || '').slice(5, 16).replace('T', ' '))}</span>
2814
+ <span class="recent-pv">${esc(pt(x.preview) || '—')}</span>
2815
+ <span class="note">${fmtUSD(x.pcost ?? x.est_cost_usd)}</span></a>`;
2816
+
2817
+ const recentPrompts = rows => rows.map(x => `<div class="item recent-it">
2818
+ ${promptLine(x)}
2819
+ <div class="mt"><span>${esc((!MASKED && x.session_title) || shortId(x.session_id))}</span>
2820
+ <span>${esc(x.project || '')}</span>
2821
+ <a href="#" data-open-sess="${esc(x.session_id)}">Session ›</a></div></div>`).join('');
2822
+
2823
+ async function loadSessionPrompts(host, sid) {
2824
+ host.innerHTML = '<div class="note">Loading…</div>';
2825
+ try {
2826
+ const s = await fetch(`/api/session/${encodeURIComponent(sid)}`).then(r => r.json());
2827
+ const ps = s.prompts || [];
2828
+ host.innerHTML = ps.length ? ps.map(promptLine).join('') : '<div class="note">No prompts recorded</div>';
2829
+ wireRecent(host);
2830
+ } catch (e) { host.innerHTML = `<div class="live-msg err">${esc(e.message)}</div>`; }
2831
+ }
2832
+
2833
+ function wireRecent(root) {
2834
+ wireRowActs(root); // Resume: copy command
2835
+ root.querySelectorAll('[data-open-sess]').forEach(a => a.onclick = e => {
2836
+ e.preventDefault(); e.stopPropagation(); openSession(a.dataset.openSess); });
2837
+ root.querySelectorAll('[data-prompt]').forEach(a => a.onclick = e => {
2838
+ e.preventDefault(); e.stopPropagation(); openPrompt(+a.dataset.prompt); });
2839
+ root.querySelectorAll('[data-go-live]').forEach(a => a.onclick = e => {
2840
+ e.preventDefault(); toggleRecent(false); go('live'); });
2841
+ root.querySelector('.recent-more')?.addEventListener('click', () => {
2842
+ RECENT.offset += RECENT.limit; renderRecent(); });
2843
+ root.querySelectorAll('.item[data-sess]').forEach(it => {
2844
+ const host = it.querySelector('.recent-prompts'), sid = it.dataset.sess;
2845
+ if (!host.hidden && !host.childElementCount) loadSessionPrompts(host, sid);
2846
+ it.onclick = e => {
2847
+ if (e.target.closest('button, a, .recent-prompts')) return;
2848
+ host.hidden = !host.hidden;
2849
+ it.classList.toggle('open', !host.hidden);
2850
+ it.querySelector('.recent-caret').textContent = (host.hidden ? '▸' : '▾') + ' prompts';
2851
+ host.hidden ? RECENT.open_ids.delete(sid) : RECENT.open_ids.add(sid);
2852
+ if (!host.hidden && !host.childElementCount) loadSessionPrompts(host, sid);
2853
+ };
2854
+ });
2855
+ root.querySelectorAll('.item[data-live]').forEach(it => {
2856
+ const x = RECENT.live[+it.dataset.live], msg = it.querySelector('.live-msg');
2857
+ it.querySelectorAll('[data-la]').forEach(b => b.onclick = async e => {
2858
+ e.stopPropagation();
2859
+ if (!armed(b)) return; // two clicks, like Running sessions
2860
+ b.disabled = true;
2861
+ try {
2862
+ const r = await sessionAction(x.pid, b.dataset.la, {agent: x.agent});
2863
+ if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
2864
+ msg.textContent = r.ok ? r.message : `Failed: ${r.error}`;
2865
+ msg.className = 'live-msg ' + (r.ok ? 'ok' : 'err');
2866
+ } catch (err) { msg.textContent = 'Failed: ' + err.message; msg.className = 'live-msg err'; }
2867
+ setTimeout(() => { b.disabled = false; }, 2500);
2868
+ });
2869
+ });
2870
+ }
2871
+
2588
2872
  /* ============================ boot ============================ */
2589
2873
  (async function boot() {
2590
2874
  const saved = localStorage.getItem('finops-theme');
@@ -2710,12 +2994,13 @@ async function syncLabel() {
2710
2994
  const b = $('#sync'); if (!b) return;
2711
2995
  const d = await fetch('/api/sync').then(r => r.json()).catch(() => ({}));
2712
2996
  if (d.job && d.job.state === 'running') return runSync();
2713
- b.title = `Last synced ${ago(d.built_at)}. Re-read every agent's local data so the dashboard is current`;
2714
- 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>`;
2715
3000
  }
2716
3001
  async function runSync() {
2717
3002
  const b = $('#sync');
2718
- b.disabled = true; b.textContent = '⟳ Syncing…';
3003
+ b.disabled = true; b.classList.add('busy'); b.innerHTML = `${ICON.sync}Syncing…`;
2719
3004
  try {
2720
3005
  const {job} = await doAction('sync');
2721
3006
  const j = await followJob(job);
@@ -3123,7 +3408,15 @@ const TOURS = {
3123
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.'},
3124
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.'}],
3125
3410
  hygiene: [
3126
- {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.'},
3127
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.'}],
3128
3421
  waste: [
3129
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.'},
@@ -3181,6 +3474,13 @@ function tourTarget(el) {
3181
3474
  const page = $('#page');
3182
3475
  if (!el) return null;
3183
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
+ }
3184
3484
  const m = /^card(\d+)$/.exec(el);
3185
3485
  if (m) return page?.querySelectorAll('.card')[+m[1]] || null;
3186
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;
@@ -457,3 +561,32 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
457
561
  .brand .ver { font-size: 9.5px; font-weight: 600; color: var(--muted);
458
562
  border: 1px solid var(--border); border-radius: 99px;
459
563
  padding: 1px 5px; margin-left: 5px; letter-spacing: .01em; }
564
+ .iconbtn.on { background: var(--surface-3); color:var(--text); border-color: var(--s1); }
565
+
566
+ /* ---------- Recent panel: sits under the detail drawer so a click opens on top ---------- */
567
+ .recent-scrim { position:fixed; inset:0; background:rgba(11,11,11,.18); z-index:98; }
568
+ .recent { position:fixed; top:0; right:0; bottom:0; width:min(440px, 100vw); background:var(--plane);
569
+ z-index:99; box-shadow:-8px 0 40px rgba(0,0,0,.18); display:flex; flex-direction:column; }
570
+ .recent > header { padding:13px 16px; background:var(--surface); border-bottom:1px solid var(--border);
571
+ display:flex; align-items:center; gap:10px; }
572
+ .recent > header h2 { margin:0; font-size:14.5px; font-weight:640; }
573
+ .recent-search { position:relative; padding:10px 16px 0; }
574
+ .recent-search input { width:100%; box-sizing:border-box; padding:7px 10px 7px 28px; border-radius:8px;
575
+ font-size:12.5px; border:1px solid var(--border); background:var(--surface-2); color:var(--text); }
576
+ .recent-search .mag { position:absolute; left:26px; top:calc(50% + 5px); transform:translateY(-50%);
577
+ color:var(--muted); font-size:12px; pointer-events:none; }
578
+ .recent .content { overflow-y:auto; padding:10px 16px 40px; display:flex; flex-direction:column; gap:8px; }
579
+ .recent-hd { display:flex; align-items:center; font-size:11px; font-weight:640; text-transform:uppercase;
580
+ letter-spacing:.04em; color:var(--muted); margin-top:4px; }
581
+ .recent-hd a { text-transform:none; letter-spacing:0; font-weight:500; }
582
+ .recent-it { display:flex; flex-direction:column; gap:5px; }
583
+ .recent-it .live-actions { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
584
+ .recent-it .act { padding:2px 8px; font-size:11px; }
585
+ .recent-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
586
+ .recent-prompts { display:flex; flex-direction:column; border-top:1px solid var(--border); padding-top:4px; }
587
+ .recent-p { display:flex; gap:8px; align-items:baseline; padding:4px 2px; border-radius:5px;
588
+ color:var(--text); text-decoration:none; font-size:12px; }
589
+ .recent-p:hover { background:var(--surface-3); }
590
+ .recent-pv { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
591
+ .live-dot { width:8px; height:8px; border-radius:50%; background:var(--axis); flex:none; }
592
+ .live-dot.busy, .recent-it .hd > .live-dot[title="Running now"] { background:var(--s1); }