@enigmax/dashboard 0.1.5 → 0.1.7

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/assets/index.html CHANGED
@@ -118,6 +118,8 @@
118
118
  }
119
119
  .tag.ext { color: var(--accent2); }
120
120
  .tag.warn { color: var(--accent); border-color: var(--accent); }
121
+ .tag.exp { color: var(--accent); border-color: var(--accent); background: rgba(224,164,88,0.10); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; }
122
+ .tag.meas { color: var(--good); border-color: var(--good); background: rgba(111,207,151,0.10); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; }
121
123
  .editor {
122
124
  width: 100%; min-height: 360px; resize: vertical; background: var(--surface2); color: var(--text);
123
125
  border: 1px solid var(--border); border-radius: 8px; padding: 12px;
@@ -128,6 +130,17 @@
128
130
  border-radius: 7px; padding: 5px 14px; font-size: 12px; cursor: pointer; font-family: inherit;
129
131
  }
130
132
  .btn-danger:hover { border-color: var(--accent2); }
133
+ /* List settings (guard block/allow globs, custom secret patterns): chips + add input. A list row spans the full grid width. */
134
+ .set-row.list { grid-column: 1 / -1; align-items: stretch; flex-direction: column; }
135
+ .lchips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
136
+ .lchip { display: inline-flex; align-items: center; gap: 4px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; padding: 3px 4px 3px 9px; font-size: 12px; color: var(--text); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
137
+ .lchip .lx { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 13px; line-height: 1; padding: 0 4px; font-family: inherit; }
138
+ .lchip .lx:hover { color: var(--accent2); }
139
+ .lnone { color: var(--muted); font-size: 12px; margin-top: 8px; font-style: italic; }
140
+ .ladd { display: flex; gap: 8px; margin-top: 8px; }
141
+ .linput { flex: 1; min-width: 0; background: var(--surface2); color: var(--text); border: 1px solid var(--border); border-radius: 7px; padding: 5px 10px; font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
142
+ .laddb { background: var(--surface2); color: var(--accent); border: 1px solid var(--border); border-radius: 7px; padding: 5px 14px; font-size: 12px; cursor: pointer; font-family: inherit; }
143
+ .laddb:hover { border-color: var(--accent); }
131
144
  .sys-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 24px; }
132
145
  .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; }
133
146
  .sys-item .k { color: var(--muted); }
@@ -146,6 +159,8 @@
146
159
  </div>
147
160
  <nav class="tabs">
148
161
  <a href="#/" data-view="savings" class="tab active">Savings</a>
162
+ <a href="#/usage" data-view="usage" class="tab">Usage</a>
163
+ <a href="#/accounts" data-view="accounts" class="tab">Accounts</a>
149
164
  <a href="#/skills" data-view="skills" class="tab">Skills</a>
150
165
  <a href="#/settings" data-view="settings" class="tab">Settings</a>
151
166
  </nav>
@@ -213,25 +228,6 @@
213
228
  <div id="chartEmpty" class="empty" style="display:none">No compression activity recorded yet. Run <code>enigma compress &lt;file&gt;</code> or enable the compression MCP (<code>enigma config compress on</code>).</div>
214
229
  </div>
215
230
 
216
- <div class="panel" id="usagePanel" style="display:none">
217
- <h2 style="margin-bottom:4px">Real Tool Usage <small id="usageSub">Claude Code transcripts</small></h2>
218
- <div class="sub" style="margin-bottom:14px">Measured token consumption and real prompt-cache savings, read-only from your own session logs. Deliberately not attributed to skills or output-style: a transcript has no counterfactual baseline, so any such number would be invented.</div>
219
- <div class="grid" style="margin-bottom:16px">
220
- <div class="card"><div class="label">Cache Saved</div><div id="uCacheMoney" class="value good">-</div><div class="foot">Est. from prompt caching</div></div>
221
- <div class="card"><div class="label">Cache Reads</div><div id="uCacheRead" class="value accent">-</div><div class="foot">Tokens served from cache</div></div>
222
- <div class="card"><div class="label">Input Tokens</div><div id="uInput" class="value">-</div><div class="foot">Consumed across sessions</div></div>
223
- <div class="card"><div class="label">Output Tokens</div><div id="uOutput" class="value">-</div><div class="foot">Generated by the agent</div></div>
224
- <div class="card"><div class="label">Sessions</div><div id="uSessions" class="value">-</div><div id="uSessionsFoot" class="foot">Transcripts scanned</div></div>
225
- </div>
226
- <h2 style="margin:0 0 10px">By Model</h2>
227
- <div id="uByModel"></div>
228
- </div>
229
-
230
- <div class="panel" id="usageHint" style="display:none">
231
- <h2 style="margin-bottom:8px">Real Tool Usage <small>opt-in</small></h2>
232
- <div class="sub">Show real token consumption and prompt-cache savings from your Claude Code sessions in <a href="#/settings">Settings</a> (Real tool-usage stats). enigma reads only your local session transcripts; nothing is sent anywhere.</div>
233
- </div>
234
-
235
231
  <div class="panel">
