openclacky 1.1.5 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/CODE_OF_CONDUCT.md +1 -1
  4. data/CONTRIBUTING.md +92 -0
  5. data/README.md +10 -0
  6. data/README_CN.md +10 -0
  7. data/ROADMAP.md +29 -0
  8. data/docs/billing-system.md +340 -0
  9. data/docs/mcp-architecture.md +114 -0
  10. data/docs/mcp.example.json +22 -0
  11. data/lib/clacky/agent/cost_tracker.rb +37 -0
  12. data/lib/clacky/agent/llm_caller.rb +0 -1
  13. data/lib/clacky/agent/session_serializer.rb +2 -11
  14. data/lib/clacky/agent/skill_manager.rb +73 -26
  15. data/lib/clacky/agent/system_prompt_builder.rb +0 -5
  16. data/lib/clacky/agent/time_machine.rb +6 -0
  17. data/lib/clacky/agent.rb +26 -1
  18. data/lib/clacky/agent_config.rb +9 -19
  19. data/lib/clacky/billing/billing_record.rb +67 -0
  20. data/lib/clacky/billing/billing_store.rb +193 -0
  21. data/lib/clacky/cli.rb +113 -63
  22. data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
  23. data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
  24. data/lib/clacky/idle_compression_timer.rb +4 -2
  25. data/lib/clacky/mcp/client.rb +204 -0
  26. data/lib/clacky/mcp/http_transport.rb +155 -0
  27. data/lib/clacky/mcp/registry.rb +229 -0
  28. data/lib/clacky/mcp/skill_provider.rb +75 -0
  29. data/lib/clacky/mcp/stdio_transport.rb +112 -0
  30. data/lib/clacky/mcp/transport.rb +23 -0
  31. data/lib/clacky/mcp/virtual_skill.rb +131 -0
  32. data/lib/clacky/message_history.rb +0 -1
  33. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
  34. data/lib/clacky/server/http_server.rb +521 -17
  35. data/lib/clacky/server/server_master.rb +8 -14
  36. data/lib/clacky/server/session_registry.rb +30 -2
  37. data/lib/clacky/server/web_ui_controller.rb +4 -0
  38. data/lib/clacky/session_manager.rb +41 -12
  39. data/lib/clacky/skill.rb +1 -5
  40. data/lib/clacky/skill_loader.rb +41 -6
  41. data/lib/clacky/tools/browser.rb +217 -38
  42. data/lib/clacky/tools/trash_manager.rb +154 -3
  43. data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
  44. data/lib/clacky/ui_interface.rb +1 -0
  45. data/lib/clacky/utils/model_pricing.rb +11 -7
  46. data/lib/clacky/utils/trash_directory.rb +37 -6
  47. data/lib/clacky/version.rb +1 -1
  48. data/lib/clacky/web/app.css +2907 -1764
  49. data/lib/clacky/web/app.js +84 -10
  50. data/lib/clacky/web/billing.js +275 -0
  51. data/lib/clacky/web/brand.js +51 -40
  52. data/lib/clacky/web/i18n.js +248 -24
  53. data/lib/clacky/web/index.html +362 -134
  54. data/lib/clacky/web/mcp.js +328 -0
  55. data/lib/clacky/web/sessions.js +299 -18
  56. data/lib/clacky/web/settings.js +686 -174
  57. data/lib/clacky/web/sidebar.js +2 -0
  58. data/lib/clacky/web/trash.js +323 -60
  59. data/lib/clacky/web/ws-dispatcher.js +15 -2
  60. data/lib/clacky.rb +4 -0
  61. data/scripts/install.ps1 +23 -11
  62. metadata +30 -10
