vibespot 1.5.1 → 1.6.2

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/ui/settings.js CHANGED
@@ -10,6 +10,98 @@ let settingsData = null;
10
10
  let activeTab = "ai";
11
11
  const activePolls = {};
12
12
 
13
+ // VIB-1835: /api/settings/status is now config-only and renders instantly. The
14
+ // expensive bits — live model lists and CLI/auth detection — are fetched on
15
+ // demand and cached here, layered over the fast /status response on each render.
16
+ let liveModels = null; // from /api/settings/models
17
+ let scannedTools = {}; // tool entries from /api/settings/tools
18
+ let scannedEngines = null; // availableEngines refined by an AI scan
19
+ const scannedGroups = { ai: false, platform: false };
20
+ let bgScanStarted = false; // one-time background scan guard (per open)
21
+
22
+ function resetScanState() {
23
+ liveModels = null;
24
+ scannedTools = {};
25
+ scannedEngines = null;
26
+ scannedGroups.ai = false;
27
+ scannedGroups.platform = false;
28
+ bgScanStarted = false;
29
+ }
30
+
31
+ // Layer the on-demand caches over the fast /status payload so a re-render after
32
+ // any action keeps already-fetched models / scanned tool state.
33
+ function applyScanCaches(data) {
34
+ if (!data || !data.environment) return;
35
+ if (liveModels) data.models = liveModels;
36
+ if (Object.keys(scannedTools).length) {
37
+ data.environment.tools = { ...data.environment.tools, ...scannedTools };
38
+ }
39
+ if (scannedEngines) data.environment.availableEngines = scannedEngines;
40
+ }
41
+
42
+ async function fetchModels(refresh) {
43
+ try {
44
+ const res = await fetch("/api/settings/models" + (refresh ? "?refresh=1" : ""));
45
+ const data = await res.json();
46
+ if (data && data.models) {
47
+ liveModels = data.models;
48
+ if (settingsData) settingsData.models = liveModels;
49
+ return true;
50
+ }
51
+ } catch { /* keep STATIC_MODELS */ }
52
+ return false;
53
+ }
54
+
55
+ async function fetchTools(group, refresh) {
56
+ try {
57
+ const qs = `?group=${group}` + (refresh ? "&refresh=1" : "");
58
+ const res = await fetch("/api/settings/tools" + qs);
59
+ const data = await res.json();
60
+ if (data && data.tools) {
61
+ scannedTools = { ...scannedTools, ...data.tools };
62
+ if (Array.isArray(data.availableEngines)) scannedEngines = data.availableEngines;
63
+ if (group === "ai" || group === "all") scannedGroups.ai = true;
64
+ if (group === "platform" || group === "all") scannedGroups.platform = true;
65
+ return true;
66
+ }
67
+ } catch { /* leave "not scanned" */ }
68
+ return false;
69
+ }
70
+
71
+ // Hybrid default (Boris-approved): after the instant render, kick off one
72
+ // non-blocking scan of models + all tools, then re-render in place. Every later
73
+ // open repeats this once; explicit Refresh/Scan/Check buttons are always live.
74
+ function maybeStartBackgroundScan() {
75
+ if (bgScanStarted) return;
76
+ bgScanStarted = true;
77
+ Promise.all([fetchModels(false), fetchTools("all", false)]).then((results) => {
78
+ // Don't yank a re-render out from under someone mid-typing.
79
+ if (results.some(Boolean) && settingsData && !settingsHasFocusedInput()) {
80
+ renderSettings(settingsData);
81
+ }
82
+ });
83
+ }
84
+
85
+ function settingsHasFocusedInput() {
86
+ const body = document.getElementById("settings-body");
87
+ const ae = document.activeElement;
88
+ return !!(body && ae && body.contains(ae) && (ae.tagName === "INPUT" || ae.tagName === "SELECT"));
89
+ }
90
+
91
+ // Small button that runs an on-demand scan with a spinner, then re-renders the
92
+ // active tab (which recreates this button in its resolved state).
93
+ function makeScanButton(label, onScan, extraClass) {
94
+ const btn = el("button", "settings__btn settings__btn--small" + (extraClass ? " " + extraClass : ""));
95
+ btn.textContent = label;
96
+ btn.addEventListener("click", async () => {
97
+ btn.disabled = true;
98
+ btn.innerHTML = '<span class="settings__spinner"></span> ' + escSettings(label) + "…";
99
+ await onScan();
100
+ if (settingsData) renderSettings(settingsData);
101
+ });
102
+ return btn;
103
+ }
104
+
13
105
  const ENGINE_LABELS = {
14
106
  "claude-code": "Claude Code",
15
107
  "anthropic-api": "Anthropic API",
@@ -34,6 +126,7 @@ function openSettings(tab) {
34
126
  }
35
127
  const overlay = document.getElementById("settings-overlay");
36
128
  if (overlay) overlay.classList.remove("hidden");
129
+ resetScanState();
37
130
  refreshSettings();
38
131
  }