236
232
  <h2 style="margin-bottom:14px">Savings By Source <small>which app/CLI compressed</small></h2>
237
233
  <div id="sources"></div>
@@ -274,6 +270,61 @@
274
270
  </div>
275
271
  </main>
276
272
 
273
+ <section id="view-usage" style="display:none">
274
+ <div class="page-head">
275
+ <h1>Claude Usage</h1>
276
+ <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>
277
+ </div>
278
+ <div class="panel" id="usagePanel" style="display:none">
279
+ <div class="grid" style="margin-bottom:16px">
280
+ <div class="card"><div class="label">Est. Cost</div><div id="uCost" class="value accent">-</div><div class="foot">All sessions, per-model pricing</div></div>
281
+ <div class="card"><div class="label">Cache Saved</div><div id="uCacheMoney" class="value good">-</div><div class="foot">Est. from prompt caching</div></div>
282
+ <div class="card"><div class="label">Input Tokens</div><div id="uInput" class="value">-</div><div class="foot">Consumed across sessions</div></div>
283
+ <div class="card"><div class="label">Output Tokens</div><div id="uOutput" class="value">-</div><div class="foot">Generated by the agent</div></div>
284
+ <div class="card"><div class="label">Cache Reads</div><div id="uCacheRead" class="value accent">-</div><div class="foot">Tokens served from cache</div></div>
285
+ <div class="card"><div class="label">Sessions</div><div id="uSessions" class="value">-</div><div id="uSessionsFoot" class="foot">Transcripts scanned</div></div>
286
+ </div>
287
+ <div id="uBlockWrap" style="display:none;margin-bottom:6px">
288
+ <h2 style="margin-bottom:10px">Current 5-hour block <small id="uBlockState"></small></h2>
289
+ <div class="bar-row"><span class="name" style="width:120px">Window</span><div class="bar-track"><div id="uBlockBar" class="bar-fill" style="background:var(--accent)"></div></div><span id="uBlockTime" class="num">-</span></div>
290
+ <div class="stat-line" style="margin:12px 0 0">
291
+ <div>Tokens <b id="uBlockTokens">-</b></div>
292
+ <div>Cost <b id="uBlockCost">-</b></div>
293
+ <div>Burn rate <b id="uBlockBurn">-</b></div>
294
+ <div>Projected <b id="uBlockProj">-</b></div>
295
+ </div>
296
+ </div>
297
+ <h2 style="margin:18px 0 10px">By Model</h2>
298
+ <div id="uByModel"></div>
299
+ <h2 style="margin:18px 0 10px">By Project</h2>
300
+ <div id="uByProject"></div>
301
+ <h2 style="margin:18px 0 10px">Recent Sessions</h2>
302
+ <div class="tbl-wrap"><div id="uRecent"></div></div>
303
+ </div>
304
+ <div class="panel" id="usageHint" style="display:none">
305
+ <h2 style="margin-bottom:8px">Real tool usage <small>opt-in</small></h2>
306
+ <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>
307
+ </div>
308
+ </section>
309
+
310
+ <section id="view-accounts" style="display:none">
311
+ <div class="page-head" style="display:flex;align-items:flex-start;gap:16px">
312
+ <div style="flex:1">
313
+ <h1>Accounts &amp; Profiles</h1>
314
+ <p>Manage per-tool logins and profiles (a profile pins one account per tool and drives launches), the same as the terminal UI. Logging an account IN happens in a terminal - the browser shows the command to run.</p>
315
+ </div>
316
+ </div>
317
+ <div class="panel">
318
+ <div class="panel-head"><h2>Accounts</h2><button id="accAdd" type="button" class="toggle on">Add account</button></div>
319
+ <div id="accountsBody"><div class="empty" style="padding:24px 0">Loading accounts...</div></div>
320
+ </div>
321
+ <div class="panel">
322
+ <div class="panel-head"><h2>Profiles</h2><button id="profAdd" type="button" class="toggle on">Add profile</button></div>
323
+ <div id="profilesBody"><div class="empty" style="padding:24px 0">Loading profiles...</div></div>
324
+ </div>
325
+ <div id="accountsNote" class="set-note"></div>
326
+ </section>
327
+
277
328
  <section id="view-skills" style="display:none">
278
329
  <div class="page-head" style="display:flex;align-items:flex-start;gap:16px">
279
330
  <div style="flex:1">
@@ -297,11 +348,17 @@
297
348
  </section>
298
349
 
299
350
  <section id="view-settings" style="display:none">
