clauderipple 0.9.5 → 0.11.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 (163) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.es.md +45 -0
  3. package/README.ja.md +17 -0
  4. package/README.ko.md +31 -0
  5. package/README.md +70 -0
  6. package/README.pt-BR.md +45 -0
  7. package/README.zh-CN.md +17 -0
  8. package/dist/app/assets/icon.icns +0 -0
  9. package/dist/app/assets/icon.ico +0 -0
  10. package/dist/app/assets/icon.png +0 -0
  11. package/dist/app/assets/trayDirectTemplate.png +0 -0
  12. package/dist/app/assets/trayDirectTemplate@2x.png +0 -0
  13. package/dist/app/assets/trayDownTemplate.png +0 -0
  14. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  15. package/dist/app/assets/trayTemplate.png +0 -0
  16. package/dist/app/assets/trayTemplate@2x.png +0 -0
  17. package/dist/app/assets/trayWarnTemplate.png +0 -0
  18. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  19. package/dist/app/assets/trayWin.png +0 -0
  20. package/dist/app/assets/trayWin@2x.png +0 -0
  21. package/dist/app/assets/trayWinDirect.png +0 -0
  22. package/dist/app/assets/trayWinDirect@2x.png +0 -0
  23. package/dist/app/assets/trayWinDown.png +0 -0
  24. package/dist/app/assets/trayWinDown@2x.png +0 -0
  25. package/dist/app/assets/trayWinWarn.png +0 -0
  26. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  27. package/dist/app/dist/claude-desktop.js +67 -0
  28. package/dist/app/dist/main.js +423 -110
  29. package/dist/app/dist/preload.js +21 -0
  30. package/dist/app/dist/protocol.js +121 -0
  31. package/dist/app/dist/status-model.js +87 -0
  32. package/dist/app/dist/window.js +133 -0
  33. package/dist/app/package.json +1 -1
  34. package/dist/cli/src/awake-setup.js +314 -0
  35. package/dist/cli/src/bundle.js +9 -5
  36. package/dist/cli/src/direct.js +159 -0
  37. package/dist/cli/src/doctor.js +205 -0
  38. package/dist/cli/src/hooks/agent-title.js +7 -4
  39. package/dist/cli/src/index.js +227 -31
  40. package/dist/cli/src/launchd.js +29 -20
  41. package/dist/cli/src/launcher.js +215 -0
  42. package/dist/cli/src/power.js +182 -0
  43. package/dist/cli/src/registration.js +157 -0
  44. package/dist/cli/src/remote.js +263 -0
  45. package/dist/cli/src/schtasks.js +50 -40
  46. package/dist/cli/src/settings.js +26 -1
  47. package/dist/cli/src/tray.js +5 -2
  48. package/dist/mod/hooks/i18n.ts +473 -227
  49. package/dist/mod/hooks/judge.ts +35 -17
  50. package/dist/mod/hooks/modes.ts +56 -1
  51. package/dist/mod/hooks/register.tsx +237 -32
  52. package/dist/mod/hooks/session.ts +102 -1
  53. package/dist/mod/hooks/settings.js +7 -3
  54. package/dist/mod/hooks/settings.ts +9 -4
  55. package/dist/mod/hooks/stats.ts +25 -1
  56. package/dist/mod/hooks/ui/agents.tsx +903 -79
  57. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  58. package/dist/mod/hooks/ui/art.ts +33 -7
  59. package/dist/mod/hooks/ui/band.tsx +262 -81
  60. package/dist/mod/hooks/ui/flat.ts +265 -0
  61. package/dist/mod/hooks/ui/kit.tsx +70 -55
  62. package/dist/mod/hooks/ui/look.ts +129 -0
  63. package/dist/mod/hooks/ui/settings.tsx +71 -46
  64. package/dist/mod/hooks/ui/setup.tsx +15 -13
  65. package/dist/mod/hooks/ui/theme.ts +3 -0
  66. package/dist/mod/hooks/ui/tools.ts +124 -0
  67. package/dist/mod/hooks/ui/view.ts +120 -4
  68. package/dist/mod/hooks/workers.ts +33 -6
  69. package/dist/mod/tests/register.test.tsx +155 -11
  70. package/dist/mod/tests/ui.test.tsx +408 -27
  71. package/dist/mod/types/index.d.ts +66 -2
  72. package/dist/remote/package.json +28 -0
  73. package/dist/remote/src/away.js +85 -0
  74. package/dist/remote/src/bridge-client.js +336 -0
  75. package/dist/remote/src/composer-menu.js +11 -0
  76. package/dist/remote/src/config.js +102 -0
  77. package/dist/remote/src/desktop.js +1018 -0
  78. package/dist/remote/src/e2ee.js +126 -0
  79. package/dist/remote/src/e2ee.ts +126 -0
  80. package/dist/remote/src/eventlog.js +298 -0
  81. package/dist/remote/src/expo-push.js +127 -0
  82. package/dist/remote/src/folders.js +79 -0
  83. package/dist/remote/src/fsutil.js +39 -0
  84. package/dist/remote/src/handoff.js +38 -0
  85. package/dist/remote/src/host-service.js +168 -0
  86. package/dist/remote/src/images.js +215 -0
  87. package/dist/remote/src/local-control.js +138 -0
  88. package/dist/remote/src/main.js +264 -0
  89. package/dist/remote/src/permissions.js +124 -0
  90. package/dist/remote/src/power.js +76 -0
  91. package/dist/remote/src/processes.js +24 -0
  92. package/dist/remote/src/protocol.js +32 -0
  93. package/dist/remote/src/protocol.ts +284 -0
  94. package/dist/remote/src/push-content.js +32 -0
  95. package/dist/remote/src/push.js +134 -0
  96. package/dist/remote/src/receipts.js +111 -0
  97. package/dist/remote/src/relay.js +438 -0
  98. package/dist/remote/src/router.js +208 -0
  99. package/dist/remote/src/sdk-launcher.js +18 -0
  100. package/dist/remote/src/server.js +706 -0
  101. package/dist/remote/src/service.js +157 -0
  102. package/dist/remote/src/sessions.js +1800 -0
  103. package/dist/remote/src/sidebar-storage.js +211 -0
  104. package/dist/remote/src/sidebar.js +173 -0
  105. package/dist/remote/src/tool-details.js +205 -0
  106. package/dist/remote/src/tool-details.ts +153 -0
  107. package/dist/remote/src/worktree.js +99 -0
  108. package/dist/remote/web/app.ts +746 -0
  109. package/dist/remote/web/conn.ts +193 -0
  110. package/dist/remote/web/demo.ts +134 -0
  111. package/dist/remote/web/dom.ts +124 -0
  112. package/dist/remote/web/esc.ts +4 -0
  113. package/dist/remote/web/i18n.ts +374 -0
  114. package/dist/remote/web/icon.svg +1 -0
  115. package/dist/remote/web/index.html +24 -0
  116. package/dist/remote/web/manifest.webmanifest +10 -0
  117. package/dist/remote/web/mascot.ts +60 -0
  118. package/dist/remote/web/md.ts +78 -0
  119. package/dist/remote/web/notify.ts +55 -0
  120. package/dist/remote/web/pushstate.ts +27 -0
  121. package/dist/remote/web/secure.ts +109 -0
  122. package/dist/remote/web/sidebar.ts +106 -0
  123. package/dist/remote/web/style.css +311 -0
  124. package/dist/remote/web/sw.ts +48 -0
  125. package/dist/remote/web/timeline.ts +224 -0
  126. package/dist/remote/web/views.ts +323 -0
  127. package/dist/router/src/admin.js +331 -5
  128. package/dist/router/src/awake-win.js +590 -0
  129. package/dist/router/src/awake.js +432 -0
  130. package/dist/router/src/config.js +94 -0
  131. package/dist/router/src/health.js +86 -3
  132. package/dist/router/src/hold.js +107 -0
  133. package/dist/router/src/index.js +108 -6
  134. package/dist/router/src/ingress/server.js +26 -4
  135. package/dist/router/src/ingress/translate.js +6 -2
  136. package/dist/router/src/phone.js +82 -0
  137. package/dist/router/src/power.js +107 -0
  138. package/dist/router/src/providers/anthropic-usage.js +33 -1
  139. package/dist/router/src/providers/google/antigravity.js +73 -10
  140. package/dist/router/src/providers/google/index.js +191 -14
  141. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  142. package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
  143. package/dist/router/src/providers/google/transport.js +1 -1
  144. package/dist/router/src/providers/google/usage.js +302 -0
  145. package/dist/router/src/providers/grok/index.js +18 -0
  146. package/dist/router/src/providers/grok/usage.js +77 -0
  147. package/dist/router/src/providers/openai/translate.js +13 -2
  148. package/dist/router/src/providers/opencode-usage.js +116 -0
  149. package/dist/router/src/providers/retry.js +4 -1
  150. package/dist/router/src/proxy.js +371 -261
  151. package/dist/router/src/remote-host.js +320 -0
  152. package/dist/router/src/remote-tunnel.js +320 -0
  153. package/dist/router/src/requestlog.js +48 -39
  154. package/dist/router/src/tether-win.js +211 -0
  155. package/dist/router/src/tether.js +712 -0
  156. package/dist/router/src/usage-history.js +227 -0
  157. package/dist/router/src/version.js +1 -1
  158. package/dist/ui/app.js +1083 -59
  159. package/dist/ui/i18n.js +670 -69
  160. package/dist/ui/index.html +33 -6
  161. package/dist/ui/style.css +177 -0
  162. package/docs/ARCHITECTURE.md +402 -14
  163. package/package.json +8 -2
package/dist/ui/app.js CHANGED
@@ -21,6 +21,20 @@ function el(tag, attrs, children) {
21
21
  return node;
22
22
  }
23
23
 
24
+ // Inside the ClaudeRipple app the page is the app's own copy, so it is here even with the router
25
+ // down; window.rippleApp is how it asks the app for what only the app can do (repair, the direct
26
+ // switch). In a browser the object is absent and none of the app-only parts are drawn.
27
+ const APP = window.rippleApp || null;
28
+ if (APP) {
29
+ document.documentElement.classList.add("in-app", `platform-${APP.platform}`);
30
+ // Korean wraps between words in the app's own sentences (style.css :lang(ko)).
31
+ document.documentElement.lang = CURRENT_LANG;
32
+ }
33
+ /** The router's admin API answered 503 "routerDown" (the app's way of saying it is not running). */
34
+ let routerDown = false;
35
+ /** What the app last said about the connection (doctor, a running repair, …); see renderConnection. */
36
+ let shellState = null;
37
+
24
38
  let toastTimer = null;
25
39
  function toast(message, isError, raw) {
26
40
  const box = $("#toast");
@@ -71,9 +85,13 @@ function modelsOf(provider) {
71
85
  }
72
86
  function labelOf(model) { return model.name || model.id; }
73
87
  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);
