clauderipple 0.9.4 → 0.10.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 (118) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +31 -0
  3. package/dist/app/package.json +1 -1
  4. package/dist/cli/src/hooks/agent-title.js +7 -4
  5. package/dist/cli/src/index.js +43 -0
  6. package/dist/cli/src/remote.js +263 -0
  7. package/dist/cli/src/schtasks.js +48 -40
  8. package/dist/cli/src/settings.js +2 -1
  9. package/dist/mod/hooks/i18n.ts +252 -6
  10. package/dist/mod/hooks/judge.ts +35 -17
  11. package/dist/mod/hooks/modes.ts +56 -1
  12. package/dist/mod/hooks/register.tsx +238 -32
  13. package/dist/mod/hooks/session.ts +98 -1
  14. package/dist/mod/hooks/settings.js +7 -3
  15. package/dist/mod/hooks/settings.ts +9 -4
  16. package/dist/mod/hooks/stats.ts +25 -1
  17. package/dist/mod/hooks/ui/agents.tsx +858 -79
  18. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  19. package/dist/mod/hooks/ui/art.ts +33 -7
  20. package/dist/mod/hooks/ui/band.tsx +262 -81
  21. package/dist/mod/hooks/ui/flat.ts +265 -0
  22. package/dist/mod/hooks/ui/kit.tsx +70 -55
  23. package/dist/mod/hooks/ui/look.ts +129 -0
  24. package/dist/mod/hooks/ui/settings.tsx +71 -46
  25. package/dist/mod/hooks/ui/setup.tsx +15 -13
  26. package/dist/mod/hooks/ui/theme.ts +3 -0
  27. package/dist/mod/hooks/ui/tools.ts +124 -0
  28. package/dist/mod/hooks/ui/view.ts +116 -3
  29. package/dist/mod/hooks/workers.ts +33 -6
  30. package/dist/mod/tests/register.test.tsx +155 -11
  31. package/dist/mod/tests/ui.test.tsx +375 -27
  32. package/dist/mod/types/index.d.ts +68 -2
  33. package/dist/remote/package.json +28 -0
  34. package/dist/remote/src/away.js +85 -0
  35. package/dist/remote/src/bridge-client.js +295 -0
  36. package/dist/remote/src/composer-menu.js +11 -0
  37. package/dist/remote/src/config.js +102 -0
  38. package/dist/remote/src/desktop.js +1018 -0
  39. package/dist/remote/src/e2ee.js +126 -0
  40. package/dist/remote/src/e2ee.ts +126 -0
  41. package/dist/remote/src/eventlog.js +298 -0
  42. package/dist/remote/src/expo-push.js +127 -0
  43. package/dist/remote/src/folders.js +79 -0
  44. package/dist/remote/src/fsutil.js +39 -0
  45. package/dist/remote/src/handoff.js +38 -0
  46. package/dist/remote/src/host-service.js +168 -0
  47. package/dist/remote/src/images.js +215 -0
  48. package/dist/remote/src/local-control.js +138 -0
  49. package/dist/remote/src/main.js +264 -0
  50. package/dist/remote/src/permissions.js +124 -0
  51. package/dist/remote/src/power.js +76 -0
  52. package/dist/remote/src/processes.js +24 -0
  53. package/dist/remote/src/protocol.js +32 -0
  54. package/dist/remote/src/protocol.ts +284 -0
  55. package/dist/remote/src/push-content.js +32 -0
  56. package/dist/remote/src/push.js +134 -0
  57. package/dist/remote/src/receipts.js +111 -0
  58. package/dist/remote/src/relay.js +438 -0
  59. package/dist/remote/src/router.js +208 -0
  60. package/dist/remote/src/sdk-launcher.js +18 -0
  61. package/dist/remote/src/server.js +706 -0
  62. package/dist/remote/src/service.js +157 -0
  63. package/dist/remote/src/sessions.js +1800 -0
  64. package/dist/remote/src/sidebar-storage.js +211 -0
  65. package/dist/remote/src/sidebar.js +173 -0
  66. package/dist/remote/src/tool-details.js +205 -0
  67. package/dist/remote/src/tool-details.ts +153 -0
  68. package/dist/remote/src/worktree.js +99 -0
  69. package/dist/remote/web/app.ts +746 -0
  70. package/dist/remote/web/conn.ts +193 -0
  71. package/dist/remote/web/demo.ts +134 -0
  72. package/dist/remote/web/dom.ts +124 -0
  73. package/dist/remote/web/esc.ts +4 -0
  74. package/dist/remote/web/i18n.ts +374 -0
  75. package/dist/remote/web/icon.svg +1 -0
  76. package/dist/remote/web/index.html +24 -0
  77. package/dist/remote/web/manifest.webmanifest +10 -0
  78. package/dist/remote/web/mascot.ts +60 -0
  79. package/dist/remote/web/md.ts +78 -0
  80. package/dist/remote/web/notify.ts +55 -0
  81. package/dist/remote/web/pushstate.ts +27 -0
  82. package/dist/remote/web/secure.ts +109 -0
  83. package/dist/remote/web/sidebar.ts +106 -0
  84. package/dist/remote/web/style.css +311 -0
  85. package/dist/remote/web/sw.ts +48 -0
  86. package/dist/remote/web/timeline.ts +224 -0
  87. package/dist/remote/web/views.ts +323 -0
  88. package/dist/router/src/admin.js +227 -4
  89. package/dist/router/src/config.js +30 -0
  90. package/dist/router/src/index.js +39 -0
  91. package/dist/router/src/ingress/translate.js +6 -2
  92. package/dist/router/src/phone.js +82 -0
  93. package/dist/router/src/presets.js +3 -0
  94. package/dist/router/src/providers/anthropic-usage.js +33 -1
  95. package/dist/router/src/providers/chatgpt/index.js +41 -11
  96. package/dist/router/src/providers/chatgpt/translate.js +147 -7
  97. package/dist/router/src/providers/google/index.js +26 -0
  98. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  99. package/dist/router/src/providers/google/usage.js +257 -0
  100. package/dist/router/src/providers/grok/index.js +18 -0
  101. package/dist/router/src/providers/grok/usage.js +77 -0
  102. package/dist/router/src/providers/openai/index.js +29 -5
  103. package/dist/router/src/providers/openai/translate.js +260 -27
  104. package/dist/router/src/providers/opencode-usage.js +116 -0
  105. package/dist/router/src/proxy.js +339 -258
  106. package/dist/router/src/reasoning.js +157 -0
  107. package/dist/router/src/remote-host.js +320 -0
  108. package/dist/router/src/remote-tunnel.js +320 -0
  109. package/dist/router/src/requestlog.js +48 -39
  110. package/dist/router/src/thinking.js +20 -9
  111. package/dist/router/src/usage-history.js +227 -0
  112. package/dist/router/src/version.js +1 -1
  113. package/dist/ui/app.js +436 -59
  114. package/dist/ui/i18n.js +184 -0
  115. package/dist/ui/index.html +24 -5
  116. package/dist/ui/style.css +39 -0
  117. package/docs/ARCHITECTURE.md +164 -12
  118. package/package.json +7 -1
package/dist/ui/app.js CHANGED
@@ -71,9 +71,13 @@ function modelsOf(provider) {
71
71
  }
72
72
  function labelOf(model) { return model.name || model.id; }
