@enigmax/dashboard 0.1.38 → 0.1.43

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.
Files changed (2) hide show
  1. package/assets/index.html +365 -106
  2. package/package.json +1 -1
package/assets/index.html CHANGED
@@ -198,6 +198,18 @@
198
198
  border-radius: var(--radius-sm); padding: 6px 10px; font-size: 12px; font-family: var(--mono); cursor: pointer;
199
199
  }
200
200
  .set-note { color: var(--accent); font-size: 12px; min-height: 16px; margin-top: 10px; }
201
+ /* A note must read as success vs. failure at a glance, not the same accent for both. */
202
+ .set-note.ok { color: var(--good); }
203
+ .set-note.err { color: var(--bad); }
204
+ /* Toasts: failures are easy to miss in a small inline note, so every error is also
205
+ raised here - fixed, coloured, and self-dismissing - so a failed action is unmissable. */
206
+ #toasts { position: fixed; right: 18px; bottom: 18px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; max-width: 360px; }
207
+ .toast { padding: 11px 14px; border-radius: 10px; font-size: 13px; line-height: 1.35; color: var(--text);
208
+ background: var(--surface2); border: 1px solid var(--border); box-shadow: var(--shadow-1); animation: toastIn 0.18s ease-out; }
209
+ .toast.err { border-color: var(--bad); }
210
+ .toast.ok { border-color: var(--good); }
211
+ .toast.hide { opacity: 0; transition: opacity 0.3s; }
212
+ @keyframes toastIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
201
213
  /* Settings: two-column grid of rows on wide screens, so a long config list stays compact. */
202
214
  .set-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 0 32px; }
203
215
  .set-grid .set-row { padding: 10px 0; }
@@ -240,6 +252,8 @@
240
252
  .aicons { display: inline-flex; gap: 5px; align-items: center; vertical-align: middle; margin-left: 6px; }
241
253
  .aicon { display: inline-flex; align-items: center; color: var(--muted); }
242
254
  .aicon svg { width: 14px; height: 14px; display: block; }
255
+ /* Brand icon + label, reused by the savings breakdowns and the recent-compressions table. */
256
+ .srctag { display: inline-flex; align-items: center; gap: 7px; }
243
257
  .set-ic { vertical-align: middle; margin-right: 7px; }
244
258
  /* List settings (guard block/allow globs, custom secret patterns): chips + add input. A list row spans the full grid width. */
245
259
  .set-row.list { grid-column: 1 / -1; align-items: stretch; flex-direction: column; }
@@ -277,13 +291,18 @@
277
291
  .fxtitle { font-size: 13px; display: flex; align-items: center; gap: 8px; }
278
292
  .fxmsg { color: var(--muted); font-size: 12px; margin-top: 2px; }
279
293
  .fxpath { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; color: var(--muted); margin-top: 3px; word-break: break-all; }
280
- .sys-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 24px; }
281
- .sys-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--surface2); font-size: 13px; }
282
- .sys-item .k { color: var(--muted); }
294
+ /* Each system reads as its own lit chip-card (name left, state pill right) in a grid,
295
+ rather than a dense list of underlined rows - easier to scan at a glance. */
296
+ .sys-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); gap: 10px; }
297
+ .sys-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 11px 14px; font-size: 13px; background: var(--surface2); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); }
298
+ .sys-item .k { color: var(--text); }
299
+ .sys-item .v { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; flex: 0 0 auto; }
300
+ /* On a surface2 card the surface2 "off" pill would vanish - keep it outlined instead. */
301
+ .sys-item .pill.off { background: transparent; }
283
302
  /* Rows whose value is a list of chips span the full width and let the chips wrap with
284
303
  even spacing, instead of cramming many tags into one compact half-column cell. */
285
304
  .sys-item.wide { grid-column: 1 / -1; flex-direction: column; align-items: stretch; gap: 8px; }
286
- .sys-item.wide .v { display: flex; flex-wrap: wrap; gap: 6px; }
305
+ .sys-item.wide .v { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-start; }
287
306
  .sys-item.wide .v .tag { margin-left: 0; }
288
307
  .pill { font-size: 11px; border-radius: 6px; padding: 2px 9px; border: 1px solid var(--border); white-space: nowrap; }
289
308
  .pill.on { color: #06210f; background: var(--good); border-color: var(--good); font-weight: 600; }
@@ -332,6 +351,22 @@
332
351
  border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 9px 12px; font-size: 13px; font-family: var(--mono);
333
352
  }
334
353
  .modal-input:focus { outline: none; border-color: var(--accent); }