88
+ function safeName(value) { return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""); }
89
+ // A name with no Latin letters ("하이브") slugs to nothing: fall back to the URL's domain ("thehive").
90
+ function domainWord(url) {
91
+ try { const parts = new URL(url).hostname.split("."); return safeName(parts.length > 1 ? parts[parts.length - 2] : parts[0]); } catch { return ""; }
92
+ }
93
+ function uniqueName(name, providers, url) {
94
+ const base = safeName(name) || (url ? domainWord(url) : "") || "provider";
77
95
  let value = base;
78
96
  let number = 2;
79
97
  while (providers[value]) value = `${base}-${number++}`;
@@ -153,6 +171,10 @@ let pickerBusy = false;
153
171
  let probeStates = new Map();
154
172
  let chatgptLoginBusy = false;
155
173
  let chatgptLoginMessage = "";
174
+ let phoneGeneration = 0;
175
+ let phoneSnapshot = null;
176
+ let phoneQrGeneration = 0;
177
+ let phoneBusy = false;
156
178
 
157
179
  function selectOption(value, text) { return el("option", { value }, [text]); }
158
180
  function badge(kind, text) { return el("span", { class: `badge ${kind} dot`, text }, []); }
@@ -195,9 +217,16 @@ function svgIcon(viewBox, paths, cls) {
195
217
  function chevronIcon() { return svgIcon("0 0 8 13", ["M1.5 1.5l5 5-5 5"], "chevron"); }
196
218
 
197
219
  function showView(name) {
220
+ if (name !== "phone") { phoneGeneration++; hidePhoneQr(); }
198
221
  for (const button of $all(".nav-btn")) button.classList.toggle("active", button.dataset.view === name);
199
222
  for (const view of $all(".view")) view.classList.toggle("active", view.id === `view-${name}`);
200
223
  if (location.hash !== `#${name}`) history.replaceState(null, "", `#${name}`);
224
+ applyOffline();
225
+ // With the router down (inside the app) a page has nothing to load; it says so instead of
226
+ // failing, and loads when the router is back (setRouterDown).
227
+ if (offline()) return;
228
+ if (name === "phone") queueMicrotask(() => void loadPhone());
229
+ if (name === "mac") queueMicrotask(() => void loadMac());
201
230
  if (name === "slots" && !slotsLoaded) void loadSlots();
202
231
  if (name === "providers" && !providersLoaded) void loadProviders();
203
232
  if (name === "clients" && !clientsLoaded) void loadClients();
@@ -241,8 +270,11 @@ async function refreshHealth() {
241
270
  status = await api("/api/status");
242
271
  } catch (error) {
243
272
  $("#health-desktop").replaceChildren(cell(t("health.connection"), badge("bad", t("health.disconnected"))));
273
+ // Inside the app a router that does not answer is a state with a way out, not an error.
274
+ if (APP && error.body && error.body.routerDown) setRouterDown(true);
244
275
  return;
245
276
  }
277
+ if (APP) setRouterDown(false);
246
278
  // Served files changed underneath an open window (e.g. after an update): reload, unless a form is open.
247
279
  if (status.uiRevision) {
248
280
  if (loadedUiRevision === null) loadedUiRevision = status.uiRevision;
@@ -322,6 +354,257 @@ async function startUpdate() {
322
354
  renderVersionRow();
323
355
  }
324
356
 
357
+ // ---- Inside the app: the connection card, and pages while the router is down -------------
358
+ // The app can do what this page cannot once the router is down: ask the CLI what is wrong
359
+ // (doctor), repair it, switch Claude to its default connection and back, reopen Claude. The card at
360
+ // the top of Status draws that, calm while all is well and plain about what to press when not. The
361
+ // other pages say where to go instead of failing. None of it exists in a browser.
362
+
363
+ function offline() { return Boolean(APP) && routerDown; }
364
+ function inDirectMode() { return Boolean(shellState && shellState.doctor && shellState.doctor.mode === "direct"); }
365
+
366
+ function setRouterDown(down) {
367
+ if (routerDown === down) return;
368
+ routerDown = down;
369
+ if (!down) {
370
+ // Back: what failed or was skipped while it was down loads now.
371
+ slotsLoaded = false;
372
+ providersLoaded = false;
373
+ clientsLoaded = false;
374
+ showView((location.hash || "#health").slice(1) || "health");
375
+ } else applyOffline();
376
+ renderConnection();
377
+ }
378
+
379
+ /** Every page but Status shows a short note (and the way to Status) instead of its contents. */
380
+ function applyOffline() {
381
+ const off = offline();
382
+ const direct = inDirectMode();
383
+ for (const view of $all(".view")) {
384
+ if (view.id === "view-health") {
385
+ view.classList.toggle("router-off", off);
386
+ continue;
387
+ }
388
+ view.classList.toggle("offline", off);
389
+ let box = $(".offline-state", view);
390
+ if (!off) {
391
+ if (box) box.remove();
392
+ continue;
393
+ }
394
+ if (!box) {
395
+ box = el("div", { class: "offline-state", role: "status" });
396
+ const header = $(".page-header", view);
397
+ view.insertBefore(box, header ? header.nextSibling : view.firstChild);
398
+ }
399
+ box.replaceChildren(
400
+ el("div", { class: `offline-icon${direct ? " direct" : ""}` }, [svgIcon("0 0 20 20", direct ? CONN_ICONS.direct : ["M3 3l14 14", "M6.2 15.5h8.1c.4 0 .8-.1 1.2-.2", "M17.4 12.6a3.3 3.3 0 0 0-2.7-3.7 4.8 4.8 0 0 0-6.8-3.5", "M5.6 8.3a3.7 3.7 0 0 0 .6 7.2"])]),
401
+ el("h2", { text: t(direct ? "conn.offlineDirectTitle" : "conn.offlineTitle") }),
402
+ el("p", { text: t(direct ? "conn.offlineDirectBody" : "conn.offlineBody") }),
403
+ el("button", { class: "btn", type: "button", text: t("conn.goStatus"), onclick: () => showView("health") }),
404
+ );
405
+ }
406
+ }
407
+
408
+ const CONN_ICONS = {
409
+ ok: ["M5.5 10.4l3 3 6-6.6"],
410
+ bad: ["M10 5.6v5.2", "M10 14.3v.1"],
411
+ warn: ["M10 5.6v5.2", "M10 14.3v.1"],
412
+ direct: ["M3.8 10h12", "M12.2 6.2L16 10l-3.8 3.8"],
413
+ checking: ["M10 3.8a6.2 6.2 0 1 1-6.2 6.2"],
414
+ };
415
+ // What doctor's codes mean to someone who only wants Claude to answer. The cause comes before the
416
+ // symptom: a startup entry gone missing is why the router is down.
417
+ const CONN_REASONS = [
418
+ ["not-installed", "conn.why.notInstalled"],
419
+ ["supervisor-missing", "conn.why.supervisorMissing"],
420
+ ["supervisor-program-missing", "conn.why.programMissing"],
421
+ ["supervisor-other-install", "conn.why.otherInstall"],
422
+ ["supervisor-not-running", "conn.why.stopped"],
423
+ ["router-down", "conn.why.stopped"],
424
+ ["router-version-mismatch", "conn.why.version"],
425
+ ["cli-port-mismatch", "conn.why.oldSetting"],
426
+ ["desktop-port-mismatch", "conn.why.oldSetting"],
427
+ ["cli-foreign-proxy", "conn.why.cliOther"],
428
+ ["desktop-foreign-proxy", "conn.why.desktopOther"],
429
+ ];
430
+ function connReason(doctor) {
431
+ const problems = (doctor && doctor.problems) || [];
432
+ for (const [code, key] of CONN_REASONS) if (problems.some((problem) => problem.code === code)) return t(key);
433
+ const other = problems.find((problem) => problem.code !== "mixed-mode" && problem.message);
434
+ return other ? other.message : null;
435
+ }
436
+ function connRoute(proxy) { return t(proxy === "ripple" ? "conn.routeRipple" : proxy === "none" ? "conn.routeDirect" : "conn.routeOther"); }
437
+
438
+ /** Whether the router answers, by this page's own 5-second check first, then the app's. */
439
+ function connAnswering(s) {
440
+ if (routerDown) return false;
441
+ if (status) return true;
442
+ if (s && typeof s.routerAnswering === "boolean") return s.routerAnswering;
443
+ return null;
444
+ }
445
+
446
+ /** Which state the card is in, what it says, and which button is the obvious one. */
447
+ function connectionModel(s) {
448
+ const d = s && s.doctor;
449
+ const answering = connAnswering(s);
450
+ const lastResult = s && s.result;
451
+ if (d && d.mode === "direct") return { kind: "direct", title: t("conn.directTitle"), lead: t("conn.directBody"), primary: "direct-off", secondary: null };
452
+ if (answering === false) {
453
+ // A repair that did not work leaves the switch as the way to a working Claude now.
454
+ const repairFailed = Boolean(lastResult && lastResult.action === "repair" && !lastResult.ok);
455
+ const reason = d ? connReason(d) || t("conn.why.stopped") : s && s.doctorFailed ? t("conn.why.checkFailed") : null;
456
+ return { kind: "bad", title: t("conn.downTitle"), lead: t("conn.downLead"), reason, primary: repairFailed ? "direct-on" : "repair", secondary: repairFailed ? "repair" : "direct-on" };
457
+ }
458
+ if (!d && !(s && s.doctorFailed) && answering === null) return { kind: "checking", title: t("conn.checking"), lead: "", primary: null, secondary: null };
459
+ if (d && d.mode === "mixed") return { kind: "warn", title: t("conn.mixedTitle"), lead: t("conn.mixedBody", { desktop: connRoute(d.desktop && d.desktop.proxy), cli: connRoute(d.cli && d.cli.proxy) }), primary: "direct-off", secondary: "direct-on" };
460
+ const reason = d && connReason(d);
461
+ if (reason) return { kind: "warn", title: t("conn.warnTitle"), lead: reason, primary: "repair", secondary: "direct-on" };
462
+ return { kind: "ok", title: t("conn.okTitle"), lead: s && s.doctorFailed ? t("conn.why.checkFailed") : t("conn.okBody"), primary: null, secondary: "direct-on" };
463
+ }
464
+
465
+ const CONN_ACTIONS = {
466
+ "repair": { label: "conn.repair", hint: "conn.repairHint", run: () => APP.repair() },
467
+ "direct-on": { label: "conn.directOn", hint: "conn.directOnHint", run: () => APP.directOn() },
468
+ "direct-off": { label: "conn.directOff", hint: "conn.directOffHint", run: () => APP.directOff() },
469
+ "restart-claude": { label: "conn.restart", hint: null, run: () => APP.restartClaude() },
470
+ };
471
+ const CONN_BUSY = { "repair": "conn.busy.repair", "direct-on": "conn.busy.directOn", "direct-off": "conn.busy.directOff", "restart-claude": "conn.busy.restart", "checking": "conn.busy.checking" };
472
+ /** A result older than this is history, not news. */
473
+ const CONN_RESULT_MS = 10 * 60 * 1000;
474
+
475
+ async function connRun(run) {
476
+ try {
477
+ const next = await run();
478
+ if (next) shellState = next;
479
+ } catch (error) { toast(t("common.actionFailed"), true, error.message); }
480
+ renderConnection();
481
+ void refreshHealth();
482
+ }
483
+ function connButton(id, cls) {
484
+ const action = CONN_ACTIONS[id];
485
+ const button = el("button", { class: cls, type: "button", text: t(action.label), onclick: () => void connRun(action.run) });
486
+ button.disabled = Boolean(shellState && shellState.busy);
487
+ return button;
488
+ }
489
+ function connResultText(r) {
490
+ if (r.timedOut) return t("conn.res.timeout");
491
+ const keys = {
492
+ "repair": r.ok ? "conn.res.repairOk" : "conn.res.repairFail",
493
+ "direct-on": r.ok ? "conn.res.directOnOk" : "conn.res.fail",
494
+ "direct-off": r.ok ? "conn.res.directOffOk" : "conn.res.fail",
495
+ "restart-claude": r.ok ? "conn.res.restartOk" : "conn.res.restartFail",
496
+ };
497
+ return t(keys[r.action] || "conn.res.fail");
498
+ }
499
+
500
+ /** The running action, the last result and the "reopen Claude" step: the same in both card sizes. */
501
+ function connNotes(s) {
502
+ const notes = [];
503
+ if (s && s.busy) notes.push(el("div", { class: "conn-note busy", role: "status" }, [el("span", { class: "spinner", "aria-hidden": "true" }), el("span", { text: t(CONN_BUSY[s.busy] || "conn.busy.checking") })]));
504
+ const r = s && s.result;
505
+ if (r && !s.busy && (!r.at || Date.now() - r.at < CONN_RESULT_MS)) {
506
+ const ok = r.ok && !r.timedOut;
507
+ notes.push(el("div", { class: `conn-note ${ok ? "good" : "bad"}`, role: "status" }, [
508
+ el("div", { class: "conn-note-line" }, [svgIcon("0 0 20 20", ok ? CONN_ICONS.ok : CONN_ICONS.bad, "conn-note-icon"), el("span", { text: connResultText(r) })]),
509
+ r.details && r.details.length ? el("details", { class: "conn-fold" }, [el("summary", { text: t("conn.details") }), el("pre", { text: r.details.join("\n") })]) : null,
510
+ ]));
511
+ }
512
+ if (s && s.restartClaude) {
513
+ notes.push(el("div", { class: "conn-note restart" }, [
514
+ el("div", { class: "conn-note-text" }, [el("strong", { text: t("conn.restartTitle") }), el("span", { text: t("conn.restartBody") })]),
515
+ connButton("restart-claude", "btn"),
516
+ ]));
517
+ }
518
+ return notes;
519
+ }
520
+
521
+ let connDetailsOpen = false;
522
+ let connLogLines = null;
523
+ function connDetails(s) {
524
+ const d = (s && s.doctor) || {};
525
+ const router = d.router || {};
526
+ const supervisor = d.supervisor || {};
527
+ const row = (k, v) => el("div", { class: "row" }, [el("span", { class: "k", text: k }), el("span", { class: "v mono", text: v })]);
528
+ const rows = [
529
+ row(t("conn.d.router"), `${router.responding ? "responding" : "not responding"}${router.version ? ` · ${router.version}` : ""}${router.url ? ` · ${router.url}` : ""}`),
530
+ row(t("conn.d.supervisor"), `${supervisor.registered ? "registered" : "not registered"}${supervisor.state ? ` · ${supervisor.state}` : ""}${supervisor.program ? ` · ${supervisor.program}` : ""}`),
531
+ row(t("conn.d.desktop"), `${(d.desktop && d.desktop.proxy) || "?"}${d.desktop && d.desktop.url ? ` · ${d.desktop.url}` : ""}`),
532
+ row(t("conn.d.cli"), `${(d.cli && d.cli.proxy) || "?"}${d.cli && d.cli.url ? ` · ${d.cli.url}` : ""}`),
533
+ ...((d.problems || []).map((problem) => row(problem.code || "?", problem.message || ""))),
534
+ ];
535
+ const log = el("pre", { class: "conn-log", text: connLogLines === null ? "…" : connLogLines.length ? connLogLines.join("\n") : t("conn.noLog") });
536
+ const fold = el("details", { class: "conn-details" }, [
537
+ el("summary", { text: t("conn.details") }),
538
+ el("div", { class: "group conn-detail-rows" }, rows),
539
+ el("div", { class: "conn-log-head" }, [el("span", { class: "section-title", text: t("conn.recentLog") }), el("button", { class: "btn secondary compact", type: "button", text: t("conn.openLogs"), onclick: () => void APP.openLogs() })]),
540
+ log,
541
+ ]);
542
+ fold.open = connDetailsOpen;
543
+ fold.addEventListener("toggle", () => {
544
+ connDetailsOpen = fold.open;
545
+ if (fold.open) void APP.recentLog().then((lines) => { connLogLines = lines || []; log.textContent = connLogLines.length ? connLogLines.join("\n") : t("conn.noLog"); log.scrollTop = log.scrollHeight; });
546
+ });
547
+ return fold;
548
+ }
549
+
550
+ function connLoginOffer(s) {
551
+ if (!s || s.loginItem !== false) return null;
552
+ return el("div", { class: "conn-login" }, [
553
+ el("span", { text: t("conn.loginOffer") }),
554
+ el("button", { class: "btn secondary compact", type: "button", text: t("conn.loginOn"), onclick: () => void connRun(() => APP.enableLoginItem()) }),
555
+ ]);
556
+ }
557
+
558
+ function renderConnection() {
559
+ const host = $("#health-connection");
560
+ if (!APP || !host) return;
561
+ host.hidden = false;
562
+ $("#health-desktop-section").hidden = true;
563
+ const s = shellState;
564
+ const m = connectionModel(s);
565
+ const calm = m.kind === "ok" || m.kind === "checking";
566
+ // After a switch, reopening Claude is the one thing left to do; the rest steps back.
567
+ const demote = Boolean(s && s.restartClaude);
568
+ host.className = `conn conn-${m.kind}${calm ? " calm" : " loud"}`;
569
+ const refresh = el("button", { class: "link-btn", type: "button", text: t("conn.refresh"), onclick: () => void connRun(() => APP.refresh()) });
570
+ refresh.disabled = Boolean(s && s.busy);
571
+ if (calm) {
572
+ host.replaceChildren(el("div", { class: "group" }, [
573
+ el("div", { class: "cell conn-calm-row" }, [
574
+ el("span", { class: "conn-dot", "aria-hidden": "true" }),
575
+ el("div", { class: "cell-text" }, [el("span", { class: "cell-label strong", text: m.title }), m.lead ? el("span", { class: "cell-sub", text: m.lead }) : null]),
576
+ m.secondary ? el("div", { class: "cell-value" }, [connButton(m.secondary, "btn secondary compact")]) : null,
577
+ ]),
578
+ ...connNotes(s).map((note) => el("div", { class: "cell-note conn-calm-note" }, [note])),
579
+ connLoginOffer(s) ? el("div", { class: "cell-note conn-calm-note" }, [connLoginOffer(s)]) : null,
580
+ ]));
581
+ return;
582
+ }
583
+ const actions = [m.primary, m.secondary].filter(Boolean).map((id, index) => el("div", { class: "conn-action" }, [
584
+ connButton(id, `btn large${index === 0 && !demote ? "" : " secondary"}`),
585
+ CONN_ACTIONS[id].hint ? el("p", { class: "hint", text: t(CONN_ACTIONS[id].hint) }) : null,
586
+ ]));
587
+ host.replaceChildren(el("div", { class: "conn-card" }, [
588
+ el("div", { class: "conn-hero" }, [
589
+ el("div", { class: "conn-icon" }, [svgIcon("0 0 20 20", CONN_ICONS[m.kind] || CONN_ICONS.bad)]),
590
+ el("div", { class: "conn-hero-text" }, [
591
+ el("h2", { class: "conn-title", text: m.title }),
592
+ m.lead ? el("p", { class: "conn-lead", text: m.lead }) : null,
593
+ m.reason ? el("p", { class: "conn-reason", text: m.reason }) : null,
594
+ ]),
595
+ ]),
596
+ ...connNotes(s),
597
+ actions.length ? el("div", { class: "conn-actions" }, actions) : null,
598
+ connLoginOffer(s),
599
+ el("div", { class: "conn-foot" }, [connDetails(s), refresh]),
600
+ ]));
601
+ }
602
+
603
+ if (APP) {
604
+ APP.onState((next) => { shellState = next; renderConnection(); applyOffline(); });
605
+ void APP.state().then((next) => { shellState = next; renderConnection(); applyOffline(); });
606
+ }
607
+
325
608
  function quotaLine(name) {
326
609
  const quota = status && status.chatgpt && status.chatgpt.quota && status.chatgpt.quota[name];
327
610
  const primary = quota && quota.rate_limits && quota.rate_limits.primary;
@@ -727,6 +1010,499 @@ async function toggleCodex(enabled) {
727
1010
  void Promise.all([loadCatalogs(), refreshHealth()]);
728
1011
  setInterval(refreshHealth, 5000);
729
1012
 
1013
+ // ---- Phone --------------------------------------------------------------------------
1014
+
1015
+ function hidePhoneQr() {
1016
+ phoneQrGeneration++;
1017
+ const box = $("#phone-qr");
1018
+ if (box) box.replaceChildren();
1019
+ }
1020
+ function phoneActive() { return $("#view-phone").classList.contains("active"); }
1021
+ function phonePost(route, body) {
1022
+ return api(`/api/phone/${route}`, { method: "POST", headers: { "content-type": "application/json" }, ...(body ? { body: JSON.stringify(body) } : {}) });
1023
+ }
1024
+ async function showPhoneQr(button) {
1025
+ if (phoneBusy) return;
1026
+ const generation = ++phoneQrGeneration;
1027
+ button.disabled = true;
1028
+ try {
1029
+ const pairing = await api("/api/phone/pairing");
1030
+ if (generation !== phoneQrGeneration || !phoneActive()) return;
1031
+ const copy = el("button", { class: "btn secondary", type: "button", text: t("phone.copyLink") });
1032
+ copy.addEventListener("click", async () => {
1033
+ try { await navigator.clipboard.writeText(pairing.url); toast(t("phone.copied")); }
1034
+ catch { toast(t("phone.copyFailed"), true); }
1035
+ });
1036
+ // An image, never trusted SVG markup in the dashboard's DOM.
1037
+ $("#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,
1038
+ el("button", { class: "btn secondary", type: "button", text: t("common.hide"), onclick: hidePhoneQr }));
1039
+ } catch { if (generation === phoneQrGeneration) toast(t("common.loadFailed"), true); }
1040
+ finally { button.disabled = false; }
1041
+ }
1042
+ function renderPhoneStatus(data) {
1043
+ const rows = [cell(t("phone.host"), badge(data.hostRunning ? "ok" : "warn", t(data.hostRunning ? "phone.running" : "phone.stopped")))];
1044
+ 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}`),
1045
+ cell(t("phone.notifications"), badge(data.pushReady ? "ok" : "warn", t(data.pushReady ? "phone.ready" : "phone.needsRelay"))));
1046
+ else rows.push(el("div", { class: "cell-note" }, [hint(t("phone.install")), el("code", { text: data.hint })]));
1047
+ $("#phone-status").replaceChildren(...rows);
1048
+ if (!data.hostRunning) return;
1049
+ $("#phone-phones").replaceChildren(...(data.phones.length ? data.phones.map(phone => cell(
1050
+ el("span", { class: "cell-label mono", text: phone.clientId.length > 12 ? `${phone.clientId.slice(0, 12)}…` : phone.clientId, title: phone.clientId }),
1051
+ badge(phone.connected ? "ok" : "", t(phone.connected ? "phone.connected" : "phone.offline")),
1052
+ [phone.lastSeen ? t("phone.lastSeen", { time: new Date(phone.lastSeen).toLocaleString(CURRENT_LANG) }) : t("phone.unknownSeen"), t(phone.push ? "phone.pushOn" : "phone.pushOff")],
1053
+ )) : [el("div", { class: "cell-note", text: t("phone.empty") })]));
1054
+ $("#phone-restart").hidden = !data.restartRequired;
1055
+ }
1056
+ function phoneSettings(data) {
1057
+ // The phone app pairs through the Relay only, so phone access is on (Relay) or off. A custom
1058
+ // relay address stays in the host's config file; the dashboard keeps whichever one is saved.
1059
+ const saved = data.settings;
1060
+ const selected = saved.relay ? "relay" : "local";
1061
+ const choices = ["relay", "local"].map(value => {
1062
+ const radio = el("input", { type: "radio", name: "phone-mode", value, checked: value === selected });
1063
+ 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}`) })])]);
1064
+ });
1065
+ const save = el("button", { class: "btn", type: "button", text: t("common.save") });
1066
+ save.addEventListener("click", async () => {
1067
+ if (phoneBusy) return;
1068
+ const mode = $('#phone-content input[name="phone-mode"]:checked').value;
1069
+ phoneBusy = true; save.disabled = true;
1070
+ hidePhoneQr();
1071
+ try {
1072
+ const result = await phonePost("settings", mode === "relay" ? { relay: saved.relay || data.defaultRelay, bind: "local" } : { relay: "off", bind: "local" });
1073
+ toast(t(result.restartRequired ? "phone.restartRequired" : "common.saved"));
1074
+ await refreshPhone();
1075
+ } catch (error) { toast(t("common.saveFailed"), true, error.message); }
1076
+ finally { phoneBusy = false; save.disabled = false; }
1077
+ });
1078
+ return section(t("phone.settings"), [...choices, cell(t("phone.apply"), save)]);
1079
+ }
1080
+ function phoneRestart() {
1081
+ const button = el("button", { class: "btn secondary", type: "button", text: t("phone.restart") });
1082
+ const message = hint("");
1083
+ button.addEventListener("click", async () => {
1084
+ if (!confirm(t("phone.restartConfirm"))) return;
1085
+ button.disabled = true;
1086
+ hidePhoneQr();
1087
+ try {
1088
+ const result = await phonePost("restart");
1089
+ if (result.manual) message.textContent = t("phone.manualRestart", { command: result.command });
1090
+ else { toast(t("phone.restarting")); setTimeout(() => void loadPhone(), 2000); }
1091
+ } catch (error) { toast(t("common.actionFailed"), true, error.message); }
1092
+ finally { button.disabled = false; }
1093
+ });
1094
+ return el("div", { id: "phone-restart", class: "group stacked-group", hidden: "" }, [cell(t("phone.restartRequired"), button, message, "wide")]);
1095
+ }
1096
+ function phoneControls(data) {
1097
+ const qr = el("button", { class: "btn secondary", type: "button", text: t("phone.showQr") });
1098
+ qr.addEventListener("click", () => void showPhoneQr(qr));
1099
+ const rotate = el("button", { class: "cell-button destructive", type: "button", text: t("phone.rotate") });
1100
+ rotate.addEventListener("click", async () => {
1101
+ if (phoneBusy || !confirm(t("phone.rotateConfirm"))) return;
1102
+ phoneBusy = true; rotate.disabled = true; hidePhoneQr();
1103
+ try { await phonePost("rotate"); toast(t("phone.rotated")); await refreshPhone(); }
1104
+ catch (error) { toast(t("common.actionFailed"), true, error.message); }
1105
+ finally { phoneBusy = false; rotate.disabled = false; }
1106
+ });
1107
+ const phones = section(t("phone.phones"), []);
1108
+ $(".group", phones).id = "phone-phones";
1109
+ 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]),
1110
+ phoneSettings(data), phoneRestart(), phones, section(null, [rotate], t("phone.sharedKey"))];
1111
+ }
1112
+ async function refreshPhone(rebuild = false) {
1113
+ if (!phoneActive() || offline()) return;
1114
+ const generation = phoneGeneration;
1115
+ try {
1116
+ const data = await api("/api/phone/status");
1117
+ if (generation !== phoneGeneration || !phoneActive()) return;
1118
+ if (rebuild || !phoneSnapshot || phoneSnapshot.hostRunning !== data.hostRunning) {
1119
+ hidePhoneQr();
1120
+ const state = section(t("phone.status"), []);
1121
+ $(".group", state).id = "phone-status";
1122
+ $("#phone-content").replaceChildren(state, ...(data.hostRunning ? phoneControls(data) : []));
1123
+ }
1124
+ phoneSnapshot = data;
1125
+ renderPhoneStatus(data);
1126
+ } catch (error) { toast(t("common.loadFailed"), true, error.message); }
1127
+ }
1128
+ async function loadPhone() { phoneGeneration++; hidePhoneQr(); await refreshPhone(true); }
1129
+ $("#phone-refresh").addEventListener("click", () => void loadPhone());
1130
+ setInterval(() => { if (!phoneBusy) void refreshPhone(); }, 5000);
1131
+
1132
+ // ---- Laptop: awake with the lid closed, and the phone hotspot when Wi-Fi fails -------------
1133
+ // GET|PUT /api/power (router power.ts) and POST|GET /api/power/setup, which runs the CLI's
1134
+ // `awake setup` — macOS shows its own password dialog, Windows needs none. Written for someone who
1135
+ // has never heard of pmset: the router's reasons and states are English codes, so each is turned
1136
+ // into a sentence here. The power status names its platform, so wording and setup follow it rather
1137
+ // than assuming a Mac and an iPhone (Windows: awake-win.ts, tether-win.ts).
1138
+
1139
+ let macPower = null;
1140
+ /** null, "unavailable" (a router without the feature) or a load error. */
1141
+ let macError = null;
1142
+ let macSetup = { running: false };
1143
+ /** A setup run this page started or saw running: its progress and result are shown. */
1144
+ let macSetupWatched = false;
1145
+ let macSetupPoll = null;
1146
+ let macBusy = false;
1147
+ let macGeneration = 0;
1148
+ let macSsidDraft = null;
1149
+ const macOpen = { awake: false, tether: false, setup: false };
1150
+
1151
+ /** The OS the router reports. A router too old to name it had macOS-only power features. */
1152
+ function macPlatform() { return (macPower && macPower.platform) || "darwin"; }
1153
+ function macIsMac() { return macPlatform() === "darwin"; }
1154
+ function macActive() { return $("#view-mac").classList.contains("active"); }
1155
+ async function loadMac() { macGeneration++; macDrawn = ""; await refreshMac(true); }
1156
+ async function refreshMac(force) {
1157
+ if (!macActive() || offline()) return;
1158
+ const generation = macGeneration;
1159
+ const [power, setup] = await Promise.allSettled([api("/api/power"), api("/api/power/setup")]);
1160
+ if (generation !== macGeneration || !macActive()) return;
1161
+ if (power.status === "fulfilled") { macPower = power.value; macError = null; }
1162
+ else macError = power.reason && power.reason.status === 501 ? "unavailable" : (power.reason && power.reason.message) || "error";
1163
+ if (setup.status === "fulfilled") macAdoptSetup(setup.value);
1164
+ // A field being typed in is not redrawn under the cursor; the next refresh catches up.
1165
+ const focused = document.activeElement;
1166
+ if (!force && focused && $("#mac-content").contains(focused) && focused.matches("input:not([type=checkbox])")) return;
1167
+ renderMac();
1168
+ }
1169
+ function macAdoptSetup(next) {
1170
+ const was = macSetup;
1171
+ macSetup = next || { running: false };
1172
+ if (macSetup.running) {
1173
+ macSetupWatched = true;
1174
+ if (!macSetupPoll) macSetupPoll = setInterval(() => void macPollSetup(), 1500);
1175
+ } else if (macSetupPoll) {
1176
+ clearInterval(macSetupPoll);
1177
+ macSetupPoll = null;
1178
+ }
1179
+ if (was.running && !macSetup.running && macSetupWatched) {
1180
+ toast(t(macSetup.ok ? "mac.setup.done" : /cancel/i.test(macSetup.output || "") ? "mac.setup.cancelled" : "mac.setup.failed"), !macSetup.ok);
1181
+ // Success shows as the controls coming alive; only a failure stays on screen to be read.
1182
+ if (macSetup.ok) macSetupWatched = false;
1183
+ }
1184
+ }
1185
+ async function macPollSetup() {
1186
+ try { macAdoptSetup(await api("/api/power/setup")); } catch { return; }
1187
+ if (!macSetup.running) {
1188
+ // The router looks for the rule again when the run ends; read what it found.
1189
+ try { macPower = await api("/api/power"); } catch { /* the 5s refresh retries */ }
1190
+ }
1191
+ if (macActive()) renderMac();
1192
+ }
1193
+ async function startMacSetup(ssid) {
1194
+ if (macSetup.running) return;
1195
+ try {
1196
+ await api("/api/power/setup", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(ssid ? { ssid } : {}) });
1197
+ macSetupWatched = true;
1198
+ macOpen.setup = false;
1199
+ macAdoptSetup({ running: true, startedAt: new Date().toISOString() });
1200
+ } catch (error) {
1201
+ if (error.status === 409) { macSetupWatched = true; macAdoptSetup({ running: true }); }
1202
+ else toast(t("mac.setup.failed"), true, error.message);
1203
+ }
1204
+ renderMac();
1205
+ }
1206
+ async function macPut(change) {
1207
+ if (macBusy) return;
1208
+ macBusy = true;
1209
+ try {
1210
+ macPower = await api("/api/power", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(change) });
1211
+ } catch (error) {
1212
+ toast(t("common.saveFailed"), true, error.message);
1213
+ // Nothing changed, but the switch or field that was touched shows the change: draw it back.
1214
+ macDrawn = "";
1215
+ }
1216
+ finally { macBusy = false; }
1217
+ renderMac();
1218
+ }
1219
+
1220
+ /** The same rule the router holds a hotspot name to (config.ts ssidProblem), said in words. */
1221
+ function macSsidProblem(name) {
1222
+ if (!name) return null;
1223
+ const bytes = new TextEncoder().encode(name).length;
1224
+ if (bytes > 32 || !/^[A-Za-z0-9 ._'’-]+$/u.test(name) || /^ | $| {2}/.test(name) || name.startsWith("-")) return t("mac.ssid.invalid");
1225
+ return null;
1226
+ }
1227
+ function macTime(iso) {
1228
+ const d = new Date(iso);
1229
+ if (Number.isNaN(d.getTime())) return "";
1230
+ const time = d.toLocaleTimeString(CURRENT_LANG, { hour: "numeric", minute: "2-digit" });
1231
+ const today = new Date();
1232
+ if (d.toDateString() === today.toDateString()) return time;
1233
+ const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
1234
+ if (d.toDateString() === tomorrow.toDateString()) return t("mac.tomorrowAt", { time });
1235
+ return `${d.toLocaleDateString(CURRENT_LANG, { month: "short", day: "numeric" })} ${time}`;
1236
+ }
1237
+ /** "HH:MM" → the next time the clock shows it, as an ISO instant. */
1238
+ function macNextAt(hhmm) {
1239
+ const [h, m] = hhmm.split(":").map(Number);
1240
+ if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
1241
+ const now = new Date();
1242
+ const at = new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m, 0, 0);
1243
+ if (at.getTime() <= now.getTime()) at.setDate(at.getDate() + 1);
1244
+ return at.toISOString();
1245
+ }
1246
+ function macHHMM(iso) {
1247
+ const d = new Date(iso);
1248
+ return Number.isNaN(d.getTime()) ? "" : `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
1249
+ }
1250
+
1251
+ /** The router's one-line reason (awake.ts decideAwake) as a sentence, with whether it is a warning. */
1252
+ function macAwakeWhy(a) {
1253
+ const r = String(a.reason || "");
1254
+ let m;
1255
+ if (r === "off") return { text: t("mac.why.off") };
1256
+ if (r === "on") return { text: a.until ? t("mac.why.onUntil", { time: macTime(a.until) }) : t("mac.why.on") };
1257
+ if (/^working: \d+ model call/.test(r)) return { text: t("mac.why.working") };
1258
+ if ((m = /^working: last model call (\d+)s ago/.exec(r))) return { text: t("mac.why.recent", { min: Math.max(1, Math.round(Number(m[1]) / 60)) }) };
1259
+ if (r.startsWith("idle")) return { text: t("mac.why.idle") };
1260
+ if (/^until .* has passed$/.test(r)) return { text: t("mac.why.until") };
1261
+ if ((m = /^battery (\d+)% is below (\d+)%/.exec(r))) return { text: t("mac.why.battery", { floor: m[2], percent: m[1] }), warn: true };
1262
+ if (r === "thermal warning") return { text: t("mac.why.thermal"), warn: true };
1263
+ if (r.startsWith("error:")) return { text: t("mac.why.error"), warn: true };
1264
+ if (r.startsWith("needs setup")) return { text: t("mac.why.setup"), warn: true };
1265
+ if (r.startsWith("pmset failed")) return { text: t("mac.why.failed"), warn: true };
1266
+ // Windows (awake-win.ts): the helper's own failures and a refused power-plan write.
1267
+ if (/^(SetThreadExecutionState failed|Windows refused|could not restore the lid-close action)/.test(r)) return { text: t("mac.why.failed"), warn: true };
1268
+ if (r === "not started") return { text: t("mac.why.starting") };
1269
+ return { text: r };
1270
+ }
1271
+ function macFacts(a) {
1272
+ const facts = [];
1273
+ if (a.battery && typeof a.battery.percent === "number") facts.push(t(a.battery.onBattery ? "mac.fact.battery" : "mac.fact.charging", { percent: a.battery.percent }));
1274
+ if (a.lidClosed === true) facts.push(t("mac.fact.lidClosed"));
1275
+ else if (a.lidClosed === false) facts.push(t("mac.fact.lidOpen"));
1276
+ if (a.thermal === "warning") facts.push(t("mac.fact.hot"));
1277
+ return facts.join(" · ");
1278
+ }
1279
+ /** A status line: a coloured dot, what is true now in a word or two, and why underneath. */
1280
+ function macStateRow(tone, title, sub, side) {
1281
+ return el("div", { class: "cell mac-state" }, [
1282
+ el("span", { class: `mac-dot ${tone}`, "aria-hidden": "true" }),
1283
+ el("div", { class: "cell-text" }, [el("span", { class: "cell-label strong", text: title }), sub ? el("span", { class: "cell-sub", text: sub }) : null]),
1284
+ side ? el("span", { class: "mac-side", text: side }) : null,
1285
+ ]);
1286
+ }
1287
+ function macDetails(key, rows) {
1288
+ const box = el("details", { class: "details mac-details" }, [
1289
+ el("summary", { text: t("mac.details") }),
1290
+ el("div", { class: "group conn-detail-rows" }, rows.filter(Boolean).map(([k, v]) => el("div", { class: "row" }, [el("span", { class: "k", text: k }), el("span", { class: "v mono", text: v })]))),
1291
+ ]);
1292
+ box.open = macOpen[key];
1293
+ box.addEventListener("toggle", () => { macOpen[key] = box.open; });
1294
+ return box;
1295
+ }
1296
+
1297
+ function macSetupNote() {
1298
+ if (macSetup.running) {
1299
+ // macOS opens its own password window; Windows only checks (no password, no elevation).
1300
+ const title = t(macIsMac() ? "mac.setup.waiting" : "mac.setup.waitingWin");
1301
+ const sub = t(macIsMac() ? "mac.setup.waitingSub" : "mac.setup.waitingSubWin");
1302
+ return el("div", { class: "conn-note busy", role: "status" }, [el("span", { class: "spinner", "aria-hidden": "true" }),
1303
+ el("div", { class: "conn-note-text" }, [el("strong", { text: title }), el("span", { text: sub })])]);
1304
+ }
1305
+ if (!macSetupWatched || macSetup.ok === undefined) return null;
1306
+ const cancelled = /cancel/i.test(macSetup.output || "");
1307
+ const good = macSetup.ok === true;
1308
+ const output = el("details", { class: "conn-fold" }, [el("summary", { text: t("mac.details") }), el("pre", { text: macSetup.output || "" })]);
1309
+ output.open = macOpen.setup;
1310
+ output.addEventListener("toggle", () => { macOpen.setup = output.open; });
1311
+ return el("div", { class: `conn-note ${good ? "good" : "bad"}`, role: "status" }, [
1312
+ el("div", { class: "conn-note-line" }, [
1313
+ svgIcon("0 0 20 20", good ? ["M4.5 10.5l3.5 3.5 7.5-8"] : ["M5.5 5.5l9 9M14.5 5.5l-9 9"], "conn-note-icon"),
1314
+ el("span", { text: t(good ? "mac.setup.done" : cancelled ? "mac.setup.cancelled" : "mac.setup.failed") }),
1315
+ ]),
1316
+ macSetup.output ? output : null,
1317
+ ]);
1318
+ }
1319
+ function macSetupCard(p) {
1320
+ const saved = (p.tether && p.tether.hotspotSsid) || "";
1321
+ if (macSsidDraft === null) macSsidDraft = saved;
1322
+ const input = el("input", { type: "text", id: "mac-ssid", value: macSsidDraft, placeholder: t("mac.ssid.placeholder"), autocomplete: "off", spellcheck: "false", maxlength: "32" });
1323
+ const problem = el("small", { class: "bad-text", role: "alert" });
1324
+ const button = el("button", { class: "btn large", type: "button", "data-key": "mac-setup", text: t(macSetup.running ? "mac.setup.running" : "mac.setup.button") });
1325
+ const check = () => {
1326
+ const issue = macSsidProblem(input.value.trim());
1327
+ problem.textContent = issue || "";
1328
+ problem.hidden = !issue;
1329
+ button.disabled = Boolean(issue) || macSetup.running;
1330
+ };
1331
+ input.addEventListener("input", () => { macSsidDraft = input.value; check(); });
1332
+ button.addEventListener("click", () => void startMacSetup(input.value.trim()));
1333
+ check();
1334
+ input.disabled = macSetup.running;
1335
+ const allow = ["mac.setup.allowSleep", "mac.setup.allowLid", "mac.setup.allowHotspot"];
1336
+ return el("section", { class: "conn-card mac-setup", "aria-labelledby": "mac-setup-title" }, [
1337
+ el("div", { class: "conn-hero" }, [
1338
+ el("div", { class: "conn-icon" }, [svgIcon("0 0 20 20", ["M6 9V6.5a4 4 0 0 1 8 0V9", "M5 9h10a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1z"])]),
1339
+ el("div", { class: "conn-hero-text" }, [
1340
+ el("h2", { class: "conn-title", id: "mac-setup-title", text: t("mac.setup.title") }),
1341
+ el("p", { class: "conn-lead", text: t("mac.setup.lead") }),
1342
+ ]),
1343
+ ]),
1344
+ el("ul", { class: "mac-allow" }, allow.map((key) => el("li", { text: t(key) }))),
1345
+ el("p", { class: "hint mac-setup-note", text: t("mac.setup.private") }),
1346
+ el("p", { class: "hint mac-setup-note", text: t("mac.setup.insomnia") }),
1347
+ el("label", { class: "form-field" }, [el("span", { text: t("mac.ssid.label") }), input, problem, el("small", { text: t("mac.ssid.help") })]),
1348
+ el("div", { class: "mac-setup-actions" }, [button]),
1349
+ macSetupNote(),
1350
+ ]);
1351
+ }
1352
+ function openMacRename(current) {
1353
+ const input = el("input", { type: "text", value: current, placeholder: t("mac.ssid.placeholder"), autocomplete: "off", spellcheck: "false", maxlength: "32" });
1354
+ const problem = el("small", { class: "bad-text", role: "alert", hidden: "" });
1355
+ const save = el("button", { class: "btn", type: "button", "data-default-action": "", text: t("mac.rename.save") });
1356
+ const check = () => {
1357
+ const name = input.value.trim();
1358
+ const issue = macSsidProblem(name);
1359
+ problem.textContent = issue || "";
1360
+ problem.hidden = !issue;
1361
+ save.disabled = !name || Boolean(issue) || name === current;
1362
+ };
1363
+ input.addEventListener("input", check);
1364
+ save.addEventListener("click", () => { const name = input.value.trim(); closeModal(); void startMacSetup(name); });
1365
+ check();
1366
+ showModal(el("div", {}, [
1367
+ el("h1", { text: t(current ? "mac.rename.title" : "mac.rename.titleNew") }),
1368
+ hint(t("mac.rename.help")),
1369
+ el("label", { class: "form-field" }, [el("span", { text: t("mac.ssid.label") }), input, problem, el("small", { text: t("mac.ssid.help") })]),
1370
+ el("div", { class: "actions end" }, [el("button", { class: "btn secondary", type: "button", text: t("common.cancel"), onclick: closeModal }), save]),
1371
+ ]));
1372
+ }
1373
+
1374
+ function macAwakeCard(a) {
1375
+ const why = macAwakeWhy(a);
1376
+ const state = a.applied
1377
+ ? macStateRow("on", t("mac.awake.on"), why.text, macFacts(a))
1378
+ : macStateRow(why.warn ? "warn" : "off", t("mac.awake.off"), why.text, macFacts(a));
1379
+ const modes = [{ value: "off", label: t("mac.mode.off") }, { value: "working", label: t("mac.mode.working") }, { value: "on", label: t("mac.mode.on") }];
1380
+ const rows = [
1381
+ state,
1382
+ cell(el("span", { class: "cell-label", text: t("mac.mode.label") }),
1383
+ modSegmented("mac-mode", modes, a.mode, t("mac.mode.label"), (mode) => void macPut({ awake: { mode, until: null } })),
1384
+ t(`mac.mode.${a.mode}Help`), "wide"),
1385
+ ];
1386
+ if (a.mode === "on") {
1387
+ const time = el("input", { type: "time", class: "mac-time", "data-key": "mac-until", value: a.until ? macHHMM(a.until) : "", "aria-label": t("mac.until.label") });
1388
+ // Saved when the field is left, not on each keystroke: a redraw mid-typing would eat the minutes.
1389
+ const commit = () => {
1390
+ const until = time.value ? macNextAt(time.value) : null;
1391
+ if (time.value && !until) return;
1392
+ if ((until ? macHHMM(until) : "") === (a.until ? macHHMM(a.until) : "")) return;
1393
+ void macPut({ awake: { until } });
1394
+ };
1395
+ time.addEventListener("blur", commit);
1396
+ time.addEventListener("keydown", (event) => { if (event.key === "Enter") time.blur(); });
1397
+ const clear = a.until ? el("button", { class: "link-btn", type: "button", "data-key": "mac-until-clear", text: t("mac.until.clear"), onclick: () => void macPut({ awake: { until: null } }) }) : null;
1398
+ rows.push(cell(t("mac.until.label"), [time, clear], a.until ? t("mac.until.set", { time: macTime(a.until) }) : t("mac.until.none")));
1399
+ }
1400
+ return el("section", { class: "mod-card mac-card", "aria-labelledby": "mac-awake-title" }, [
1401
+ el("div", { class: "mod-card-head" }, [el("h2", { id: "mac-awake-title", text: t("mac.awake.title") }), hint(t("mac.awake.blurb"))]),
1402
+ el("div", { class: "group" }, rows),
1403
+ el("p", { class: "group-footer", text: t("mac.awake.safety", { floor: macBatteryFloor(a) }) }),
1404
+ macDetails("awake", [
1405
+ [t("mac.d.reason"), a.reason || "—"],
1406
+ [t("mac.d.applied"), a.applied ? (macIsMac() ? "disablesleep 1" : "ES_SYSTEM_REQUIRED") : macIsMac() ? "disablesleep 0" : "—"],
1407
+ [t("mac.d.battery"), a.battery ? `${a.battery.percent ?? "?"}% · ${a.battery.onBattery ? "battery" : "AC"}` : "—"],
1408
+ [t("mac.d.thermal"), a.thermal || "—"],
1409
+ [t("mac.d.lid"), a.lidClosed === null || a.lidClosed === undefined ? "—" : a.lidClosed ? "closed" : "open"],
1410
+ [t("mac.d.until"), a.until || "—"],
1411
+ ]),
1412
+ ]);
1413
+ }
1414
+ /** The battery floor is in config, not in the status; the reason names it when it bites. */
1415
+ function macBatteryFloor(a) {
1416
+ const m = /below (\d+)%/.exec(String(a.reason || ""));
1417
+ return m ? m[1] : "20";
1418
+ }
1419
+
1420
+ const MAC_HINT_STATES = new Set(["hotspot-not-visible", "join-failed", "no-wifi", "no-hotspot-ssid", "needs-setup", "no-hotspot-profile", "needs-location"]);
1421
+ function macTetherCard(tt, setupOk) {
1422
+ const network = tt.network === "hotspot" ? "hotspot" : tt.network === "wifi" ? "wifi" : "none";
1423
+ const tone = network === "wifi" ? "ok" : network === "hotspot" ? "on" : "bad";
1424
+ let side = "";
1425
+ if (tt.lastSwitch) {
1426
+ const time = macTime(tt.lastSwitch.at);
1427
+ side = !tt.lastSwitch.ok ? t("mac.switch.failed", { time }) : t(tt.lastSwitch.to === "hotspot" ? "mac.switch.toHotspot" : "mac.switch.toWifi", { time });
1428
+ }
1429
+ const enabled = modSwitch("mac-tether", tt.enabled, t("mac.tether.switch"), (on) => void macPut({ tether: { enabled: on } }));
1430
+ const name = tt.hotspotSsid || "";
1431
+ const rename = el("button", { class: "btn secondary compact", type: "button", "data-key": "mac-rename", text: t(name ? "mac.ssid.change" : "mac.ssid.set") });
1432
+ rename.disabled = macSetup.running || !setupOk;
1433
+ rename.addEventListener("click", () => openMacRename(name));
1434
+ const rows = [
1435
+ cell(el("label", { class: "cell-label", text: t("mac.tether.switch") }), enabled, t("mac.tether.switchHelp")),
1436
+ macStateRow(tone, t(`mac.net.${network}`), I18N.en[`mac.tstate.${tt.state}`] ? t(`mac.tstate.${tt.state}`) : tt.state, side),
1437
+ // No "keep this network" pin here: beside the switch above it read as the same thing twice
1438
+ // (2026-10-20). The API keeps `pin` for the CLI.
1439
+ cell(t("mac.ssid.label"), [el("span", { class: name ? "mac-ssid" : "mac-ssid empty", text: name || t("mac.ssid.none") }), rename]),
1440
+ ];
1441
+ let notice = null;
1442
+ // Before setup the card above says what to do; the router's own setup hint would only repeat it.
1443
+ if (tt.hint && tt.state !== "pinned" && setupOk) {
1444
+ const known = MAC_HINT_STATES.has(tt.state);
1445
+ notice = el("div", { class: "notice warn mac-hint", role: "status" }, [
1446
+ el("strong", { text: t(known ? `mac.tstate.${tt.state}` : "mac.hint.title") }),
1447
+ el("span", { text: known ? t(`mac.hint.${tt.state}`) : tt.hint }),
1448
+ ]);
1449
+ }
1450
+ return el("section", { class: "mod-card mac-card", "aria-labelledby": "mac-tether-title" }, [
1451
+ el("div", { class: "mod-card-head" }, [el("h2", { id: "mac-tether-title", text: t("mac.tether.title") }), hint(t("mac.tether.blurb"))]),
1452
+ notice,
1453
+ el("div", { class: "group" }, rows),
1454
+ el("p", { class: "group-footer", text: t(macIsMac() ? "mac.tether.help" : "mac.tether.helpWin") }),
1455
+ macDetails("tether", [
1456
+ [t("mac.d.state"), tt.state],
1457
+ [t("mac.d.network"), tt.network],
1458
+ [t("mac.d.probe"), tt.lastProbe ? `${tt.lastProbe.ok ? "ok" : "failed"} · ${tt.lastProbe.network} · ${macTime(tt.lastProbe.at)}` : "—"],
1459
+ [t("mac.d.switch"), tt.lastSwitch ? `${tt.lastSwitch.to} · ${tt.lastSwitch.ok ? "ok" : "failed"} · ${tt.lastSwitch.reason}` : "—"],
1460
+ tt.hint ? [t("mac.d.hint"), tt.hint] : null,
1461
+ ]),
1462
+ ]);
1463
+ }
1464
+ function macEmpty(title, body) {
1465
+ return el("div", { class: "offline-state", role: "status" }, [
1466
+ el("div", { class: "offline-icon direct" }, [svgIcon("0 0 20 20", ["M3.5 13V5.6c0-.9.7-1.6 1.6-1.6h9.8c.9 0 1.6.7 1.6 1.6V13", "M1.8 15.8h16.4"])]),
1467
+ el("h2", { text: title }),
1468
+ el("p", { text: body }),
1469
+ ]);
1470
+ }
1471
+ /** What the page was last drawn from: an unchanged refresh leaves the controls alone, so a click
1472
+ * that lands while it arrives is not lost to a button replaced under the pointer. */
1473
+ let macDrawn = "";
1474
+ function renderMac() {
1475
+ const root = $("#mac-content");
1476
+ const drawn = JSON.stringify([macPower, macError, macSetup, macSetupWatched, CURRENT_LANG]);
1477
+ if (drawn === macDrawn && root.childElementCount) return;
1478
+ macDrawn = drawn;
1479
+ if (!macPower) {
1480
+ if (macError === "unavailable") root.replaceChildren(macEmpty(t("mac.unavailable.title"), t("mac.unavailable.body")));
1481
+ else if (macError) root.replaceChildren(section(null, [el("div", { class: "cell-note bad-text", text: `${t("common.loadFailed")} ${macError}` })]));
1482
+ return;
1483
+ }
1484
+ const a = macPower.awake;
1485
+ if (a.setup === "unsupported") {
1486
+ root.replaceChildren(macEmpty(t("mac.unsupported.title"), t("mac.unsupported.body")));
1487
+ return;
1488
+ }
1489
+ const setupOk = a.setup === "ok";
1490
+ // The password/setup card is macOS's alone: Windows holds sleep and edits the power plan as the
1491
+ // signed-in user, so its setup() always answers "ok" and this card never appears there (a Linux
1492
+ // power status has no backend at all and shows the unsupported state above).
1493
+ const needsSetup = !setupOk && macIsMac();
1494
+ const cards = el("div", { class: `mac-cards${needsSetup ? " mac-locked" : ""}` }, [macAwakeCard(a), macTetherCard(macPower.tether, setupOk)]);
1495
+ if (needsSetup) cards.setAttribute("inert", "");
1496
+ // Once set up, a later run (a new hotspot name) reports here, above the cards.
1497
+ const note = setupOk && macSetupWatched ? macSetupNote() : null;
1498
+ modRedraw(root, [
1499
+ needsSetup ? macSetupCard(macPower) : null,
1500
+ note ? el("div", { class: "mac-run" }, [note]) : null,
1501
+ cards,
1502
+ ]);
1503
+ }
1504
+ setInterval(() => { if (!macBusy && !macSetup.running) void refreshMac(); }, 5000);
1505
+
730
1506
  // ---- Model links --------------------------------------------------------------------
731
1507
 
732
1508
  function claudeSelect(selected) {
@@ -1928,10 +2704,12 @@ function openAnthropicProviderForm(options) {
1928
2704
  function syncModeHint() { modeHint.textContent = auth.value === "claude-code" && accountPool ? t("providers.anthropicPoolRouting") : t("providers.anthropicIngressOnly"); }
1929
2705
  auth.addEventListener("change", syncModeHint);
1930
2706
  syncModeHint();
2707
+ const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
2708
+ 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
2709
  const form = el("div", { class: "provider-form" }, [
1932
2710
  el("h1", { id: "modal-title", text: existing ? t("providers.edit") : t("providers.addTitle") }),
1933
2711
  inputRow(t("providers.name"), nameInput, t("providers.nameHelp")), authField, keyField, probeButton, sourceLine, result, modelArea,
1934
- modeHint,
2712
+ modeHint, limitsField,
1935
2713
  ]);
1936
2714
  const saveButton = el("button", { class: "btn", type: "button", "data-default-action": "", text: existing ? t("common.save") : t("providers.add") });
1937
2715
  saveButton.addEventListener("click", async () => {
@@ -1940,7 +2718,7 @@ function openAnthropicProviderForm(options) {
1940
2718
  const next = clone(await latestConfig());
1941
2719
  const providerName = existing ? options.name : uniqueName(typedName, next.providers);
1942
2720
  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 };
2721
+ 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
2722
  next.providers[providerName] = provider;
1945
2723
  // accountPool makes these models native Claude routing targets. Without it this remains the
1946
2724
  // historical OpenAI ingress provider, and routing deliberately ignores it.
@@ -2007,9 +2785,11 @@ function openGrokProviderForm(options) {
2007
2785
  probeButton.addEventListener("click", () => void runProbe());
2008
2786
  const advanced = el("details", { class: "details" }, [el("summary", { text: t("common.advanced") })]);
2009
2787
  const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
2788
+ const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
2010
2789
  const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
2011
2790
  advanced.appendChild(el("div", { class: "advanced-content" }, [
2012
2791
  el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
2792
+ el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]),
2013
2793
  inputRow(t("providers.append"), append, t("providers.appendHelp")),
2014
2794
  ]));
2015
2795
  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 +2810,13 @@ function openGrokProviderForm(options) {
2030
2810
  const providerName = existing ? options.name : uniqueName(typedName, next.providers);
2031
2811
  const saved = existing && (next.providers[options.name] || existing);
2032
2812
  const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), saved);
2033
- const formKeys = new Set(["type", "identity", "instructionsAppend", "models"]);
2813
+ const formKeys = new Set(["type", "identity", "limits", "instructionsAppend", "models"]);
2034
2814
  const kept = saved ? Object.fromEntries(Object.entries(saved).filter(([key]) => !formKeys.has(key))) : {};
2035
2815
  next.providers[providerName] = {
2036
2816
  ...kept,
2037
2817
  type: "grok",
2038
2818
  identity: identity.checked,
2819
+ limits: limitsInput.checked,
2039
2820
  ...(append.value.trim() ? { instructionsAppend: append.value.trim() } : {}),
2040
2821
  models: checkedModels,
2041
2822
  };
@@ -2110,9 +2891,11 @@ function openGoogleProviderForm(options) {
2110
2891
  probeButton.addEventListener("click", () => void runProbe());
2111
2892
  const advanced = el("details", { class: "details" }, [el("summary", { text: t("common.advanced") })]);
2112
2893
  const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
2894
+ const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
2113
2895
  const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
2114
2896
  advanced.appendChild(el("div", { class: "advanced-content" }, [
2115
2897
  el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
2898
+ el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]),
2116
2899
  inputRow(t("providers.append"), append, t("providers.appendHelp")),
2117
2900
  ]));
2118
2901
  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 +2916,7 @@ function openGoogleProviderForm(options) {
2133
2916
  const saved = existing && (next.providers[options.name] || existing);
2134
2917
  const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), saved);
2135
2918
  // 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"]);
2919
+ const formKeys = new Set(["type", "auth", "apiKey", "identity", "limits", "instructionsAppend", "models"]);
2137
2920
  const kept = saved ? Object.fromEntries(Object.entries(saved).filter(([key]) => !formKeys.has(key))) : {};
2138
2921
  next.providers[providerName] = {
2139
2922
  ...kept,
@@ -2142,6 +2925,7 @@ function openGoogleProviderForm(options) {
2142
2925
  ...(apiKey ? { apiKey } : {}),
2143
2926
  ...(existing && existing.url ? { url: existing.url } : {}),
2144
2927
  identity: identity.checked,
2928
+ limits: limitsInput.checked,
2145
2929
  ...(append.value.trim() ? { instructionsAppend: append.value.trim() } : {}),
2146
2930
  models: checkedModels,
2147
2931
  };
@@ -2242,12 +3026,14 @@ function openProviderForm(options) {
2242
3026
  // Without a line of its own the model reads Claude Code's system prompt and answers that it is
2243
3027
  // Claude, which is what DeepSeek did before this was offered here too.
2244
3028
  const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
3029
+ const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
2245
3030
  const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
2246
3031
  advancedContent.append(
2247
3032
  el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
3033
+ el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]),
2248
3034
  inputRow(t("providers.append"), append, t("providers.appendHelp")),
2249
3035
  );
2250
- advancedContent._prompt = { identity, append };
3036
+ advancedContent._prompt = { identity, limits: limitsInput, append };
2251
3037
  } else {
2252
3038
  const auth = el("select", {}, [selectOption("auto", t("providers.authAuto")), selectOption("own", t("providers.authOwn")), selectOption("borrow-codex", t("providers.authBorrow"))]);
2253
3039
  auth.value = (existing && existing.auth) || "auto";
@@ -2259,6 +3045,7 @@ function openProviderForm(options) {
2259
3045
  const loginHelp = el("small", { text: t("providers.loginHelp") });
2260
3046
  const effort = effortSelect((existing && existing.defaultEffort) || "high", ["low", "medium", "high", "xhigh", "max"]);
2261
3047
  const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
3048
+ const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
2262
3049
  const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
2263
3050
  chatgptFields = [
2264
3051
  inputRow(t("providers.credentials"), auth, t("providers.credentialsHelp")),
@@ -2267,9 +3054,10 @@ function openProviderForm(options) {
2267
3054
  ];
2268
3055
  advancedContent.append(
2269
3056
  el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
3057
+ el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]),
2270
3058
  inputRow(t("providers.append"), append, t("providers.appendHelp")),
2271
3059
  );
2272
- advancedContent._chatgpt = { auth, effort, identity, append };
3060
+ advancedContent._chatgpt = { auth, effort, identity, limits: limitsInput, append };
2273
3061
  }
2274
3062
  advanced.appendChild(advancedContent);
2275
3063
  const customUrlRow = isCustom ? inputRow(t("providers.url"), urlInput, t("providers.urlHelp")) : null;
@@ -2311,13 +3099,13 @@ function openProviderForm(options) {
2311
3099
  function draftProvider() {
2312
3100
  if (isChatgpt) {
2313
3101
  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() } : {}) };
3102
+ 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
3103
  }
2316
3104
  const prompt = advancedContent._prompt;
2317
3105
  return {
2318
3106
  type: openAiNow() ? "openai-compatible" : "anthropic-compatible",
2319
3107
  url: urlInput.value.trim(),
2320
- ...(prompt ? { identity: prompt.identity.checked } : {}),
3108
+ ...(prompt ? { identity: prompt.identity.checked, limits: prompt.limits.checked } : {}),
2321
3109
  ...(prompt && prompt.append.value.trim() ? { instructionsAppend: prompt.append.value.trim() } : {}),
2322
3110
  ...(preset ? { preset: preset.id } : {}),
2323
3111
  // A vendor that asks for a session header keys its prompt cache on it, so carry it from the
@@ -2410,7 +3198,7 @@ function openProviderForm(options) {
2410
3198
  const provider = draftProvider();
2411
3199
  if (!isChatgpt && (!provider.url || !/^https?:\/\//.test(provider.url))) { toast(t("providers.urlRequired"), true); return; }
2412
3200
  const next = clone(await latestConfig());
2413
- const providerName = existing ? options.name : uniqueName(typedName, next.providers);
3201
+ const providerName = existing ? options.name : uniqueName(typedName, next.providers, provider.url);
2414
3202
  const saved = existing && (next.providers[options.name] || existing);
2415
3203
  if (existing && providerName !== options.name) delete next.providers[options.name];
2416
3204
  const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), saved);
@@ -2419,8 +3207,8 @@ function openProviderForm(options) {
2419
3207
  // (debugDump, a hand-written setting) is carried over from the saved one rather than dropped.
2420
3208
  // A field the form owns is left to the draft, so clearing it in the form still clears it.
2421
3209
  const formKeys = new Set(isChatgpt
2422
- ? ["type", "auth", "defaultEffort", "identity", "instructionsAppend", "models"]
2423
- : ["type", "url", "identity", "instructionsAppend", "preset", "sessionHeader", "wire", "caps", "headers", "models"]);
3210
+ ? ["type", "auth", "defaultEffort", "identity", "limits", "instructionsAppend", "models"]
3211
+ : ["type", "url", "identity", "limits", "instructionsAppend", "preset", "sessionHeader", "wire", "caps", "headers", "models"]);
2424
3212
  const kept = saved ? Object.fromEntries(Object.entries(saved).filter(([key]) => !formKeys.has(key))) : {};
2425
3213
  next.providers[providerName] = { ...kept, ...provider };
2426
3214
  // Read after `next.providers` has been updated, so an unticked model is already undeclared here
@@ -2459,8 +3247,8 @@ function openProviderForm(options) {
2459
3247
  //
2460
3248
  // The mod's settings live in the router (GET/PUT /api/mod-settings); the ⚙ panel in the bar edits
2461
3249
  // 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.
3250
+ // with the revision it was made on. A 409 means another surface saved first: the fields changed
3251
+ // here are laid over its settings and sent again, so neither side's change is lost.
2464
3252
 
2465
3253
  const MOD_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
2466
3254
  // The band's parts the person can turn off (`hide`), then the ones it shows only when turned on
@@ -2490,13 +3278,16 @@ const MOD_DEFAULTS = {
2490
3278
  fallback: { agent: "general-purpose", model: "sonnet" },
2491
3279
  keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
2492
3280
  compactWith: "haiku", handoffSkill: "", handoffAfter: "continue", handoffButton: "advised",
2493
- swampAt: 50, layout: "dashboard", appearance: "auto", language: "auto", hide: ["reason"], extras: [], setupDone: false,
3281
+ swampAt: 50, layout: "dashboard", appearance: "auto", skin: "deep", language: "auto", hide: ["reason"], extras: [], setupDone: false,
2494
3282
  };
2495
3283
 
2496
- let modState = { settings: null, revision: 0, options: null, retiredNote: false };
3284
+ // `base` is the router's copy `settings` was edited from: what differs between them is this page's own change.
3285
+ let modState = { settings: null, base: null, revision: 0, options: null, retiredNote: false };
2497
3286
  let modSaveTimer = null;
2498
3287
  let modSaving = false;
2499
3288
  let modSaveAgain = false;
3289
+ // Counts the router's answers to saves, so a read that went out before one does not undo it.
3290
+ let modWrites = 0;
2500
3291
 
2501
3292
  /**
2502
3293
  * The router hands back the mod's full normalized settings; this only fills what an older router
@@ -2517,6 +3308,42 @@ function modNormalize(raw) {
2517
3308
  };
2518
3309
  }
2519
3310
 
3311
+ /**
3312
+ * The fields changed from `before` to `after`, found as the mod's settingsChanges finds them: values
3313
+ * and arrays whole, objects field by field, and a rule whose mode changed replaced whole.
3314
+ */
3315
+ function modChanges(before, after, path = []) {
3316
+ if (JSON.stringify(before) === JSON.stringify(after)) return [];
3317
+ if (after === undefined) return [{ path, remove: true }];
3318
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
3319
+ if (isObject(before) && isObject(after) && before.mode === after.mode) {
3320
+ return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => modChanges(before[key], after[key], [...path, key]));
3321
+ }
3322
+ return [{ path, value: after }];
3323
+ }
3324
+ function modApply(base, changes) {
3325
+ const result = clone(base);
3326
+ for (const change of changes) {
3327
+ const key = change.path.at(-1);
3328
+ if (key === undefined) continue;
3329
+ let parent = result;
3330
+ for (const step of change.path.slice(0, -1)) {
3331
+ if (!parent[step] || typeof parent[step] !== "object" || Array.isArray(parent[step])) parent[step] = {};
3332
+ parent = parent[step];
3333
+ }
3334
+ if (change.remove) delete parent[key];
3335
+ else parent[key] = clone(change.value);
3336
+ }
3337
+ return result;
3338
+ }
3339
+ /** Take the router's copy, keeping on it what was changed here and is not saved yet (unless `drop`). */
3340
+ function modAdopt(snapshot, drop) {
3341
+ const changes = !drop && modState.base && modState.settings ? modChanges(modState.base, modState.settings) : [];
3342
+ modState.base = modNormalize(snapshot.settings);
3343
+ modState.settings = modApply(modState.base, changes);
3344
+ modState.revision = Number(snapshot.revision) || 0;
3345
+ }
3346
+
2520
3347
  function modCap(text) { return text ? text.charAt(0).toLocaleUpperCase() + text.slice(1) : text; }
2521
3348
  function modEffortName(effort) { return t(`mod.effort.${effort}`); }
2522
3349
  function modSigned(n) { return n > 0 ? `+${n}` : n < 0 ? `−${-n}` : "0"; }
@@ -2604,42 +3431,57 @@ async function saveMod() {
2604
3431
  if (modSaving) { modSaveAgain = true; return; }
2605
3432
  modSaving = true;
2606
3433
  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);
3434
+ // Another surface saving first (the bar's ⚙ panel, another tab of this page) costs this change
3435
+ // nothing: it is laid over their settings and sent again. Only a router that keeps moving under
3436
+ // it, three times running, gets the change dropped.
3437
+ for (let attempt = 1; ; attempt++) {
3438
+ const sent = clone(modState.settings);
3439
+ let out;
3440
+ try {
3441
+ out = await api("/api/mod-settings", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ schemaVersion: 1, settings: sent, revision: modState.revision }) });
3442
+ } catch (error) {
3443
+ if (error.status !== 409 || !error.body || !("settings" in error.body)) throw error;
3444
+ modWrites++;
3445
+ if (attempt < 3) { modAdopt(error.body); modRefresh(); continue; }
3446
+ clearTimeout(modSaveTimer);
3447
+ modSaveTimer = null;
3448
+ modSaveAgain = false;
3449
+ modAdopt(error.body, true);
3450
+ modRefresh();
3451
+ modStatus(t("mod.conflict"), true);
3452
+ toast(t("mod.conflict"), true);
3453
+ return;
3454
+ }
3455
+ modWrites++;
3456
+ modState.revision = Number(out.revision) || modState.revision + 1;
3457
+ // The router's copy is the normalized one; take it unless a newer change is already waiting.
3458
+ if (out.settings && !modSaveTimer && !modSaveAgain) { modAdopt(out, true); modRefresh(); }
3459
+ else modState.base = sent;
3460
+ modStatus(t("mod.saved"));
3461
+ return;
2624
3462
  }
3463
+ } catch (error) {
3464
+ modStatus(`${t("common.saveFailed")} ${error.message}`, true);
2625
3465
  } finally {
2626
3466
  modSaving = false;
2627
3467
  if (modSaveAgain) { modSaveAgain = false; void saveMod(); }
2628
3468
  }
2629
3469
  }
2630
3470
  async function loadMod() {
2631
- // A change still on its way would be overwritten by what the router holds now.
3471
+ // A save on its way brings back the router's copy itself.
2632
3472
  if (modSaveTimer || modSaving) return;
2633
3473
  let retired = false;
3474
+ const writes = modWrites;
2634
3475
  const [settingsResult, optionsResult] = await Promise.allSettled([api("/api/mod-settings"), api("/api/mod-options")]);
2635
3476
  if (settingsResult.status === "rejected") {
2636
3477
  if (!modState.settings) {
2637
3478
  $("#mod-content").replaceChildren(modSection(null, [el("div", { class: "cell-note bad-text", text: `${t("common.loadFailed")} ${settingsResult.reason.message}` })]));
2638
3479
  return;
2639
3480
  }
2640
- } else {
2641
- modState.settings = modNormalize(settingsResult.value.settings);
2642
- modState.revision = Number(settingsResult.value.revision) || 0;
3481
+ } else if (!modSaving && modWrites === writes) {
3482
+ // A change made while this read was out stays on top of what it brought. One already saved, or
3483
+ // being saved, has the router's newer answer from that save instead.
3484
+ modAdopt(settingsResult.value);
2643
3485
  retired = settingsResult.value.legacy === true;
2644
3486
  }
2645
3487
  modState.options = optionsResult.status === "fulfilled" ? optionsResult.value : null;
@@ -2649,6 +3491,10 @@ async function loadMod() {
2649
3491
  // The settings came back without Auto and the field rules; saving them as they are keeps the note to this once.
2650
3492
  if (retired) modPatch({});
2651
3493
  }
3494
+ // Back on this page after a while: the bar's ⚙ panel or another tab may have saved meanwhile.
3495
+ document.addEventListener("visibilitychange", () => {
3496
+ if (document.visibilityState === "visible" && $("#view-mod").classList.contains("active")) void loadMod();
3497
+ });
2652
3498
  function modRefresh() {
2653
3499
  if (!modState.settings) return;
2654
3500
  renderModNotice();
@@ -3056,6 +3902,7 @@ function modLookCard() {
3056
3902
  modSection(null, [
3057
3903
  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
3904
  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"),
3905
+ 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
3906
  cell(t("mod.language"), modSelect("language", languages, s.language, t("mod.language"), (language) => modPatch({ language }), "narrow")),
3060
3907
  ]),
3061
3908
  modSection(t("mod.show"), [
@@ -3080,6 +3927,29 @@ let knownRequestProviders = new Set();
3080
3927
  let requestProvider = "";
3081
3928
  let showCountTokens = false;
3082
3929
  let expandedRequestId = null;
3930
+ // The usage period. "hour" is the exact rolling hour; every other period reads the hourly history.
3931
+ const USAGE_PERIODS = ["hour", "today", "7d", "30d", "month", "all", "custom"];
3932
+ const USAGE_STORE = "clauderipple_logs_usage";
3933
+ let usagePeriod = "hour";
3934
+ let usageFrom = ""; // custom start, a local "YYYY-MM-DD"
3935
+ let usageTo = ""; // custom end (inclusive), a local "YYYY-MM-DD"
3936
+ let lastSummary = null;
3937
+ let configuredProviders = null; // provider names from /api/config, fetched once for this view
3938
+ let renderedProviderNames = "";
3939
+ let requestsGeneration = 0;
3940
+ (function restoreUsage() {
3941
+ try {
3942
+ const saved = JSON.parse(localStorage.getItem(USAGE_STORE) || "null");
3943
+ if (!saved || typeof saved !== "object") return;
3944
+ if (USAGE_PERIODS.includes(saved.period)) usagePeriod = saved.period;
3945
+ if (isDay(saved.from)) usageFrom = saved.from;
3946
+ if (isDay(saved.to)) usageTo = saved.to;
3947
+ if (typeof saved.provider === "string") requestProvider = saved.provider;
3948
+ } catch { /* storage unavailable or unreadable: start from the defaults */ }
3949
+ })();
3950
+ function saveUsage() {
3951
+ try { localStorage.setItem(USAGE_STORE, JSON.stringify({ period: usagePeriod, from: usageFrom, to: usageTo, provider: requestProvider })); } catch { /* unavailable */ }
3952
+ }
3083
3953
 
3084
3954
  function colorizeLogLine(line) {
3085
3955
  const cls = /\bERROR\b|\berror\b/.test(line) ? "tag-ERR" : /\bWARN\b/.test(line) ? "tag-WARN" : "tag-PASS";
@@ -3110,10 +3980,97 @@ function showLogsPanel(name) {
3110
3980
  }
3111
3981
  $("#logs-requests-tab").addEventListener("click", () => showLogsPanel("requests"));
3112
3982
  $("#logs-raw-tab").addEventListener("click", () => showLogsPanel("raw"));
3113
- $("#logs-provider").addEventListener("change", (event) => { requestProvider = event.target.value; void refreshRequests(); });
3983
+ $("#logs-provider").addEventListener("change", (event) => { requestProvider = event.target.value; saveUsage(); void refreshRequests(); });
3114
3984
 
3985
+ // ---- Usage period: local calendar days, so "today" starts at this machine's midnight.
3986
+ function isDay(value) { return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(dayStart(value).valueOf()); }
3987
+ function dayStart(value) { const [y, m, d] = value.split("-").map(Number); return new Date(y, m - 1, d); }
3988
+ function dayKey(date) { const two = (n) => String(n).padStart(2, "0"); return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`; }
3989
+ function daysAgo(n) { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), now.getDate() - n); }
3990
+ /** The period as `{from, to}` in epoch ms (`to` exclusive), or null for the rolling hour. */
3991
+ function usageRange() {
3992
+ const now = Date.now();
3993
+ if (usagePeriod === "today") return { from: daysAgo(0).valueOf(), to: now };
3994
+ if (usagePeriod === "7d") return { from: daysAgo(6).valueOf(), to: now };
3995
+ if (usagePeriod === "30d") return { from: daysAgo(29).valueOf(), to: now };
3996
+ if (usagePeriod === "month") { const today = new Date(); return { from: new Date(today.getFullYear(), today.getMonth(), 1).valueOf(), to: now }; }
3997
+ if (usagePeriod === "all") return { from: 0, to: now };
3998
+ if (usagePeriod === "custom") {
3999
+ if (!isDay(usageFrom) || !isDay(usageTo)) return null;
4000
+ const [first, last] = usageFrom <= usageTo ? [usageFrom, usageTo] : [usageTo, usageFrom];
4001
+ const end = dayStart(last);
4002
+ return { from: dayStart(first).valueOf(), to: new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1).valueOf() };
4003
+ }
4004
+ return null;
4005
+ }
4006
+ function formatDay(ms, withYear) {
4007
+ const options = withYear ? { year: "numeric", month: "short", day: "numeric" } : { month: "short", day: "numeric" };
4008
+ try { return new Intl.DateTimeFormat(CURRENT_LANG, options).format(new Date(ms)); } catch { return dayKey(new Date(ms)); }
4009
+ }
4010
+ /** What the usage numbers cover, in words: "Last hour · …" or "Oct 14 – Oct 20 · Records start Oct 12". */
4011
+ function usageCaption(range, earliest) {
4012
+ if (!range) return `${t("logs.lastHour")} · ${t("logs.polling")}`;
4013
+ const firstRecord = earliest ? new Date(earliest).valueOf() : NaN;
4014
+ // "All" starts where the records do; any other period that starts before them says so.
4015
+ const start = usagePeriod === "all" && Number.isFinite(firstRecord) ? firstRecord : range.from;
4016
+ const end = Math.max(start, Math.min(range.to, Date.now()) - 1);
4017
+ const year = new Date().getFullYear();
4018
+ const withYear = new Date(start).getFullYear() !== year || new Date(end).getFullYear() !== year;
4019
+ const first = formatDay(start, withYear);
4020
+ const last = formatDay(end, withYear);
4021
+ const parts = [first === last ? first : `${first} – ${last}`];
4022
+ if (usagePeriod !== "all" && Number.isFinite(firstRecord) && firstRecord > range.from) parts.push(t("logs.since", { date: formatDay(firstRecord, withYear) }));
4023
+ return parts.join(" · ");
4024
+ }
4025
+ function renderPeriodControl() {
4026
+ const control = $("#logs-period");
4027
+ control.replaceChildren(...USAGE_PERIODS.map((period) => {
4028
+ const on = period === usagePeriod;
4029
+ return el("button", { type: "button", class: on ? "active" : "", role: "radio", "aria-checked": String(on), text: t(`logs.period.${period}`), onclick: () => pickPeriod(period) });
4030
+ }));
4031
+ $("#logs-custom").hidden = usagePeriod !== "custom";
4032
+ }
4033
+ function pickPeriod(period) {
4034
+ if (period === usagePeriod) return;
4035
+ usagePeriod = period;
4036
+ if (period === "custom") {
4037
+ // A first custom range starts as the last seven days, so both fields hold something to adjust.
4038
+ if (!isDay(usageFrom)) usageFrom = dayKey(daysAgo(6));
4039
+ if (!isDay(usageTo)) usageTo = dayKey(daysAgo(0));
4040
+ fillCustomDates();
4041
+ }
4042
+ saveUsage();
4043
+ renderPeriodControl();
4044
+ void refreshRequests();
4045
+ }
4046
+ // Written only on load and when the period becomes custom: the 3-second refresh never touches these
4047
+ // fields, so a date being typed is not reset under the cursor.
4048
+ function fillCustomDates() {
4049
+ const today = dayKey(daysAgo(0));
4050
+ for (const [selector, value] of [["#logs-from", usageFrom], ["#logs-to", usageTo]]) {
4051
+ const input = $(selector);
4052
+ input.value = value;
4053
+ input.max = today;
4054
+ }
4055
+ }
4056
+ for (const selector of ["#logs-from", "#logs-to"]) {
4057
+ $(selector).addEventListener("change", () => {
4058
+ const from = $("#logs-from").value;
4059
+ const to = $("#logs-to").value;
4060
+ if (!isDay(from) || !isDay(to)) return;
4061
+ usageFrom = from;
4062
+ usageTo = to;
4063
+ saveUsage();
4064
+ void refreshRequests();
4065
+ });
4066
+ }
4067
+ renderPeriodControl();
4068
+ fillCustomDates();
4069
+
4070
+ const EMPTY_BUCKET = { count: 0, ok: 0, failed: 0, input: 0, cached: 0, cacheWrite: 0, output: 0, avgMs: 0, cacheHitPercent: 0 };
3115
4071
  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 };