73
73
  function clone(value) { return JSON.parse(JSON.stringify(value)); }
74
- function safeName(value) { return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "provider"; }
75
- function uniqueName(name, providers) {
76
- const base = safeName(name);
74
+ function safeName(value) { return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""); }
75
+ // A name with no Latin letters ("하이브") slugs to nothing: fall back to the URL's domain ("thehive").
76
+ function domainWord(url) {
77
+ try { const parts = new URL(url).hostname.split("."); return safeName(parts.length > 1 ? parts[parts.length - 2] : parts[0]); } catch { return ""; }
78
+ }
79
+ function uniqueName(name, providers, url) {
80
+ const base = safeName(name) || (url ? domainWord(url) : "") || "provider";
77
81
  let value = base;
78
82
  let number = 2;
79
83
  while (providers[value]) value = `${base}-${number++}`;
@@ -153,6 +157,10 @@ let pickerBusy = false;
153
157
  let probeStates = new Map();
154
158
  let chatgptLoginBusy = false;
155
159
  let chatgptLoginMessage = "";
160
+ let phoneGeneration = 0;
161
+ let phoneSnapshot = null;
162
+ let phoneQrGeneration = 0;
163
+ let phoneBusy = false;
156
164
 
157
165
  function selectOption(value, text) { return el("option", { value }, [text]); }
158
166
  function badge(kind, text) { return el("span", { class: `badge ${kind} dot`, text }, []); }
@@ -195,6 +203,8 @@ function svgIcon(viewBox, paths, cls) {
195
203
  function chevronIcon() { return svgIcon("0 0 8 13", ["M1.5 1.5l5 5-5 5"], "chevron"); }
196
204
 
197
205
  function showView(name) {
206
+ if (name !== "phone") { phoneGeneration++; hidePhoneQr(); }
207
+ if (name === "phone") queueMicrotask(() => void loadPhone());
198
208
  for (const button of $all(".nav-btn")) button.classList.toggle("active", button.dataset.view === name);
199
209
  for (const view of $all(".view")) view.classList.toggle("active", view.id === `view-${name}`);
200
210
  if (location.hash !== `#${name}`) history.replaceState(null, "", `#${name}`);
@@ -727,6 +737,125 @@ async function toggleCodex(enabled) {
727
737
  void Promise.all([loadCatalogs(), refreshHealth()]);
728
738
  setInterval(refreshHealth, 5000);
729
739
 
740
+ // ---- Phone --------------------------------------------------------------------------
741
+
742
+ function hidePhoneQr() {
743
+ phoneQrGeneration++;
744
+ const box = $("#phone-qr");
745
+ if (box) box.replaceChildren();
746
+ }
747
+ function phoneActive() { return $("#view-phone").classList.contains("active"); }
748
+ function phonePost(route, body) {
749
+ return api(`/api/phone/${route}`, { method: "POST", headers: { "content-type": "application/json" }, ...(body ? { body: JSON.stringify(body) } : {}) });
750
+ }
751
+ async function showPhoneQr(button) {
752
+ if (phoneBusy) return;
753
+ const generation = ++phoneQrGeneration;
754
+ button.disabled = true;
755
+ try {
756
+ const pairing = await api("/api/phone/pairing");
757
+ if (generation !== phoneQrGeneration || !phoneActive()) return;
758
+ const copy = el("button", { class: "btn secondary", type: "button", text: t("phone.copyLink") });
759
+ copy.addEventListener("click", async () => {
760
+ try { await navigator.clipboard.writeText(pairing.url); toast(t("phone.copied")); }
761
+ catch { toast(t("phone.copyFailed"), true); }
762
+ });
763
+ // An image, never trusted SVG markup in the dashboard's DOM.
764
+ $("#phone-qr").replaceChildren(el("img", { class: "phone-qr-image", alt: t("phone.qrAlt"), src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(pairing.svg)}`, width: "240", height: "240" }), copy,
765
+ el("button", { class: "btn secondary", type: "button", text: t("common.hide"), onclick: hidePhoneQr }));
766
+ } catch { if (generation === phoneQrGeneration) toast(t("common.loadFailed"), true); }
767
+ finally { button.disabled = false; }
768
+ }
769
+ function renderPhoneStatus(data) {
770
+ const rows = [cell(t("phone.host"), badge(data.hostRunning ? "ok" : "warn", t(data.hostRunning ? "phone.running" : "phone.stopped")))];
771
+ if (data.hostRunning) rows.push(cell(t("phone.mode"), el("span", { text: t(data.mode === "relay" ? "phone.relayStatus" : data.mode === "direct" ? "phone.directStatus" : "phone.localStatus") }), `${data.bind}:${data.port} · ${data.version}`),
772
+ cell(t("phone.notifications"), badge(data.pushReady ? "ok" : "warn", t(data.pushReady ? "phone.ready" : "phone.needsRelay"))));
773
+ else rows.push(el("div", { class: "cell-note" }, [hint(t("phone.install")), el("code", { text: data.hint })]));
774
+ $("#phone-status").replaceChildren(...rows);
775
+ if (!data.hostRunning) return;
776
+ $("#phone-phones").replaceChildren(...(data.phones.length ? data.phones.map(phone => cell(
777
+ el("span", { class: "cell-label mono", text: phone.clientId.length > 12 ? `${phone.clientId.slice(0, 12)}…` : phone.clientId, title: phone.clientId }),
778
+ badge(phone.connected ? "ok" : "", t(phone.connected ? "phone.connected" : "phone.offline")),
779
+ [phone.lastSeen ? t("phone.lastSeen", { time: new Date(phone.lastSeen).toLocaleString(CURRENT_LANG) }) : t("phone.unknownSeen"), t(phone.push ? "phone.pushOn" : "phone.pushOff")],
780
+ )) : [el("div", { class: "cell-note", text: t("phone.empty") })]));
781
+ $("#phone-restart").hidden = !data.restartRequired;
782
+ }
783
+ function phoneSettings(data) {
784
+ // The phone app pairs through the Relay only, so phone access is on (Relay) or off. A custom
785
+ // relay address stays in the host's config file; the dashboard keeps whichever one is saved.
786
+ const saved = data.settings;
787
+ const selected = saved.relay ? "relay" : "local";
788
+ const choices = ["relay", "local"].map(value => {
789
+ const radio = el("input", { type: "radio", name: "phone-mode", value, checked: value === selected });
790
+ return el("label", { class: "cell phone-choice" }, [radio, el("span", { class: "cell-text" }, [el("span", { class: "cell-label", text: t(`phone.choice.${value}`) }), el("span", { class: "cell-sub", text: t(`phone.help.${value}`) })])]);
791
+ });
792
+ const save = el("button", { class: "btn", type: "button", text: t("common.save") });
793
+ save.addEventListener("click", async () => {
794
+ if (phoneBusy) return;
795
+ const mode = $('#phone-content input[name="phone-mode"]:checked').value;
796
+ phoneBusy = true; save.disabled = true;
797
+ hidePhoneQr();
798
+ try {
799
+ const result = await phonePost("settings", mode === "relay" ? { relay: saved.relay || data.defaultRelay, bind: "local" } : { relay: "off", bind: "local" });
800
+ toast(t(result.restartRequired ? "phone.restartRequired" : "common.saved"));
801
+ await refreshPhone();
802
+ } catch (error) { toast(t("common.saveFailed"), true, error.message); }
803
+ finally { phoneBusy = false; save.disabled = false; }
804
+ });
805
+ return section(t("phone.settings"), [...choices, cell(t("phone.apply"), save)]);
806
+ }
807
+ function phoneRestart() {
808
+ const button = el("button", { class: "btn secondary", type: "button", text: t("phone.restart") });
809
+ const message = hint("");
810
+ button.addEventListener("click", async () => {
811
+ if (!confirm(t("phone.restartConfirm"))) return;
812
+ button.disabled = true;
813
+ hidePhoneQr();
814
+ try {
815
+ const result = await phonePost("restart");
816
+ if (result.manual) message.textContent = t("phone.manualRestart", { command: result.command });
817
+ else { toast(t("phone.restarting")); setTimeout(() => void loadPhone(), 2000); }
818
+ } catch (error) { toast(t("common.actionFailed"), true, error.message); }
819
+ finally { button.disabled = false; }
820
+ });
821
+ return el("div", { id: "phone-restart", class: "group stacked-group", hidden: "" }, [cell(t("phone.restartRequired"), button, message, "wide")]);
822
+ }
823
+ function phoneControls(data) {
824
+ const qr = el("button", { class: "btn secondary", type: "button", text: t("phone.showQr") });
825
+ qr.addEventListener("click", () => void showPhoneQr(qr));
826
+ const rotate = el("button", { class: "cell-button destructive", type: "button", text: t("phone.rotate") });
827
+ rotate.addEventListener("click", async () => {
828
+ if (phoneBusy || !confirm(t("phone.rotateConfirm"))) return;
829
+ phoneBusy = true; rotate.disabled = true; hidePhoneQr();
830
+ try { await phonePost("rotate"); toast(t("phone.rotated")); await refreshPhone(); }
831
+ catch (error) { toast(t("common.actionFailed"), true, error.message); }
832
+ finally { phoneBusy = false; rotate.disabled = false; }
833
+ });
834
+ const phones = section(t("phone.phones"), []);
835
+ $(".group", phones).id = "phone-phones";
836
+ return [section(t("phone.pairing"), [cell(t("phone.scan"), qr), el("div", { id: "phone-qr", class: "phone-qr" })], [t("phone.qrWarning"), !data.reachableFromPhone ? t("phone.unreachable") : null]),
837
+ phoneSettings(data), phoneRestart(), phones, section(null, [rotate], t("phone.sharedKey"))];
838
+ }
839
+ async function refreshPhone(rebuild = false) {
840
+ if (!phoneActive()) return;
841
+ const generation = phoneGeneration;
842
+ try {
843
+ const data = await api("/api/phone/status");
844
+ if (generation !== phoneGeneration || !phoneActive()) return;
845
+ if (rebuild || !phoneSnapshot || phoneSnapshot.hostRunning !== data.hostRunning) {
846
+ hidePhoneQr();
847
+ const state = section(t("phone.status"), []);
848
+ $(".group", state).id = "phone-status";
849
+ $("#phone-content").replaceChildren(state, ...(data.hostRunning ? phoneControls(data) : []));
850
+ }
851
+ phoneSnapshot = data;
852
+ renderPhoneStatus(data);
853
+ } catch (error) { toast(t("common.loadFailed"), true, error.message); }
854
+ }
855
+ async function loadPhone() { phoneGeneration++; hidePhoneQr(); await refreshPhone(true); }
856
+ $("#phone-refresh").addEventListener("click", () => void loadPhone());
857
+ setInterval(() => { if (!phoneBusy) void refreshPhone(); }, 5000);
858
+
730
859
  // ---- Model links --------------------------------------------------------------------
731
860
 
732
861
  function claudeSelect(selected) {
@@ -1928,10 +2057,12 @@ function openAnthropicProviderForm(options) {
1928
2057
  function syncModeHint() { modeHint.textContent = auth.value === "claude-code" && accountPool ? t("providers.anthropicPoolRouting") : t("providers.anthropicIngressOnly"); }
1929
2058
  auth.addEventListener("change", syncModeHint);
1930
2059
  syncModeHint();
2060
+ const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
2061
+ const limitsField = el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]);
1931
2062
  const form = el("div", { class: "provider-form" }, [
1932
2063
  el("h1", { id: "modal-title", text: existing ? t("providers.edit") : t("providers.addTitle") }),
1933
2064
  inputRow(t("providers.name"), nameInput, t("providers.nameHelp")), authField, keyField, probeButton, sourceLine, result, modelArea,
1934
- modeHint,
2065
+ modeHint, limitsField,
1935
2066
  ]);
1936
2067
  const saveButton = el("button", { class: "btn", type: "button", "data-default-action": "", text: existing ? t("common.save") : t("providers.add") });
1937
2068
  saveButton.addEventListener("click", async () => {
@@ -1940,7 +2071,7 @@ function openAnthropicProviderForm(options) {
1940
2071
  const next = clone(await latestConfig());
1941
2072
  const providerName = existing ? options.name : uniqueName(typedName, next.providers);
1942
2073
  const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), existing && next.providers[options.name]);
1943
- const provider = { type: "anthropic", auth: auth.value, ...(auth.value === "claude-code" && accountPool ? { accountPool: true } : {}), ...(auth.value === "api-key" && (keyInput.value || (existing && existing.apiKey)) ? { apiKey: keyInput.value || existing.apiKey } : {}), models: checkedModels };
2074
+ const provider = { type: "anthropic", auth: auth.value, ...(auth.value === "claude-code" && accountPool ? { accountPool: true } : {}), ...(auth.value === "api-key" && (keyInput.value || (existing && existing.apiKey)) ? { apiKey: keyInput.value || existing.apiKey } : {}), limits: limitsInput.checked, models: checkedModels };
1944
2075
  next.providers[providerName] = provider;
1945
2076
  // accountPool makes these models native Claude routing targets. Without it this remains the
1946
2077
  // historical OpenAI ingress provider, and routing deliberately ignores it.
@@ -2007,9 +2138,11 @@ function openGrokProviderForm(options) {
2007
2138
  probeButton.addEventListener("click", () => void runProbe());
2008
2139
  const advanced = el("details", { class: "details" }, [el("summary", { text: t("common.advanced") })]);
2009
2140
  const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
2141
+ const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
2010
2142
  const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
2011
2143
  advanced.appendChild(el("div", { class: "advanced-content" }, [
2012
2144
  el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
2145
+ el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]),
2013
2146
  inputRow(t("providers.append"), append, t("providers.appendHelp")),
2014
2147
  ]));
2015
2148
  const pickerInput = el("input", { type: "checkbox", checked: existing ? modelsOf(existing).some((model) => ((currentConfig.cli && currentConfig.cli.extraModels) || []).some((extra) => extra.model === model.id)) : true });
@@ -2030,12 +2163,13 @@ function openGrokProviderForm(options) {
2030
2163
  const providerName = existing ? options.name : uniqueName(typedName, next.providers);
2031
2164
  const saved = existing && (next.providers[options.name] || existing);
2032
2165
  const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), saved);
2033
- const formKeys = new Set(["type", "identity", "instructionsAppend", "models"]);
2166
+ const formKeys = new Set(["type", "identity", "limits", "instructionsAppend", "models"]);
2034
2167
  const kept = saved ? Object.fromEntries(Object.entries(saved).filter(([key]) => !formKeys.has(key))) : {};
2035
2168
  next.providers[providerName] = {
2036
2169
  ...kept,
2037
2170
  type: "grok",
2038
2171
  identity: identity.checked,
2172
+ limits: limitsInput.checked,
2039
2173
  ...(append.value.trim() ? { instructionsAppend: append.value.trim() } : {}),
2040
2174
  models: checkedModels,
2041
2175
  };
@@ -2110,9 +2244,11 @@ function openGoogleProviderForm(options) {
2110
2244
  probeButton.addEventListener("click", () => void runProbe());
2111
2245
  const advanced = el("details", { class: "details" }, [el("summary", { text: t("common.advanced") })]);
2112
2246
  const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
2247
+ const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
2113
2248
  const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
2114
2249
  advanced.appendChild(el("div", { class: "advanced-content" }, [
2115
2250
  el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
2251
+ el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]),
2116
2252
  inputRow(t("providers.append"), append, t("providers.appendHelp")),
2117
2253
  ]));
2118
2254
  const pickerInput = el("input", { type: "checkbox", checked: Boolean(existing && modelsOf(existing).some((model) => ((currentConfig.cli && currentConfig.cli.extraModels) || []).some((extra) => extra.model === model.id))) });
@@ -2133,7 +2269,7 @@ function openGoogleProviderForm(options) {
2133
2269
  const saved = existing && (next.providers[options.name] || existing);
2134
2270
  const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), saved);
2135
2271
  // Fields this form has no control for (defaultEffort, a hand-written setting) survive the save.
2136
- const formKeys = new Set(["type", "auth", "apiKey", "identity", "instructionsAppend", "models"]);
2272
+ const formKeys = new Set(["type", "auth", "apiKey", "identity", "limits", "instructionsAppend", "models"]);
2137
2273
  const kept = saved ? Object.fromEntries(Object.entries(saved).filter(([key]) => !formKeys.has(key))) : {};
2138
2274
  next.providers[providerName] = {
2139
2275
  ...kept,
@@ -2142,6 +2278,7 @@ function openGoogleProviderForm(options) {
2142
2278
  ...(apiKey ? { apiKey } : {}),
2143
2279
  ...(existing && existing.url ? { url: existing.url } : {}),
2144
2280
  identity: identity.checked,
2281
+ limits: limitsInput.checked,
2145
2282
  ...(append.value.trim() ? { instructionsAppend: append.value.trim() } : {}),
2146
2283
  models: checkedModels,
2147
2284
  };
@@ -2242,12 +2379,14 @@ function openProviderForm(options) {
2242
2379
  // Without a line of its own the model reads Claude Code's system prompt and answers that it is
2243
2380
  // Claude, which is what DeepSeek did before this was offered here too.
2244
2381
  const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
2382
+ const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
2245
2383
  const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
2246
2384
  advancedContent.append(
2247
2385
  el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
2386
+ el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]),
2248
2387
  inputRow(t("providers.append"), append, t("providers.appendHelp")),
2249
2388
  );
2250
- advancedContent._prompt = { identity, append };
2389
+ advancedContent._prompt = { identity, limits: limitsInput, append };
2251
2390
  } else {
2252
2391
  const auth = el("select", {}, [selectOption("auto", t("providers.authAuto")), selectOption("own", t("providers.authOwn")), selectOption("borrow-codex", t("providers.authBorrow"))]);
2253
2392
  auth.value = (existing && existing.auth) || "auto";
@@ -2259,6 +2398,7 @@ function openProviderForm(options) {
2259
2398
  const loginHelp = el("small", { text: t("providers.loginHelp") });
2260
2399
  const effort = effortSelect((existing && existing.defaultEffort) || "high", ["low", "medium", "high", "xhigh", "max"]);
2261
2400
  const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
2401
+ const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
2262
2402
  const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
2263
2403
  chatgptFields = [
2264
2404
  inputRow(t("providers.credentials"), auth, t("providers.credentialsHelp")),
@@ -2267,9 +2407,10 @@ function openProviderForm(options) {
2267
2407
  ];
2268
2408
  advancedContent.append(
2269
2409
  el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
2410
+ el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]),
2270
2411
  inputRow(t("providers.append"), append, t("providers.appendHelp")),
2271
2412
  );
2272
- advancedContent._chatgpt = { auth, effort, identity, append };
2413
+ advancedContent._chatgpt = { auth, effort, identity, limits: limitsInput, append };
2273
2414
  }
2274
2415
  advanced.appendChild(advancedContent);
2275
2416
  const customUrlRow = isCustom ? inputRow(t("providers.url"), urlInput, t("providers.urlHelp")) : null;
@@ -2311,13 +2452,13 @@ function openProviderForm(options) {
2311
2452
  function draftProvider() {
2312
2453
  if (isChatgpt) {
2313
2454
  const chat = advancedContent._chatgpt;
2314
- return { type: "chatgpt", auth: chat.auth.value, defaultEffort: chat.effort.value, identity: chat.identity.checked, ...(chat.append.value.trim() ? { instructionsAppend: chat.append.value.trim() } : {}) };
2455
+ return { type: "chatgpt", auth: chat.auth.value, defaultEffort: chat.effort.value, identity: chat.identity.checked, limits: chat.limits.checked, ...(chat.append.value.trim() ? { instructionsAppend: chat.append.value.trim() } : {}) };
2315
2456
  }
2316
2457
  const prompt = advancedContent._prompt;
2317
2458
  return {
2318
2459
  type: openAiNow() ? "openai-compatible" : "anthropic-compatible",
2319
2460
  url: urlInput.value.trim(),
2320
- ...(prompt ? { identity: prompt.identity.checked } : {}),
2461
+ ...(prompt ? { identity: prompt.identity.checked, limits: prompt.limits.checked } : {}),
2321
2462
  ...(prompt && prompt.append.value.trim() ? { instructionsAppend: prompt.append.value.trim() } : {}),
2322
2463
  ...(preset ? { preset: preset.id } : {}),
2323
2464
  // A vendor that asks for a session header keys its prompt cache on it, so carry it from the
@@ -2410,7 +2551,7 @@ function openProviderForm(options) {
2410
2551
  const provider = draftProvider();
2411
2552
  if (!isChatgpt && (!provider.url || !/^https?:\/\//.test(provider.url))) { toast(t("providers.urlRequired"), true); return; }
2412
2553
  const next = clone(await latestConfig());
2413
- const providerName = existing ? options.name : uniqueName(typedName, next.providers);
2554
+ const providerName = existing ? options.name : uniqueName(typedName, next.providers, provider.url);
2414
2555
  const saved = existing && (next.providers[options.name] || existing);
2415
2556
  if (existing && providerName !== options.name) delete next.providers[options.name];
2416
2557
  const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), saved);
@@ -2419,8 +2560,8 @@ function openProviderForm(options) {
2419
2560
  // (debugDump, a hand-written setting) is carried over from the saved one rather than dropped.
2420
2561
  // A field the form owns is left to the draft, so clearing it in the form still clears it.
2421
2562
  const formKeys = new Set(isChatgpt
2422
- ? ["type", "auth", "defaultEffort", "identity", "instructionsAppend", "models"]
2423
- : ["type", "url", "identity", "instructionsAppend", "preset", "sessionHeader", "wire", "caps", "headers", "models"]);
2563
+ ? ["type", "auth", "defaultEffort", "identity", "limits", "instructionsAppend", "models"]
2564
+ : ["type", "url", "identity", "limits", "instructionsAppend", "preset", "sessionHeader", "wire", "caps", "headers", "models"]);
2424
2565
  const kept = saved ? Object.fromEntries(Object.entries(saved).filter(([key]) => !formKeys.has(key))) : {};
2425
2566
  next.providers[providerName] = { ...kept, ...provider };
2426
2567
  // Read after `next.providers` has been updated, so an unticked model is already undeclared here
@@ -2459,8 +2600,8 @@ function openProviderForm(options) {
2459
2600
  //
2460
2601
  // The mod's settings live in the router (GET/PUT /api/mod-settings); the ⚙ panel in the bar edits
2461
2602
  // the same object. As in System Settings a change applies itself: it is saved after a short pause
2462
- // with the revision it was made on. A 409 means another surface saved first — its settings are
2463
- // shown and the change made here is dropped, rather than quietly overwriting theirs.
2603
+ // with the revision it was made on. A 409 means another surface saved first: the fields changed
2604
+ // here are laid over its settings and sent again, so neither side's change is lost.
2464
2605
 
2465
2606
  const MOD_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
2466
2607
  // The band's parts the person can turn off (`hide`), then the ones it shows only when turned on
@@ -2490,13 +2631,16 @@ const MOD_DEFAULTS = {
2490
2631
  fallback: { agent: "general-purpose", model: "sonnet" },
2491
2632
  keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
2492
2633
  compactWith: "haiku", handoffSkill: "", handoffAfter: "continue", handoffButton: "advised",
2493
- swampAt: 50, layout: "dashboard", appearance: "auto", language: "auto", hide: ["reason"], extras: [], setupDone: false,
2634
+ swampAt: 50, layout: "dashboard", appearance: "auto", skin: "deep", language: "auto", hide: ["reason"], extras: [], setupDone: false,
2494
2635
  };
2495
2636
 
2496
- let modState = { settings: null, revision: 0, options: null, retiredNote: false };
2637
+ // `base` is the router's copy `settings` was edited from: what differs between them is this page's own change.
2638
+ let modState = { settings: null, base: null, revision: 0, options: null, retiredNote: false };
2497
2639
  let modSaveTimer = null;
2498
2640
  let modSaving = false;
2499
2641
  let modSaveAgain = false;
2642
+ // Counts the router's answers to saves, so a read that went out before one does not undo it.
2643
+ let modWrites = 0;
2500
2644
 
2501
2645
  /**
2502
2646
  * The router hands back the mod's full normalized settings; this only fills what an older router
@@ -2517,6 +2661,42 @@ function modNormalize(raw) {
2517
2661
  };
2518
2662
  }
2519
2663
 
2664
+ /**
2665
+ * The fields changed from `before` to `after`, found as the mod's settingsChanges finds them: values
2666
+ * and arrays whole, objects field by field, and a rule whose mode changed replaced whole.
2667
+ */
2668
+ function modChanges(before, after, path = []) {
2669
+ if (JSON.stringify(before) === JSON.stringify(after)) return [];
2670
+ if (after === undefined) return [{ path, remove: true }];
2671
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
2672
+ if (isObject(before) && isObject(after) && before.mode === after.mode) {
2673
+ return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => modChanges(before[key], after[key], [...path, key]));
2674
+ }
2675
+ return [{ path, value: after }];
2676
+ }
2677
+ function modApply(base, changes) {
2678
+ const result = clone(base);
2679
+ for (const change of changes) {
2680
+ const key = change.path.at(-1);
2681
+ if (key === undefined) continue;
2682
+ let parent = result;
2683
+ for (const step of change.path.slice(0, -1)) {
2684
+ if (!parent[step] || typeof parent[step] !== "object" || Array.isArray(parent[step])) parent[step] = {};
2685
+ parent = parent[step];
2686
+ }
2687
+ if (change.remove) delete parent[key];
2688
+ else parent[key] = clone(change.value);
2689
+ }
2690
+ return result;
2691
+ }
2692
+ /** Take the router's copy, keeping on it what was changed here and is not saved yet (unless `drop`). */
2693
+ function modAdopt(snapshot, drop) {
2694
+ const changes = !drop && modState.base && modState.settings ? modChanges(modState.base, modState.settings) : [];
2695
+ modState.base = modNormalize(snapshot.settings);
2696
+ modState.settings = modApply(modState.base, changes);
2697
+ modState.revision = Number(snapshot.revision) || 0;
2698
+ }
2699
+
2520
2700
  function modCap(text) { return text ? text.charAt(0).toLocaleUpperCase() + text.slice(1) : text; }
2521
2701
  function modEffortName(effort) { return t(`mod.effort.${effort}`); }
2522
2702
  function modSigned(n) { return n > 0 ? `+${n}` : n < 0 ? `−${-n}` : "0"; }
@@ -2604,42 +2784,57 @@ async function saveMod() {
2604
2784
  if (modSaving) { modSaveAgain = true; return; }
2605
2785
  modSaving = true;
2606
2786
  try {
2607
- const out = await api("/api/mod-settings", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ schemaVersion: 1, settings: modState.settings, revision: modState.revision }) });
2608
- modState.revision = Number(out.revision) || modState.revision + 1;
2609
- // The router's copy is the normalized one; take it unless a newer change is already waiting.
2610
- if (out.settings && !modSaveTimer && !modSaveAgain) { modState.settings = modNormalize(out.settings); modRefresh(); }
2611
- modStatus(t("mod.saved"));
2612
- } catch (error) {
2613
- if (error.status === 409 && error.body && "settings" in error.body) {
2614
- clearTimeout(modSaveTimer);
2615
- modSaveTimer = null;
2616
- modSaveAgain = false;
2617
- modState.settings = modNormalize(error.body.settings);
2618
- modState.revision = Number(error.body.revision) || 0;
2619
- modRefresh();
2620
- modStatus(t("mod.conflict"), true);
2621
- toast(t("mod.conflict"), true);
2622
- } else {
2623
- modStatus(`${t("common.saveFailed")} ${error.message}`, true);
2787
+ // Another surface saving first (the bar's ⚙ panel, another tab of this page) costs this change
2788
+ // nothing: it is laid over their settings and sent again. Only a router that keeps moving under
2789
+ // it, three times running, gets the change dropped.
2790
+ for (let attempt = 1; ; attempt++) {
2791
+ const sent = clone(modState.settings);
2792
+ let out;
2793
+ try {
2794
+ out = await api("/api/mod-settings", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ schemaVersion: 1, settings: sent, revision: modState.revision }) });
2795
+ } catch (error) {
2796
+ if (error.status !== 409 || !error.body || !("settings" in error.body)) throw error;
2797
+ modWrites++;
2798
+ if (attempt < 3) { modAdopt(error.body); modRefresh(); continue; }
2799
+ clearTimeout(modSaveTimer);
2800
+ modSaveTimer = null;
2801
+ modSaveAgain = false;
2802
+ modAdopt(error.body, true);
2803
+ modRefresh();
2804
+ modStatus(t("mod.conflict"), true);
2805
+ toast(t("mod.conflict"), true);
2806
+ return;
2807
+ }
2808
+ modWrites++;
2809
+ modState.revision = Number(out.revision) || modState.revision + 1;
2810
+ // The router's copy is the normalized one; take it unless a newer change is already waiting.
2811
+ if (out.settings && !modSaveTimer && !modSaveAgain) { modAdopt(out, true); modRefresh(); }
2812
+ else modState.base = sent;
2813
+ modStatus(t("mod.saved"));
2814
+ return;
2624
2815
  }
2816
+ } catch (error) {
2817
+ modStatus(`${t("common.saveFailed")} ${error.message}`, true);
2625
2818
  } finally {
2626
2819
  modSaving = false;
2627
2820
  if (modSaveAgain) { modSaveAgain = false; void saveMod(); }
2628
2821
  }
2629
2822
  }
2630
2823
  async function loadMod() {
2631
- // A change still on its way would be overwritten by what the router holds now.
2824
+ // A save on its way brings back the router's copy itself.
2632
2825
  if (modSaveTimer || modSaving) return;
2633
2826
  let retired = false;
2827
+ const writes = modWrites;
2634
2828
  const [settingsResult, optionsResult] = await Promise.allSettled([api("/api/mod-settings"), api("/api/mod-options")]);
2635
2829
  if (settingsResult.status === "rejected") {
2636
2830
  if (!modState.settings) {
2637
2831
  $("#mod-content").replaceChildren(modSection(null, [el("div", { class: "cell-note bad-text", text: `${t("common.loadFailed")} ${settingsResult.reason.message}` })]));
2638
2832
  return;
2639
2833
  }
2640
- } else {
2641
- modState.settings = modNormalize(settingsResult.value.settings);
2642
- modState.revision = Number(settingsResult.value.revision) || 0;
2834
+ } else if (!modSaving && modWrites === writes) {
2835
+ // A change made while this read was out stays on top of what it brought. One already saved, or
2836
+ // being saved, has the router's newer answer from that save instead.
2837
+ modAdopt(settingsResult.value);
2643
2838
  retired = settingsResult.value.legacy === true;
2644
2839
  }
2645
2840
  modState.options = optionsResult.status === "fulfilled" ? optionsResult.value : null;
@@ -2649,6 +2844,10 @@ async function loadMod() {
2649
2844
  // The settings came back without Auto and the field rules; saving them as they are keeps the note to this once.
2650
2845
  if (retired) modPatch({});
2651
2846
  }
2847
+ // Back on this page after a while: the bar's ⚙ panel or another tab may have saved meanwhile.
2848
+ document.addEventListener("visibilitychange", () => {
2849
+ if (document.visibilityState === "visible" && $("#view-mod").classList.contains("active")) void loadMod();
2850
+ });
2652
2851
  function modRefresh() {
2653
2852
  if (!modState.settings) return;
2654
2853
  renderModNotice();
@@ -3056,6 +3255,7 @@ function modLookCard() {
3056
3255
  modSection(null, [
3057
3256
  cell(t("mod.layout"), modSegmented("layout", [{ value: "dashboard", label: t("mod.dashboard") }, { value: "minimal", label: t("mod.minimal") }], s.layout, t("mod.layout"), (layout) => modPatch({ layout })), s.layout === "minimal" ? modCap(t("mod.footerHint")) : null, "wide"),
3058
3257
  cell(t("mod.appearance"), modSegmented("appearance", [{ value: "auto", label: t("mod.auto") }, { value: "dark", label: t("mod.dark") }, { value: "light", label: t("mod.lightAppearance") }], s.appearance, t("mod.appearance"), (appearance) => modPatch({ appearance })), null, "wide"),
3258
+ cell(t("mod.skin"), modSegmented("skin", [{ value: "deep", label: t("mod.skinDeep") }, { value: "flat", label: t("mod.skinFlat") }], s.skin || "deep", t("mod.skin"), (skin) => modPatch({ skin })), null, "wide"),
3059
3259
  cell(t("mod.language"), modSelect("language", languages, s.language, t("mod.language"), (language) => modPatch({ language }), "narrow")),
3060
3260
  ]),
3061
3261
  modSection(t("mod.show"), [
@@ -3080,6 +3280,29 @@ let knownRequestProviders = new Set();
3080
3280
  let requestProvider = "";
3081
3281
  let showCountTokens = false;
3082
3282
  let expandedRequestId = null;
3283
+ // The usage period. "hour" is the exact rolling hour; every other period reads the hourly history.
3284
+ const USAGE_PERIODS = ["hour", "today", "7d", "30d", "month", "all", "custom"];
3285
+ const USAGE_STORE = "clauderipple_logs_usage";
3286
+ let usagePeriod = "hour";
3287
+ let usageFrom = ""; // custom start, a local "YYYY-MM-DD"
3288
+ let usageTo = ""; // custom end (inclusive), a local "YYYY-MM-DD"
3289
+ let lastSummary = null;
3290
+ let configuredProviders = null; // provider names from /api/config, fetched once for this view
3291
+ let renderedProviderNames = "";
3292
+ let requestsGeneration = 0;
3293
+ (function restoreUsage() {
3294
+ try {
3295
+ const saved = JSON.parse(localStorage.getItem(USAGE_STORE) || "null");
3296
+ if (!saved || typeof saved !== "object") return;
3297
+ if (USAGE_PERIODS.includes(saved.period)) usagePeriod = saved.period;
3298
+ if (isDay(saved.from)) usageFrom = saved.from;
3299
+ if (isDay(saved.to)) usageTo = saved.to;
3300
+ if (typeof saved.provider === "string") requestProvider = saved.provider;
3301
+ } catch { /* storage unavailable or unreadable: start from the defaults */ }
3302
+ })();
3303
+ function saveUsage() {
3304
+ try { localStorage.setItem(USAGE_STORE, JSON.stringify({ period: usagePeriod, from: usageFrom, to: usageTo, provider: requestProvider })); } catch { /* unavailable */ }
3305
+ }
3083
3306
 
3084
3307
  function colorizeLogLine(line) {
3085
3308
  const cls = /\bERROR\b|\berror\b/.test(line) ? "tag-ERR" : /\bWARN\b/.test(line) ? "tag-WARN" : "tag-PASS";
@@ -3110,10 +3333,97 @@ function showLogsPanel(name) {
3110
3333
  }
3111
3334
  $("#logs-requests-tab").addEventListener("click", () => showLogsPanel("requests"));
3112
3335
  $("#logs-raw-tab").addEventListener("click", () => showLogsPanel("raw"));
3113
- $("#logs-provider").addEventListener("change", (event) => { requestProvider = event.target.value; void refreshRequests(); });
3336
+ $("#logs-provider").addEventListener("change", (event) => { requestProvider = event.target.value; saveUsage(); void refreshRequests(); });
3337
+
3338
+ // ---- Usage period: local calendar days, so "today" starts at this machine's midnight.
3339
+ function isDay(value) { return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(dayStart(value).valueOf()); }
3340
+ function dayStart(value) { const [y, m, d] = value.split("-").map(Number); return new Date(y, m - 1, d); }
3341
+ function dayKey(date) { const two = (n) => String(n).padStart(2, "0"); return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`; }
3342
+ function daysAgo(n) { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), now.getDate() - n); }
3343
+ /** The period as `{from, to}` in epoch ms (`to` exclusive), or null for the rolling hour. */
3344
+ function usageRange() {
3345
+ const now = Date.now();
3346
+ if (usagePeriod === "today") return { from: daysAgo(0).valueOf(), to: now };
3347
+ if (usagePeriod === "7d") return { from: daysAgo(6).valueOf(), to: now };
3348
+ if (usagePeriod === "30d") return { from: daysAgo(29).valueOf(), to: now };
3349
+ if (usagePeriod === "month") { const today = new Date(); return { from: new Date(today.getFullYear(), today.getMonth(), 1).valueOf(), to: now }; }
3350
+ if (usagePeriod === "all") return { from: 0, to: now };
3351
+ if (usagePeriod === "custom") {
3352
+ if (!isDay(usageFrom) || !isDay(usageTo)) return null;
3353
+ const [first, last] = usageFrom <= usageTo ? [usageFrom, usageTo] : [usageTo, usageFrom];
3354
+ const end = dayStart(last);
3355
+ return { from: dayStart(first).valueOf(), to: new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1).valueOf() };
3356
+ }
3357
+ return null;
3358
+ }
3359
+ function formatDay(ms, withYear) {
3360
+ const options = withYear ? { year: "numeric", month: "short", day: "numeric" } : { month: "short", day: "numeric" };
3361
+ try { return new Intl.DateTimeFormat(CURRENT_LANG, options).format(new Date(ms)); } catch { return dayKey(new Date(ms)); }
3362
+ }
3363
+ /** What the usage numbers cover, in words: "Last hour · …" or "Oct 14 – Oct 20 · Records start Oct 12". */
3364
+ function usageCaption(range, earliest) {
3365
+ if (!range) return `${t("logs.lastHour")} · ${t("logs.polling")}`;
3366
+ const firstRecord = earliest ? new Date(earliest).valueOf() : NaN;
3367
+ // "All" starts where the records do; any other period that starts before them says so.
3368
+ const start = usagePeriod === "all" && Number.isFinite(firstRecord) ? firstRecord : range.from;
3369
+ const end = Math.max(start, Math.min(range.to, Date.now()) - 1);
3370
+ const year = new Date().getFullYear();
3371
+ const withYear = new Date(start).getFullYear() !== year || new Date(end).getFullYear() !== year;
3372
+ const first = formatDay(start, withYear);
3373
+ const last = formatDay(end, withYear);
3374
+ const parts = [first === last ? first : `${first} – ${last}`];
3375
+ if (usagePeriod !== "all" && Number.isFinite(firstRecord) && firstRecord > range.from) parts.push(t("logs.since", { date: formatDay(firstRecord, withYear) }));
3376
+ return parts.join(" · ");
3377
+ }
3378
+ function renderPeriodControl() {
3379
+ const control = $("#logs-period");
3380
+ control.replaceChildren(...USAGE_PERIODS.map((period) => {
3381
+ const on = period === usagePeriod;
3382
+ return el("button", { type: "button", class: on ? "active" : "", role: "radio", "aria-checked": String(on), text: t(`logs.period.${period}`), onclick: () => pickPeriod(period) });
3383
+ }));
3384
+ $("#logs-custom").hidden = usagePeriod !== "custom";
3385
+ }
3386
+ function pickPeriod(period) {
3387
+ if (period === usagePeriod) return;
3388
+ usagePeriod = period;
3389
+ if (period === "custom") {
3390
+ // A first custom range starts as the last seven days, so both fields hold something to adjust.
3391
+ if (!isDay(usageFrom)) usageFrom = dayKey(daysAgo(6));
3392
+ if (!isDay(usageTo)) usageTo = dayKey(daysAgo(0));
3393
+ fillCustomDates();
3394
+ }
3395
+ saveUsage();
3396
+ renderPeriodControl();
3397
+ void refreshRequests();
3398
+ }
3399
+ // Written only on load and when the period becomes custom: the 3-second refresh never touches these
3400
+ // fields, so a date being typed is not reset under the cursor.
3401
+ function fillCustomDates() {
3402
+ const today = dayKey(daysAgo(0));
3403
+ for (const [selector, value] of [["#logs-from", usageFrom], ["#logs-to", usageTo]]) {
3404
+ const input = $(selector);
3405
+ input.value = value;
3406
+ input.max = today;
3407
+ }
3408
+ }
3409
+ for (const selector of ["#logs-from", "#logs-to"]) {
3410
+ $(selector).addEventListener("change", () => {
3411
+ const from = $("#logs-from").value;
3412
+ const to = $("#logs-to").value;
3413
+ if (!isDay(from) || !isDay(to)) return;
3414
+ usageFrom = from;
3415
+ usageTo = to;
3416
+ saveUsage();
3417
+ void refreshRequests();
3418
+ });
3419
+ }
3420
+ renderPeriodControl();
3421
+ fillCustomDates();
3114
3422
 
3423
+ const EMPTY_BUCKET = { count: 0, ok: 0, failed: 0, input: 0, cached: 0, cacheWrite: 0, output: 0, avgMs: 0, cacheHitPercent: 0 };
3115
3424
  function renderSummary(summary) {
3116
- const total = summary && summary.total || { count: 0, ok: 0, failed: 0, input: 0, cached: 0, output: 0, avgMs: 0, cacheHitPercent: 0 };
3425
+ const providers = (summary && summary.providers) || {};
3426
+ const total = (requestProvider ? providers[requestProvider] : summary && summary.total) || EMPTY_BUCKET;
3117
3427
  $("#logs-summary").replaceChildren(
3118
3428
  summaryChip(t("logs.summary.requests"), formatNumber(total.count)),
3119
3429
  summaryChip(t("logs.summary.success"), `${formatNumber(total.ok)} / ${formatNumber(total.failed)}`),
@@ -3123,17 +3433,70 @@ function renderSummary(summary) {
3123
3433
  summaryChip(t("logs.summary.output"), formatNumber(total.output)),
3124
3434
  summaryChip(t("logs.summary.duration"), formatSeconds(total.avgMs)),
3125
3435
  );
3436
+ fitSummaryValues();
3437
+ }
3438
+ // A long period runs the input to ten digits and the requests to "52,939 / 310", wider than a chip
3439
+ // in a 1,024px window (2026-10-20): a value that overflows steps its type down before it ellipsizes.
3440
+ function fitSummaryValues() {
3441
+ for (const value of document.querySelectorAll("#logs-summary .logs-chip .value")) {
3442
+ value.style.fontSize = "";
3443
+ for (let size = parseFloat(getComputedStyle(value).fontSize); value.scrollWidth > value.clientWidth && size > 14; size--) value.style.fontSize = `${size - 1}px`;
3444
+ // A pane about 300px wide fits no ten digits even then: a plain number goes compact ("78.9억"),
3445
+ // the whole of it on hover. The next refresh draws it whole again.
3446
+ const whole = value.dataset.whole || value.textContent;
3447
+ const number = Number(whole.replace(/,/g, ""));
3448
+ if (value.scrollWidth > value.clientWidth && whole.includes(",") && Number.isFinite(number)) {
3449
+ value.dataset.whole = whole;
3450
+ value.title = whole;
3451
+ value.textContent = new Intl.NumberFormat(CURRENT_LANG, { notation: "compact", maximumFractionDigits: 1 }).format(number);
3452
+ }
3453
+ }
3126
3454
  }
3127
- function renderProviderFilter(records) {
3128
- const select = $("#logs-provider");
3455
+ window.addEventListener("resize", fitSummaryValues);
3456
+ // Every provider that is configured or shows up in the usage or the requests, not only the ones in
3457
+ // the last 200 requests: those were all one provider for a heavy user, so the menu offered nothing
3458
+ // else (2026-10-20). The chosen provider stays listed even before anything names it.
3459
+ function renderProviderFilter(summary, records) {
3460
+ const configured = (currentConfig && currentConfig.providers) || configuredProviders || (status && status.providers) || {};
3461
+ for (const name of Object.keys(configured)) knownRequestProviders.add(name);
3462
+ for (const name of Object.keys((summary && summary.providers) || {})) knownRequestProviders.add(name);
3463
+ for (const model of (summary && Array.isArray(summary.models) ? summary.models : [])) if (model.provider) knownRequestProviders.add(model.provider);
3129
3464
  for (const record of records) if (record.provider) knownRequestProviders.add(record.provider);
3465
+ if (requestProvider) knownRequestProviders.add(requestProvider);
3130
3466
  const names = [...knownRequestProviders].sort();
3131
- const before = select.value;
3132
- select.replaceChildren(el("option", { value: "", text: t("logs.all") }));
3133
- for (const name of names) select.appendChild(el("option", { value: name, text: name }));
3134
- select.value = names.includes(requestProvider) ? requestProvider : "";
3135
- if (!names.includes(requestProvider)) requestProvider = "";
3136
- if (before && before !== select.value) select.value = requestProvider;
3467
+ // Rebuilt only when the list changes, so the 3-second refresh does not close an open menu.
3468
+ const key = names.join("\n");
3469
+ const select = $("#logs-provider");
3470
+ if (key !== renderedProviderNames) {
3471
+ renderedProviderNames = key;
3472
+ select.replaceChildren(el("option", { value: "", text: t("logs.all") }), ...names.map((name) => el("option", { value: name, text: name })));
3473
+ }
3474
+ if (select.value !== requestProvider) select.value = requestProvider;
3475
+ }
3476
+ async function loadConfiguredProviders() {
3477
+ if (configuredProviders || currentConfig) return;
3478
+ try { configuredProviders = (await api("/api/config")).providers || {}; } catch { /* the usage and requests still name their providers */ }
3479
+ }
3480
+ function modelUsageRow(entry) {
3481
+ const total = totalInputOf(entry);
3482
+ const model = el("div", { class: "request-models" }, [
3483
+ el("span", { class: "model", text: entry.model, title: entry.model }),
3484
+ el("span", { class: `provider-badge ${providerClass(entry.provider)}`, text: entry.provider }),
3485
+ ]);
3486
+ // The labels show only where a narrow window drops the header row.
3487
+ return el("tr", {}, [
3488
+ el("td", { class: "c-model" }, [model]),
3489
+ el("td", { class: "c-count", "data-label": t("logs.th.count") }, [formatNumber(entry.count)]),
3490
+ el("td", { class: "c-in", "data-label": t("logs.th.input") }, [el("span", { class: "token-cell", text: formatNumber(total) }, [el("span", { class: "cache-pill", text: t("logs.cacheHit", { percent: entry.cacheHitPercent || 0 }) })])]),
3491
+ el("td", { class: "c-out", "data-label": t("logs.th.output") }, [formatNumber(entry.output)]),
3492
+ el("td", { class: "c-dur", "data-label": t("logs.th.avg") }, [formatSeconds(entry.avgMs)]),
3493
+ ]);
3494
+ }
3495
+ function renderModelUsage(summary) {
3496
+ const models = (summary && Array.isArray(summary.models) ? summary.models : []).filter((entry) => !requestProvider || entry.provider === requestProvider);
3497
+ $("#models-table tbody").replaceChildren(...models.map(modelUsageRow));
3498
+ $("#models-table").closest(".logs-table-card").hidden = models.length === 0;
3499
+ $("#models-empty").hidden = models.length !== 0;
3137
3500
  }
3138
3501
  /** Everything the model read: uncached, read from the cache, and written to it. */
3139
3502
  function totalInputOf(usage) {
@@ -3184,25 +3547,39 @@ function requestRow(record) {
3184
3547
  return row;
3185
3548
  }
3186
3549
  function renderRequests(records) {
3187
- const scroll = $(".logs-table-card");
3550
+ const scroll = $("#requests-table").closest(".logs-table-card");
3188
3551
  const left = scroll.scrollLeft;
3189
3552
  const tbody = $("#requests-table tbody");
3190
3553
  tbody.replaceChildren(...records.flatMap((record) => expandedRequestId === record.id ? [requestRow(record), requestDetail(record)] : [requestRow(record)]));
3191
- $("#requests-table").closest(".logs-table-card").hidden = records.length === 0;
3554
+ scroll.hidden = records.length === 0;
3192
3555
  $("#requests-empty").hidden = records.length !== 0;
3193
3556
  scroll.scrollLeft = left;
3194
3557
  }
3195
3558
  async function refreshRequests() {
3196
3559
  if (logsPanel !== "requests") return;
3197
- try {
3198
- const suffix = requestProvider ? `&provider=${encodeURIComponent(requestProvider)}` : "";
3199
- const kind = "&kind=messages";
3200
- const [records, summary, allRecords] = await Promise.all([api(`/api/requests?n=200${suffix}${kind}`), api("/api/requests/summary?since=3600"), api("/api/requests?n=200")]);
3201
- requestRows = Array.isArray(records.requests) ? records.requests : [];
3202
- renderProviderFilter(Array.isArray(allRecords.requests) ? allRecords.requests : requestRows);
3203
- renderSummary(summary);
3560
+ // A period or provider picked while an older refresh is in flight must not be painted over by it.
3561
+ const generation = ++requestsGeneration;
3562
+ const provider = requestProvider;
3563
+ const range = usageRange();
3564
+ // A custom range with an unusable date waits for the fields instead of falling back to the hour.
3565
+ if (usagePeriod === "custom" && !range) return;
3566
+ $("#logs-range-caption").textContent = usageCaption(range, lastSummary && lastSummary.earliest);
3567
+ const summaryPath = range ? `/api/requests/summary?from=${range.from}&to=${range.to}` : "/api/requests/summary?since=3600";
3568
+ const suffix = provider ? `&provider=${encodeURIComponent(provider)}` : "";
3569
+ const [records, summary] = await Promise.allSettled([api(`/api/requests?n=200${suffix}&kind=messages`), api(summaryPath), loadConfiguredProviders()]);
3570
+ if (generation !== requestsGeneration) return;
3571
+ // Each half keeps its last good contents when its own call fails.
3572
+ if (summary.status === "fulfilled") {
3573
+ lastSummary = summary.value;
3574
+ $("#logs-range-caption").textContent = usageCaption(range, lastSummary.earliest);
3575
+ renderSummary(lastSummary);
3576
+ renderModelUsage(lastSummary);
3577
+ }
3578
+ if (records.status === "fulfilled") {
3579
+ requestRows = Array.isArray(records.value.requests) ? records.value.requests : [];
3204
3580
  renderRequests(requestRows);
3205
- } catch { /* preserve the last successful request table */ }
3581
+ }
3582
+ renderProviderFilter(lastSummary, requestRows);
3206
3583
  }
3207
3584
  async function refreshLogs() {
3208
3585
  if (logsPanel !== "raw") return;