354
+ /* Usage view provider/account pickers. */
355
+ .useg-pick { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); }
356
+ .useg-pick select { width: auto; min-width: 170px; }
357
+ /* Icon picker: clickable app cards (a native <select> can't render SVG in options). */
358
+ .modal-picker { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; }
359
+ .pick-card {
360
+ display: flex; align-items: center; gap: 11px; width: 100%; text-align: left; cursor: pointer;
361
+ background: var(--surface2); color: var(--text); border: 1px solid var(--border);
362
+ border-radius: var(--radius-sm); padding: 11px 13px; font-size: 13px; font-family: var(--mono);
363
+ transition: border-color .15s, background .15s, transform .1s;
364
+ }
365
+ .pick-card:hover { border-color: #2f3744; }
366
+ .pick-card:active { transform: scale(0.98); }
367
+ .pick-card.sel { border-color: var(--accent); }
368
+ .pick-card svg { width: 20px; height: 20px; display: block; flex: none; color: var(--muted); }
369
+ .pick-card.sel svg, .pick-card:hover svg { color: var(--text); }
335
370
  .modal-acts { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
336
371
  .modal-ok.danger { background: var(--accent2); border-color: var(--accent2); color: #2a0f08; }
337
372
  .modal-ok:disabled { opacity: .45; cursor: not-allowed; }
@@ -463,14 +498,18 @@
463
498
  <section id="view-usage" style="display:none">
464
499
  <div class="page-head" style="display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap">
465
500
  <div style="flex:1;min-width:280px">
466
- <h1>Claude Usage</h1>
467
- <p>Measured token consumption and estimated cost, read-only from your own Claude Code session transcripts (<code>~/.claude/projects</code>). Cost is an estimate from a per-model price table; real spend is billed by Anthropic. Nothing is sent anywhere.</p>
501
+ <h1 id="usageTitle">Claude Usage</h1>
502
+ <p id="usageDesc">Measured token consumption and estimated cost, read-only from your own Claude Code session transcripts (<code>~/.claude/projects</code>). Cost is an estimate from a per-model price table; real spend is billed by Anthropic. Nothing is sent anywhere.</p>
468
503
  </div>
469
504
  <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px">
470
- <span id="claudeStatus" class="cstatus"><span class="cdot"></span><span class="ctext">Checking Claude status...</span></span>
471
- <a href="https://claude.ai/settings/usage" target="_blank" rel="noopener noreferrer" class="toggle" style="white-space:nowrap">View in Claude -&gt;</a>
505
+ <span id="claudeStatus" class="cstatus"><span class="cdot"></span><span class="ctext">Checking status...</span></span>
506
+ <a id="usageViewLink" href="https://claude.ai/settings/usage" target="_blank" rel="noopener noreferrer" class="toggle" style="white-space:nowrap">View in Claude -&gt;</a>
472
507
  </div>
473
508
  </div>
509
+ <div class="useg-controls" style="display:flex;gap:14px;flex-wrap:wrap;align-items:center;margin-bottom:16px">
510
+ <label class="useg-pick"><span>Provider</span><span id="uProviderIcon" class="aicon" aria-hidden="true"></span><select id="uProvider" class="modal-input"></select></label>
511
+ <label class="useg-pick" id="uAccountWrap"><span>Account</span><select id="uAccount" class="modal-input"></select></label>
512
+ </div>
474
513
  <div class="panel" id="usagePanel" style="display:none">
475
514
  <h2 style="margin:0 0 4px">Usage limits <small>same windows Claude Code shows</small></h2>
476
515
  <div class="sub" style="margin-bottom:12px">Cards marked <span class="tag meas">live</span> show the <b>real % and reset</b> Anthropic reports (the same as Claude's own UI). Turn on <code>enigma config usage-api on</code> to fetch them with your local Claude Code login - no proxy or plan limit needed. (The proxy also captures them from <code>enigma claude</code> traffic.) Until live data lands, the card falls back to tokens used, or a % against a plan limit you set (on the card, or <code>enigma config plan-weekly-limit &lt;tokens&gt;</code>). Per-model live data appears only when Anthropic sends it.</div>
@@ -494,8 +533,10 @@
494
533
  </div>
495
534
  </div>
496
535
  <div id="uProviders" class="sub" style="margin:4px 0 0"></div>
497
- <h2 style="margin:18px 0 10px">By Account <small>per Claude login</small></h2>
498
- <div id="uByAccount"></div>
536
+ <div id="uByAccountWrap">
537
+ <h2 style="margin:18px 0 10px">By Account <small>per Claude login</small></h2>
538
+ <div id="uByAccount"></div>
539
+ </div>
499
540
  <h2 style="margin:18px 0 10px">By Model</h2>
500
541
  <div id="uByModel"></div>
501
542
  <h2 style="margin:18px 0 10px">By Project</h2>
@@ -507,6 +548,10 @@
507
548
  <h2 style="margin-bottom:8px">Real tool usage <small>opt-in</small></h2>
508
549
  <div class="sub">Turn on <b>Real tool-usage stats</b> in <a href="#/settings">Settings</a> to read your Claude Code sessions for token consumption and cost. enigma reads only your local session transcripts; nothing is sent anywhere.</div>
509
550
  </div>
551
+ <div class="panel" id="usageUnavail" style="display:none">
552
+ <h2 id="unavailTitle" style="margin-bottom:8px">No local usage data</h2>
553
+ <div class="sub" id="unavailBody"></div>
554
+ </div>
510
555
  </section>
511
556
 
512
557
  <section id="view-accounts" style="display:none">
@@ -573,8 +618,8 @@
573
618
  <div class="set-grid">
574
619
  <div class="set-row">
575
620
  <div class="set-meta"><div class="set-name">Preferred port</div><div class="set-hint" id="dashPortHint">loopback only; never network-facing</div></div>
576
- <div class="set-ctl" style="display:flex;gap:8px;align-items:center">
577
- <input id="dashPortInput" type="number" min="0" max="65535" step="1" class="choice" style="width:120px" inputmode="numeric" />
621
+ <div class="set-ctl ladd" style="align-items:center">
622
+ <input id="dashPortInput" type="number" min="0" max="65535" step="1" class="linput" style="flex:none;width:120px" inputmode="numeric" />
578
623
  <button id="dashPortSave" type="button" class="toggle on">Save</button>
579
624
  </div>
580
625
  </div>
@@ -1601,7 +1646,7 @@ export {
1601
1646
  function prettyType(k) { return TYPE_LABELS[k] || String(k).replace(/\b\w/g, (c) => c.toUpperCase()); }
1602
1647
 
1603
1648
  // One bar per key (source or type), sorted by tokens saved, with an optional $ column.
1604
- function renderBreakdown(el, map, label, money) {
1649
+ function renderBreakdown(el, map, label, money, iconFn) {
1605
1650
  const entries = Object.entries(map || {}).sort((a, b) => (b[1].tokensSaved || 0) - (a[1].tokensSaved || 0));
1606
1651
  if (!entries.length) {
1607
1652
  el.innerHTML = '<div class="empty" style="padding:20px 0">No data yet - this fills in as compressions are recorded.</div>';
@@ -1611,7 +1656,7 @@ export {
1611
1656
  el.innerHTML = entries.map(([k, v]) => {
1612
1657
  const saved = v.tokensSaved || 0, pct = Math.round(saved / max * 100);
1613
1658
  const m = money ? '<span class="money">' + fmtUsd(moneyOf(k, saved)) + '</span>' : '';
1614
- return '<div class="bar-row"><span class="name" style="width:140px">' + label(k)
1659
+ return '<div class="bar-row"><span class="name" style="width:140px">' + iconLabel(iconFn ? iconFn(k) : "", label(k))
1615
1660
  + '</span><div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:var(--accent)"></div></div>'
1616
1661
  + '<span class="num">' + fmt(saved) + '</span>' + m
1617
1662
  + '<span class="foot" style="width:64px;text-align:right;margin:0">' + fmt(v.calls || 0) + ' calls</span></div>';
@@ -1633,8 +1678,9 @@ export {
1633
1678
  + '<th class="r">Before</th><th class="r">After</th><th class="r">Saved</th></tr></thead><tbody>'
1634
1679
  + rows.map((p) => {
1635
1680
  const saved = savedOf(p), pct = p.b ? Math.round(saved / p.b * 100) : 0;
1681
+ const src = p.s || "unknown";
1636
1682
  return '<tr><td>' + new Date(p.t).toLocaleString() + '</td><td>' + prettyType(p.c || "unknown")
1637
- + '</td><td>' + prettySource(p.s || "unknown") + '</td><td class="r">' + fmt(p.b || 0)
1683
+ + '</td><td>' + iconLabel(sourceSvg(src), prettySource(src)) + '</td><td class="r">' + fmt(p.b || 0)
1638
1684
  + '</td><td class="r">' + fmt(p.a || 0) + '</td><td class="r good">' + fmt(saved) + " (" + pct + "%)</td></tr>";
1639
1685
  }).join("") + "</tbody></table>";
1640
1686
  }
@@ -1678,6 +1724,22 @@ export {
1678
1724
  // Real tool-usage panel (Claude Code transcripts). null = the opt-in flag is off.
1679
1725
  function shortProject(p) { p = String(p || ""); return p.length > 44 ? "..." + p.slice(-41) : p; }
1680
1726
 
1727
+ // --- provider / account scoping for the Usage view ---------------------------------
1728
+ // Only Claude Code keeps a local per-message usage log enigma can read; Codex/OpenCode
1729
+ // are honestly reported as having no readable store. The header (title, status pill,
1730
+ // "View in ..." link) adapts to the selected provider.
1731
+ const CLAUDE_USAGE_DESC = $("usageDesc").innerHTML; // preserve the rich Claude copy
1732
+ const USAGE_PROVIDERS = {
1733
+ claude: { label: "Claude Code", status: "https://status.claude.com/api/v2/status.json", viewHref: "https://claude.ai/settings/usage", viewLabel: "View in Claude" },
1734
+ codex: { label: "Codex", status: "https://status.openai.com/api/v2/status.json", viewHref: "https://platform.openai.com/usage", viewLabel: "View in OpenAI" },
1735
+ opencode: { label: "OpenCode", status: null, viewHref: "https://opencode.ai", viewLabel: "View OpenCode" },
1736
+ };
1737
+ let usageData = null; // the latest /api/stats usage report (null when opt-in is off)
1738
+ let usageProvider = "claude";
1739
+ let usageAccount = "__all__"; // "__all__" or a Claude account name
1740
+ let usageAccountsMeta = []; // claude accounts from /api/accounts, for email labels
1741
+ let usageAccountsLoaded = false;
1742
+
1681
1743
  // --- Claude-style usage windows (current session / weekly all / weekly Sonnet) ---
1682
1744
  function fmtResetIn(ms) {
1683
1745
  const d = ms - Date.now();
@@ -1747,28 +1809,90 @@ export {
1747
1809
  + "</div>";
1748
1810
  }
1749
1811
 
1750
- // Claude operational status from the public statuspage (CORS-open, no auth). Maps the
1751
- // indicator to a colored dot; degrades quietly to "unavailable" if the fetch is blocked.
1752
- async function loadClaudeStatus() {
1812
+ // Community incident signal for Claude Code: even when the official statuspage reads
1813
+ // green, a sudden cluster of freshly-opened "500/529 server error" issues on the
1814
+ // claude-code repo is an early outage indicator (the statuspage usually lags a live
1815
+ // incident by many minutes). Browser-side, unauthenticated GitHub API (CORS-open,
1816
+ // 60 req/hr per IP) - throttled to one query / 5 min to stay well under the limit.
1817
+ const CLAUDE_ISSUES_API = "https://api.github.com/repos/anthropics/claude-code/issues?state=open&sort=created&direction=desc&per_page=30";
1818
+ const CLAUDE_ISSUES_VIEW = "https://github.com/anthropics/claude-code/issues?q=is%3Aissue+is%3Aopen+500";
1819
+ // A title is a server-error report when it carries an HTTP 500/529 AND error wording
1820
+ // ("API Error: 500", "500 errors", "Internal Server Error (500)", "API Error 529 and 500"...).
1821
+ const SERVER_ERR_NUM_RE = /\b(500|529)\b/;
1822
+ const SERVER_ERR_WORD_RE = /error|internal server/i;
1823
+ const INCIDENT_WINDOW_MS = 30 * 60 * 1000; // only issues opened in the last 30 minutes
1824
+ const INCIDENT_MIN = 2; // need a small cluster, not a single one-off
1825
+ const INCIDENT_TTL_MS = 5 * 60 * 1000; // re-query GitHub at most every 5 minutes
1826
+ let claudeIncidents = { at: 0, count: 0 }; // last GitHub probe (cached count)
1827
+ async function checkClaudeIncidents() {
1828
+ if (Date.now() - claudeIncidents.at < INCIDENT_TTL_MS) return claudeIncidents.count;
1829
+ try {
1830
+ const r = await fetch(CLAUDE_ISSUES_API, { cache: "no-store", headers: { Accept: "application/vnd.github+json" } });
1831
+ if (!r.ok) throw 0;
1832
+ const list = await r.json();
1833
+ const cut = Date.now() - INCIDENT_WINDOW_MS;
1834
+ const count = (Array.isArray(list) ? list : []).filter((it) => {
1835
+ if (!it || it.pull_request) return false; // the issues endpoint also returns PRs
1836
+ if (new Date(it.created_at).getTime() < cut) return false;
1837
+ const t = it.title || "";
1838
+ return SERVER_ERR_NUM_RE.test(t) && SERVER_ERR_WORD_RE.test(t);
1839
+ }).length;
1840
+ claudeIncidents = { at: Date.now(), count };
1841
+ } catch { /* keep last known count; a probe failure must never break the pill */ }
1842
+ return claudeIncidents.count;
1843
+ }
1844
+
1845
+ // Operational status for the selected provider, from its public statuspage (CORS-open,
1846
+ // no auth; same Atlassian Statuspage shape for Claude and OpenAI). Maps the indicator to
1847
+ // a colored dot; degrades quietly to "unavailable", and hides for providers with no page.
1848
+ async function loadProviderStatus() {
1753
1849
  const el = $("claudeStatus");
1754
1850
  if (!el || document.hidden) return;
1851
+ const p = USAGE_PROVIDERS[usageProvider] || USAGE_PROVIDERS.claude;
1852
+ if (!p.status) { el.style.display = "none"; return; }
1853
+ el.style.display = "";
1854
+ el.onclick = null; el.style.cursor = ""; el.title = ""; // clear any prior incident affordance
1855
+ let ind = "unknown", desc = p.label + " status unavailable";
1755
1856
  try {
1756
- const r = await fetch("https://status.claude.com/api/v2/status.json", { cache: "no-store" });
1757
- const d = await r.json();
1758
- const ind = (d.status && d.status.indicator) || "unknown";
1759
- const desc = (d.status && d.status.description) || "Status unknown";
1760
- el.className = "cstatus " + (ind === "none" ? "ok" : ind === "minor" ? "warn" : ind === "major" ? "orange" : ind === "critical" ? "bad" : "");
1761
- el.querySelector(".ctext").textContent = desc;
1762
- } catch {
1763
- el.className = "cstatus";
1764
- el.querySelector(".ctext").textContent = "Claude status unavailable";
1857
+ // Prefer the same-origin server proxy: the browser's direct cross-origin fetch is
1858
+ // unreliable in the field (blockers/MITM proxies/captive portals returned a stub
1859
+ // 200 -> a bogus "Status unknown"). Fall back to direct if the server predates it.
1860
+ let s = null;
1861
+ try {
1862
+ const rp = await fetch("/api/provider-status?provider=" + encodeURIComponent(usageProvider), { cache: "no-store" });
1863
+ if (rp.ok) { const dp = await rp.json(); if (!dp.error) s = dp; }
1864
+ } catch { /* fall through to the direct statuspage */ }
1865
+ if (!s) { const d = await (await fetch(p.status, { cache: "no-store" })).json(); s = d.status || {}; }
1866
+ ind = s.indicator || "unknown";
1867
+ desc = s.description || "Status unknown";
1868
+ } catch { ind = "unknown"; }
1869
+ // Claude Code only: overlay the community incident signal. It elevates an otherwise
1870
+ // green/unknown pill to a warning; an official major/critical statuspage state wins.
1871
+ if (usageProvider === "claude" && ind !== "major" && ind !== "critical") {
1872
+ const n = await checkClaudeIncidents();
1873
+ if (n >= INCIDENT_MIN) {
1874
+ el.className = "cstatus orange";
1875
+ el.querySelector(".ctext").textContent = n + " recent 500/529 reports (30 min)";
1876
+ el.title = "Possible Anthropic API incident: " + n + " open claude-code issues reporting 500/529 errors in the last 30 minutes. Click to view.";
1877
+ el.style.cursor = "pointer";
1878
+ el.onclick = () => window.open(CLAUDE_ISSUES_VIEW, "_blank", "noopener");
1879
+ return;
1880
+ }
1765
1881
  }
1882
+ el.className = "cstatus " + (ind === "none" ? "ok" : ind === "minor" ? "warn" : ind === "major" ? "orange" : ind === "critical" ? "bad" : "");
1883
+ el.querySelector(".ctext").textContent = desc;
1766
1884
  }
1767
1885
  async function planSet(key, value) {
1768
1886
  try { await fetch("/api/plan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value: Number(value) }) }); refreshAll(true); }
1769
1887
  catch { /* leave as-is */ }
1770
1888
  }
1771
1889
  function wireUsage() {
1890
+ // Provider + account selectors drive a client-side re-render from the cached report.
1891
+ const ps = $("uProvider");
1892
+ ps.innerHTML = Object.entries(USAGE_PROVIDERS).map(([k, v]) => '<option value="' + k + '">' + esc(v.label) + "</option>").join("");
1893
+ ps.value = usageProvider;
1894
+ ps.addEventListener("change", () => { usageProvider = ps.value; usageAccount = "__all__"; applyUsageView(); loadProviderStatus(); });
1895
+ $("uAccount").addEventListener("change", () => { usageAccount = $("uAccount").value; applyUsageView(); });
1772
1896
  const el = $("uWindows");
1773
1897
  el.addEventListener("click", (e) => {
1774
1898
  const b = e.target.closest("button[data-plan]"); if (!b) return;
@@ -1810,21 +1934,48 @@ export {
1810
1934
  '<span class="tag ' + (p.available ? "meas" : "") + '">' + esc(p.label) + (p.available ? "" : " - " + esc(p.note)) + "</span>").join("");
1811
1935
  }
1812
1936
 
1813
- function renderUsage(u) {
1814
- const panel = $("usagePanel"), hint = $("usageHint");
1815
- if (!u) { panel.style.display = "none"; hint.style.display = "block"; return; }
1816
- hint.style.display = "none";
1817
- panel.style.display = "block";
1818
- renderWindows(u);
1819
- $("uCost").textContent = fmtUsd(u.cost || 0);
1820
- $("uCacheMoney").textContent = fmtUsd(cacheMoney(u.cacheRead || 0));
1821
- $("uCacheRead").textContent = fmt(u.cacheRead || 0);
1822
- $("uInput").textContent = fmt(u.input || 0);
1823
- $("uOutput").textContent = fmt(u.output || 0);
1824
- $("uSessions").textContent = fmt(u.sessions || 0);
1825
- $("uSessionsFoot").textContent = u.pending ? "scanning sessions..." : fmt(u.scannedFiles || 0) + " files scanned";
1937
+ // The Claude account names that have usage, default first then alphabetical.
1938
+ function usageAccountNames() {
1939
+ const names = Object.keys((usageData && usageData.accounts) || {});
1940
+ return names.sort((a, b) => (a === "default" ? -1 : b === "default" ? 1 : a < b ? -1 : a > b ? 1 : 0));
1941
+ }
1942
+ function accountEmail(name) {
1943
+ const a = usageAccountsMeta.find((x) => x.name === name);
1944
+ return a && a.email ? a.email : "";
1945
+ }
1946
+ function accountLabel(name) {
1947
+ const display = name === "default" ? "Default" : name;
1948
+ const email = accountEmail(name);
1949
+ return email ? display + " · " + email : display;
1950
+ }
1951
+ // Rebuild the account <select> only when the account set/labels actually changed, so a
1952
+ // routine poll never resets an open dropdown or the current selection.
1953
+ function buildAccountOptions() {
1954
+ const sel = $("uAccount");
1955
+ const names = usageAccountNames();
1956
+ const sig = names.join("|") + "::" + usageAccountsMeta.map((a) => a.name + ":" + (a.email || "")).join(",");
1957
+ if (usageAccount !== "__all__" && !names.includes(usageAccount)) usageAccount = "__all__";
1958
+ if (sel.dataset.sig !== sig) {
1959
+ sel.dataset.sig = sig;
1960
+ sel.innerHTML = '<option value="__all__">All accounts</option>'
1961
+ + names.map((n) => '<option value="' + esc(n) + '">' + esc(accountLabel(n)) + "</option>").join("");
1962
+ }
1963
+ sel.value = usageAccount;
1964
+ }
1965
+
1966
+ // Fill the panel from one scope: the whole report (isAll) or one account's sub-report.
1967
+ // The Providers line and By Account table are only meaningful for the whole report.
1968
+ function renderUsageScoped(x, isAll, pending) {
1969
+ renderWindows(x);
1970
+ $("uCost").textContent = fmtUsd(x.cost || 0);
1971
+ $("uCacheMoney").textContent = fmtUsd(cacheMoney(x.cacheRead || 0));
1972
+ $("uCacheRead").textContent = fmt(x.cacheRead || 0);
1973
+ $("uInput").textContent = fmt(x.input || 0);
1974
+ $("uOutput").textContent = fmt(x.output || 0);
1975
+ $("uSessions").textContent = fmt(x.sessions || 0);
1976
+ $("uSessionsFoot").textContent = pending ? "scanning sessions..." : fmt(x.scannedFiles || 0) + " files scanned";
1826
1977
  // Current 5-hour block (computed locally from transcript timestamps).
1827
- const b = u.block, bw = $("uBlockWrap");
1978
+ const b = x.block, bw = $("uBlockWrap");
1828
1979
  if (b && b.startedAt) {
1829
1980
  bw.style.display = "block";
1830
1981
  const now = Date.now(), total = 5 * 3600 * 1000;
@@ -1839,11 +1990,58 @@ export {
1839
1990
  $("uBlockBurn").textContent = fmt(Math.round(b.burnRatePerMin || 0)) + " tok/min";
1840
1991
  $("uBlockProj").textContent = fmt(b.projectedTokens || 0) + " · " + fmtUsd(b.projectedCost || 0);
1841
1992
  } else { bw.style.display = "none"; }
1842
- renderProviders(u.providers);
1843
- renderUsageTable($("uByAccount"), u.byAccount, u.pending, "Account");
1844
- renderUsageTable($("uByModel"), u.byModel, u.pending, "Model");
1845
- renderUsageTable($("uByProject"), u.byProject, u.pending, "Project");
1846
- renderRecentSessions($("uRecent"), u.recentSessions, u.pending);
1993
+ $("uByAccountWrap").style.display = isAll ? "" : "none";
1994
+ if (isAll) { renderProviders(x.providers); renderUsageTable($("uByAccount"), x.byAccount, pending, "Account"); }
1995
+ else { $("uProviders").innerHTML = ""; }
1996
+ renderUsageTable($("uByModel"), x.byModel, pending, "Model");
1997
+ renderUsageTable($("uByProject"), x.byProject, pending, "Project");
1998
+ renderRecentSessions($("uRecent"), x.recentSessions, pending);
1999
+ }
2000
+
2001
+ // Adapt the header to the selected provider, then render its scope. Only Claude has a
2002
+ // measurable local store; the others show an honest "no local usage" panel instead.
2003
+ function applyUsageView() {
2004
+ const p = USAGE_PROVIDERS[usageProvider] || USAGE_PROVIDERS.claude;
2005
+ $("uProviderIcon").innerHTML = agentSvg(usageProvider);
2006
+ $("usageTitle").textContent = p.label + " Usage";
2007
+ const link = $("usageViewLink");
2008
+ link.href = p.viewHref; link.textContent = p.viewLabel + " ->";
2009
+ $("uAccountWrap").style.display = (usageProvider === "claude" && usageData) ? "" : "none";
2010
+ const panel = $("usagePanel"), hint = $("usageHint"), unavail = $("usageUnavail");
2011
+ if (usageProvider !== "claude") {
2012
+ panel.style.display = "none"; hint.style.display = "none"; unavail.style.display = "block";
2013
+ $("usageDesc").textContent = p.label + " token usage. enigma can only measure usage for tools that keep a local per-message log it can read.";
2014
+ const cov = ((usageData && usageData.providers) || []).find((x) => x.tool === usageProvider);
2015
+ const note = (cov && cov.note) || "no local token-usage store";
2016
+ $("unavailTitle").textContent = "No local usage data for " + p.label;
2017
+ $("unavailBody").innerHTML = "<b>" + esc(p.label) + "</b>: " + esc(note) + "."
2018
+ + " enigma measures token usage only for tools that keep a local per-message log it can read - today that is Claude Code."
2019
+ + " Switch the provider to <b>Claude Code</b> for measured usage, or open " + esc(p.label) + "'s own dashboard via the link above.";
2020
+ return;
2021
+ }
2022
+ $("usageDesc").innerHTML = CLAUDE_USAGE_DESC;
2023
+ unavail.style.display = "none";
2024
+ if (!usageData) { panel.style.display = "none"; hint.style.display = "block"; return; }
2025
+ hint.style.display = "none"; panel.style.display = "block";
2026
+ buildAccountOptions();
2027
+ const isAll = usageAccount === "__all__";
2028
+ const scoped = isAll ? usageData : ((usageData.accounts && usageData.accounts[usageAccount]) || usageData);
2029
+ renderUsageScoped(scoped, isAll, usageData.pending);
2030
+ }
2031
+
2032
+ // Entry point from the stats poll: cache the report, then render the active scope.
2033
+ function renderUsage(u) {
2034
+ usageData = u || null;
2035
+ applyUsageView();
2036
+ }
2037
+
2038
+ // Claude account identities (for the email labels), fetched once when the view opens.
2039
+ async function loadUsageAccounts() {
2040
+ try {
2041
+ const d = await (await fetch("/api/accounts", { cache: "no-store" })).json();
2042
+ usageAccountsMeta = (d.accounts || []).filter((a) => a.tool === "claude");
2043
+ if (usageProvider === "claude" && usageData) applyUsageView();
2044
+ } catch { /* labels fall back to account names */ }
1847
2045
  }
1848
2046
 
1849
2047
  function renderCache(c) {
@@ -2014,7 +2212,7 @@ export {
2014
2212
  $("removed").textContent = fmt(saved) + " (" + rate + "%)";
2015
2213
  $("reduction").textContent = before ? (100 - rate) + "% of input sent" : "-";
2016
2214
  $("ratio").textContent = after ? "~" + (before / after).toFixed(1) + ":1" : "-";
2017
- renderBreakdown($("sources"), s.bySource, prettySource, true);
2215
+ renderBreakdown($("sources"), s.bySource, prettySource, true, sourceSvg);
2018
2216
  renderBreakdown($("types"), s.byType, prettyType, false);
2019
2217
 
2020
2218
  renderUsage(data.usage);
@@ -2096,6 +2294,26 @@ export {
2096
2294
  // --- settings subpage (mirrors the TUI registry over /api/settings) ---
2097
2295
  function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); }
2098
2296
 
2297
+ // A self-dismissing notification so a failed action can't slip by unseen. Errors
2298
+ // linger longer than confirmations; both auto-clear so they never pile up.
2299
+ function toast(msg, kind) {
2300
+ let wrap = $("toasts");
2301
+ if (!wrap) { wrap = document.createElement("div"); wrap.id = "toasts"; document.body.appendChild(wrap); }
2302
+ const el = document.createElement("div");
2303
+ el.className = "toast " + (kind === "err" ? "err" : kind === "ok" ? "ok" : "");
2304
+ el.setAttribute("role", kind === "err" ? "alert" : "status");
2305
+ el.textContent = msg;
2306
+ wrap.appendChild(el);
2307
+ setTimeout(() => { el.classList.add("hide"); setTimeout(() => el.remove(), 320); }, kind === "err" ? 6000 : 3000);
2308
+ }
2309
+ // Set a status note's text and ok/err styling in one place. Errors are also toasted
2310
+ // (the inline note alone is easy to miss), so callers just call setNote(el, msg, "err").
2311
+ function setNote(el, msg, kind) {
2312
+ if (typeof el === "string") el = $(el);
2313
+ if (el) { el.textContent = msg; el.classList.toggle("ok", kind === "ok"); el.classList.toggle("err", kind === "err"); }
2314
+ if (kind === "err") toast(msg, "err");
2315
+ }
2316
+
2099
2317
  // Capitalize a choice value for display ("on-demand" -> "On demand"), keeping the raw
2100
2318
  // value as the option's value so the server still receives the exact token.
2101
2319
  function prettyChoice(c) { c = String(c).replace(/[-_]/g, " "); return c.charAt(0).toUpperCase() + c.slice(1); }
@@ -2107,7 +2325,12 @@ export {
2107
2325
  const back = document.createElement("div");
2108
2326
  back.className = "modal-back";
2109
2327
  let inner = "";
2110
- if (field && field.options) {
2328
+ if (field && field.iconOptions) {
2329
+ // Clickable app cards with logos; a native <select> can't render SVG options.
2330
+ inner = '<div class="modal-picker">' + field.iconOptions.map((o) =>
2331
+ '<button type="button" class="pick-card' + (o.value === field.value ? " sel" : "") + '" data-pick="' + esc(o.value) + '">'
2332
+ + (o.icon ? '<span class="pick-ic">' + o.icon + "</span>" : "") + "<span>" + esc(o.label || o.value) + "</span></button>").join("") + "</div>";
2333
+ } else if (field && field.options) {
2111
2334
  inner = '<select class="modal-input">' + field.options.map((o) =>
2112
2335
  '<option value="' + esc(o.value) + '"' + (o.value === field.value ? " selected" : "") + ">" + esc(o.label || o.value) + "</option>").join("") + "</select>";
2113
2336
  } else if (field) {
@@ -2138,6 +2361,10 @@ export {
2138
2361
  const ok = () => { if (invalid) return; close(field ? (inp ? inp.value : true) : true); };
2139
2362
  const cancel = () => close(field ? null : false);
2140
2363
  okBtn.addEventListener("click", ok);
2364
+ if (field && field.iconOptions) {
2365
+ okBtn.style.display = "none"; // a card click is the confirm
2366
+ back.querySelectorAll(".pick-card").forEach((c) => c.addEventListener("click", () => close(c.dataset.pick)));
2367
+ }
2141
2368
  if (inp && validate) { inp.addEventListener("input", runValidate); runValidate(); }
2142
2369
  back.querySelector(".modal-cancel").addEventListener("click", cancel);
2143
2370
  back.addEventListener("mousedown", (e) => { if (e.target === back) cancel(); });
@@ -2145,11 +2372,13 @@ export {
2145
2372
  if (e.key === "Escape") { e.preventDefault(); cancel(); }
2146
2373
  else if (e.key === "Enter" && (!inp || inp.tagName !== "TEXTAREA")) { e.preventDefault(); ok(); }
2147
2374
  });
2148
- if (inp) { inp.focus(); if (inp.select) inp.select(); } else okBtn.focus();
2375
+ if (inp) { inp.focus(); if (inp.select) inp.select(); }
2376
+ else { const c = back.querySelector(".pick-card.sel") || back.querySelector(".pick-card") || okBtn; c.focus(); }
2149
2377
  });
2150
2378
  }
2151
2379
  const askText = (title, value, message, validate) => openModal({ title, message, field: { value: value || "", validate } }).then((v) => (v == null ? null : String(v).trim()));
2152
2380
  const askChoice = (title, options, value, message) => openModal({ title, message, field: { options, value } });
2381
+ const askIconChoice = (title, iconOptions, value, message) => openModal({ title, message, field: { iconOptions, value } });
2153
2382
  const askConfirm = (title, message, danger) => openModal({ title, message, danger, okLabel: danger ? "Remove" : "OK" }).then(Boolean);
2154
2383
 
2155
2384
  // Turn "(experimental)" and " · measured/experimental" label suffixes into visual badges.
@@ -2196,7 +2425,7 @@ export {
2196
2425
  // render it as a switch (like a pure bool); 3+ values stay a select with pretty labels.
2197
2426
  const asSwitch = !s.choices || (s.choices.length === 2 && s.choices.indexOf(s.offChoice) !== -1);
2198
2427
  const ctl = !asSwitch
2199
- ? '<select class="choice" data-skey="' + esc(s.key) + '">'
2428
+ ? '<select class="choice" data-skey="' + esc(s.key) + '" data-prev="' + esc(s.choice) + '">'
2200
2429
  + s.choices.map((c) => '<option value="' + esc(c) + '"' + (c === s.choice ? " selected" : "") + ">" + esc(prettyChoice(c)) + "</option>").join("")
2201
2430
  + "</select>"
2202
2431
  : '<button type="button" class="toggle' + (s.value ? " on" : "") + '" data-skey="' + esc(s.key)
@@ -2218,32 +2447,44 @@ export {
2218
2447
  + '<div class="set-grid">' + c.settings.map(settingRow).join("") + "</div></div>").join("");
2219
2448
  }
2220
2449
 
2450
+ function applyBool(ctl, on) { ctl.classList.toggle("on", on); ctl.textContent = on ? "On" : "Off"; ctl.dataset.on = on ? 1 : 0; }
2451
+
2452
+ // Optimistic: flip the control immediately, then reconcile with the server. On any
2453
+ // failure - rejected write, unreachable server, OR a write that silently did not take
2454
+ // effect (returned value != requested) - roll the control back and surface the error.
2221
2455
  async function postSetting(key, value, ctl, noteEl) {
2222
- const note = noteEl || $("settingsNote");
2223
- note.textContent = "Saving " + key + "...";
2456
+ const el = noteEl || $("settingsNote");
2457
+ const isBool = ctl.dataset.kind === "bool";
2458
+ const want = isBool ? (value === true || value === 1) : String(value);
2459
+ const prevBool = ctl.dataset.on === "1";
2460
+ const prevChoice = ctl.dataset.prev != null ? ctl.dataset.prev : ctl.value; // <select> already changed natively
2461
+ const rollback = () => { if (isBool) applyBool(ctl, prevBool); else ctl.value = prevChoice; };
2462
+ if (isBool) applyBool(ctl, want);
2463
+ setNote(el, "Saving " + key + "...");
2224
2464
  try {
2225
2465
  const res = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value }) });
2226
2466
  const out = await res.json();
2227
- if (!out.ok) { note.textContent = "Could not change " + key + ": " + (out.error || "error"); return; }
2467
+ if (!out.ok) { rollback(); setNote(el, "Could not change " + key + ": " + (out.error || "error"), "err"); return; }
2228
2468
  const s = out.setting;
2229
- if (s && ctl.dataset.kind === "bool") { ctl.classList.toggle("on", s.value); ctl.textContent = s.value ? "On" : "Off"; ctl.dataset.on = s.value ? 1 : 0; }
2230
- else if (s) { ctl.value = s.choice; }
2231
- note.textContent = out.restartNote || ("Saved " + key + ".");
2232
- } catch { note.textContent = "Could not reach the server to change " + key + "."; }
2469
+ if (s && isBool) applyBool(ctl, s.value);
2470
+ else if (s) { ctl.value = s.choice; ctl.dataset.prev = s.choice; }
2471
+ if (s && (isBool ? s.value !== want : String(s.choice) !== want)) { setNote(el, key + " did not change (the write had no effect).", "err"); return; }
2472
+ setNote(el, out.restartNote || ("Saved " + key + "."), "ok");
2473
+ } catch { rollback(); setNote(el, "Could not reach the server to change " + key + ".", "err"); }
2233
2474
  }
2234
2475
 
2235
2476
  // List settings: add/remove one entry, then reload so the chips reflect disk truth.
2236
2477
  async function postListOp(key, op, item) {
2237
2478
  item = (item || "").trim();
2238
2479
  if (!item) return;
2239
- $("settingsNote").textContent = (op === "add" ? "Adding to " : "Removing from ") + key + "...";
2480
+ setNote("settingsNote", (op === "add" ? "Adding to " : "Removing from ") + key + "...");
2240
2481
  try {
2241
2482
  const res = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value: { op, item } }) });
2242
2483
  const out = await res.json();
2243
- if (!out.ok) { $("settingsNote").textContent = "Could not change " + key + ": " + (out.error || "error"); return; }
2244
- $("settingsNote").textContent = "Saved " + key + ".";
2484
+ if (!out.ok) { setNote("settingsNote", "Could not change " + key + ": " + (out.error || "error"), "err"); return; }
2485
+ setNote("settingsNote", "Saved " + key + ".", "ok");
2245
2486
  await loadSettings();
2246
- } catch { $("settingsNote").textContent = "Could not reach the server to change " + key + "."; }
2487
+ } catch { setNote("settingsNote", "Could not reach the server to change " + key + ".", "err"); }
2247
2488
  }
2248
2489
 
2249
2490
  function wireSettings() {
@@ -2293,12 +2534,13 @@ export {
2293
2534
  async function saveDashPort() {
2294
2535
  const raw = $("dashPortInput").value.trim();
2295
2536
  const value = raw === "" ? 0 : Number(raw);
2296
- if (!Number.isInteger(value) || value < 0 || value > 65535) { $("settingsNote").textContent = "Port must be 0 (auto) or 1-65535."; return; }
2297
- $("settingsNote").textContent = "Saving dashboard port...";
2537
+ if (!Number.isInteger(value) || value < 0 || value > 65535) { setNote("settingsNote", "Port must be 0 (auto) or 1-65535.", "err"); return; }
2538
+ setNote("settingsNote", "Saving dashboard port...");
2298
2539
  try {
2299
2540
  const out = await (await fetch("/api/dashboard-port", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ value }) })).json();
2300
- $("settingsNote").textContent = out.ok ? (out.restartNote || ("Saved dashboard port = " + (value || "auto") + ".")) : ("Could not save: " + (out.error || "error"));
2301
- } catch { $("settingsNote").textContent = "Could not reach the server to set the port."; }
2541
+ if (out.ok) setNote("settingsNote", out.restartNote || ("Saved dashboard port = " + (value || "auto") + "."), "ok");
2542
+ else setNote("settingsNote", "Could not save: " + (out.error || "error"), "err");
2543
+ } catch { setNote("settingsNote", "Could not reach the server to set the port.", "err"); }
2302
2544
  }
2303
2545
 
2304
2546
  // --- "Enigma systems" overview (factual state, never invented savings) ---
@@ -2359,19 +2601,19 @@ export {
2359
2601
  }
2360
2602
 
2361
2603
  async function runUpdate() {
2362
- $("updateNote").textContent = "Checking for updates...";
2604
+ setNote("updateNote", "Checking for updates...");
2363
2605
  try {
2364
2606
  const res = await fetch("/api/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
2365
2607
  const out = await res.json();
2366
- if (!out.ok) { $("updateNote").textContent = "Update failed: " + (out.error || "error"); return; }
2608
+ if (!out.ok) { setNote("updateNote", "Update failed: " + (out.error || "error"), "err"); return; }
2367
2609
  // Only reload when something actually changed; an up-to-date check just reports.
2368
2610
  if (out.changed) {
2369
- $("updateNote").textContent = (out.note || "Updated.") + " Refreshing the dashboard...";
2611
+ setNote("updateNote", (out.note || "Updated.") + " Refreshing the dashboard...", "ok");
2370
2612
  setTimeout(() => location.reload(), 1200);
2371
2613
  } else {
2372
- $("updateNote").textContent = out.note || "Already up to date.";
2614
+ setNote("updateNote", out.note || "Already up to date.", "ok");
2373
2615
  }
2374
- } catch { $("updateNote").textContent = "Could not reach the server to update."; }
2616
+ } catch { setNote("updateNote", "Could not reach the server to update.", "err"); }
2375
2617
  }
2376
2618
 
2377
2619
  // Detect & repair every tool's launch path; the server persists any off-PATH
@@ -2387,10 +2629,12 @@ export {
2387
2629
  $("updateNote").textContent = "";
2388
2630
  renderFixPath(out);
2389
2631
  loadSystems(true);
2632
+ if (!out.ok) toast("Some tools could not be fixed - see the details below.", "err");
2390
2633
  } catch {
2391
2634
  $("updateNote").textContent = "";
2392
2635
  $("fixPathResult").className = "fixres show";
2393
2636
  $("fixPathResult").innerHTML = '<div class="fixres-head bad">Could not reach the server to fix tool paths.</div>';
2637
+ toast("Could not reach the server to fix tool paths.", "err");
2394
2638
  } finally { if (btn) { btn.disabled = false; } }
2395
2639
  }
2396
2640
 
@@ -2455,6 +2699,14 @@ export {
2455
2699
  const AGENT_NAME_BY_LABEL = { "Claude Code": "claude", "OpenAI Codex": "codex", "OpenCode": "opencode" };
2456
2700
  const agentSvg = (name) => AGENT_ICONS[name] || "";
2457
2701
 
2702
+ // Compression sources -> a brand icon. Reuses the app icons; the CLI source is enigma itself
2703
+ // (the monochrome cube mark, recoloured to currentColor so it sits uniformly in the tables).
2704
+ const ENIGMA_ICON = '<svg viewBox="0 0 2187.36 2500.37" fill="currentColor" aria-hidden="true"><path d="m2152.75 605.14c-2.56-1.46-5.13-2.93-8.06-4.76q-254.23-145.51-507.76-291.38-266.32-152.69-532.28-305.34l-6.23-3.66-6.59 2.56a202.53 202.53 0 0 0 -29.28 13.55q-231.73 135.63-463.1 270.89c-191.83 112.4-384.03 224.42-574.39 336.44-23.8 11-23.07 35.88-22.7 50.89 0 3.29.37 7 0 10.62q-.55 426.12-1.46 852.24l-.74 316.3c-1.46 19 7 35.15 21.24 41.74q330 187.24 660.05 375.23l389.15 221.54c8.79 5.49 17.57 8.42 25.63 8.42a29.12 29.12 0 0 0 18.3-6.23l564.47-325.13c160.35-92.26 320.7-184.88 479.94-276.4 17.94-8.05 28.56-26 26.36-42.83l1.83-1197.1c2.56-27.46-20.5-40.27-34.41-47.59m-1058.32 75.41 464.19 270.17-463.46 267.28-157.79-89.33q-155.94-88.41-311.9-176.45l469-271.64m527.52 233.57-504.1-293.24c-13.91-9.88-32.21-9.88-45.39-1.1l-510.73 295.77-463.83-262.12 323.62-189.26q336.62-197.15 673.6-393.55 309.15 178.47 618.68 355.47 188.91 108.74 377.8 217.09l-469.69 270.91m-1555.86-205.74 461.63 261.38 9.52 576.59c-1.1 17.93 7.69 34 21.6 40.27q153.19 88.38 307.19 176.45l197 113.12-.36 538.51-998.78-568.16zm525.7 297.62 471.88 266.88-.73 530.46-462.36-265.78zm534.85 870.56 197.32-113.12c101-58.21 202.45-116 304.22-174.63l2.93-1.83c5.49-3.29 12.81-7.69 17.93-15l2.93-4v-4.76c-1.09-127-1.09-256.63-.73-381.83 0-70.29.37-140.58 0-210.87l472.25-272.36-.73 464.56-1.1 678.36q-316.29 181.76-633 364.25l-362.79 209 .73-537.78m0-72.86.37-530.82 464.93-268v531.92q-232.29 132.89-465.3 266.87"/></svg>';
2705
+ const SOURCE_ICON_KEY = { "claude-code": "claude", "claude": "claude", "claude-desktop": "claude", "opencode": "opencode", "codex": "codex", "codex-cli": "codex" };
2706
+ function sourceSvg(k) { return k === "cli" ? ENIGMA_ICON : (AGENT_ICONS[SOURCE_ICON_KEY[k]] || ""); }
2707
+ // Brand icon + text in one inline tag; the icon is omitted when there is none (e.g. content types).
2708
+ function iconLabel(ic, text) { return '<span class="srctag">' + (ic ? '<span class="aicon">' + ic + '</span>' : '') + esc(text) + '</span>'; }
2709
+
2458
2710
  function skillRow(s) {
2459
2711
  const ver = s.version ? '<span class="tag">v' + esc(s.version) + "</span>" : "";
2460
2712
  // Last-edited date (from skill.json), shown relative via the vendored <relative-time>.
@@ -2512,24 +2764,24 @@ export {
2512
2764
  }
2513
2765
 
2514
2766
  async function postSkill(name, action, content) {
2515
- $("skillsNote").textContent = "Working...";
2767
+ setNote("skillsNote", "Working...");
2516
2768
  try {
2517
2769
  const res = await fetch("/api/skills", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, action, content }) });
2518
2770
  const out = await res.json();
2519
- if (!out.ok) { $("skillsNote").textContent = "Could not " + action + " " + name + ": " + (out.error || "error"); return; }
2771
+ if (!out.ok) { setNote("skillsNote", "Could not " + action + " " + name + ": " + (out.error || "error"), "err"); return; }
2520
2772
  if (out.skills) renderSkills(out.skills);
2521
- $("skillsNote").textContent = out.note || ("Done: " + name);
2522
- } catch { $("skillsNote").textContent = "Could not reach the server to change " + name + "."; }
2773
+ setNote("skillsNote", out.note || ("Done: " + name), "ok");
2774
+ } catch { setNote("skillsNote", "Could not reach the server to change " + name + ".", "err"); }
2523
2775
  }
2524
2776
 
2525
2777
  // Inline SKILL.md editor (one skill at a time).
2526
2778
  let editing = null;
2527
2779
  async function openEditor(name, isEnigma) {
2528
- $("skillsNote").textContent = "Loading " + name + "...";
2780
+ setNote("skillsNote", "Loading " + name + "...");
2529
2781
  try {
2530
2782
  const res = await fetch("/api/skills", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, action: "read" }) });
2531
2783
  const out = await res.json();
2532
- if (!out.ok) { $("skillsNote").textContent = "Could not open " + name + ": " + (out.error || "error"); return; }
2784
+ if (!out.ok) { setNote("skillsNote", "Could not open " + name + ": " + (out.error || "error"), "err"); return; }
2533
2785
  editing = name;
2534
2786
  $("seName").textContent = name;
2535
2787
  $("seWarn").textContent = isEnigma ? "managed by enigma - see Customized-skill updates below for whether edits survive an update" : "external skill";
@@ -2539,7 +2791,7 @@ export {
2539
2791
  $("skillsNote").textContent = "";
2540
2792
  window.scrollTo({ top: 0, behavior: "smooth" });
2541
2793
  $("seText").focus();
2542
- } catch { $("skillsNote").textContent = "Could not reach the server."; }
2794
+ } catch { setNote("skillsNote", "Could not reach the server.", "err"); }
2543
2795
  }
2544
2796
  function closeEditor() { editing = null; $("skillEditor").style.display = "none"; }
2545
2797
  // Render per-app save targets (one checkbox per app that holds the skill), only when
@@ -2559,18 +2811,18 @@ export {
2559
2811
  let content = $("seText").value;
2560
2812
  if (agEl.dataset.has === "1") {
2561
2813
  const agents = [...agEl.querySelectorAll("input[type=checkbox]:checked")].map((i) => i.dataset.agent);
2562
- if (!agents.length) { $("skillsNote").textContent = "Pick at least one app to save to."; return; }
2814
+ if (!agents.length) { setNote("skillsNote", "Pick at least one app to save to.", "err"); return; }
2563
2815
  content = { text: $("seText").value, agents }; // per-app save
2564
2816
  }
2565
- $("skillsNote").textContent = "Saving " + editing + "...";
2817
+ setNote("skillsNote", "Saving " + editing + "...");
2566
2818
  try {
2567
2819
  const res = await fetch("/api/skills", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editing, action: "save", content }) });
2568
2820
  const out = await res.json();
2569
- if (!out.ok) { $("skillsNote").textContent = "Could not save " + editing + ": " + (out.error || "error"); return; }
2821
+ if (!out.ok) { setNote("skillsNote", "Could not save " + editing + ": " + (out.error || "error"), "err"); return; }
2570
2822
  if (out.skills) renderSkills(out.skills);
2571
- $("skillsNote").textContent = out.note || "Saved.";
2823
+ setNote("skillsNote", out.note || "Saved.", "ok");
2572
2824
  closeEditor();
2573
- } catch { $("skillsNote").textContent = "Could not reach the server."; }
2825
+ } catch { setNote("skillsNote", "Could not reach the server.", "err"); }
2574
2826
  }