4072
+ const providers = (summary && summary.providers) || {};
4073
+ const total = (requestProvider ? providers[requestProvider] : summary && summary.total) || EMPTY_BUCKET;
3117
4074
  $("#logs-summary").replaceChildren(
3118
4075
  summaryChip(t("logs.summary.requests"), formatNumber(total.count)),
3119
4076
  summaryChip(t("logs.summary.success"), `${formatNumber(total.ok)} / ${formatNumber(total.failed)}`),
@@ -3123,17 +4080,70 @@ function renderSummary(summary) {
3123
4080
  summaryChip(t("logs.summary.output"), formatNumber(total.output)),
3124
4081
  summaryChip(t("logs.summary.duration"), formatSeconds(total.avgMs)),
3125
4082
  );
4083
+ fitSummaryValues();
4084
+ }
4085
+ // A long period runs the input to ten digits and the requests to "52,939 / 310", wider than a chip
4086
+ // in a 1,024px window (2026-10-20): a value that overflows steps its type down before it ellipsizes.
4087
+ function fitSummaryValues() {
4088
+ for (const value of document.querySelectorAll("#logs-summary .logs-chip .value")) {
4089
+ value.style.fontSize = "";
4090
+ for (let size = parseFloat(getComputedStyle(value).fontSize); value.scrollWidth > value.clientWidth && size > 14; size--) value.style.fontSize = `${size - 1}px`;
4091
+ // A pane about 300px wide fits no ten digits even then: a plain number goes compact ("78.9억"),
4092
+ // the whole of it on hover. The next refresh draws it whole again.
4093
+ const whole = value.dataset.whole || value.textContent;
4094
+ const number = Number(whole.replace(/,/g, ""));
4095
+ if (value.scrollWidth > value.clientWidth && whole.includes(",") && Number.isFinite(number)) {
4096
+ value.dataset.whole = whole;
4097
+ value.title = whole;
4098
+ value.textContent = new Intl.NumberFormat(CURRENT_LANG, { notation: "compact", maximumFractionDigits: 1 }).format(number);
4099
+ }
4100
+ }
3126
4101
  }