39
132
 
@@ -70,14 +163,17 @@ async function refreshSettings() {
70
163
  const body = document.getElementById("settings-body");
71
164
  body.innerHTML = `<div class="settings__loading"><div class="settings__spinner-lg"></div><span>Loading environment...</span></div>`;
72
165
 
166
+ // /status is config-only and returns in single-digit ms; this is just a relaxed
167
+ // safety net for an unreachable server, not the old 3s budget that timed out.
73
168
  const controller = new AbortController();
74
- const timeout = setTimeout(() => controller.abort(), 3000);
169
+ const timeout = setTimeout(() => controller.abort(), 12000);
75
170
 
76
171
  try {
77
172
  const res = await fetch("/api/settings/status", { signal: controller.signal });
78
173
  clearTimeout(timeout);
79
174
  settingsData = await res.json();
80
175
  renderSettings(settingsData);
176
+ maybeStartBackgroundScan();
81
177
  } catch (err) {
82
178
  clearTimeout(timeout);
83
179
  const aborted = err && err.name === "AbortError";
@@ -108,6 +204,7 @@ function renderSettingsError(body, timedOut) {
108
204
  }
109
205
 
110
206
  function renderSettings(data) {
207
+ applyScanCaches(data);
111
208
  const body = document.getElementById("settings-body");
112
209
  body.innerHTML = "";
113
210
 
@@ -230,6 +327,9 @@ function renderAITab(body, data) {
230
327
  });
231
328
 
232
329
  modelRow.appendChild(modelSelect);
330
+ // Dropdowns populate instantly from STATIC_MODELS; Refresh pulls the live
331
+ // provider catalog on demand (VIB-1835).
332
+ modelRow.appendChild(makeScanButton("Refresh", () => fetchModels(true)));
233
333
  section.appendChild(modelRow);
234
334
  }
235
335
 
@@ -376,7 +476,14 @@ function renderAITab(body, data) {
376
476
  // CLI Tools section with toggles
377
477
  const cliSection = el("section", "settings__section");
378
478
  cliSection.appendChild(sectionTitle("CLI Tools"));
379
- cliSection.appendChild(desc("Enable CLI tools you have installed. Install status is only checked when you toggle a tool on, so disabled tools add zero overhead."));
479
+ cliSection.appendChild(desc("Enable the CLI tools you have installed. Install and auth status is detected on demand scan to check or refresh it."));
480
+
481
+ const cliScanRow = el("div", "settings__card-row");
482
+ const cliScanHint = el("span", "settings__card-meta");
483
+ cliScanHint.textContent = scannedGroups.ai ? "Status scanned" : "Not scanned yet";
484
+ cliScanRow.appendChild(cliScanHint);
485
+ cliScanRow.appendChild(makeScanButton(scannedGroups.ai ? "Rescan" : "Scan AI tools", () => fetchTools("ai", true)));
486
+ cliSection.appendChild(cliScanRow);
380
487
 
381
488
  const cliTools = [
382
489
  { key: "claudeCode", id: "claude-code", name: "Claude Code", installId: "claude", url: "https://claude.ai/code" },
@@ -397,7 +504,12 @@ function renderAITab(body, data) {
397
504
  label.textContent = tool.name;
398
505
  labelWrap.appendChild(label);
399
506
 
400
- if (enabled && info.found) {
507
+ if (enabled && !scannedGroups.ai) {
508
+ const sub = el("div", "settings__toggle-label-sub");
509
+ sub.textContent = "Not scanned";
510
+ sub.style.color = "var(--text-muted)";
511
+ labelWrap.appendChild(sub);
512
+ } else if (enabled && info.found) {
401
513
  const sub = el("div", "settings__toggle-label-sub");
402
514
  sub.textContent = `v${info.version}` + (info.authenticated ? " \u2014 authenticated" : " \u2014 not authenticated");
403
515
  sub.style.color = info.authenticated ? "var(--success)" : "var(--warning)";
@@ -425,8 +537,9 @@ function renderAITab(body, data) {
425
537
 
426
538
  row.appendChild(toggleRow);
427
539
 
428
- // If enabled but not installed, show install button
429
- if (enabled && !info.found) {
540
+ // If enabled but not installed, show install button (only once we've scanned
541
+ // before that, info.found is just an unscanned placeholder).
542
+ if (enabled && scannedGroups.ai && !info.found) {
430
543
  const installRow = el("div", "settings__card-row");
431
544
  const installBtn = el("button", "settings__btn settings__btn--primary");
432
545
  installBtn.textContent = "Install";
@@ -444,7 +557,7 @@ function renderAITab(body, data) {
444
557
  }
445
558
 
446
559
  // If enabled, installed, but not authenticated — show sign in
447
- if (enabled && info.found && !info.authenticated) {
560
+ if (enabled && scannedGroups.ai && info.found && !info.authenticated) {
448
561
  const authRow = el("div", "settings__card-row");
449
562
  const authBtn = el("button", "settings__btn settings__btn--primary");
450
563
  authBtn.textContent = "Sign in";
@@ -456,6 +569,105 @@ function renderAITab(body, data) {
456
569
  cliSection.appendChild(row);
457
570
  }
458
571
  body.appendChild(cliSection);
572
+
573
+ // Observability section — opt-in Langfuse keys, base URL, and enable toggle
574
+ body.appendChild(renderObservabilitySection(env, config));
575
+ }
576
+
577
+ // ---------------------------------------------------------------------------
578
+ // Observability — Langfuse keys + base URL + enable toggle
579
+ // ---------------------------------------------------------------------------
580
+
581
+ function renderObservabilitySection(env, config) {
582
+ const section = el("section", "settings__section");
583
+ section.appendChild(sectionTitle("Observability"));
584
+ section.appendChild(desc("Langfuse captures token usage, estimated cost, and traces for every API model call. Off by default — turn it on with the toggle and set both keys to start sending traces. Keys are stored locally in ~/.vibespot/config.json and sent only to your Langfuse instance."));
585
+
586
+ const pub = env.apiKeys.langfusePublic || { configured: false, masked: "" };
587
+ const sec = env.apiKeys.langfuseSecret || { configured: false, masked: "" };
588
+ const hasKeys = pub.configured && sec.configured;
589
+ const enabledFlag = config.langfuseEnabled; // undefined | true | false
590
+ const isOn = enabledFlag === true; // off by default; explicit opt-in required
591
+
592
+ // Enable toggle
593
+ const toggleRow = el("div", "settings__toggle-row");
594
+ const labelWrap = el("div", "");
595
+ const label = el("div", "settings__toggle-label");
596
+ label.textContent = "Send traces to Langfuse";
597
+ labelWrap.appendChild(label);
598
+
599
+ const sub = el("div", "settings__toggle-label-sub");
600
+ if (!isOn) {
601
+ sub.textContent = "Off — no traces or usage sent";
602
+ sub.style.color = "var(--text-muted)";
603
+ } else if (hasKeys) {
604
+ sub.textContent = "Active — traces, token usage & cost sent to Langfuse";
605
+ sub.style.color = "var(--success)";
606
+ } else {
607
+ sub.textContent = "Enabled — add a public + secret key below to start sending traces";
608
+ sub.style.color = "var(--warning)";
609
+ }
610
+ labelWrap.appendChild(sub);
611
+ toggleRow.appendChild(labelWrap);
612
+
613
+ const toggle = el("button", "settings__toggle" + (isOn ? " active" : ""));
614
+ toggle.addEventListener("click", async () => {
615
+ await fetch("/api/settings", {
616
+ method: "POST",
617
+ headers: { "Content-Type": "application/json" },
618
+ body: JSON.stringify({ langfuseEnabled: !isOn }),
619
+ });
620
+ refreshSettings();
621
+ });
622
+ toggleRow.appendChild(toggle);
623
+ section.appendChild(toggleRow);
624
+
625
+ // Keys — masked display, persisted via the shared /api/settings/apikey route
626
+ section.appendChild(createApiKeyCard("langfuse-public", "Public Key", "pk-lf-...", pub));
627
+ section.appendChild(createApiKeyCard("langfuse-secret", "Secret Key", "sk-lf-...", sec));
628
+
629
+ // Base URL
630
+ const urlCard = el("div", "settings__card");
631
+ const urlRow = el("div", "settings__card-row");
632
+ urlRow.style.gap = "8px";
633
+ const urlLabel = el("span", "settings__card-label");
634
+ urlLabel.textContent = "Base URL";
635
+ urlRow.appendChild(urlLabel);
636
+
637
+ const urlInput = el("input", "settings__apikey-input");
638
+ urlInput.type = "text";
639
+ urlInput.placeholder = "https://cloud.langfuse.com";
640
+ urlInput.value = config.langfuseBaseUrl || "";
641
+ urlRow.appendChild(urlInput);
642
+
643
+ const urlSaveBtn = el("button", "settings__btn settings__btn--primary");
644
+ urlSaveBtn.textContent = "Save";
645
+ const saveBaseUrl = async () => {
646
+ urlSaveBtn.disabled = true;
647
+ urlSaveBtn.textContent = "Saving...";
648
+ await fetch("/api/settings", {
649
+ method: "POST",
650
+ headers: { "Content-Type": "application/json" },
651
+ body: JSON.stringify({ langfuseBaseUrl: urlInput.value.trim() }),
652
+ });
653
+ refreshSettings();
654
+ };
655
+ urlSaveBtn.addEventListener("click", saveBaseUrl);
656
+ urlInput.addEventListener("keydown", (e) => {
657
+ if (e.key === "Enter") { e.preventDefault(); saveBaseUrl(); }
658
+ });
659
+ urlRow.appendChild(urlSaveBtn);
660
+ urlCard.appendChild(urlRow);
661
+
662
+ const urlHint = el("div", "settings__toggle-label-sub");
663
+ urlHint.textContent = "cloud.langfuse.com (EU) · us.cloud.langfuse.com (US) · or your self-host URL. Leave blank for EU cloud.";
664
+ urlHint.style.color = "var(--text-muted)";
665
+ urlHint.style.marginTop = "6px";
666
+ urlCard.appendChild(urlHint);
667
+
668
+ section.appendChild(urlCard);
669
+
670
+ return section;
459
671
  }
460
672
 
461
673
  // ---------------------------------------------------------------------------
@@ -771,6 +983,28 @@ function renderHubSpotTab(body, data) {
771
983
  // CLI mode — show CLI status and accounts from hs accounts list
772
984
  acctSection.appendChild(desc("HubSpot CLI accounts are managed by the hs command. Use \u201chs auth\u201d to add accounts."));
773
985
 
986
+ // The hs CLI + accounts probe is a subprocess, so it runs on demand rather
987
+ // than on every settings open (VIB-1835).
988
+ const hsCheckRow = el("div", "settings__card-row");
989
+ const hsCheckHint = el("span", "settings__card-meta");
990
+ hsCheckHint.textContent = scannedGroups.platform ? "Status checked" : "Not checked yet";
991
+ hsCheckRow.appendChild(hsCheckHint);
992
+ hsCheckRow.appendChild(makeScanButton(scannedGroups.platform ? "Recheck" : "Check", () => fetchTools("platform", true)));
993
+ acctSection.appendChild(hsCheckRow);
994
+
995
+ if (!scannedGroups.platform) {
996
+ const pending = el("div", "settings__card");
997
+ const prow = el("div", "settings__card-row");
998
+ prow.appendChild(dot("muted"));
999
+ const plabel = el("span", "settings__card-label");
1000
+ plabel.textContent = "HubSpot CLI — not scanned";
1001
+ prow.appendChild(plabel);
1002
+ pending.appendChild(prow);
1003
+ acctSection.appendChild(pending);
1004
+ body.appendChild(acctSection);
1005
+ return;
1006
+ }
1007
+
774
1008
  const cliCard = el("div", "settings__card");
775
1009
  const cliRow = el("div", "settings__card-row");
776
1010
  cliRow.appendChild(dot(hs.found ? "success" : "warn"));
@@ -903,6 +1137,28 @@ function renderGitHubTab(body, data) {
903
1137
  section.appendChild(sectionTitle("GitHub CLI"));
904
1138
  section.appendChild(desc("GitHub CLI enables pushing your theme to a repository. Optional \u2014 not needed for HubSpot deployment."));
905
1139
 
1140
+ // GitHub install/auth is detected via subprocess, so it's checked on demand
1141
+ // rather than on every settings open (VIB-1835).
1142
+ const checkRow = el("div", "settings__card-row");
1143
+ const checkHint = el("span", "settings__card-meta");
1144
+ checkHint.textContent = scannedGroups.platform ? "Status checked" : "Not checked yet";
1145
+ checkRow.appendChild(checkHint);
1146
+ checkRow.appendChild(makeScanButton(scannedGroups.platform ? "Recheck" : "Check", () => fetchTools("platform", true)));
1147
+ section.appendChild(checkRow);
1148
+
1149
+ if (!scannedGroups.platform) {
1150
+ const pending = el("div", "settings__card");
1151
+ const pendingRow = el("div", "settings__card-row");
1152
+ pendingRow.appendChild(dot("muted"));
1153
+ const pendingLabel = el("span", "settings__card-label");
1154
+ pendingLabel.textContent = "GitHub CLI \u2014 not scanned";
1155
+ pendingRow.appendChild(pendingLabel);
1156
+ pending.appendChild(pendingRow);
1157
+ section.appendChild(pending);
1158
+ body.appendChild(section);
1159
+ return;
1160
+ }
1161
+
906
1162
  const card = el("div", "settings__card");
907
1163
 
908
1164
  // CLI status
package/ui/styles.css CHANGED
@@ -4184,6 +4184,19 @@ body { display: flex; }
4184
4184
  font-size: var(--text-sm);
4185
4185
  color: var(--text-dim);
4186
4186
  }
4187
+ /* Per-project running generation cost chip (VIB-1770) */
4188
+ .chat__cost-total {
4189
+ margin-left: auto;
4190
+ font-size: var(--text-xs, 11px);
4191
+ font-variant-numeric: tabular-nums;
4192
+ color: var(--text-dim);
4193
+ background: var(--bg-subtle, rgba(127, 127, 127, 0.12));
4194
+ border: 1px solid var(--border);
4195
+ border-radius: 999px;
4196
+ padding: 2px 8px;
4197
+ cursor: default;
4198
+ white-space: nowrap;
4199
+ }
4187
4200
 
4188
4201
  .chat__messages {
4189
4202
  flex: 1;
@@ -5032,6 +5045,15 @@ body { display: flex; }
5032
5045
  color: var(--warning, #f59e0b);
5033
5046
  }
5034
5047
 
5048
+ /* Per-page estimated generation cost (VIB-1770) */
5049
+ .pipeline-cost {
5050
+ font-size: var(--text-xs, 11px);
5051
+ color: var(--text-dim);
5052
+ font-variant-numeric: tabular-nums;
5053
+ padding: 0 0 2px;
5054
+ cursor: default;
5055
+ }
5056
+
5035
5057
  .pipeline-footer {
5036
5058
  display: flex;
5037
5059
  justify-content: space-between;