@@ -25,6 +25,8 @@ const Sessions = (() => {
25
25
  // Search state
26
26
  const _filter = { q: "", date: "", type: "" }; // committed filter (applied to server)
27
27
  let _searchOpen = false; // is the search panel visible?
28
+ let _cronView = false; // are we in the cron sub-view?
29
+ let _cronCount = 0; // total cron sessions from server
28
30
  let _pendingRunTaskId = null; // session_id waiting to send "run_task" after subscribe
29
31
  let _pendingMessage = null; // { session_id, content } — slash command to send after subscribe
30
32
  // Buffer for tool_stdout lines that arrive before history has finished rendering.
@@ -284,6 +286,7 @@ const Sessions = (() => {
284
286
  const tip1 = I18n.t("chat.empty.tip1");
285
287
  const tip2 = I18n.t("chat.empty.tip2");
286
288
  const tip3 = I18n.t("chat.empty.tip3");
289
+ const tip4 = I18n.t("chat.empty.tip4");
287
290
  return `
288
291
  <div class="chat-empty-icon" aria-hidden="true">
289
292
  <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
@@ -296,6 +299,7 @@ const Sessions = (() => {
296
299
  <li>${escapeHtml(tip1)}</li>
297
300
  <li>${escapeHtml(tip2)}</li>
298
301
  <li>${escapeHtml(tip3)}</li>
302
+ <li>${escapeHtml(tip4)}</li>
299
303
  </ul>
300
304
  `;
301
305
  }
@@ -482,6 +486,7 @@ const Sessions = (() => {
482
486
 
483
487
  const _pendingImages = [];
484
488
  const _pendingFiles = [];
489
+ let _imageSeq = 0;
485
490
  const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB — hard reject before compression
486
491
  const MAX_IMAGE_BYTES_SEND = 512 * 1024; // 512 KB — target after compression
487
492
  const MAX_IMAGE_LONG_EDGE = 1920; // px — scale down if larger
@@ -608,9 +613,13 @@ const Sessions = (() => {
608
613
  alert(`Image too large: ${file.name} (max 5 MB)`);
609
614
  return;
610
615
  }
616
+ const seq = ++_imageSeq;
617
+ const ext = (file.name.split('.').pop() || 'png').toLowerCase();
618
+ const displayName = `IMG_${String(seq).padStart(3, '0')}.${ext}`;
619
+
611
620
  _compressImage(file)
612
621
  .then(dataUrl => {
613
- _pendingImages.push({ dataUrl, name: file.name, mimeType: "image/jpeg" });
622
+ _pendingImages.push({ dataUrl, name: displayName, mimeType: "image/jpeg", seq });
614
623
  _renderAttachmentPreviews();
615
624
  })
616
625
  .catch(err => alert(`Image processing failed: ${err.message}`));
@@ -778,6 +787,8 @@ const Sessions = (() => {
778
787
  Sessions.appendMsg("user", bubbleHtml, { time: new Date() });
779
788
 
780
789
  // Merge images and files into unified files array for WS payload.
790
+ _pendingImages.sort((a, b) => a.seq - b.seq);
791
+
781
792
  const files = [
782
793
  ..._pendingImages.map(img => ({
783
794
  name: img.name,
@@ -792,9 +803,19 @@ const Sessions = (() => {
792
803
  ];
793
804
  _pendingImages.length = 0;
794
805
  _pendingFiles.length = 0;
806
+ _imageSeq = 0;
795
807
  _renderAttachmentPreviews();
796
808
 
797
809
  WS.send({ type: "message", session_id: Sessions.activeId, content, files });
810
+
811
+ // Disable any pending feedback cards — user has replied (either by clicking
812
+ // an option button or by typing directly). The backend has already consumed
813
+ // the feedback; make the frontend reflect that immediately.
814
+ document.querySelectorAll(".feedback-card:not(.feedback-card--submitted)").forEach(card => {
815
+ card.querySelectorAll(".feedback-option-btn").forEach(b => { b.disabled = true; });
816
+ card.classList.add("feedback-card--submitted");
817
+ });
818
+
798
819
  input.value = "";
799
820
  input.style.height = "auto";
800
821
  setTimeout(() => { _sending = false; }, 300);
@@ -1227,6 +1248,63 @@ const Sessions = (() => {
1227
1248
  break;
1228
1249
  }
1229
1250
 
1251
+ case "request_feedback": {
1252
+ // Collapse any open tool group
1253
+ if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
1254
+
1255
+ const rfQuestion = ev.question || "";
1256
+ const rfContext = ev.context || "";
1257
+ const rfOptions = ev.options;
1258
+ const rfHasOptions = Array.isArray(rfOptions) && rfOptions.length > 0;
1259
+
1260
+ if (!rfHasOptions) {
1261
+ // No options — render as plain assistant bubble
1262
+ const normalizeBullets = (t) => t ? t.replace(/^[•·‣▸▪\-–]\s*/gm, "- ") : t;
1263
+ const parts = [rfContext && rfContext.trim(), rfQuestion].filter(Boolean);
1264
+ const rfText = parts.map(normalizeBullets).join("\n\n");
1265
+ const rfEl = document.createElement("div");
1266
+ rfEl.className = "msg msg-assistant";
1267
+ rfEl.dataset.raw = rfText;
1268
+ rfEl.innerHTML = _renderMarkdown(rfText);
1269
+ _appendCopyButton(rfEl);
1270
+ container.appendChild(rfEl);
1271
+ break;
1272
+ }
1273
+
1274
+ // Has options — answered → disabled card; pending → active card (same as live)
1275
+ const rfAnswered = ev._answered === true;
1276
+ const rfCard = document.createElement("div");
1277
+ rfCard.className = rfAnswered ? "feedback-card feedback-card--submitted" : "feedback-card";
1278
+ let rfHtml = "";
1279
+ if (rfContext && rfContext.trim()) {
1280
+ rfHtml += `<div class="feedback-context msg-assistant">${_renderMarkdown(rfContext)}</div>`;
1281
+ }
1282
+ rfHtml += `<div class="feedback-question msg-assistant">${_renderMarkdown(rfQuestion)}</div>`;
1283
+ rfHtml += `<div class="feedback-options">`;
1284
+ rfOptions.forEach((opt, idx) => {
1285
+ rfHtml += `<button class="feedback-option-btn" data-option-index="${idx}"${rfAnswered ? " disabled" : ""}>${escapeHtml(opt)}</button>`;
1286
+ });
1287
+ rfHtml += `</div>`;
1288
+ rfHtml += `<div class="feedback-hint">${I18n.t("chat.feedback_hint")}</div>`;
1289
+ rfCard.innerHTML = rfHtml;
1290
+
1291
+ // If still pending, wire up click handlers (same as showFeedbackRequest)
1292
+ if (!rfAnswered) {
1293
+ rfCard.querySelectorAll(".feedback-option-btn").forEach(btn => {
1294
+ btn.onclick = () => {
1295
+ rfCard.querySelectorAll(".feedback-option-btn").forEach(b => b.disabled = true);
1296
+ rfCard.classList.add("feedback-card--submitted");
1297
+ const input = $("user-input");
1298
+ if (input) input.value = btn.textContent.trim();
1299
+ _sendMessage();
1300
+ };
1301
+ });
1302
+ }
1303
+
1304
+ container.appendChild(rfCard);
1305
+ break;
1306
+ }
1307
+
1230
1308
  default:
1231
1309
  return; // skip unknown types
1232
1310
  }
@@ -1296,6 +1374,20 @@ const Sessions = (() => {
1296
1374
  }
1297
1375
  });
1298
1376
 
1377
+ // Pre-scan: mark each request_feedback as answered.
1378
+ // A feedback card is disabled as soon as any subsequent history_user_message appears
1379
+ // (user either clicked an option or typed a reply).
1380
+ {
1381
+ let lastFeedbackIdx = -1;
1382
+ events.forEach((ev, i) => {
1383
+ if (ev.type === "request_feedback") { ev._answered = false; lastFeedbackIdx = i; }
1384
+ if (ev.type === "history_user_message" && lastFeedbackIdx >= 0 && i > lastFeedbackIdx) {
1385
+ events[lastFeedbackIdx]._answered = true;
1386
+ lastFeedbackIdx = -1;
1387
+ }
1388
+ });
1389
+ }
1390
+
1299
1391
  // Dedup by created_at: skip rounds already rendered (e.g. arrived via live WS)
1300
1392
  const dedup = _renderedCreatedAt[id] || (_renderedCreatedAt[id] = new Set());
1301
1393
  const frag = document.createDocumentFragment();
@@ -1637,6 +1729,49 @@ const Sessions = (() => {
1637
1729
  container.appendChild(el);
1638
1730
  }
1639
1731
 
1732
+ // ── Cron group entry (renders the folded "Scheduled Tasks" entry) ─────
1733
+ function _renderCronGroupItem(container, count) {
1734
+ const el = document.createElement("div");
1735
+ el.className = "session-item cron-group-item";
1736
+ el.innerHTML = `
1737
+ <div class="session-body">
1738
+ <div class="session-name">
1739
+ <span class="session-dot dot-idle" style="display:inline-block;opacity:0.6"></span>
1740
+ <span class="session-name__text">📋 ${I18n.t("sessions.cronGroup")} (${count})</span>
1741
+ </div>
1742
+ <div class="session-meta">${I18n.t("sessions.cronGroupMeta", { n: count })}</div>
1743
+ </div>
1744
+ `;
1745
+ el.onclick = () => {
1746
+ _cronView = true;
1747
+ Sessions.renderList();
1748
+ };
1749
+ container.appendChild(el);
1750
+ }
1751
+
1752
+ // ── Chat-section header visibility ────────────────────────────────────
1753
+ function _updateChatHeader(isCronView) {
1754
+ const chatSection = document.getElementById("chat-section");
1755
+ if (!chatSection) return;
1756
+
1757
+ const normalHeader = chatSection.querySelector(":scope > .sidebar-divider:first-of-type");
1758
+ const cronHeader = document.getElementById("cron-view-header");
1759
+ const searchBar = document.getElementById("session-search-bar");
1760
+ const newSessionBtn = document.getElementById("btn-session-search-toggle");
1761
+
1762
+ if (isCronView) {
1763
+ if (normalHeader) normalHeader.style.display = "none";
1764
+ if (cronHeader) cronHeader.style.display = "";
1765
+ if (searchBar) searchBar.hidden = true;
1766
+ if (newSessionBtn) newSessionBtn.style.display = "none";
1767
+ } else {
1768
+ if (normalHeader) normalHeader.style.display = "";
1769
+ if (cronHeader) cronHeader.style.display = "none";
1770
+ if (searchBar) searchBar.hidden = !_searchOpen;
1771
+ // newSessionBtn display managed by renderList's magnifier logic
1772
+ }
1773
+ }
1774
+
1640
1775
  // ── Public API ─────────────────────────────────────────────────────────
1641
1776
  return {
1642
1777
  get all() { return _sessions; },
@@ -1658,6 +1793,14 @@ const Sessions = (() => {
1658
1793
  _initMessageHistory();
1659
1794
  // Re-render session list (badges/labels) when the user switches language
1660
1795
  document.addEventListener("langchange", () => Sessions.renderList());
1796
+
1797
+ // Cron view back button
1798
+ document.getElementById("btn-cron-back")
1799
+ .addEventListener("click", () => {
1800
+ _cronView = false;
1801
+ Sessions.renderList();
1802
+ });
1803
+
1661
1804
  // Browsers block file:// navigation from http:// pages. Intercept clicks on
1662
1805
  // file:// links and delegate to the backend API.
1663
1806
  // Local deployments (localhost / 127.0.0.1 / ::1): open the file with the
@@ -1702,16 +1845,18 @@ const Sessions = (() => {
1702
1845
  // ── List management ───────────────────────────────────────────────────
1703
1846
 
1704
1847
  /** Populate list from initial session_list WS event (connect only). */
1705
- setAll(list, hasMore = false) {
1848
+ setAll(list, hasMore = false, cronCount = 0) {
1706
1849
  _sessions.length = 0;
1707
1850
  _sessions.push(...list);
1708
- _hasMore = !!hasMore;
1851
+ _hasMore = !!hasMore;
1852
+ _cronCount = cronCount;
1709
1853
  },
1710
1854
 
1711
1855
  /** Insert a newly created session into the local list. */
1712
1856
  add(session) {
1713
1857
  if (!_sessions.find(s => s.id === session.id)) {
1714
1858
  _sessions.push(session);
1859
+ if (session.source === "cron") _cronCount++;
1715
1860
  }
1716
1861
  },
1717
1862
 
@@ -1730,7 +1875,10 @@ const Sessions = (() => {
1730
1875
  /** Remove a session from the list (from session_deleted event). */
1731
1876
  remove(id) {
1732
1877
  const idx = _sessions.findIndex(s => s.id === id);
1733
- if (idx !== -1) _sessions.splice(idx, 1);
1878
+ if (idx !== -1) {
1879
+ if (_sessions[idx].source === "cron") _cronCount = Math.max(0, _cronCount - 1);
1880
+ _sessions.splice(idx, 1);
1881
+ }
1734
1882
  // Clean up per-session progress state (timer + DOM + logical state)
1735
1883
  Sessions._deleteProgressState(id);
1736
1884
  },
@@ -1772,7 +1920,8 @@ const Sessions = (() => {
1772
1920
  (data.sessions || []).forEach(s => {
1773
1921
  if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
1774
1922
  });
1775
- _hasMore = !!data.has_more;
1923
+ _hasMore = !!data.has_more;
1924
+ _cronCount = data.cron_count || 0;
1776
1925
  } catch (e) {
1777
1926
  console.error("loadMore error:", e);
1778
1927
  } finally {
@@ -1809,7 +1958,8 @@ const Sessions = (() => {
1809
1958
  if (!res.ok) return;
1810
1959
  const data = await res.json();
1811
1960
  _sessions.push(...(data.sessions || []));
1812
- _hasMore = !!data.has_more;
1961
+ _hasMore = !!data.has_more;
1962
+ _cronCount = data.cron_count || 0;
1813
1963
  } catch (e) {
1814
1964
  console.error("commitSearch error:", e);
1815
1965
  } finally {
@@ -1994,14 +2144,53 @@ const Sessions = (() => {
1994
2144
  const qEl = document.getElementById("session-search-q");
1995
2145
  if (qClearBtn) qClearBtn.hidden = !(qEl && qEl.value);
1996
2146
 
2147
+ // ── Split cron vs non-cron for folding ───────────────────────────
2148
+ const hasActiveFilter = !!(_filter.q || _filter.type || _filter.date);
2149
+ const isCronView = _cronView && !hasActiveFilter;
2150
+ const cronSessions = visible.filter(s => s.source === "cron");
2151
+ const nonCronSessions = visible.filter(s => s.source !== "cron");
2152
+
2153
+ // Update chat-section header based on view mode
2154
+ _updateChatHeader(isCronView);
2155
+
1997
2156
  const list = $("session-list");
1998
2157
  list.innerHTML = "";
1999
- if (visible.length === 0) {
2000
- list.innerHTML = `<div class="session-empty">${I18n.t("sessions.empty")}</div>`;
2158
+
2159
+ if (hasActiveFilter) {
2160
+ // Filter active: show all matching results flat, no group entry
2161
+ visible.forEach(s => _renderSessionItem(list, s));
2162
+ } else if (isCronView) {
2163
+ // Cron sub-view: show only cron sessions.
2164
+ // If none are loaded yet, auto-load more pages until we find them.
2165
+ if (cronSessions.length === 0) {
2166
+ if (_hasMore && !_loadingMore) {
2167
+ list.innerHTML = `<div class="session-empty">${I18n.t("sessions.cronLoading")}</div>`;
2168
+ Sessions.loadMore(); // async — will call renderList() again when done
2169
+ return; // skip empty-state / load-more button for now
2170
+ }
2171
+ if (_loadingMore) {
2172
+ // A loadMore() call is already in flight (its own renderList call
2173
+ // reached us). Keep the loading indicator so the user never sees
2174
+ // the "no sessions" empty state during the gap.
2175
+ list.innerHTML = `<div class="session-empty">${I18n.t("sessions.cronLoading")}</div>`;
2176
+ return;
2177
+ }
2178
+ }
2179
+ cronSessions.forEach(s => _renderSessionItem(list, s));
2180
+ } else if (_cronCount > 0) {
2181
+ // Normal list view: group entry (uses total count, not just loaded) + non-cron sessions
2182
+ _renderCronGroupItem(list, _cronCount);
2183
+ nonCronSessions.forEach(s => _renderSessionItem(list, s));
2001
2184
  } else {
2185
+ // Normal list view, no cron sessions
2002
2186
  visible.forEach(s => _renderSessionItem(list, s));
2003
2187
  }
2004
2188
 
2189
+ // Empty state fallback
2190
+ if (list.children.length === 0) {
2191
+ list.innerHTML = `<div class="session-empty">${I18n.t("sessions.empty")}</div>`;
2192
+ }
2193
+
2005
2194
  if (_hasMore) list.appendChild(_makeLoadMoreBtn());
2006
2195
 
2007
2196
  // Scroll active session into view so the sidebar always shows the current session.
@@ -2089,6 +2278,8 @@ const Sessions = (() => {
2089
2278
  if (action === "pin") {
2090
2279
  await Sessions.togglePin(session.id);
2091
2280
  } else if (action === "rename") {
2281
+ // Close sidebar on mobile so the rename dialog isn't obscured
2282
+ window.mobileCloseSidebar?.();
2092
2283
  // Find the session item by data-session-id attribute
2093
2284
  const sessionItem = document.querySelector(`.session-item[data-session-id="${session.id}"]`);
2094
2285
  if (sessionItem) {
@@ -2096,6 +2287,8 @@ const Sessions = (() => {
2096
2287
  Sessions._startRename(session.id, nameDiv, session.name);
2097
2288
  }
2098
2289
  } else if (action === "delete") {
2290
+ // Close sidebar on mobile so the delete dialog isn't obscured
2291
+ window.mobileCloseSidebar?.();
2099
2292
  await Sessions.deleteSession(session.id);
2100
2293
  }
2101
2294
  });
@@ -2178,7 +2371,10 @@ const Sessions = (() => {
2178
2371
  // info while the agent is working.
2179
2372
  const inp = $("user-input");
2180
2373
  if (inp) {
2181
- const key = status === "running" ? "chat.input.placeholderRunning" : "chat.input.placeholder";
2374
+ const mobile = window.innerWidth <= 768;
2375
+ const key = status === "running"
2376
+ ? (mobile ? "chat.input.placeholderRunningMobile" : "chat.input.placeholderRunning")
2377
+ : (mobile ? "chat.input.placeholderMobile" : "chat.input.placeholder");
2182
2378
  inp.setAttribute("data-i18n-placeholder", key);
2183
2379
  inp.setAttribute("placeholder", I18n.t(key));
2184
2380
  }
@@ -2258,6 +2454,17 @@ const Sessions = (() => {
2258
2454
  sibModel.textContent = s.model || "";
2259
2455
  // Store current session ID on the model element for later use
2260
2456
  sibModel.dataset.sessionId = s.id;
2457
+ if (s.model_id) {
2458
+ sibModel.dataset.modelId = s.model_id;
2459
+ } else {
2460
+ delete sibModel.dataset.modelId;
2461
+ }
2462
+ // Disable model switching while the agent is responding
2463
+ const busy = s.status === "running";
2464
+ sibModel.classList.toggle("sib-model-disabled", busy);
2465
+ sibModel.title = busy
2466
+ ? I18n.t("sib.model.tooltip.busy")
2467
+ : I18n.t("sib.model.tooltip");
2261
2468
  }
2262
2469
  if (sibModelWrap) sibModelWrap.style.display = s.model ? "" : "none";
2263
2470
 
@@ -2288,7 +2495,9 @@ const Sessions = (() => {
2288
2495
  const sibCost = $("sib-cost");
2289
2496
  if (sibCost) {
2290
2497
  if (s.cost_source && s.cost_source !== "estimated") {
2291
- sibCost.textContent = `$${(s.total_cost || 0).toFixed(2)}`;
2498
+ const symbol = typeof Billing !== "undefined" ? Billing.getCurrencySymbol() : "$";
2499
+ const cost = typeof Billing !== "undefined" ? Billing.convertCost(s.total_cost || 0) : (s.total_cost || 0);
2500
+ sibCost.textContent = `${symbol}${cost.toFixed(2)}`;
2292
2501
  } else {
2293
2502
  sibCost.textContent = "N/A";
2294
2503
  }
@@ -2367,6 +2576,46 @@ const Sessions = (() => {
2367
2576
 
2368
2577
  wrap.style.display = "";
2369
2578
  if (sep) sep.style.display = "";
2579
+
2580
+ // Mobile: bind tap-to-show popup once (flag prevents re-binding on every update)
2581
+ if (!el._signalTapBound) {
2582
+ el._signalTapBound = true;
2583
+ el.addEventListener("click", (e) => {
2584
+ if (window.innerWidth > 768) return; // desktop: native title tooltip is fine
2585
+ e.stopPropagation();
2586
+ // Remove any existing popup
2587
+ const existing = document.querySelector(".sib-signal-popup");
2588
+ if (existing) { existing.remove(); return; }
2589
+
2590
+ const popup = document.createElement("div");
2591
+ popup.className = "sib-signal-popup";
2592
+ // Format tooltip text: replace " · " with newlines for readability
2593
+ popup.textContent = el.title.replace(/ · /g, "\n");
2594
+ document.body.appendChild(popup);
2595
+
2596
+ // Position: above the signal element, aligned to its left edge
2597
+ const rect = el.getBoundingClientRect();
2598
+ let left = rect.left;
2599
+ // Prevent overflow off right edge
2600
+ const popupWidth = 220;
2601
+ if (left + popupWidth > window.innerWidth - 8) {
2602
+ left = window.innerWidth - popupWidth - 8;
2603
+ }
2604
+ popup.style.left = left + "px";
2605
+ popup.style.visibility = "hidden";
2606
+ // Use rAF to get actual rendered height before positioning
2607
+ requestAnimationFrame(() => {
2608
+ const popupHeight = popup.getBoundingClientRect().height;
2609
+ popup.style.top = (rect.top - popupHeight - 6) + "px";
2610
+ popup.style.visibility = "";
2611
+ });
2612
+
2613
+ // Close on next tap anywhere
2614
+ setTimeout(() => {
2615
+ document.addEventListener("click", () => popup.remove(), { once: true });
2616
+ }, 0);
2617
+ });
2618
+ }
2370
2619
  },
2371
2620
 
2372
2621
  // ── Message helpers ────────────────────────────────────────────────────
@@ -2456,14 +2705,16 @@ const Sessions = (() => {
2456
2705
  // :api => "$0.00123" (exact, from API response)
2457
2706
  // :price => "~$0.00123" (estimated from pricing table)
2458
2707
  // :estimated => "N/A" (model unknown in pricing table)
2459
- const cost = ev.cost || 0;
2708
+ const rawCost = ev.cost || 0;
2709
+ const symbol = typeof Billing !== "undefined" ? Billing.getCurrencySymbol() : "$";
2710
+ const cost = typeof Billing !== "undefined" ? Billing.convertCost(rawCost) : rawCost;
2460
2711
  let costStr;
2461
2712
  if (!ev.cost_source || ev.cost_source === "estimated") {
2462
2713
  costStr = "N/A";
2463
2714
  } else if (ev.cost_source === "price") {
2464
- costStr = `~$${cost.toFixed(5)}`;
2715
+ costStr = `~${symbol}${cost.toFixed(5)}`;
2465
2716
  } else {
2466
- costStr = `$${cost.toFixed(5)}`;
2717
+ costStr = `${symbol}${cost.toFixed(5)}`;
2467
2718
  }
2468
2719
 
2469
2720
  // Always-visible: label, delta, cache indicator, cost
@@ -3143,6 +3394,7 @@ const Sessions = (() => {
3143
3394
  const modelEl = e.target.closest("#sib-model");
3144
3395
  if (modelEl) {
3145
3396
  e.stopPropagation();
3397
+ if (modelEl.classList.contains("sib-model-disabled")) return;
3146
3398
  const dropdown = $("sib-model-dropdown");
3147
3399
  if (!dropdown) return;
3148
3400
 
@@ -3150,7 +3402,7 @@ const Sessions = (() => {
3150
3402
  dropdown.style.display = "none";
3151
3403
  _isOpen = false;
3152
3404
  } else {
3153
- await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.textContent.trim());
3405
+ await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.dataset.modelId || null);
3154
3406
 
3155
3407
  // Calculate position relative to the model element (fixed positioning)
3156
3408
  const rect = modelEl.getBoundingClientRect();
@@ -3173,7 +3425,7 @@ const Sessions = (() => {
3173
3425
  });
3174
3426
 
3175
3427
  // Populate dropdown with available models
3176
- async function _populateModelDropdown(sessionId, currentModel) {
3428
+ async function _populateModelDropdown(sessionId, currentModelId) {
3177
3429
  const dropdown = $("sib-model-dropdown");
3178
3430
  if (!dropdown) return;
3179
3431
 
@@ -3217,16 +3469,41 @@ const Sessions = (() => {
3217
3469
  });
3218
3470
 
3219
3471
  // ── Model rows ─────────────────────────────────────────────────────
3472
+ const _nameCounts = models.reduce((acc, m) => {
3473
+ acc[m.model] = (acc[m.model] || 0) + 1;
3474
+ return acc;
3475
+ }, {});
3476
+
3220
3477
  models.forEach(m => {
3221
- console.log("[Model Switcher] Adding model:", m.model, "id:", m.id, "current:", currentModel);
3478
+ console.log("[Model Switcher] Adding model:", m.model, "id:", m.id, "current:", currentModelId);
3222
3479
  const opt = document.createElement("div");
3223
3480
  opt.className = "sib-model-option";
3224
3481
  opt.dataset.modelId = m.id;
3225
- if (m.model === currentModel) opt.classList.add("current");
3482
+ if (m.id === currentModelId) opt.classList.add("current");
3226
3483
 
3227
3484
  const left = document.createElement("span");
3228
3485
  left.className = "sib-model-name";
3229
- left.textContent = m.model;
3486
+
3487
+ const nameLine = document.createElement("span");
3488
+ nameLine.className = "sib-model-name-main";
3489
+ nameLine.textContent = m.model;
3490
+ left.appendChild(nameLine);
3491
+
3492
+ if (_nameCounts[m.model] > 1) {
3493
+ left.classList.add("has-sub");
3494
+ const host = (() => {
3495
+ try { return new URL(m.base_url).host; } catch { return m.base_url || ""; }
3496
+ })();
3497
+ const subBits = [host, m.api_key_masked].filter(Boolean);
3498
+ if (subBits.length) {
3499
+ const subLine = document.createElement("span");
3500
+ subLine.className = "sib-model-name-sub";
3501
+ subLine.textContent = subBits.join(" · ");
3502
+ left.appendChild(subLine);
3503
+ opt.title = `${m.model} · ${subBits.join(" · ")}`;
3504
+ }
3505
+ }
3506
+
3230
3507
  opt.appendChild(left);
3231
3508
 
3232
3509
  const right = document.createElement("span");
@@ -3677,3 +3954,7 @@ const Sessions = (() => {
3677
3954
  document.addEventListener("langchange", () => {
3678
3955
  if (Sessions._lastSession) Sessions.updateInfoBar(Sessions._lastSession);
3679
3956
  });
3957
+
3958
+ document.addEventListener("currencychange", () => {
3959
+ if (Sessions._lastSession) Sessions.updateInfoBar(Sessions._lastSession);
3960
+ });