300
- <div class="page-head">
301
- <h1>Settings</h1>
302
- <p>Everything you can configure with <code>enigma config</code> or the terminal UI, editable here. Changes apply at global scope and take effect immediately; toggles that change agent memory need an agent restart.</p>
303
- <div id="settingsNote" class="set-note"></div>
351
+ <div class="page-head" style="display:flex;align-items:flex-start;gap:16px">
352
+ <div style="flex:1">
353
+ <h1>Settings</h1>
354
+ <p>Everything you can configure with <code>enigma config</code> or the terminal UI, editable here. Changes apply at global scope and take effect immediately; toggles that change agent memory need an agent restart.</p>
355
+ </div>
356
+ <div style="display:flex;gap:8px;white-space:nowrap">
357
+ <button id="cfgExport" type="button" class="toggle">Export config</button>
358
+ <button id="cfgImport" type="button" class="toggle">Import config</button>
359
+ </div>
304
360
  </div>
361
+ <div id="settingsNote" class="set-note"></div>
305
362
  <div id="settingsBody"><div class="empty" style="padding:24px 0">Loading settings...</div></div>
306
363
  </section>
307
364
 
@@ -456,28 +513,58 @@
456
513
  }
457
514
 
458
515
  // Real tool-usage panel (Claude Code transcripts). null = the opt-in flag is off.
516
+ function shortProject(p) { p = String(p || ""); return p.length > 44 ? "..." + p.slice(-41) : p; }
517
+
518
+ // One token/cost breakdown table (By Model / By Project), sorted by cost then tokens.
519
+ function renderUsageTable(el, map, pending, label) {
520
+ const entries = Object.entries(map || {}).sort((a, b) => ((b[1].cost || 0) - (a[1].cost || 0)) || ((b[1].input + b[1].output) - (a[1].input + a[1].output)));
521
+ if (!entries.length) { el.innerHTML = '<div class="empty" style="padding:16px 0">' + (pending ? "Scanning..." : "No usage recorded yet.") + "</div>"; return; }
522
+ el.innerHTML = '<table class="tbl"><thead><tr><th>' + label + '</th><th class="r">Input</th><th class="r">Output</th>'
523
+ + '<th class="r">Cache reads</th><th class="r">Msgs</th><th class="r">Cost</th></tr></thead><tbody>'
524
+ + entries.map(([k, v]) => '<tr><td>' + esc(label === "Project" ? shortProject(k) : k) + '</td><td class="r">' + fmt(v.input) + '</td><td class="r">'
525
+ + fmt(v.output) + '</td><td class="r good">' + fmt(v.cacheRead) + '</td><td class="r">' + fmt(v.messages) + '</td><td class="r">' + fmtUsd(v.cost || 0) + "</td></tr>").join("")
526
+ + "</tbody></table>";
527
+ }
528
+
529
+ function renderRecentSessions(el, rows, pending) {
530
+ rows = rows || [];
531
+ if (!rows.length) { el.innerHTML = '<div class="empty" style="padding:16px 0">' + (pending ? "Scanning..." : "No sessions yet.") + "</div>"; return; }
532
+ el.innerHTML = '<table class="tbl"><thead><tr><th>Last active</th><th>Project</th><th>Model</th><th class="r">Input</th><th class="r">Output</th><th class="r">Cost</th></tr></thead><tbody>'
533
+ + rows.map((s) => '<tr><td>' + (s.lastActive ? new Date(s.lastActive).toLocaleString() : "-") + '</td><td>' + esc(shortProject(s.project)) + '</td><td>' + esc(s.model || "-")
534
+ + '</td><td class="r">' + fmt(s.input) + '</td><td class="r">' + fmt(s.output) + '</td><td class="r">' + fmtUsd(s.cost || 0) + "</td></tr>").join("")
535
+ + "</tbody></table>";
536
+ }
537
+
459
538
  function renderUsage(u) {
460
539
  const panel = $("usagePanel"), hint = $("usageHint");
461
540
  if (!u) { panel.style.display = "none"; hint.style.display = "block"; return; }
462
541
  hint.style.display = "none";
463
542
  panel.style.display = "block";
543
+ $("uCost").textContent = fmtUsd(u.cost || 0);
464
544
  $("uCacheMoney").textContent = fmtUsd(cacheMoney(u.cacheRead || 0));
465
545
  $("uCacheRead").textContent = fmt(u.cacheRead || 0);
466
546
  $("uInput").textContent = fmt(u.input || 0);
467
547
  $("uOutput").textContent = fmt(u.output || 0);
468
548
  $("uSessions").textContent = fmt(u.sessions || 0);
469
549
  $("uSessionsFoot").textContent = u.pending ? "scanning sessions..." : fmt(u.scannedFiles || 0) + " files scanned";
470
- const entries = Object.entries(u.byModel || {}).sort((a, b) => ((b[1].input + b[1].output) - (a[1].input + a[1].output)));
471
- const el = $("uByModel");
472
- if (!entries.length) {
473
- el.innerHTML = '<div class="empty" style="padding:20px 0">' + (u.pending ? "Scanning session transcripts..." : "No usage recorded yet.") + "</div>";
474
- return;
475
- }
476
- el.innerHTML = '<table class="tbl"><thead><tr><th>Model</th><th class="r">Input</th><th class="r">Output</th>'
477
- + '<th class="r">Cache reads</th><th class="r">Messages</th></tr></thead><tbody>'
478
- + entries.map(([m, v]) => '<tr><td>' + m + '</td><td class="r">' + fmt(v.input) + '</td><td class="r">'
479
- + fmt(v.output) + '</td><td class="r good">' + fmt(v.cacheRead) + '</td><td class="r">' + fmt(v.messages) + "</td></tr>").join("")
480
- + "</tbody></table>";
550
+ // Current 5-hour block (computed locally from transcript timestamps).
551
+ const b = u.block, bw = $("uBlockWrap");
552
+ if (b && b.startedAt) {
553
+ bw.style.display = "block";
554
+ const now = Date.now(), total = 5 * 3600 * 1000;
555
+ const pct = Math.max(0, Math.min(100, Math.round((now - b.startedAt) / total * 100)));
556
+ $("uBlockBar").style.width = pct + "%";
557
+ $("uBlockState").textContent = b.active ? "active" : "window elapsed";
558
+ const remMin = Math.max(0, Math.round((b.endsAt - now) / 60000));
559
+ $("uBlockTime").textContent = b.active ? (remMin + " min left") : "ended";
560
+ $("uBlockTokens").textContent = fmt(b.tokens || 0);
561
+ $("uBlockCost").textContent = fmtUsd(b.cost || 0);
562
+ $("uBlockBurn").textContent = fmt(Math.round(b.burnRatePerMin || 0)) + " tok/min";
563
+ $("uBlockProj").textContent = fmt(b.projectedTokens || 0) + " · " + fmtUsd(b.projectedCost || 0);
564
+ } else { bw.style.display = "none"; }
565
+ renderUsageTable($("uByModel"), u.byModel, u.pending, "Model");
566
+ renderUsageTable($("uByProject"), u.byProject, u.pending, "Project");
567
+ renderRecentSessions($("uRecent"), u.recentSessions, u.pending);
481
568
  }