2575
2827
 
2576
2828
  function wireSkills() {
@@ -2666,7 +2918,7 @@ export {
2666
2918
  const ic = agentSvg(t.name);
2667
2919
  const lbl = ic ? '<span class="aicon" title="' + esc(t.label) + '" aria-label="' + esc(t.label) + '">' + ic + "</span>" : esc(t.label);
2668
2920
  return '<label style="font-size:12px;color:var(--muted);display:inline-flex;align-items:center;gap:6px;margin:0 12px 6px 0">' + lbl
2669
- + ' <select class="choice" data-prof-tool="' + esc(t.name) + '" data-prof="' + esc(p.name) + '">' + opts + "</select></label>";
2921
+ + ' <select class="choice" data-prof-tool="' + esc(t.name) + '" data-prof="' + esc(p.name) + '" data-prev="' + esc(cur) + '">' + opts + "</select></label>";
2670
2922
  }).join("");
2671
2923
  const acts = '<div class="set-ctl" style="display:flex;gap:6px;align-items:center">'
2672
2924
  + (p.active ? "" : '<button type="button" class="toggle" data-prof-act="activate" data-name="' + esc(p.name) + '">Use</button>')
@@ -2683,19 +2935,24 @@ export {
2683
2935
  $("profilesBody").innerHTML = profiles.length ? profiles.map((p) => profileRow(p, tools, accounts)).join("")
2684
2936
  : '<div class="empty" style="padding:20px 0">No profiles yet. Add one to pin an account per tool.</div>';
2685
2937
  }