3127
- function renderProviderFilter(records) {
3128
- const select = $("#logs-provider");
4102
+ window.addEventListener("resize", fitSummaryValues);
4103
+ // Every provider that is configured or shows up in the usage or the requests, not only the ones in
4104
+ // the last 200 requests: those were all one provider for a heavy user, so the menu offered nothing
4105
+ // else (2026-10-20). The chosen provider stays listed even before anything names it.
4106
+ function renderProviderFilter(summary, records) {
4107
+ const configured = (currentConfig && currentConfig.providers) || configuredProviders || (status && status.providers) || {};
4108
+ for (const name of Object.keys(configured)) knownRequestProviders.add(name);
4109
+ for (const name of Object.keys((summary && summary.providers) || {})) knownRequestProviders.add(name);
4110
+ for (const model of (summary && Array.isArray(summary.models) ? summary.models : [])) if (model.provider) knownRequestProviders.add(model.provider);
3129
4111
  for (const record of records) if (record.provider) knownRequestProviders.add(record.provider);
4112
+ if (requestProvider) knownRequestProviders.add(requestProvider);
3130
4113
  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;
4114
+ // Rebuilt only when the list changes, so the 3-second refresh does not close an open menu.
4115
+ const key = names.join("\n");
4116
+ const select = $("#logs-provider");
4117
+ if (key !== renderedProviderNames) {
4118
+ renderedProviderNames = key;
4119
+ select.replaceChildren(el("option", { value: "", text: t("logs.all") }), ...names.map((name) => el("option", { value: name, text: name })));
4120
+ }
4121
+ if (select.value !== requestProvider) select.value = requestProvider;
4122
+ }
4123
+ async function loadConfiguredProviders() {
4124
+ if (configuredProviders || currentConfig) return;
4125
+ try { configuredProviders = (await api("/api/config")).providers || {}; } catch { /* the usage and requests still name their providers */ }
4126
+ }
4127
+ function modelUsageRow(entry) {
4128
+ const total = totalInputOf(entry);
4129
+ const model = el("div", { class: "request-models" }, [
4130
+ el("span", { class: "model", text: entry.model, title: entry.model }),
4131
+ el("span", { class: `provider-badge ${providerClass(entry.provider)}`, text: entry.provider }),
4132
+ ]);
4133
+ // The labels show only where a narrow window drops the header row.
4134
+ return el("tr", {}, [
4135
+ el("td", { class: "c-model" }, [model]),
4136
+ el("td", { class: "c-count", "data-label": t("logs.th.count") }, [formatNumber(entry.count)]),
4137
+ 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 }) })])]),
4138
+ el("td", { class: "c-out", "data-label": t("logs.th.output") }, [formatNumber(entry.output)]),
4139
+ el("td", { class: "c-dur", "data-label": t("logs.th.avg") }, [formatSeconds(entry.avgMs)]),
4140
+ ]);
4141
+ }
4142
+ function renderModelUsage(summary) {
4143
+ const models = (summary && Array.isArray(summary.models) ? summary.models : []).filter((entry) => !requestProvider || entry.provider === requestProvider);
4144
+ $("#models-table tbody").replaceChildren(...models.map(modelUsageRow));
4145
+ $("#models-table").closest(".logs-table-card").hidden = models.length === 0;
4146
+ $("#models-empty").hidden = models.length !== 0;
3137
4147
  }