482
569
 
483
570
  function renderCache(c) {
@@ -669,14 +756,36 @@
669
756
  // --- settings subpage (mirrors the TUI registry over /api/settings) ---
670
757
  function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); }
671
758
 
759
+ // Turn "(experimental)" and " · measured/experimental" label suffixes into visual badges.
760
+ function labelWithBadges(text) {
761
+ let name = String(text);
762
+ const badges = [];
763
+ if (/\(experimental\)/i.test(name)) { name = name.replace(/\s*\(experimental\)/i, ""); badges.push(["experimental", "exp"]); }
764
+ name = name.replace(/\s*·\s*(measured|experimental)\b/gi, (_, w) => { badges.push([w.toLowerCase(), w.toLowerCase() === "experimental" ? "exp" : "meas"]); return ""; });
765
+ return esc(name) + badges.map(([t, c]) => ' <span class="tag ' + c + '">' + esc(t) + "</span>").join("");
766
+ }
767
+
672
768
  function settingRow(s) {
769
+ if (s.kind === "list") {
770
+ const items = s.items || [];
771
+ const chips = items.length
772
+ ? '<div class="lchips">' + items.map((it) => '<span class="lchip">' + esc(it)
773
+ + '<button type="button" class="lx" title="remove" data-skey="' + esc(s.key) + '" data-item="' + esc(it) + '">x</button></span>').join("") + "</div>"
774
+ : '<div class="lnone">none - using the built-in defaults</div>';
775
+ const adder = '<div class="ladd"><input type="text" class="linput" data-skey="' + esc(s.key)
776
+ + '" placeholder="' + esc(s.itemHint || "add an entry...") + '">'
777
+ + '<button type="button" class="laddb" data-skey="' + esc(s.key) + '">Add</button></div>';
778
+ return '<div class="set-row list"><div class="set-meta"><div class="set-name">' + labelWithBadges(s.label) + "</div>"
779
+ + '<div class="set-hint">' + esc(s.hint) + (s.globalOnly ? " · global" : "") + "</div>"
780
+ + chips + adder + "</div></div>";
781
+ }
673
782
  const ctl = s.choices
674
783
  ? '<select class="choice" data-skey="' + esc(s.key) + '">'
675
784
  + s.choices.map((c) => '<option value="' + esc(c) + '"' + (c === s.choice ? " selected" : "") + ">" + esc(c) + "</option>").join("")
676
785
  + "</select>"
677
786
  : '<button type="button" class="toggle' + (s.value ? " on" : "") + '" data-skey="' + esc(s.key)
678
787
  + '" data-kind="bool" data-on="' + (s.value ? 1 : 0) + '">' + (s.value ? "On" : "Off") + "</button>";
679
- return '<div class="set-row"><div class="set-meta"><div class="set-name">' + esc(s.label) + "</div>"
788
+ return '<div class="set-row"><div class="set-meta"><div class="set-name">' + labelWithBadges(s.label) + "</div>"
680
789
  + '<div class="set-hint">' + esc(s.hint) + (s.globalOnly ? " · global" : "") + "</div></div>"
681
790
  + '<div class="set-ctl">' + ctl + "</div></div>";
682
791
  }