2686
- async function accAction(op, payload) {
2687
- $("accountsNote").textContent = "Working...";
2938
+ // `rollback` (optional) restores an optimistically-changed control (e.g. a profile
2939
+ // <select>) when the server rejects or is unreachable, so the UI never shows a value
2940
+ // that was not saved. A successful action re-renders from out.data (authoritative).
2941
+ async function accAction(op, payload, rollback) {
2942
+ setNote("accountsNote", "Working...");
2688
2943
  try {
2689
2944
  const r = await fetch("/api/accounts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.assign({ op }, payload)) });
2690
2945
  const out = await r.json();
2691
- $("accountsNote").textContent = out.ok ? "Saved." : ("Error: " + (out.error || "failed"));
2692
2946
  if (out.data) renderAccounts(out.data);
2693
- } catch { $("accountsNote").textContent = "Could not reach the server."; }
2947
+ if (out.ok) { setNote("accountsNote", "Saved.", "ok"); return; }
2948
+ if (rollback && !out.data) rollback();
2949
+ setNote("accountsNote", "Error: " + (out.error || "failed"), "err");
2950
+ } catch { if (rollback) rollback(); setNote("accountsNote", "Could not reach the server.", "err"); }
2694
2951
  }
2695
2952
  function wireAccounts() {
2696
2953
  $("accAdd").addEventListener("click", async () => {
2697
2954
  const tool = accTools.length === 1 ? accTools[0].name
2698
- : await askChoice("Add account", accTools.map((t) => ({ value: t.name, label: t.label || t.name })), accTools[0] ? accTools[0].name : "", "Which tool is this account for?");
2955
+ : await askIconChoice("Add account", accTools.map((t) => ({ value: t.name, label: t.label || t.name, icon: agentSvg(t.name) })), accTools[0] ? accTools[0].name : "", "Which tool is this account for?");
2699
2956
  if (!tool) return;
2700
2957
  const taken = accAccounts.filter((a) => a.tool === tool).map((a) => a.name);
2701
2958
  const name = await askText("Add account", "", "New account name (e.g. work):", nameValidator(taken, null));
@@ -2708,7 +2965,7 @@ export {
2708
2965
  if (op === "activate") accAction("account.activate", { tool, name });
2709
2966
  else if (op === "remove") { if (await askConfirm("Remove account", "Remove account '" + name + "' and delete its local config dir?", true)) accAction("account.remove", { tool, name }); }
2710
2967
  else if (op === "rename") { const taken = accAccounts.filter((a) => a.tool === tool).map((a) => a.name); const nn = await askText("Rename account", name, "Rename '" + name + "' to:", nameValidator(taken, name)); if (nn && nn !== name) accAction("account.rename", { tool, name, newName: nn }); }
2711
- else if (op === "login") { $("accountsNote").textContent = "To log in, run this in a terminal: enigma " + tool + " " + name; }
2968
+ else if (op === "login") { setNote("accountsNote", "To log in, run this in a terminal: enigma " + tool + " " + name); }
2712
2969
  });
2713
2970
  $("profilesBody").addEventListener("click", async (e) => {
2714
2971
  const b = e.target.closest("button[data-prof-act]"); if (!b) return;
@@ -2719,27 +2976,28 @@ export {
2719
2976
  });
2720
2977
  $("profilesBody").addEventListener("change", (e) => {
2721
2978
  const sel = e.target.closest("select[data-prof-tool]"); if (!sel) return;
2722
- accAction("profile.setAccount", { profile: sel.dataset.prof, tool: sel.dataset.profTool, account: sel.value || null });
2979
+ const prev = sel.dataset.prev != null ? sel.dataset.prev : "";
2980
+ accAction("profile.setAccount", { profile: sel.dataset.prof, tool: sel.dataset.profTool, account: sel.value || null }, () => { sel.value = prev; });
2723
2981
  });
2724
2982
  }
2725
2983
 
2726
2984
  // --- config import / export (the browser file picker is what "asks where") ---
2727
2985
  async function exportConfig() {
2728
- $("settingsNote").textContent = "Exporting...";
2986
+ setNote("settingsNote", "Exporting...");
2729
2987
  let text;
2730
2988
  try { text = await (await fetch("/api/config-export", { cache: "no-store" })).text(); }
2731
- catch { $("settingsNote").textContent = "Export failed."; return; }
2989
+ catch { setNote("settingsNote", "Export failed.", "err"); return; }
2732
2990
  const name = "enigma-config-" + new Date().toISOString().slice(0, 10) + ".json";
2733
2991
  if (window.showSaveFilePicker) {
2734
2992
  try {
2735
2993
  const h = await window.showSaveFilePicker({ suggestedName: name, types: [{ description: "JSON", accept: { "application/json": [".json"] } }] });
2736
2994
  const w = await h.createWritable(); await w.write(text); await w.close();
2737
- $("settingsNote").textContent = "Exported (no secrets included)."; return;
2738
- } catch (e) { if (e && e.name === "AbortError") { $("settingsNote").textContent = "Export cancelled."; return; } }
2995
+ setNote("settingsNote", "Exported (no secrets included).", "ok"); return;
2996
+ } catch (e) { if (e && e.name === "AbortError") { setNote("settingsNote", "Export cancelled."); return; } }
2739
2997
  }
2740
2998
  const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
2741
2999
  const a = document.createElement("a"); a.href = url; a.download = name; a.click(); URL.revokeObjectURL(url);
2742
- $("settingsNote").textContent = "Exported to your downloads (no secrets included).";
3000
+ setNote("settingsNote", "Exported to your downloads (no secrets included).", "ok");
2743
3001
  }
2744
3002
  async function importConfig() {
2745
3003
  let text = null;
@@ -2755,14 +3013,14 @@ export {
2755
3013
  });
2756
3014
  if (text === null) return;
2757
3015
  }
2758
- let body; try { body = JSON.parse(text); } catch { $("settingsNote").textContent = "That file is not valid JSON."; return; }
2759
- $("settingsNote").textContent = "Importing...";
3016
+ let body; try { body = JSON.parse(text); } catch { setNote("settingsNote", "That file is not valid JSON.", "err"); return; }
3017
+ setNote("settingsNote", "Importing...");
2760
3018
  try {
2761
3019
  const out = await (await fetch("/api/config-import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })).json();
2762
- if (!out.ok) { $("settingsNote").textContent = "Import failed: " + (out.error || "error"); return; }
2763
- $("settingsNote").textContent = "Imported: " + (out.applied || []).join(", ") + ((out.skipped && out.skipped.length) ? (" · skipped: " + out.skipped.join(", ")) : "");
3020
+ if (!out.ok) { setNote("settingsNote", "Import failed: " + (out.error || "error"), "err"); return; }
3021
+ setNote("settingsNote", "Imported: " + (out.applied || []).join(", ") + ((out.skipped && out.skipped.length) ? (" · skipped: " + out.skipped.join(", ")) : ""), "ok");
2764
3022
  settingsLoaded = false; loadSettings(); accountsLoaded = false;
2765
- } catch { $("settingsNote").textContent = "Could not reach the server to import."; }
3023
+ } catch { setNote("settingsNote", "Could not reach the server to import.", "err"); }
2766
3024
  }
2767
3025
 
2768
3026
  // --- hash routing between the subpages ---
@@ -2776,6 +3034,7 @@ export {
2776
3034
  if (v === "settings" && !settingsLoaded) { settingsLoaded = true; loadSettings(); }
2777
3035
  if (v === "skills" && !skillsLoaded) { skillsLoaded = true; loadSkills(); }
2778
3036
  if (v === "accounts" && !accountsLoaded) { accountsLoaded = true; loadAccounts(); }
3037
+ if (v === "usage") { loadProviderStatus(); if (!usageAccountsLoaded) { usageAccountsLoaded = true; loadUsageAccounts(); } }
2779
3038
  if (v === "resources") loadResources();
2780
3039
  // The chart was sized while its view may have been hidden; nudge it on return.
2781
3040
  if (v === "savings") { loadSystems(true); if (chart) { try { applyRange(); } catch { /* not ready */ } } }
@@ -2810,12 +3069,12 @@ export {
2810
3069
  }
2811
3070
  async function resAction(op, value, confirmMsg) {
2812
3071
  if (confirmMsg && !(await askConfirm("Confirm", confirmMsg, true))) return;
2813
- $("resNote").textContent = "Running...";
3072
+ setNote("resNote", "Running...");
2814
3073
  try {
2815
3074
  const out = await (await fetch("/api/resources", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ op, value }) })).json();
2816
- $("resNote").textContent = out.message || (out.ok ? "Done." : "Failed.");
2817
3075
  if (out.status) renderResources(out.status);
2818
- } catch { $("resNote").textContent = "Could not reach the server."; }
3076
+ setNote("resNote", out.message || (out.ok ? "Done." : "Action failed."), out.ok ? "ok" : "err");
3077
+ } catch { setNote("resNote", "Could not reach the server.", "err"); }
2819
3078
  }