3138
4148
  /** Everything the model read: uncached, read from the cache, and written to it. */
3139
4149
  function totalInputOf(usage) {
@@ -3184,25 +4194,39 @@ function requestRow(record) {
3184
4194
  return row;
3185
4195
  }
3186
4196
  function renderRequests(records) {
3187
- const scroll = $(".logs-table-card");
4197
+ const scroll = $("#requests-table").closest(".logs-table-card");
3188
4198
  const left = scroll.scrollLeft;
3189
4199
  const tbody = $("#requests-table tbody");
3190
4200
  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;
4201
+ scroll.hidden = records.length === 0;
3192
4202
  $("#requests-empty").hidden = records.length !== 0;
3193
4203
  scroll.scrollLeft = left;
3194
4204
  }
3195
4205
  async function refreshRequests() {
3196
4206
  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);
4207
+ // A period or provider picked while an older refresh is in flight must not be painted over by it.
4208
+ const generation = ++requestsGeneration;
4209
+ const provider = requestProvider;
4210
+ const range = usageRange();
4211
+ // A custom range with an unusable date waits for the fields instead of falling back to the hour.
4212
+ if (usagePeriod === "custom" && !range) return;
4213
+ $("#logs-range-caption").textContent = usageCaption(range, lastSummary && lastSummary.earliest);
4214
+ const summaryPath = range ? `/api/requests/summary?from=${range.from}&to=${range.to}` : "/api/requests/summary?since=3600";
4215
+ const suffix = provider ? `&provider=${encodeURIComponent(provider)}` : "";
4216
+ const [records, summary] = await Promise.allSettled([api(`/api/requests?n=200${suffix}&kind=messages`), api(summaryPath), loadConfiguredProviders()]);
4217
+ if (generation !== requestsGeneration) return;
4218
+ // Each half keeps its last good contents when its own call fails.
4219
+ if (summary.status === "fulfilled") {
4220
+ lastSummary = summary.value;
4221
+ $("#logs-range-caption").textContent = usageCaption(range, lastSummary.earliest);
4222
+ renderSummary(lastSummary);
4223
+ renderModelUsage(lastSummary);
4224
+ }
4225
+ if (records.status === "fulfilled") {
4226
+ requestRows = Array.isArray(records.value.requests) ? records.value.requests : [];
3204
4227
  renderRequests(requestRows);
3205
- } catch { /* preserve the last successful request table */ }
4228
+ }
4229
+ renderProviderFilter(lastSummary, requestRows);
3206
4230
  }
3207
4231
  async function refreshLogs() {
3208
4232
  if (logsPanel !== "raw") return;