@@ -704,16 +813,42 @@
704
813
  } catch { $("settingsNote").textContent = "Could not reach the server to change " + key + "."; }
705
814
  }
706
815
 
816
+ // List settings: add/remove one entry, then reload so the chips reflect disk truth.
817
+ async function postListOp(key, op, item) {
818
+ item = (item || "").trim();
819
+ if (!item) return;
820
+ $("settingsNote").textContent = (op === "add" ? "Adding to " : "Removing from ") + key + "...";
821
+ try {
822
+ const res = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value: { op, item } }) });
823
+ const out = await res.json();
824
+ if (!out.ok) { $("settingsNote").textContent = "Could not change " + key + ": " + (out.error || "error"); return; }
825
+ $("settingsNote").textContent = "Saved " + key + ".";
826
+ await loadSettings();
827
+ } catch { $("settingsNote").textContent = "Could not reach the server to change " + key + "."; }
828
+ }
829
+
707
830
  function wireSettings() {
708
831
  const el = $("settingsBody");
709
832
  el.addEventListener("click", (e) => {
710
833
  const b = e.target.closest("button.toggle");
711
- if (b) postSetting(b.dataset.skey, b.dataset.on !== "1", b);
834
+ if (b) { postSetting(b.dataset.skey, b.dataset.on !== "1", b); return; }
835
+ const rm = e.target.closest("button.lx");
836
+ if (rm) { postListOp(rm.dataset.skey, "remove", rm.dataset.item); return; }
837
+ const add = e.target.closest("button.laddb");
838
+ if (add) {
839
+ const inp = el.querySelector('input.linput[data-skey="' + (window.CSS && CSS.escape ? CSS.escape(add.dataset.skey) : add.dataset.skey) + '"]');
840
+ if (inp) { postListOp(add.dataset.skey, "add", inp.value); inp.value = ""; }
841
+ }
712
842
  });
713
843
  el.addEventListener("change", (e) => {
714
844
  const sel = e.target.closest("select.choice");
715
845
  if (sel) postSetting(sel.dataset.skey, sel.value, sel);
716
846
  });
847
+ el.addEventListener("keydown", (e) => {
848
+ if (e.key !== "Enter") return;
849
+ const inp = e.target.closest("input.linput");
850
+ if (inp) { e.preventDefault(); postListOp(inp.dataset.skey, "add", inp.value); inp.value = ""; }
851
+ });
717
852
  }
718
853
 
719
854
  let settingsLoaded = false;