2820
3079
  function wireResources() {
2821
3080
  $("resRefresh").addEventListener("click", loadResources);
@@ -2838,12 +3097,12 @@ export {
2838
3097
 
2839
3098
  initChart();
2840
3099
  poll(true);
2841
- loadClaudeStatus();
3100
+ loadProviderStatus();
2842
3101
  checkDashboardUpdate();
2843
3102
  setInterval(() => refreshAll(false), POLL_MS);
2844
- setInterval(loadClaudeStatus, 60000);
3103
+ setInterval(loadProviderStatus, 60000);
2845
3104
  setInterval(checkDashboardUpdate, UPDATE_CHECK_MS);
2846
- document.addEventListener("visibilitychange", () => { if (!document.hidden) { refreshAll(true); loadClaudeStatus(); checkDashboardUpdate(); } });
3105
+ document.addEventListener("visibilitychange", () => { if (!document.hidden) { refreshAll(true); loadProviderStatus(); checkDashboardUpdate(); } });
2847
3106
  wireSettings();
2848
3107
  wireSkills();
2849
3108
  wireAccounts();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/dashboard",
3
- "version": "0.1.38",
3
+ "version": "0.1.43",
4
4
  "description": "Local browser dashboard UI for enigma: the static page and chart assets enigma serves on its loopback dashboard (savings, real tool usage, in-browser settings). Installed on demand by enigma-cli; not a runtime dependency.",
5
5
  "type": "module",
6
6
  "files": [