@enigmax/dashboard 0.1.63 → 0.1.65

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 +216 -8
  2. package/package.json +1 -1
package/assets/index.html CHANGED
@@ -321,6 +321,12 @@
321
321
  /* Per-memory delete in the Recall list: kept to the right and revealed on row hover/focus. */
322
322
  .recall-del { margin-left: auto; opacity: 0; transition: opacity .12s; }
323
323
  .set-row:hover .recall-del, .recall-del:focus-visible { opacity: 1; }
324
+ /* Recall multi-select: a checkbox per row, a highlight for selected rows, and a bulk-action
325
+ bar that appears only while something is selected. Ctrl/Cmd+click toggles, Shift+click ranges. */
326
+ .rc-check { flex: 0 0 auto; width: 15px; height: 15px; cursor: pointer; accent-color: var(--accent); margin: 0; }
327
+ .set-row.sel { background: rgba(224, 164, 88, .10); }
328
+ .rc-bulk { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
329
+ .rc-bulk .rc-seln { font-size: 12px; color: var(--muted); }
324
330
  /* Per-agent skill toggles: one pill per app (the app's icon), green when the skill is on for it; the app name is the tooltip. */
325
331
  .set-agents { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
326
332
  .achip { display: inline-flex; align-items: center; justify-content: center; padding: 5px 8px; border-radius: 999px; border: 1px solid var(--border); background: var(--surface2); color: var(--muted); cursor: pointer; text-decoration: none; transition: color .15s, background .15s, border-color .15s; }
@@ -454,6 +460,10 @@
454
460
  .modal-fl { display: block; margin-top: 14px; margin-bottom: 5px; font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
455
461
  .modal-fl .modal-opt { text-transform: none; letter-spacing: 0; opacity: .75; }
456
462
  .modal-fl + .modal-input { margin-top: 0; }
463
+ /* Provider-override editor labels + key hint. */
464
+ .prov-l { display: block; margin-top: 12px; margin-bottom: 4px; font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
465
+ .prov-hint { margin-top: 8px; font-size: 12px; color: var(--muted); word-break: break-word; }
466
+ .prov-hint a { color: var(--accent); }
457
467
  /* Usage view provider/account pickers. */
458
468
  .useg-pick { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); }
459
469
  .useg-pick select { width: auto; min-width: 170px; }
@@ -516,6 +526,7 @@
516
526
  <a href="#/settings" data-view="settings" class="tab">Settings</a>
517
527
  <a href="#/resources" data-view="resources" class="tab">Resources</a>
518
528
  <a href="#/recall" data-view="recall" class="tab">Recall</a>
529
+ <a href="#/packs" data-view="packs" class="tab">Packs</a>
519
530
  </nav>
520
531
  <div class="nav">
521
532
  <label class="scope-wrap" title="Scope: global config or a specific project"><span class="scope-lbl">Scope</span><select id="scopeSel" class="scope-sel"><option value="global">Global</option></select></label>
@@ -793,6 +804,18 @@
793
804
  <div class="panel"><div class="set-cat-h">Processes <small>by memory</small></div><div id="resProcs"><div class="empty" style="padding:12px 0">Loading...</div></div></div>
794
805
  </section>
795
806
 
807
+ <section id="view-packs" style="display:none">
808
+ <div class="page-head" style="display:flex;align-items:flex-start;gap:16px">
809
+ <div style="flex:1">
810
+ <h1>Packs</h1>
811
+ <p>Optional harness packs you can add on demand. Each runs in its own <strong>isolated agent context</strong>, so its skills and commands never load into your normal coding agent. Launch a pack from a terminal with <code>enigma &lt;pack&gt;</code>.</p>
812
+ </div>
813
+ <button id="packsRefresh" type="button" class="iconbtn" title="Refresh" aria-label="Refresh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
814
+ </div>
815
+ <div id="packsNote" class="set-note"></div>
816
+ <div id="packsBody"><div class="empty" style="padding:12px 0">Loading...</div></div>
817
+ </section>
818
+
796
819
  <section id="view-recall" style="display:none">
797
820
  <div class="page-head" style="display:flex;align-items:flex-start;gap:16px">
798
821
  <div style="flex:1">
@@ -3460,7 +3483,7 @@ export {
3460
3483
 
3461
3484
  // --- accounts & profiles subpage (mirrors the TUI hub over /api/accounts) ---
3462
3485
  let accountsLoaded = false;
3463
- let accTools = [], accAccounts = [], accProfiles = [];
3486
+ let accTools = [], accAccounts = [], accProfiles = [], accPresets = [];
3464
3487
  // Real-time name validator mirroring the server (accounts.ts validateAccountName + the
3465
3488
  // duplicate checks), so the modal blocks an invalid or already-used name as the user types.
3466
3489
  // `taken` = names already in scope (per-tool for accounts, global for profiles); `current`
@@ -3483,16 +3506,23 @@ export {
3483
3506
  + (a.loggedIn ? '<span class="tag">' + esc(a.email || "logged in") + "</span>" : '<span class="tag warn">not logged in</span>');
3484
3507
  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>";
3485
3508
  const removeBtn = '<button type="button" class="btn-danger icon" title="Remove" aria-label="Remove account ' + esc(a.name) + '" data-acc="remove" data-tool="' + esc(a.tool) + '" data-name="' + esc(a.name) + '">' + TRASH_ICON + "</button>";
3509
+ // Provider override (e.g. MiniMax) only for tools that support it, and never the
3510
+ // built-in "default" account (kept on the vendor default). Managed accounts only.
3511
+ const provBtn = (a.supportsProvider && a.removable) ? btn("provider", "Provider") : "";
3486
3512
  const acts = '<div class="set-ctl" style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">'
3487
3513
  + (a.active ? "" : btn("activate", "Use"))
3488
3514
  + (a.loggedIn ? "" : btn("login", "Log in..."))
3515
+ + provBtn
3489
3516
  + (a.removable ? btn("rename", "Rename") + removeBtn : "")
3490
3517
  + "</div>";
3491
3518
  const toolIc = agentSvg(a.tool);
3492
3519
  const toolBadge = toolIc
3493
3520
  ? '<span class="aicon set-ic" title="' + esc(a.toolLabel) + '" aria-label="' + esc(a.toolLabel) + '">' + toolIc + "</span>"
3494
3521
  : '<span class="tag ext">' + esc(a.toolLabel) + "</span> ";
3495
- return '<div class="set-row"><div class="set-meta"><div class="set-name">' + esc(a.name) + " " + toolBadge + badges
3522
+ const provTag = a.provider
3523
+ ? ' <span class="tag meas" title="' + esc(a.provider.baseUrl) + '">via ' + esc(a.provider.preset && a.provider.preset !== "custom" ? a.provider.preset : "custom") + (a.provider.model ? " · " + esc(a.provider.model) : "") + "</span>"
3524
+ : "";
3525
+ return '<div class="set-row"><div class="set-meta"><div class="set-name">' + esc(a.name) + " " + toolBadge + badges + provTag
3496
3526
  + '</div><div class="set-hint">' + esc(a.dir) + "</div></div>" + acts + "</div>";
3497
3527
  }
3498
3528
  function profileRow(p, tools, accounts) {
@@ -3515,7 +3545,7 @@ export {
3515
3545
  function renderAccounts(d) {
3516
3546
  d = d || {};
3517
3547
  const accounts = d.accounts || [], profiles = d.profiles || [], tools = d.tools || [];
3518
- accTools = tools; accAccounts = accounts; accProfiles = profiles;
3548
+ accTools = tools; accAccounts = accounts; accProfiles = profiles; accPresets = d.presets || [];
3519
3549
  $("accountsBody").innerHTML = accounts.length ? accounts.map(accountRow).join("") : '<div class="empty" style="padding:20px 0">No accounts.</div>';
3520
3550
  $("profilesBody").innerHTML = profiles.length ? profiles.map((p) => profileRow(p, tools, accounts)).join("")
3521
3551
  : '<div class="empty" style="padding:20px 0">No profiles yet. Add one to pin an account per tool.</div>';
@@ -3534,6 +3564,69 @@ export {
3534
3564
  setNote("accountsNote", "Error: " + (out.error || "failed"), "err");
3535
3565
  } catch { if (rollback) rollback(); setNote("accountsNote", "Could not reach the server.", "err"); }
3536
3566
  }
3567
+ // Provider-override editor: a small multi-field modal (backend select + base/model/token)
3568
+ // since openModal only does single fields. Resolves to { clear:true } to drop the override,
3569
+ // an object to set it ({ baseUrl, model?, preset?, token? } - token only when typed), or null.
3570
+ function editProvider(a) {
3571
+ return new Promise((resolve) => {
3572
+ const toolPresets = accPresets.filter((p) => p.tool === a.tool);
3573
+ const cur = a.provider; // { baseUrl, model?, preset?, hasToken } | null
3574
+ const opts = ['<option value="default">Default (Anthropic)</option>']
3575
+ .concat(toolPresets.map((p) => '<option value="' + esc(p.id) + '">' + esc(p.label) + "</option>"))
3576
+ .concat(['<option value="custom">Custom (Anthropic-compatible)</option>']).join("");
3577
+ const back = document.createElement("div");
3578
+ back.className = "modal-back";
3579
+ back.innerHTML = '<div class="modal" role="dialog" aria-modal="true" style="max-width:480px;text-align:left">'
3580
+ + '<div class="modal-title">Provider for ' + esc(a.name) + "</div>"
3581
+ + '<div class="modal-msg">Point ' + esc(a.toolLabel) + " at another Anthropic-compatible backend (e.g. MiniMax), or keep the default.</div>"
3582
+ + '<label class="prov-l">Backend</label><select class="modal-input" id="provSel">' + opts + "</select>"
3583
+ + '<div id="provFields" style="margin-top:8px">'
3584
+ + '<label class="prov-l">Base URL</label><input class="modal-input" id="provBase" placeholder="https://...">'
3585
+ + '<label class="prov-l">Model</label><input class="modal-input" id="provModel" placeholder="(provider default)">'
3586
+ + '<label class="prov-l">API key</label><input class="modal-input" id="provToken" type="password" autocomplete="off" placeholder="' + (cur && cur.hasToken ? "set - leave blank to keep" : "API key") + '">'
3587
+ + '<div class="prov-hint" id="provHint"></div></div>'
3588
+ + '<div class="modal-acts"><button type="button" class="toggle modal-cancel">Cancel</button>'
3589
+ + '<button type="button" class="toggle on modal-ok">Save</button></div></div>';
3590
+ document.body.appendChild(back);
3591
+ requestAnimationFrame(() => back.classList.add("show"));
3592
+ const sel = back.querySelector("#provSel"), fields = back.querySelector("#provFields");
3593
+ const base = back.querySelector("#provBase"), model = back.querySelector("#provModel"), token = back.querySelector("#provToken"), hint = back.querySelector("#provHint");
3594
+ sel.value = cur ? (cur.preset && cur.preset !== "custom" ? cur.preset : "custom") : "default";
3595
+ function applySel() {
3596
+ const v = sel.value;
3597
+ fields.style.display = v === "default" ? "none" : "";
3598
+ if (v === "default") return;
3599
+ const preset = toolPresets.find((p) => p.id === v);
3600
+ if (preset) {
3601
+ base.value = (cur && cur.preset === v && cur.baseUrl) || preset.baseUrl;
3602
+ model.value = (cur && cur.preset === v && cur.model) || preset.model || "";
3603
+ hint.innerHTML = preset.tokenUrl ? 'Get a key at <a href="' + esc(preset.tokenUrl) + '" target="_blank" rel="noopener">' + esc(preset.tokenUrl) + "</a>" : "";
3604
+ } else {
3605
+ base.value = (cur && (!cur.preset || cur.preset === "custom") && cur.baseUrl) || "";
3606
+ model.value = (cur && cur.model) || "";
3607
+ hint.textContent = "";
3608
+ }
3609
+ }
3610
+ applySel();
3611
+ sel.addEventListener("change", applySel);
3612
+ let done = false;
3613
+ const close = (val) => { if (done) return; done = true; back.classList.remove("show"); setTimeout(() => back.remove(), 200); resolve(val); };
3614
+ back.querySelector(".modal-cancel").addEventListener("click", () => close(null));
3615
+ back.addEventListener("mousedown", (e) => { if (e.target === back) close(null); });
3616
+ back.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.preventDefault(); close(null); } });
3617
+ back.querySelector(".modal-ok").addEventListener("click", () => {
3618
+ if (sel.value === "default") { close({ clear: true }); return; }
3619
+ const b = base.value.trim();
3620
+ if (!/^https?:\/\//i.test(b)) { hint.textContent = "Enter a base URL starting with http:// or https://"; return; }
3621
+ const out = { baseUrl: b };
3622
+ if (model.value.trim()) out.model = model.value.trim();
3623
+ if (sel.value !== "custom") out.preset = sel.value;
3624
+ if (token.value) out.token = token.value; // only send a typed key (blank keeps the stored one)
3625
+ close(out);
3626
+ });
3627
+ sel.focus();
3628
+ });
3629
+ }
3537
3630
  function wireAccounts() {
3538
3631
  $("accAdd").addEventListener("click", async () => {
3539
3632
  const tool = accTools.length === 1 ? accTools[0].name
@@ -3551,6 +3644,12 @@ export {
3551
3644
  else if (op === "remove") { if (await askConfirm("Remove account", "Remove account '" + name + "' and delete its local config dir?", true)) accAction("account.remove", { tool, name }); }
3552
3645
  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 }); }
3553
3646
  else if (op === "login") { setNote("accountsNote", "To log in, run this in a terminal: enigma " + tool + " " + name); }
3647
+ else if (op === "provider") {
3648
+ const a = accAccounts.find((x) => x.tool === tool && x.name === name);
3649
+ if (!a) return;
3650
+ const out = await editProvider(a);
3651
+ if (out) accAction("account.provider", { tool, name, provider: out.clear ? null : out });
3652
+ }
3554
3653
  });
3555
3654
  $("profilesBody").addEventListener("click", async (e) => {
3556
3655
  const b = e.target.closest("button[data-prof-act]"); if (!b) return;
@@ -3610,7 +3709,7 @@ export {
3610
3709
 
3611
3710
  // --- hash routing between the subpages ---
3612
3711
  // Single source of truth for the subpage ids (savings is the default/fallback).
3613
- const VIEWS = ["savings", "usage", "accounts", "skills", "settings", "resources", "recall"];
3712
+ const VIEWS = ["savings", "usage", "accounts", "skills", "settings", "resources", "recall", "packs"];
3614
3713
  // Views that apply to a single project; the rest are global/machine-level only.
3615
3714
  const PROJECT_VIEWS = ["settings", "skills"];
3616
3715
  let activeView = "savings";
@@ -3631,6 +3730,7 @@ export {
3631
3730
  else if (v === "usage") { loadProviderStatus(); if (!usageAccountsLoaded) { usageAccountsLoaded = true; loadUsageAccounts(); } }
3632
3731
  else if (v === "resources") loadResources();
3633
3732
  else if (v === "recall") loadRecall();
3733
+ else if (v === "packs") loadPacks();
3634
3734
  // The chart was sized while its view may have been hidden; nudge it on return.
3635
3735
  else if (v === "savings") { loadSystems(true); if (chart) { try { applyRange(); } catch { /* not ready */ } } }
3636
3736
  }
@@ -3690,6 +3790,52 @@ export {
3690
3790
  });
3691
3791
  }
3692
3792
 
3793
+ // --- packs subpage (optional isolated harness packs; marketplace) ---
3794
+ function renderPacks(list) {
3795
+ list = list || [];
3796
+ if (!list.length) { $("packsBody").innerHTML = '<div class="empty" style="padding:12px 0">No packs available.</div>'; return; }
3797
+ $("packsBody").innerHTML = list.map((p) => {
3798
+ const tags = (p.tags || []).map((t) => '<span class="tag">' + esc(t) + '</span>').join(" ");
3799
+ const state = p.installed ? '<span class="tag">' + (p.version ? "installed " + esc(p.version) : "installed") + '</span>' : '<span class="tag">not installed</span>';
3800
+ const added = p.enabled ? '<span class="tag" style="color:var(--ok)">added</span>' : "";
3801
+ const actions = p.installed
3802
+ ? '<button type="button" class="toggle on" data-pack="launch" data-id="' + esc(p.id) + '">Launch command</button>'
3803
+ + '<button type="button" class="toggle" data-pack="setup" data-id="' + esc(p.id) + '" title="Register the pack MCP servers (needs Python 3)">Setup MCP</button>'
3804
+ + '<button type="button" class="toggle" data-pack="update" data-id="' + esc(p.id) + '">Update</button>'
3805
+ + '<button type="button" class="btn-danger" data-pack="remove" data-id="' + esc(p.id) + '">Remove</button>'
3806
+ : '<button type="button" class="toggle on" data-pack="install" data-id="' + esc(p.id) + '">Install</button>';
3807
+ return '<div class="panel"><div class="set-row"><div class="set-meta">'
3808
+ + '<div class="set-name">' + esc(p.label) + " " + state + " " + added + '</div>'
3809
+ + '<div class="set-hint">' + esc(p.description) + '</div>'
3810
+ + '<div class="set-hint" style="margin-top:6px">' + tags + ' &nbsp; <a href="' + esc(p.homepage) + '" target="_blank" rel="noopener">source</a></div>'
3811
+ + '</div></div><div class="set-ctl" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">' + actions + '</div></div>';
3812
+ }).join("");
3813
+ }
3814
+ async function loadPacks() {
3815
+ $("packsNote").textContent = "";
3816
+ try { renderPacks((await (await fetch("/api/packs", { cache: "no-store" })).json()).packs); }
3817
+ catch { $("packsBody").innerHTML = '<div class="empty" style="padding:12px 0">Packs unavailable.</div>'; }
3818
+ }
3819
+ async function packAction(id, action) {
3820
+ const confirmMsg = action === "remove" ? "Remove this pack and its isolated context?" : null;
3821
+ if (confirmMsg && !(await askConfirm("Confirm", confirmMsg, true))) return;
3822
+ setNote("packsNote", action === "install" || action === "update" ? "Working..." : "Saving...");
3823
+ try {
3824
+ const out = await (await fetch("/api/packs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, action }) })).json();
3825
+ if (out.packs) renderPacks(out.packs);
3826
+ if (out.command) { try { await navigator.clipboard.writeText(out.command); } catch { /* clipboard blocked */ } }
3827
+ setNote("packsNote", out.note || out.error || (out.ok ? "Done." : "Action failed."), out.ok ? "ok" : "err");
3828
+ } catch { setNote("packsNote", "Could not reach the server.", "err"); }
3829
+ }
3830
+ function wirePacks() {
3831
+ $("packsRefresh").addEventListener("click", loadPacks);
3832
+ $("view-packs").addEventListener("click", (e) => {
3833
+ const b = e.target.closest("button[data-pack]");
3834
+ if (!b) return;
3835
+ packAction(b.dataset.id, b.dataset.pack);
3836
+ });
3837
+ }
3838
+
3693
3839
  // --- recall subpage (local session memory; reads your own transcripts) ---
3694
3840
  const RECALL_TYPES = ["bugfix", "feature", "refactor", "change", "discovery", "decision", "security"];
3695
3841
  let recallProjSig = "";
@@ -3765,6 +3911,9 @@ export {
3765
3911
  llmBtn.setAttribute("aria-checked", p.llm ? "true" : "false");
3766
3912
  }
3767
3913
  let recallItemsById = {};
3914
+ // Multi-select state for the Recall list: a Set of selected ids, the ordered ids as
3915
+ // rendered (for Shift range selection), and the anchor index for the next Shift+click.
3916
+ let recallSel = new Set(), recallOrderIds = [], recallAnchorIdx = null;
3768
3917
  function recallRowHtml(it, anchorId) {
3769
3918
  const ic = '<span class="aicon" title="' + esc(it.source) + '" aria-label="' + esc(it.source) + '">' + (agentSvg(it.source) || esc(it.source)) + "</span>";
3770
3919
  const date = it.createdAt ? new Date(it.createdAt).toISOString().slice(0, 10) : "";
@@ -3786,13 +3935,45 @@ export {
3786
3935
  }
3787
3936
  function renderRecallList(items) {
3788
3937
  recallItemsById = {};
3938
+ // A fresh list invalidates the previous selection (ids may have changed).
3939
+ recallSel = new Set(); recallOrderIds = []; recallAnchorIdx = null;
3789
3940
  if (!items || !items.length) { $("recallList").innerHTML = '<div class="empty" style="padding:16px 0">No memories yet - run a sync, or adjust your search.</div>'; return; }
3790
- $("recallList").innerHTML = '<div class="panel">' + items.map((it) => {
3791
- if (it.id) recallItemsById[it.id] = it;
3941
+ const rows = items.map((it) => {
3942
+ // data-idx aligns with recallOrderIds (id'd rows only) so Shift ranges are exact.
3943
+ let dataIdx = "";
3944
+ if (it.id) { recallItemsById[it.id] = it; recallOrderIds.push(it.id); dataIdx = recallOrderIds.length - 1; }
3792
3945
  const del = it.id ? '<button type="button" class="btn-danger icon recall-del" title="Delete memory" aria-label="Delete this memory" data-del="' + it.id + '">' + TRASH_ICON + "</button>" : "";
3793
- return '<div class="set-row" style="cursor:pointer" data-rid="' + (it.id || "") + '">' + recallRowHtml(it) + del + "</div>"
3946
+ const check = it.id ? '<input type="checkbox" class="rc-check" data-id="' + it.id + '" aria-label="Select this memory">' : '<span style="width:15px;flex:0 0 auto"></span>';
3947
+ return '<div class="set-row" style="cursor:pointer" data-rid="' + (it.id || "") + '"' + (it.id ? ' data-idx="' + dataIdx + '"' : "") + '>' + check + recallRowHtml(it) + del + "</div>"
3794
3948
  + '<div id="rd-' + (it.id || "") + '" style="display:none"></div>';
3795
- }).join("") + "</div>";
3949
+ }).join("");
3950
+ // Bulk-action bar (hidden until something is selected) + the rows.
3951
+ $("recallList").innerHTML = '<div id="recallBulk" class="rc-bulk" style="display:none">'
3952
+ + '<span class="rc-seln" id="recallSelN"></span>'
3953
+ + '<button type="button" class="btn-danger" data-bulk-del>Delete selected</button>'
3954
+ + '<button type="button" class="toggle" data-bulk-clear>Clear selection</button></div>'
3955
+ + '<div class="panel">' + rows + "</div>";
3956
+ }
3957
+ /** Reflect the current selection in the DOM: row highlight, checkboxes, and the bulk bar. */
3958
+ function updateRecallSelUI() {
3959
+ document.querySelectorAll("#recallList .set-row[data-rid]").forEach((row) => {
3960
+ const id = Number(row.dataset.rid);
3961
+ const on = recallSel.has(id);
3962
+ row.classList.toggle("sel", on);
3963
+ const cb = row.querySelector(".rc-check");
3964
+ if (cb) cb.checked = on;
3965
+ });
3966
+ const bar = $("recallBulk");
3967
+ if (bar) {
3968
+ bar.style.display = recallSel.size ? "" : "none";
3969
+ const n = $("recallSelN");
3970
+ if (n) n.textContent = recallSel.size + (recallSel.size === 1 ? " memory selected" : " memories selected");
3971
+ }
3972
+ }
3973
+ /** Select every id between two row indices (inclusive), for Shift+click range selection. */
3974
+ function recallSelectRange(fromIdx, toIdx) {
3975
+ const lo = Math.min(fromIdx, toIdx), hi = Math.max(fromIdx, toIdx);
3976
+ for (let i = lo; i <= hi; i++) { const id = recallOrderIds[i]; if (id) recallSel.add(id); }
3796
3977
  }
3797
3978
  async function loadRecallTimeline(id) {
3798
3979
  const el = $("rtl-" + id);
@@ -3887,6 +4068,16 @@ export {
3887
4068
  $("recallType").addEventListener("change", loadRecall);
3888
4069
  $("recallProject").addEventListener("change", loadRecall);
3889
4070
  $("recallList").addEventListener("click", async (e) => {
4071
+ // Bulk actions on the current multi-selection.
4072
+ if (e.target.closest("[data-bulk-clear]")) { recallSel.clear(); recallAnchorIdx = null; updateRecallSelUI(); return; }
4073
+ if (e.target.closest("[data-bulk-del]")) {
4074
+ const ids = [...recallSel];
4075
+ if (!ids.length) return;
4076
+ const noun = ids.length === 1 ? "memory" : "memories";
4077
+ if (!(await askConfirm("Delete memories", "Delete " + ids.length + " selected " + noun + "? This cannot be undone.", true))) return;
4078
+ recallOpPost({ op: "delete", ids }, "Deleting " + ids.length + "...", ids.length + " " + noun + " deleted.");
4079
+ return;
4080
+ }
3890
4081
  const del = e.target.closest("[data-del]");
3891
4082
  if (del) {
3892
4083
  if (!(await askConfirm("Delete memory", "Delete this memory? This cannot be undone.", true))) return;
@@ -3897,6 +4088,22 @@ export {
3897
4088
  if (tl) { loadRecallTimeline(tl.dataset.tl); return; }
3898
4089
  const row = e.target.closest("[data-rid]");
3899
4090
  if (!row || !row.dataset.rid) return;
4091
+ const id = Number(row.dataset.rid);
4092
+ const cb = e.target.closest(".rc-check");
4093
+ // Selection shortcuts: a checkbox click, Ctrl/Cmd+click (toggle one), or Shift+click
4094
+ // (select the range from the last-clicked anchor through this row, inclusive).
4095
+ if (cb || e.ctrlKey || e.metaKey || e.shiftKey) {
4096
+ const idx = row.dataset.idx === undefined ? -1 : Number(row.dataset.idx);
4097
+ if (e.shiftKey && recallAnchorIdx !== null && idx >= 0) {
4098
+ recallSelectRange(recallAnchorIdx, idx);
4099
+ } else {
4100
+ if (recallSel.has(id)) recallSel.delete(id); else recallSel.add(id);
4101
+ if (idx >= 0) recallAnchorIdx = idx;
4102
+ }
4103
+ updateRecallSelUI();
4104
+ return;
4105
+ }
4106
+ // Plain click expands/collapses the detail (unchanged).
3900
4107
  const detail = $("rd-" + row.dataset.rid);
3901
4108
  if (!detail) return;
3902
4109
  if (detail.innerHTML === "") detail.innerHTML = recallDetailHtml(recallItemsById[row.dataset.rid]);
@@ -4197,6 +4404,7 @@ export {
4197
4404
  wireUsage();
4198
4405
  wireResources();
4199
4406
  wireRecall();
4407
+ wirePacks();
4200
4408
  wireScope();
4201
4409
  enhanceAll(); // upgrade every static <select> into the shared pretty dropdown
4202
4410
  $("cfgExport").addEventListener("click", exportConfig);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/dashboard",
3
- "version": "0.1.63",
3
+ "version": "0.1.65",
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": [