@@ -732,7 +867,7 @@
732
867
  function renderSystems(s) {
733
868
  const el = $("systems");
734
869
  if (!s) { el.innerHTML = '<div class="empty" style="padding:16px 0">Status unavailable.</div>'; return; }
735
- const item = (k, v) => '<div class="sys-item"><span class="k">' + k + '</span><span class="v">' + v + "</span></div>";
870
+ const item = (k, v) => '<div class="sys-item"><span class="k">' + labelWithBadges(k) + '</span><span class="v">' + v + "</span></div>";
736
871
  const sk = s.skills || {};
737
872
  const skillTags = '<span class="tag">' + (sk.enigma || 0) + " enigma</span>"
738
873
  + '<span class="tag ext">' + (sk.external || 0) + " external</span>"
@@ -743,12 +878,30 @@
743
878
  ? item("Proxy measured · real", '<span class="tag">' + fmt(ps.calls) + " calls</span><span class=\"tag\">"
744
879
  + fmt(ps.input) + " in</span><span class=\"tag\">" + fmt(ps.output) + " out</span><span class=\"tag\">" + fmt(ps.cacheRead) + " cache</span>")
745
880
  : "";
881
+ const proxyLast = (ps.lastRequestAt || 0) > 0
882
+ ? item("Proxy last request · real", '<span class="tag">' + esc(fmtDuration((Date.now() - ps.lastRequestAt) / 1000) + " ago")
883
+ + '</span><span class="tag">' + esc(new Date(ps.lastRequestAt).toLocaleString()) + "</span>")
884
+ : "";
885
+ // Prompt secret guard: configured state + what it has actually blocked (measured).
886
+ const guardState = s.promptSecretGuard
887
+ ? '<span class="pill on">on</span><span class="tag">' + esc(s.promptSecretMode || "redact") + "</span>"
888
+ : pill("off", "off");
889
+ const promptGuard = item("Prompt secret guard · experimental", guardState);
890
+ const blockedTotal = (ps.redacted || 0) + (ps.rejected || 0);
891
+ const promptBlocked = blockedTotal > 0
892
+ ? item("Prompt secrets blocked · real", '<span class="tag">' + fmt(ps.redacted || 0) + " redacted</span><span class=\"tag\">"
893
+ + fmt(ps.rejected || 0) + " rejected</span>"
894
+ + ((ps.lastBlockedAt || 0) > 0 ? '<span class="tag">' + esc(fmtDuration((Date.now() - ps.lastBlockedAt) / 1000) + " ago") + "</span>" : ""))
895
+ : "";
746
896
  const guard = (sec.guardProtects || []).map((p) => '<span class="tag">' + esc(p) + "</span>").join("");
747
897
  el.innerHTML =
748
898
  item("Context compression (MCP) · measured", boolPill(s.compress))
749
899
  + item("Real tool-usage stats · measured", boolPill(s.usageStats))
750
- + item("Claude Code measuring proxy · experimental", boolPill(s.proxy))
900
+ + item("Claude Code proxy · experimental", boolPill(s.proxy))
751
901
  + proxyMeasured
902
+ + proxyLast
903
+ + promptGuard
904
+ + promptBlocked
752
905
  + item("Token-efficient output", levelPill(s.outputStyle))
753
906
  + item("Minimal code", levelPill(s.minimalCode))
754
907
  + item("Parallel sub-agents", boolPill(s.parallelSubagents))
@@ -765,9 +918,11 @@
765
918
  try {
766
919
  const res = await fetch("/api/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
767
920
  const out = await res.json();
768
- $("updateNote").textContent = out.ok ? (out.note || "Updated.") : ("Update failed: " + (out.error || "error"));
769
- loadSystems();
770
- if (skillsLoaded) loadSkills();
921
+ if (!out.ok) { $("updateNote").textContent = "Update failed: " + (out.error || "error"); return; }
922
+ // The UI bundle may have been refreshed server-side; reload so the browser
923
+ // picks up the new dashboard and fresh data automatically.
924
+ $("updateNote").textContent = (out.note || "Updated.") + " Refreshing the dashboard...";
925
+ setTimeout(() => location.reload(), 1200);
771
926
  } catch { $("updateNote").textContent = "Could not reach the server to update."; }
772
927
  }
773
928
 
@@ -879,16 +1034,141 @@
879
1034
  } catch { $("skillsBody").innerHTML = '<div class="empty" style="padding:24px 0">Skills unavailable.</div>'; }
880
1035
  }
881
1036
 
882
- // --- hash routing between the Savings, Skills and Settings subpages ---
1037
+ // --- accounts & profiles subpage (mirrors the TUI hub over /api/accounts) ---
1038
+ let accountsLoaded = false;
1039
+ let accTools = [];
1040
+ async function loadAccounts() {
1041
+ try { const r = await fetch("/api/accounts", { cache: "no-store" }); renderAccounts(await r.json()); }
1042
+ catch { $("accountsBody").innerHTML = '<div class="empty" style="padding:24px 0">Accounts unavailable.</div>'; }
1043
+ }
1044
+ function accountRow(a) {
1045
+ const badges = (a.active ? '<span class="tag meas">active</span>' : "")
1046
+ + (a.loggedIn ? '<span class="tag">' + esc(a.email || "logged in") + "</span>" : '<span class="tag warn">not logged in</span>');
1047
+ const btn = (act, label, cls) => '<button type="button" class="' + (cls || "toggle") + '" data-acc="' + act + '" data-tool="' + esc(a.tool) + '" data-name="' + esc(a.name) + '">' + label + "</button>";
1048
+ const acts = '<div class="set-ctl" style="display:flex;gap:6px;flex-wrap:wrap">'
1049
+ + (a.active ? "" : btn("activate", "Use"))
1050
+ + (a.loggedIn ? "" : btn("login", "Log in..."))
1051
+ + (a.removable ? btn("rename", "Rename") + btn("remove", "Remove", "btn-danger") : "")
1052
+ + "</div>";
1053
+ return '<div class="set-row"><div class="set-meta"><div class="set-name">' + esc(a.name) + ' <span class="tag ext">' + esc(a.toolLabel) + "</span> " + badges
1054
+ + '</div><div class="set-hint">' + esc(a.dir) + "</div></div>" + acts + "</div>";
1055
+ }
1056
+ function profileRow(p, tools, accounts) {
1057
+ const maps = tools.map((t) => {
1058
+ const cur = p.accounts[t.name] || "";
1059
+ const opts = ['<option value="">(none)</option>'].concat(accounts.filter((a) => a.tool === t.name)
1060
+ .map((a) => '<option value="' + esc(a.name) + '"' + (a.name === cur ? " selected" : "") + ">" + esc(a.name) + "</option>")).join("");
1061
+ return '<label style="font-size:12px;color:var(--muted);display:inline-flex;align-items:center;gap:6px;margin:0 12px 6px 0">' + esc(t.label)
1062
+ + ' <select class="choice" data-prof-tool="' + esc(t.name) + '" data-prof="' + esc(p.name) + '">' + opts + "</select></label>";
1063
+ }).join("");
1064
+ const acts = '<div class="set-ctl" style="display:flex;gap:6px">'
1065
+ + (p.active ? "" : '<button type="button" class="toggle" data-prof-act="activate" data-name="' + esc(p.name) + '">Use</button>')
1066
+ + '<button type="button" class="toggle" data-prof-act="rename" data-name="' + esc(p.name) + '">Rename</button>'
1067
+ + '<button type="button" class="btn-danger" data-prof-act="remove" data-name="' + esc(p.name) + '">Remove</button></div>';
1068
+ return '<div class="set-row"><div class="set-meta"><div class="set-name">' + esc(p.name) + (p.active ? ' <span class="tag meas">active</span>' : "")
1069
+ + '</div><div class="set-hint" style="margin-top:6px">' + maps + "</div></div>" + acts + "</div>";
1070
+ }
1071
+ function renderAccounts(d) {
1072
+ d = d || {};
1073
+ const accounts = d.accounts || [], profiles = d.profiles || [], tools = d.tools || [];
1074
+ accTools = tools;
1075
+ $("accountsBody").innerHTML = accounts.length ? accounts.map(accountRow).join("") : '<div class="empty" style="padding:20px 0">No accounts.</div>';
1076
+ $("profilesBody").innerHTML = profiles.length ? profiles.map((p) => profileRow(p, tools, accounts)).join("")
1077
+ : '<div class="empty" style="padding:20px 0">No profiles yet. Add one to pin an account per tool.</div>';
1078
+ }
1079
+ async function accAction(op, payload) {
1080
+ $("accountsNote").textContent = "Working...";
1081
+ try {
1082
+ const r = await fetch("/api/accounts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.assign({ op }, payload)) });
1083
+ const out = await r.json();
1084
+ $("accountsNote").textContent = out.ok ? "Saved." : ("Error: " + (out.error || "failed"));
1085
+ if (out.data) renderAccounts(out.data);
1086
+ } catch { $("accountsNote").textContent = "Could not reach the server."; }
1087
+ }
1088
+ function wireAccounts() {
1089
+ $("accAdd").addEventListener("click", () => {
1090
+ const tool = accTools.length === 1 ? accTools[0].name : prompt("Which tool? (" + accTools.map((t) => t.name).join(" / ") + ")", accTools[0] ? accTools[0].name : "");
1091
+ if (!tool) return;
1092
+ const name = prompt("New account name (e.g. work):", "");
1093
+ if (name) accAction("account.add", { tool, name });
1094
+ });
1095
+ $("profAdd").addEventListener("click", () => { const name = prompt("New profile name:", ""); if (name) accAction("profile.add", { name }); });
1096
+ $("accountsBody").addEventListener("click", (e) => {
1097
+ const b = e.target.closest("button[data-acc]"); if (!b) return;
1098
+ const op = b.dataset.acc, tool = b.dataset.tool, name = b.dataset.name;
1099
+ if (op === "activate") accAction("account.activate", { tool, name });
1100
+ else if (op === "remove") { if (confirm("Remove account '" + name + "' and delete its local config dir?")) accAction("account.remove", { tool, name }); }
1101
+ else if (op === "rename") { const nn = prompt("Rename '" + name + "' to:", name); if (nn && nn !== name) accAction("account.rename", { tool, name, newName: nn }); }
1102
+ else if (op === "login") { $("accountsNote").textContent = "To log in, run this in a terminal: enigma " + tool + " " + name; }
1103
+ });
1104
+ $("profilesBody").addEventListener("click", (e) => {
1105
+ const b = e.target.closest("button[data-prof-act]"); if (!b) return;
1106
+ const op = b.dataset.profAct, name = b.dataset.name;
1107
+ if (op === "activate") accAction("profile.activate", { name });
1108
+ else if (op === "remove") { if (confirm("Remove profile '" + name + "'? (its accounts are kept)")) accAction("profile.remove", { name }); }
1109
+ else if (op === "rename") { const nn = prompt("Rename profile '" + name + "' to:", name); if (nn && nn !== name) accAction("profile.rename", { name, newName: nn }); }
1110
+ });
1111
+ $("profilesBody").addEventListener("change", (e) => {
1112
+ const sel = e.target.closest("select[data-prof-tool]"); if (!sel) return;
1113
+ accAction("profile.setAccount", { profile: sel.dataset.prof, tool: sel.dataset.profTool, account: sel.value || null });
1114
+ });
1115
+ }
1116
+
1117
+ // --- config import / export (the browser file picker is what "asks where") ---
1118
+ async function exportConfig() {
1119
+ $("settingsNote").textContent = "Exporting...";
1120
+ let text;
1121
+ try { text = await (await fetch("/api/config-export", { cache: "no-store" })).text(); }
1122
+ catch { $("settingsNote").textContent = "Export failed."; return; }
1123
+ const name = "enigma-config-" + new Date().toISOString().slice(0, 10) + ".json";
1124
+ if (window.showSaveFilePicker) {
1125
+ try {
1126
+ const h = await window.showSaveFilePicker({ suggestedName: name, types: [{ description: "JSON", accept: { "application/json": [".json"] } }] });
1127
+ const w = await h.createWritable(); await w.write(text); await w.close();
1128
+ $("settingsNote").textContent = "Exported (no secrets included)."; return;
1129
+ } catch (e) { if (e && e.name === "AbortError") { $("settingsNote").textContent = "Export cancelled."; return; } }
1130
+ }
1131
+ const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
1132
+ const a = document.createElement("a"); a.href = url; a.download = name; a.click(); URL.revokeObjectURL(url);
1133
+ $("settingsNote").textContent = "Exported to your downloads (no secrets included).";
1134
+ }
1135
+ async function importConfig() {
1136
+ let text = null;
1137
+ if (window.showOpenFilePicker) {
1138
+ try { const [h] = await window.showOpenFilePicker({ types: [{ description: "JSON", accept: { "application/json": [".json"] } }] }); text = await (await h.getFile()).text(); }
1139
+ catch (e) { if (e && e.name === "AbortError") return; }
1140
+ }
1141
+ if (text === null) {
1142
+ text = await new Promise((res) => {
1143
+ const inp = document.createElement("input"); inp.type = "file"; inp.accept = "application/json,.json";
1144
+ inp.onchange = () => { const f = inp.files[0]; if (!f) { res(null); return; } const rd = new FileReader(); rd.onload = () => res(String(rd.result)); rd.readAsText(f); };
1145
+ inp.click();
1146
+ });
1147
+ if (text === null) return;
1148
+ }
1149
+ let body; try { body = JSON.parse(text); } catch { $("settingsNote").textContent = "That file is not valid JSON."; return; }
1150
+ $("settingsNote").textContent = "Importing...";
1151
+ try {
1152
+ const out = await (await fetch("/api/config-import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })).json();
1153
+ if (!out.ok) { $("settingsNote").textContent = "Import failed: " + (out.error || "error"); return; }
1154
+ $("settingsNote").textContent = "Imported: " + (out.applied || []).join(", ") + ((out.skipped && out.skipped.length) ? (" · skipped: " + out.skipped.join(", ")) : "");
1155
+ settingsLoaded = false; loadSettings(); accountsLoaded = false;
1156
+ } catch { $("settingsNote").textContent = "Could not reach the server to import."; }
1157
+ }
1158
+
1159
+ // --- hash routing between the subpages ---
883
1160
  function route() {
884
1161
  const h = (location.hash || "").replace(/^#\/?/, "");
885
- const v = (h === "settings" || h === "skills") ? h : "savings";
1162
+ const v = ["usage", "accounts", "settings", "skills"].includes(h) ? h : "savings";
886
1163
  $("view-savings").style.display = v === "savings" ? "" : "none";
1164
+ $("view-usage").style.display = v === "usage" ? "" : "none";
1165
+ $("view-accounts").style.display = v === "accounts" ? "" : "none";
887
1166
  $("view-skills").style.display = v === "skills" ? "" : "none";
888
1167
  $("view-settings").style.display = v === "settings" ? "" : "none";
889
1168
  document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.dataset.view === v));
890
1169
  if (v === "settings" && !settingsLoaded) { settingsLoaded = true; loadSettings(); }
891
1170
  if (v === "skills" && !skillsLoaded) { skillsLoaded = true; loadSkills(); }
1171
+ if (v === "accounts" && !accountsLoaded) { accountsLoaded = true; loadAccounts(); }
892
1172
  // The chart was sized while its view may have been hidden; nudge it on return.
893
1173
  if (v === "savings") { loadSystems(true); if (chart) { try { applyRange(); } catch { /* not ready */ } } }
894
1174
  }
@@ -899,6 +1179,9 @@
899
1179
  document.addEventListener("visibilitychange", () => { if (!document.hidden) refreshAll(true); });
900
1180
  wireSettings();
901
1181
  wireSkills();
1182
+ wireAccounts();
1183
+ $("cfgExport").addEventListener("click", exportConfig);
1184
+ $("cfgImport").addEventListener("click", importConfig);
902
1185
  $("updateBtn").addEventListener("click", runUpdate);
903
1186
  $("refreshBtn").addEventListener("click", () => refreshAll(true));
904
1187
  window.addEventListener("hashchange", route);