clauderipple 0.8.9 → 0.9.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 (75) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.es.md +0 -4
  3. package/README.ja.md +0 -4
  4. package/README.ko.md +5 -1
  5. package/README.md +5 -1
  6. package/README.pt-BR.md +0 -4
  7. package/README.zh-CN.md +0 -4
  8. package/dist/app/dist/main.js +23 -3
  9. package/dist/app/package.json +1 -1
  10. package/dist/cli/src/codex.js +3 -1
  11. package/dist/cli/src/index.js +1 -1
  12. package/dist/cli/src/settings.js +5 -2
  13. package/dist/cli/src/tray.js +41 -5
  14. package/dist/mod/.claude-plugin/plugin.json +9 -1
  15. package/dist/mod/hooks/cache.ts +60 -0
  16. package/dist/mod/hooks/compact.ts +40 -0
  17. package/dist/mod/hooks/handoff.ts +43 -0
  18. package/dist/mod/hooks/i18n.ts +2716 -0
  19. package/dist/mod/hooks/judge.ts +226 -0
  20. package/dist/mod/hooks/ladder.js +179 -0
  21. package/dist/mod/hooks/ladder.ts +174 -0
  22. package/dist/mod/hooks/modes.ts +93 -0
  23. package/dist/mod/hooks/register.tsx +1458 -256
  24. package/dist/mod/hooks/session.ts +62 -0
  25. package/dist/mod/hooks/settings.js +342 -0
  26. package/dist/mod/hooks/settings.ts +321 -0
  27. package/dist/mod/hooks/stats.ts +59 -0
  28. package/dist/mod/hooks/ui/agents.tsx +138 -0
  29. package/dist/mod/hooks/ui/alerts.tsx +370 -0
  30. package/dist/mod/hooks/ui/art.ts +628 -0
  31. package/dist/mod/hooks/ui/band.tsx +836 -0
  32. package/dist/mod/hooks/ui/kit.tsx +258 -0
  33. package/dist/mod/hooks/ui/log.tsx +109 -0
  34. package/dist/mod/hooks/ui/parts.tsx +217 -0
  35. package/dist/mod/hooks/ui/ripple.ts +614 -0
  36. package/dist/mod/hooks/ui/settings.tsx +376 -0
  37. package/dist/mod/hooks/ui/setup.tsx +148 -0
  38. package/dist/mod/hooks/ui/theme.ts +217 -0
  39. package/dist/mod/hooks/ui/view.ts +371 -0
  40. package/dist/mod/hooks/worker.ts +1 -1
  41. package/dist/mod/hooks/workers.ts +217 -0
  42. package/dist/mod/tests/register.test.tsx +1730 -0
  43. package/dist/mod/tests/ui.test.tsx +853 -0
  44. package/dist/mod/tsconfig.json +2 -1
  45. package/dist/mod/types/index.d.ts +320 -1
  46. package/dist/router/src/admin.js +195 -53
  47. package/dist/router/src/agents.js +24 -5
  48. package/dist/router/src/bootstrap.js +4 -2
  49. package/dist/router/src/compat.js +10 -2
  50. package/dist/router/src/config.js +3 -0
  51. package/dist/router/src/health.js +33 -0
  52. package/dist/router/src/index.js +1 -0
  53. package/dist/router/src/ingress/models.js +4 -1
  54. package/dist/router/src/mod-settings.js +119 -0
  55. package/dist/router/src/model-policy.js +6 -0
  56. package/dist/router/src/model-scores.js +159 -0
  57. package/dist/router/src/picker.js +7 -0
  58. package/dist/router/src/providers/anthropic-account-pool.js +10 -4
  59. package/dist/router/src/providers/anthropic-accounts.js +26 -1
  60. package/dist/router/src/providers/chatgpt/catalog.js +45 -14
  61. package/dist/router/src/providers/chatgpt/index.js +9 -3
  62. package/dist/router/src/providers/chatgpt/translate.js +9 -5
  63. package/dist/router/src/providers/google/index.js +6 -2
  64. package/dist/router/src/providers/openai/index.js +4 -1
  65. package/dist/router/src/providers/openai/translate.js +1 -1
  66. package/dist/router/src/proxy.js +14 -3
  67. package/dist/router/src/version.js +1 -1
  68. package/dist/ui/app.js +932 -209
  69. package/dist/ui/favicon-32.png +0 -0
  70. package/dist/ui/favicon.svg +12 -0
  71. package/dist/ui/i18n.js +923 -237
  72. package/dist/ui/index.html +159 -87
  73. package/dist/ui/style.css +789 -408
  74. package/docs/ARCHITECTURE.md +134 -2
  75. package/package.json +7 -3
package/dist/ui/app.js CHANGED
@@ -155,6 +155,42 @@ let chatgptLoginMessage = "";
155
155
  function selectOption(value, text) { return el("option", { value }, [text]); }
156
156
  function badge(kind, text) { return el("span", { class: `badge ${kind} dot`, text }, []); }
157
157
  function hint(text) { return el("p", { class: "hint", text }, []); }
158
+ /** One row of an inset group: a label (and quieter lines under it) on the left, a value or control on the right. */
159
+ function cell(label, value, sub, extraClass) {
160
+ const lines = (Array.isArray(sub) ? sub : [sub]).filter(Boolean).map((line) => typeof line === "string" ? el("span", { class: "cell-sub", text: line }) : line);
161
+ return el("div", { class: `cell${extraClass ? ` ${extraClass}` : ""}` }, [
162
+ el("div", { class: "cell-text" }, [typeof label === "string" ? el("span", { class: "cell-label", text: label }) : label, ...lines]),
163
+ value ? el("div", { class: "cell-value" }, Array.isArray(value) ? value : [value]) : null,
164
+ ]);
165
+ }
166
+ /** A titled group of rows with an optional note under it — the System Settings section shape. */
167
+ function section(title, rows, footer, action) {
168
+ return el("section", { class: "section" }, [
169
+ title || action ? el("div", { class: "section-head" }, [title ? el("h3", { class: "section-title", text: title }) : el("span"), action || null]) : null,
170
+ el("div", { class: "group" }, rows.filter(Boolean)),
171
+ ...(Array.isArray(footer) ? footer : [footer]).filter(Boolean).map((text) => el("p", { class: "group-footer", text })),
172
+ ]);
173
+ }
174
+ const SVG_NS = "http://www.w3.org/2000/svg";
175
+ /** A small line icon; the paths are drawn in currentColor so the icon follows the text around it. */
176
+ function svgIcon(viewBox, paths, cls) {
177
+ const svg = document.createElementNS(SVG_NS, "svg");
178
+ svg.setAttribute("viewBox", viewBox);
179
+ svg.setAttribute("fill", "none");
180
+ svg.setAttribute("stroke", "currentColor");
181
+ svg.setAttribute("stroke-width", "1.7");
182
+ svg.setAttribute("stroke-linecap", "round");
183
+ svg.setAttribute("stroke-linejoin", "round");
184
+ svg.setAttribute("aria-hidden", "true");
185
+ if (cls) svg.setAttribute("class", cls);
186
+ for (const d of paths) {
187
+ const path = document.createElementNS(SVG_NS, "path");
188
+ path.setAttribute("d", d);
189
+ svg.appendChild(path);
190
+ }
191
+ return svg;
192
+ }
193
+ function chevronIcon() { return svgIcon("0 0 8 13", ["M1.5 1.5l5 5-5 5"], "chevron"); }
158
194
 
159
195
  function showView(name) {
160
196
  for (const button of $all(".nav-btn")) button.classList.toggle("active", button.dataset.view === name);
@@ -164,25 +200,29 @@ function showView(name) {
164
200
  if (name === "providers" && !providersLoaded) void loadProviders();
165
201
  if (name === "clients" && !clientsLoaded) void loadClients();
166
202
  if (name === "logs") queueMicrotask(() => void refreshRequests());
203
+ // Read again on every visit: the bar's ⚙ panel saves the same settings.
204
+ if (name === "mod") queueMicrotask(() => void loadMod());
167
205
  }
168
206
  for (const button of $all(".nav-btn")) button.addEventListener("click", () => showView(button.dataset.view));
169
207
  window.addEventListener("hashchange", () => showView((location.hash || "#health").slice(1)));
170
208
  showView((location.hash || "#health").slice(1) || "health");
171
209
 
210
+ // The sidebar carries one language menu and About another (the sidebar is a tab bar on a phone).
172
211
  (function setupLanguage() {
173
- const select = $("#lang-toggle");
174
212
  const lang = (typeof CURRENT_LANG !== "undefined" && CURRENT_LANG) || "en";
175
- for (const [code, name] of LANGUAGES) {
176
- const option = document.createElement("option");
177
- option.value = code;
178
- option.textContent = name;
179
- option.selected = code === lang;
180
- select.append(option);
213
+ for (const select of $all(".lang-select")) {
214
+ for (const [code, name] of LANGUAGES) {
215
+ const option = document.createElement("option");
216
+ option.value = code;
217
+ option.textContent = name;
218
+ option.selected = code === lang;
219
+ select.append(option);
220
+ }
221
+ select.addEventListener("change", () => {
222
+ try { localStorage.setItem("clauderipple_lang", select.value); } catch { /* unavailable */ }
223
+ location.reload();
224
+ });
181
225
  }
182
- select.addEventListener("change", () => {
183
- try { localStorage.setItem("clauderipple_lang", select.value); } catch { /* unavailable */ }
184
- location.reload();
185
- });
186
226
  })();
187
227
 
188
228
  async function loadCatalogs() {
@@ -198,7 +238,7 @@ async function refreshHealth() {
198
238
  try {
199
239
  status = await api("/api/status");
200
240
  } catch (error) {
201
- $("#health-desktop").replaceChildren(el("div", { class: "row" }, [el("span", { class: "k", text: t("health.desktop") }), badge("bad", t("health.disconnected"))]));
241
+ $("#health-desktop").replaceChildren(cell(t("health.connection"), badge("bad", t("health.disconnected"))));
202
242
  return;
203
243
  }
204
244
  // Served files changed underneath an open window (e.g. after an update): reload, unless a form is open.
@@ -207,18 +247,20 @@ async function refreshHealth() {
207
247
  else if (loadedUiRevision !== status.uiRevision && $("#modal-backdrop").hidden) location.reload();
208
248
  }
209
249
  const desktop = $("#health-desktop");
210
- desktop.replaceChildren(
211
- el("div", { class: "row" }, [el("span", { class: "k", text: t("health.connection") }), status.settings.pointsAtRouter ? badge("ok", t("health.connected")) : badge("bad", t("health.notConnected"))]),
212
- hint(status.settings.pointsAtRouter ? t("health.connectedHelp") : t("health.notConnectedHelp")),
213
- );
214
- $("#health-requests").replaceChildren(el("div", { class: "row" }, [el("span", { class: "k", text: t("health.requests") }), el("span", { class: "v", text: t("health.requestsFmt", status.stats) })]));
250
+ desktop.replaceChildren(cell(
251
+ t("health.connection"),
252
+ status.settings.pointsAtRouter ? badge("ok", t("health.connected")) : badge("bad", t("health.notConnected")),
253
+ status.settings.pointsAtRouter ? t("health.connectedHelp") : t("health.notConnectedHelp"),
254
+ ));
255
+ $("#health-requests").replaceChildren(el("div", { class: "row" }, [el("span", { class: "k", text: t("health.requests") }), el("span", { class: "v", text: t("health.requestsFmt", Object.fromEntries(Object.entries(status.stats || {}).map(([key, value]) => [key, typeof value === "number" ? value.toLocaleString() : value]))) })]));
215
256
  renderHealthProviders();
216
257
  if (clientsLoaded) renderClients();
258
+ if (modState.settings) renderModNotice();
217
259
  const details = $("#health-details");
218
260
  details.replaceChildren(
219
261
  versionRow(),
220
262
  // Which files answer, and since when: the only way to see that an update actually replaced the router.
221
- ...(status.runtime ? [el("div", { class: "row" }, [el("span", { class: "k", text: t("health.runtime") }), el("span", { class: "v", text: `${status.runtime.router || "?"} · ${new Date(status.runtime.startedAt).toLocaleString()}` })])] : []),
263
+ ...(status.runtime ? [cell(t("health.runtime"), null, [el("span", { class: "cell-sub mono", text: status.runtime.router || "?" }), new Date(status.runtime.startedAt).toLocaleString()])] : []),
222
264
  el("div", { class: "row" }, [el("span", { class: "k", text: t("health.routes") }), el("span", { class: "v", text: String(status.routes) })]),
223
265
  el("div", { class: "row" }, [el("span", { class: "k", text: t("health.cli") }), el("span", { class: "v", text: status.cliVersion })]),
224
266
  );
@@ -230,22 +272,25 @@ async function refreshHealth() {
230
272
  let updateState = { busy: false, check: null };
231
273
  function versionRow() {
232
274
  const check = updateState.check;
233
- const children = [el("span", { class: "k", text: t("health.version") }), el("span", { class: "v", text: status.version })];
275
+ // The version reads as the row's label; what the update check found sits under it, the action on the right.
276
+ let note = null;
277
+ let button = null;
234
278
  if (check && check.newer && check.kind === "checkout") {
235
- children.push(el("span", { class: "small", text: t("update.checkout", { latest: check.latest }) }));
279
+ note = t("update.checkout", { latest: check.latest });
236
280
  } else {
237
281
  const action = check && check.newer;
238
282
  const label = updateState.busy ? t("update.checking")
239
283
  : check && check.running ? t("update.running")
240
284
  : action ? (check.kind === "packaged" ? t("update.download", { latest: check.latest }) : t("update.install", { latest: check.latest }))
241
285
  : t("update.check");
242
- const button = el("button", { class: "btn secondary compact", type: "button", text: label });
286
+ button = el("button", { class: action ? "btn" : "btn secondary", type: "button", text: label });
243
287
  button.disabled = updateState.busy || Boolean(check && check.running);
244
288
  button.addEventListener("click", () => void (action ? startUpdate() : checkForUpdate()));
245
- children.push(button);
246
- if (check && !check.newer && !updateState.busy) children.push(el("span", { class: "small", text: t("update.latest") }));
289
+ if (check && !check.newer && !updateState.busy) note = t("update.latest");
247
290
  }
248
- return el("div", { class: "row", id: "health-version-row" }, children);
291
+ const row = cell(el("span", { class: "cell-label", text: `${t("health.version")} ${status.version}` }), button, note);
292
+ row.id = "health-version-row";
293
+ return row;
249
294
  }
250
295
  function renderVersionRow() {
251
296
  const row = $("#health-version-row");
@@ -372,6 +417,8 @@ function credentialLine(name) {
372
417
  */
373
418
  function providerConnection(name) {
374
419
  const live = status && status.providers && status.providers[name];
420
+ // A successful TCP probe (or an older model probe) cannot undo a real billing refusal.
421
+ if (live && live.exhaustion) return { level: "warn", text: `${live.exhaustion.reason.replace(/_/g, " ")} · ${new Date(live.exhaustion.until).toLocaleTimeString()}` };
375
422
  // Reaching the host is not the same as being able to use it. A ChatGPT provider with no
376
423
  // credentials would otherwise read "Connected" and send the user off believing it works.
377
424
  if (live && live.needsLogin) return { level: "warn", text: t("providerStatus.loginNeeded") };
@@ -407,14 +454,9 @@ function renderHealthProviders() {
407
454
  }
408
455
  box.replaceChildren(...providers.map(([name, provider]) => {
409
456
  const live = status && status.providers && status.providers[name];
410
- const line = el("div", { class: "provider-status" }, [
411
- el("strong", { text: name }),
412
- providerState(name, provider),
413
- live && live.needsLogin && provider.type === "chatgpt" ? chatgptLoginButton(() => renderHealthProviders()) : null,
414
- live && live.needsLogin && provider.type === "chatgpt" && chatgptLoginMessage ? el("span", { class: "small", text: chatgptLoginMessage }) : null,
415
- ].filter(Boolean));
457
+ const lines = [];
416
458
  const quota = quotaLine(name);
417
- if (quota) line.appendChild(el("div", { class: "small", text: quota }));
459
+ if (quota) lines.push(el("span", { class: "cell-sub", text: quota }));
418
460
  // Claude subscription accounts, one line each: the current login and every account added here.
419
461
  // Before the config has loaded the rows come from the status, which names the sign-in source
420
462
  // rather than the auth mode.
@@ -423,15 +465,26 @@ function renderHealthProviders() {
423
465
  .map((account) => ({ account, text: chatgptQuotaText(account.quota) }))
424
466
  .filter(({ text }) => text)
425
467
  .map(({ account, text }) => el("div", { text: `${claudeAccountLabel(account)} · ${text}` }));
426
- if (usage.length) line.appendChild(el("div", { class: "small account-usage" }, usage));
468
+ if (usage.length) lines.push(el("div", { class: "cell-sub account-usage" }, usage));
427
469
  }
428
470
  const pool = credentialLine(name);
429
- if (pool) line.appendChild(pool);
430
- return line;
471
+ if (pool) lines.push(pool);
472
+ if (live && live.needsLogin && provider.type === "chatgpt") {
473
+ lines.push(el("div", { class: "cell-actions" }, [
474
+ chatgptLoginButton(() => renderHealthProviders()),
475
+ chatgptLoginMessage ? el("span", { class: "small", text: chatgptLoginMessage }) : null,
476
+ ]));
477
+ }
478
+ // The row opens the provider's own page; a button inside it keeps its own click.
479
+ const row = cell(el("span", { class: "cell-label", text: name }), [providerState(name, provider), chevronIcon()], lines, "provider-status link");
480
+ row.tabIndex = 0;
481
+ const open = (event) => { if (!event.target.closest("button, a")) openProvider(name, provider); };
482
+ row.addEventListener("click", open);
483
+ row.addEventListener("keydown", (event) => { if (event.key === "Enter" && event.target === row) open(event); });
484
+ return row;
431
485
  }));
432
486
  }
433
487
 
434
- let agentTitleBusy = false;
435
488
  let statusModBusy = false;
436
489
  let codexBusy = false;
437
490
  function renderClients() {
@@ -439,41 +492,43 @@ function renderClients() {
439
492
  const picker = status.picker || { enabled: false, last: null };
440
493
  const last = picker.last || null;
441
494
  const names = status.pickerModels || [];
442
- $("#client-picker-rows").replaceChildren(...[
443
- el("div", { class: "row" }, [el("span", { class: "k", text: t("picker.state") }), picker.enabled ? badge("ok", t("picker.on")) : el("span", { class: "small", text: t("picker.off") })]),
444
- picker.enabled ? el("div", { class: "small", text: t("picker.models", { count: names.length, names: names.join(", ") || "—" }) }) : null,
445
- picker.enabled ? (last && last.at ? el("div", { class: "small", text: t("picker.lastAt", { at: new Date(last.at).toLocaleString() }) }) : hint(t("picker.never"))) : null,
446
- ].filter(Boolean));
447
- const pickerButton = $("#client-picker-toggle");
448
- pickerButton.textContent = picker.enabled ? t("picker.turnOff") : t("picker.turnOn");
449
- pickerButton.className = picker.enabled ? "btn secondary" : "btn";
450
- pickerButton.disabled = pickerBusy;
451
- pickerButton.onclick = () => togglePicker(!picker.enabled);
495
+ // The switch rows are static markup, so a five-second refresh never takes keyboard focus off one;
496
+ // only the rows after the switch are rebuilt.
497
+ const pickerGroup = $("#client-picker-rows");
498
+ while (pickerGroup.children.length > 1) pickerGroup.lastElementChild.remove();
499
+ if (picker.enabled) {
500
+ pickerGroup.append(
501
+ el("div", { class: "cell-note", text: t("picker.models", { count: names.length, names: names.join(", ") || "—" }) }),
502
+ el("div", { class: "cell-note", text: last && last.at ? t("picker.lastAt", { at: new Date(last.at).toLocaleString() }) : t("picker.never") }),
503
+ );
504
+ }
505
+ const pickerSwitch = $("#client-picker-toggle");
506
+ pickerSwitch.checked = Boolean(picker.enabled);
507
+ pickerSwitch.disabled = pickerBusy;
508
+ // Turning it on asks first (the OS trust prompt follows); a "no" puts the switch back.
509
+ pickerSwitch.onchange = () => {
510
+ if (!picker.enabled && !confirm(t("picker.confirmOn"))) { pickerSwitch.checked = false; return; }
511
+ pickerSwitch.disabled = true;
512
+ void togglePicker(!picker.enabled, true);
513
+ };
452
514
  renderClientPickerModels(picker.enabled);
453
515
  renderModelSlots();
454
- const agentEnabled = Boolean(status.agentTitle);
455
- $("#client-agent-title-rows").replaceChildren(el("div", { class: "row" }, [el("span", { class: "k", text: t("picker.state") }), agentEnabled ? badge("ok", t("agentTitle.on")) : el("span", { class: "small", text: t("agentTitle.off") })]));
456
- const agentButton = $("#client-agent-title-toggle");
457
- agentButton.textContent = agentEnabled ? t("agentTitle.turnOff") : t("agentTitle.turnOn");
458
- agentButton.className = agentEnabled ? "btn secondary" : "btn";
459
- agentButton.disabled = agentTitleBusy;
460
- agentButton.onclick = () => toggleAgentTitle(!agentEnabled);
461
516
  const modEnabled = Boolean(status.statusMod);
462
- $("#client-status-mod-rows").replaceChildren(el("div", { class: "row" }, [el("span", { class: "k", text: t("picker.state") }), modEnabled ? badge("ok", t("agentTitle.on")) : el("span", { class: "small", text: t("agentTitle.off") })]));
463
- const modButton = $("#client-status-mod-toggle");
464
- modButton.textContent = modEnabled ? t("statusMod.turnOff") : t("statusMod.turnOn");
465
- modButton.className = modEnabled ? "btn secondary" : "btn";
466
- modButton.disabled = statusModBusy;
467
- modButton.onclick = () => toggleStatusMod(!modEnabled);
517
+ const modSwitch = $("#client-status-mod-toggle");
518
+ modSwitch.checked = modEnabled;
519
+ modSwitch.disabled = statusModBusy;
520
+ modSwitch.onchange = () => { modSwitch.disabled = true; void toggleStatusMod(!modEnabled); };
468
521
  const toolsLimited = Boolean(currentConfig.cli && currentConfig.cli.limitWorkerTools);
469
- $("#client-worker-tools-rows").replaceChildren(el("div", { class: "row" }, [el("span", { class: "k", text: t("picker.state") }), toolsLimited ? badge("ok", t("workerTools.on")) : el("span", { class: "small", text: t("workerTools.off") })]));
470
- const toolsButton = $("#client-worker-tools-toggle");
471
- toolsButton.textContent = toolsLimited ? t("workerTools.turnOff") : t("workerTools.turnOn");
472
- toolsButton.className = toolsLimited ? "btn secondary" : "btn";
473
- toolsButton.onclick = () => toggleWorkerTools(!toolsLimited);
522
+ const toolsSwitch = $("#client-worker-tools-toggle");
523
+ toolsSwitch.checked = toolsLimited;
524
+ toolsSwitch.onchange = () => { toolsSwitch.disabled = true; void toggleWorkerTools(!toolsLimited).finally(() => { toolsSwitch.disabled = false; }); };
474
525
  void renderCodexClient();
475
- const mapped = Object.entries(currentConfig.routes || {}).map(([source, route]) => `${labelOf(claudeModels.find((model) => model.id === source) || { id: source })} → ${labelOf((groupedModels(currentConfig).find((group) => group.name === route.provider) || { models: [] }).models.find((model) => model.id === route.model) || { id: route.model })}`);
476
- $("#client-claude-code-rows").replaceChildren(el("div", { class: "small", text: mapped.join(" · ") || t("slots.noChanges") }));
526
+ const groups = groupedModels(currentConfig);
527
+ const mapped = Object.entries(currentConfig.routes || {}).map(([source, route]) => cell(
528
+ labelOf(claudeModels.find((model) => model.id === source) || { id: source }),
529
+ el("span", { text: labelOf((groups.find((group) => group.name === route.provider) || { models: [] }).models.find((model) => model.id === route.model) || { id: route.model }) }),
530
+ ));
531
+ $("#client-claude-code-rows").replaceChildren(...(mapped.length ? mapped : [el("div", { class: "cell-note", text: t("slots.noChanges") })]));
477
532
  }
478
533
  function renderClientPickerModels(enabled) {
479
534
  const card = $("#client-picker-models-card");
@@ -552,11 +607,8 @@ function renderModelSlots() {
552
607
  }
553
608
  select.onchange = () => void saveModelSlots();
554
609
  select.dataset.slot = slot;
555
- return el("div", { class: "slot-row" }, [
556
- el("span", { class: "k", text: t(`slots.slot.${slot}`) }),
557
- select,
558
- el("span", { class: "hint", text: t(`slots.slotHelp.${slot}`) }),
559
- ]);
610
+ select.setAttribute("aria-label", t(`slots.slot.${slot}`));
611
+ return cell(t(`slots.slot.${slot}`), select, t(`slots.slotHelp.${slot}`), "slot-row");
560
612
  }));
561
613
  }
562
614
 
@@ -622,19 +674,6 @@ async function togglePicker(enabled, confirmed = false) {
622
674
  void refreshHealth();
623
675
  }
624
676
  }
625
- async function toggleAgentTitle(enabled) {
626
- if (agentTitleBusy) return;
627
- agentTitleBusy = true;
628
- try {
629
- await api("/api/agent-title", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled }) });
630
- toast(t("common.saved"));
631
- } catch (error) {
632
- toast(t("common.actionFailed"), true, error.message);
633
- } finally {
634
- agentTitleBusy = false;
635
- void refreshHealth();
636
- }
637
- }
638
677
  async function toggleStatusMod(enabled) {
639
678
  if (statusModBusy) return;
640
679
  statusModBusy = true;
@@ -660,17 +699,19 @@ async function toggleWorkerTools(enabled) {
660
699
  renderClients();
661
700
  }
662
701
  async function renderCodexClient() {
663
- const rows = $("#client-codex-rows");
664
- const button = $("#client-codex-toggle");
702
+ const path = $("#client-codex-rows");
703
+ const toggle = $("#client-codex-toggle");
665
704
  try {
666
705
  const codex = await api("/api/codex");
667
- rows.replaceChildren(el("div", { class: "row" }, [el("span", { class: "k", text: t("picker.state") }), codex.enabled ? badge("ok", t("picker.on")) : el("span", { class: "small", text: t("picker.off") })]), el("div", { class: "small", text: codex.configPath }));
668
- button.textContent = codex.enabled ? t("clients.codex.turnOff") : t("clients.codex.turnOn");
669
- button.className = codex.enabled ? "btn secondary" : "btn";
670
- button.disabled = codexBusy;
671
- button.onclick = () => toggleCodex(!codex.enabled);
706
+ path.textContent = codex.configPath || "";
707
+ path.classList.remove("bad-text");
708
+ toggle.checked = Boolean(codex.enabled);
709
+ toggle.disabled = codexBusy;
710
+ toggle.onchange = () => { toggle.disabled = true; void toggleCodex(!codex.enabled); };
672
711
  } catch (error) {
673
- rows.replaceChildren(el("div", { class: "small bad-text", text: error.message }));
712
+ path.textContent = error.message;
713
+ path.classList.add("bad-text");
714
+ toggle.disabled = true;
674
715
  }
675
716
  }
676
717
  async function toggleCodex(enabled) {
@@ -716,14 +757,19 @@ function routeFromTarget(value) {
716
757
  return provider && model ? { provider, model } : null;
717
758
  } catch { return null; }
718
759
  }
760
+ // One mapping per row: the Claude model, what answers it, and its effort. On a narrow window the
761
+ // three stack, so each carries its own small label (hidden where the column heads show).
719
762
  function slotRow(id, route) {
720
- const row = el("tr", {});
763
+ const row = el("div", { class: "map-row" });
721
764
  const source = claudeSelect(id);
722
765
  const target = targetSelect(route);
766
+ source.setAttribute("aria-label", t("slots.th.claude"));
767
+ target.setAttribute("aria-label", t("slots.th.target"));
723
768
  const initialTarget = routeFromTarget(target.value);
724
769
  const effort = effortSelect(route && route.effort, initialTarget ? effortLevelsFor(initialTarget.provider, initialTarget.model) : ["high"]);
725
- const effortCell = el("td", {}, [effort]);
726
- const remove = el("button", { class: "icon-btn", type: "button", title: t("common.remove"), text: "×", onclick: () => { row.remove(); updateSlotSummary(); scheduleSlotsSave(); } });
770
+ effort.setAttribute("aria-label", t("slots.th.effort"));
771
+ const effortCell = el("div", { class: "map-col map-effort" }, [el("span", { class: "map-label", text: t("slots.th.effort") }), effort]);
772
+ const remove = el("button", { class: "icon-btn remove", type: "button", title: t("common.remove"), "aria-label": t("common.remove"), onclick: () => { row.remove(); updateSlotSummary(); scheduleSlotsSave(); } }, [svgIcon("0 0 20 20", ["M10 2.8a7.2 7.2 0 1 0 0 14.4 7.2 7.2 0 0 0 0-14.4z", "M6.6 10h6.8"])]);
727
773
  function sync() {
728
774
  const selected = routeFromTarget(target.value);
729
775
  const levels = selected ? effortLevelsFor(selected.provider, selected.model) : [];
@@ -745,7 +791,13 @@ function slotRow(id, route) {
745
791
  target.addEventListener("change", () => { sync(); scheduleSlotsSave(); });
746
792
  source.addEventListener("change", () => { updateSlotSummary(); scheduleSlotsSave(); });
747
793
  effort.addEventListener("change", scheduleSlotsSave);
748
- row.append(el("td", {}, [source]), el("td", {}, [target]), effortCell, el("td", { class: "icon-cell" }, [remove]));
794
+ row.append(
795
+ el("div", { class: "map-col map-source" }, [el("span", { class: "map-label", text: t("slots.th.claude") }), source]),
796
+ el("span", { class: "map-arrow", "aria-hidden": "true", text: "→" }),
797
+ el("div", { class: "map-col map-target" }, [el("span", { class: "map-label", text: t("slots.th.target") }), target]),
798
+ effortCell,
799
+ el("div", { class: "map-remove" }, [remove]),
800
+ );
749
801
  row._get = () => ({ id: source.value, route: routeFromTarget(target.value), effort: effort.value });
750
802
  sync();
751
803
  return row;
@@ -755,7 +807,7 @@ async function loadSlots() {
755
807
  try {
756
808
  await loadCatalogs();
757
809
  currentConfig = await api("/api/config");
758
- const rows = $("#slots-table tbody");
810
+ const rows = $("#slots-list");
759
811
  rows.replaceChildren();
760
812
  const routes = currentConfig.routes || {};
761
813
  const ids = [...claudeModels.map((model) => model.id), ...Object.keys(routes).filter((id) => !claudeModels.some((model) => model.id === id))];
@@ -767,7 +819,7 @@ async function loadSlots() {
767
819
  }
768
820
  $("#slots-add").addEventListener("click", () => {
769
821
  if (!currentConfig) return;
770
- $("#slots-table tbody").appendChild(slotRow(claudeModels[0].id, null));
822
+ $("#slots-list").appendChild(slotRow(claudeModels[0].id, null));
771
823
  updateSlotSummary();
772
824
  });
773
825
  function allKnownModelIds(config) { return new Set(groupedModels(config).flatMap((group) => group.models.map((model) => model.id))); }
@@ -836,7 +888,7 @@ function applyPickerSelections(next, selections) {
836
888
  return next;
837
889
  }
838
890
  function updateSlotSummary() {
839
- const summaries = $all("#slots-table tbody tr").map((row) => row._get()).filter((item) => item.route).slice(0, 3).map((item) => {
891
+ const summaries = $all("#slots-list .map-row").map((row) => row._get()).filter((item) => item.route).slice(0, 3).map((item) => {
840
892
  const source = claudeModels.find((model) => model.id === item.id);
841
893
  const group = groupedModels(currentConfig).find((itemGroup) => itemGroup.name === item.route.provider);
842
894
  const target = group && group.models.find((model) => model.id === item.route.model);
@@ -864,7 +916,7 @@ async function saveSlots() {
864
916
  const next = clone(await latestConfig());
865
917
  const routes = {};
866
918
  const seen = new Set();
867
- for (const row of $all("#slots-table tbody tr")) {
919
+ for (const row of $all("#slots-list .map-row")) {
868
920
  const item = row._get();
869
921
  if (seen.has(item.id)) { slotsStatus(t("slots.duplicate"), true); slotsSaving = false; return; }
870
922
  seen.add(item.id);
@@ -1026,14 +1078,27 @@ function providerRailItem(name, provider) {
1026
1078
  ]),
1027
1079
  providerState(name, provider),
1028
1080
  ]);
1029
- button.addEventListener("click", () => {
1030
- selectedProviderName = name;
1031
- providerDetailTab = provider.type === "anthropic" && provider.auth === "claude-code" ? "accounts" : "overview";
1032
- renderProviderWorkspace();
1033
- });
1081
+ button.addEventListener("click", () => openProvider(name, provider));
1034
1082
  return button;
1035
1083
  }
1036
1084
 
1085
+ /**
1086
+ * Show one provider's page, from its list or from the Status screen. A narrow window shows the list
1087
+ * and the page one at a time (`show-detail`), so this is also the "push" to the page.
1088
+ */
1089
+ function openProvider(name, provider) {
1090
+ selectedProviderName = name;
1091
+ providerDetailTab = provider.type === "anthropic" && provider.auth === "claude-code" ? "accounts" : "overview";
1092
+ $("#view-providers").classList.add("show-detail");
1093
+ if (!$("#view-providers").classList.contains("active")) showView("providers");
1094
+ if (currentConfig && currentConfig.providers && currentConfig.providers[name]) renderProviderWorkspace();
1095
+ window.scrollTo(0, 0);
1096
+ }
1097
+ function closeProviderDetail() {
1098
+ $("#view-providers").classList.remove("show-detail");
1099
+ window.scrollTo(0, 0);
1100
+ }
1101
+
1037
1102
  function renderProviderRail() {
1038
1103
  const list = $("#providers-list");
1039
1104
  const entries = Object.entries((currentConfig && currentConfig.providers) || {});
@@ -1058,6 +1123,7 @@ async function removeProvider(name, provider) {
1058
1123
  providerDetailTab = "overview";
1059
1124
  slotsLoaded = false;
1060
1125
  clientsLoaded = false;
1126
+ closeProviderDetail();
1061
1127
  renderProviderWorkspace();
1062
1128
  toast(t("common.saved"));
1063
1129
  } catch (error) { toast(t("common.saveFailed"), true, error.message); }
@@ -1077,36 +1143,36 @@ function providerTabs(name, provider) {
1077
1143
  function providerOverview(name, provider) {
1078
1144
  const state = stateFor(name);
1079
1145
  const live = status && status.providers && status.providers[name];
1080
- const connection = el("section", { class: "detail-section" }, [
1081
- el("h3", { text: t("providers.connection") }),
1082
- el("div", { class: "detail-setting-row" }, [
1083
- el("div", { class: "setting-copy" }, [el("strong", { text: providerConnection(name).text }), el("span", { text: providerKind(name, provider) })]),
1084
- providerState(name, provider),
1146
+ const connection = section(t("providers.connection"), [
1147
+ // The kind is already under the page title; this row is only the verdict and what to do about it.
1148
+ cell(t("health.connection"), providerState(name, provider), [
1149
+ state && !state.ok && state.error ? el("span", { class: "cell-sub bad-text", text: shortError(state.error), title: state.error }) : null,
1150
+ live && live.needsLogin && provider.type === "chatgpt" ? el("div", { class: "cell-actions" }, [chatgptLoginButton(renderProviderDetail)]) : null,
1085
1151
  ]),
1086
- state && !state.ok && state.error ? el("p", { class: "bad-text small provider-detail-error", text: shortError(state.error), title: state.error }) : null,
1087
- live && live.needsLogin && provider.type === "chatgpt" ? chatgptLoginButton(renderProviderDetail) : null,
1088
- ].filter(Boolean));
1089
- const models = modelsOf(provider);
1090
- const modelSummary = el("section", { class: "detail-section" }, [
1091
- el("h3", { text: t("providers.selectedModels") }),
1092
- models.length ? el("div", { class: "model-chip-list" }, models.map((model) => el("span", { class: "model-chip", text: labelOf(model) }))) : hint(t("providers.noModels")),
1093
1152
  ]);
1153
+ const models = modelsOf(provider);
1094
1154
  const measuring = measureStates.get(name);
1095
- if (measuring && measuring.state === "running") {
1096
- modelSummary.appendChild(el("p", { class: "small", text: t("measure.running", { done: measuring.done, total: measuring.total }) }));
1097
- modelSummary.appendChild(hint(t("measure.help")));
1098
- }
1155
+ const running = measuring && measuring.state === "running";
1156
+ // Names only, as one quiet line: the Models tab is where each one is listed and edited.
1157
+ const modelSummary = section(t("providers.selectedModels"), [
1158
+ models.length ? el("div", { class: "cell-note" }, [el("span", { class: "cell-label", text: models.map(labelOf).join(", ") })]) : el("div", { class: "cell-note", text: t("providers.noModels") }),
1159
+ running ? el("div", { class: "cell-note", text: t("measure.running", { done: measuring.done, total: measuring.total }) }) : null,
1160
+ ], running ? t("measure.help") : null);
1099
1161
  const quota = quotaLine(name);
1100
- return el("div", { class: "provider-panel" }, [connection, quota ? el("section", { class: "detail-section" }, [el("h3", { text: t("health.quota", { percent: "", reset: "" }).trim() }), el("p", { text: quota })]) : null, modelSummary].filter(Boolean));
1162
+ return el("div", { class: "provider-panel" }, [
1163
+ connection,
1164
+ quota ? section(null, [cell(t("providers.usage"), el("span", { text: quota }))]) : null,
1165
+ modelSummary,
1166
+ ]);
1101
1167
  }
1102
1168
 
1103
1169
  function providerModelsPanel(name, provider) {
1104
1170
  const models = modelsOf(provider);
1171
+ const edit = el("button", { class: "btn secondary compact", type: "button", text: t("common.edit"), onclick: () => openProviderForm({ name, provider }) });
1105
1172
  return el("div", { class: "provider-panel" }, [
1106
- el("section", { class: "detail-section" }, [
1107
- el("div", { class: "section-heading" }, [el("div", {}, [el("h3", { text: t("providers.selectedModels") }), hint(t("providers.modelsHelp"))]), el("button", { class: "btn secondary", type: "button", text: t("common.edit"), onclick: () => openProviderForm({ name, provider }) })]),
1108
- models.length ? el("div", { class: "provider-model-list" }, models.map((model) => el("div", { class: "provider-model-row" }, [el("strong", { text: labelOf(model) }), el("span", { class: "small", text: model.id }), modelEffortTag(model) ? el("span", { class: `model-effort-tag ${model.effortLevels.length ? "has-effort" : "no-effort"}`, text: modelEffortTag(model) }) : null].filter(Boolean)))) : hint(t("providers.noModels")),
1109
- ]),
1173
+ section(t("providers.selectedModels"), models.length
1174
+ ? models.map((model) => cell(labelOf(model), modelEffortTag(model) ? el("span", { class: `model-effort-tag ${model.effortLevels.length ? "has-effort" : "no-effort"}`, text: modelEffortTag(model) }) : null, el("span", { class: "cell-sub mono", text: model.id }), "provider-model-row"))
1175
+ : [el("div", { class: "cell-note", text: t("providers.noModels") })], t("providers.modelsHelp"), edit),
1110
1176
  ]);
1111
1177
  }
1112
1178
 
@@ -1141,14 +1207,37 @@ function claudeQuotaNode(account) {
1141
1207
  return text ? el("p", { class: "small account-quota", text }) : null;
1142
1208
  }
1143
1209
 
1210
+ /** "Use first" for an account the pool does not lead with; `null` hands first place back to the current login. */
1211
+ function claudePreferButton(name, id, disabled) {
1212
+ const button = el("button", { class: "btn secondary compact", type: "button", text: t("providers.preferAction") });
1213
+ button.disabled = disabled;
1214
+ button.addEventListener("click", async () => {
1215
+ button.disabled = true;
1216
+ try { await api("/api/claude-accounts", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ preferred: id }) }); await refreshClaudeAccountPanel(name); }
1217
+ catch (error) { button.disabled = false; toast(t("common.actionFailed"), true, error.message); }
1218
+ });
1219
+ return button;
1220
+ }
1221
+
1144
1222
  function renderClaudeAccountRows(target, data, name, generation) {
1145
1223
  if (generation !== providerDetailGeneration) return;
1224
+ // Rows follow the order the router tries them in: the preferred account, else the current login, first.
1225
+ const stored = Array.isArray(data.accounts) ? [...data.accounts] : [];
1226
+ const preferredIndex = stored.findIndex((account) => account.preferred);
1227
+ if (preferredIndex > 0) stored.unshift(...stored.splice(preferredIndex, 1));
1228
+ const leader = preferredIndex >= 0 ? stored[0].id : data.current ? "current" : stored[0] && stored[0].id;
1229
+ const choosable = (data.current ? 1 : 0) + stored.length > 1;
1230
+ const firstBadge = (id) => (choosable && id === leader ? el("span", { class: "badge first", text: t("providers.preferBadge") }) : null);
1146
1231
  const rows = [];
1147
- if (data.current) rows.push(el("article", { class: "account-card current" }, [
1148
- el("div", { class: "account-card-copy" }, [el("strong", { text: data.current.label }), el("span", { class: "small", text: anthropicSourceText(data.current.source) }), claudeQuotaNode(data.current), hint(t("providers.currentAccountHelp"))].filter(Boolean)),
1149
- el("span", { class: "badge ok", text: t("providers.anthropicCurrent") }),
1150
- ]));
1151
- for (const account of Array.isArray(data.accounts) ? data.accounts : []) {
1232
+ const currentRow = data.current ? el("article", { class: "cell account-card current" }, [
1233
+ el("div", { class: "cell-text account-card-copy" }, [
1234
+ el("strong", { text: data.current.label }), el("span", { class: "small", text: anthropicSourceText(data.current.source) }), claudeQuotaNode(data.current), hint(t("providers.currentAccountHelp")),
1235
+ choosable && leader !== "current" ? el("div", { class: "account-card-actions" }, [claudePreferButton(name, null, false)]) : null,
1236
+ ]),
1237
+ el("div", { class: "account-card-badges" }, [firstBadge("current"), el("span", { class: "badge ok", text: t("providers.anthropicCurrent") })]),
1238
+ ]) : null;
1239
+ if (currentRow && preferredIndex < 0) rows.push(currentRow);
1240
+ for (const account of stored) {
1152
1241
  const unavailable = account.needsReauth || account.expiresAt <= Date.now();
1153
1242
  const rename = el("button", { class: "btn secondary compact", type: "button", text: t("common.edit") });
1154
1243
  rename.addEventListener("click", async () => {
@@ -1164,19 +1253,25 @@ function renderClaudeAccountRows(target, data, name, generation) {
1164
1253
  catch (error) { toast(t("common.actionFailed"), true, error.message); }
1165
1254
  });
1166
1255
  const actions = [rename, remove];
1256
+ if (choosable && account.id !== leader) actions.unshift(claudePreferButton(name, account.id, unavailable));
1167
1257
  if (unavailable) {
1168
1258
  const reauth = el("button", { class: "btn secondary compact", type: "button", text: t("providers.reauthAction") });
1169
1259
  reauth.addEventListener("click", () => openClaudeAccountConsent(name));
1170
1260
  actions.unshift(reauth);
1171
1261
  }
1172
- rows.push(el("article", { class: "account-card" }, [
1173
- el("div", { class: "account-card-copy" }, [el("strong", { text: account.label }), account.email && account.email !== account.label ? el("span", { class: "small", text: account.email }) : null, claudeQuotaNode(account), hint(t("providers.addedAccountHelp"))].filter(Boolean)),
1174
- el("span", { class: `badge ${unavailable ? "bad" : "ok"}`, text: unavailable ? t("providers.anthropicReauth") : t("pool.ready") }),
1175
- el("div", { class: "account-card-actions" }, actions),
1262
+ rows.push(el("article", { class: "cell account-card" }, [
1263
+ el("div", { class: "cell-text account-card-copy" }, [
1264
+ el("strong", { text: account.label }), account.email && account.email !== account.label ? el("span", { class: "small", text: account.email }) : null, claudeQuotaNode(account), hint(t("providers.addedAccountHelp")),
1265
+ el("div", { class: "account-card-actions" }, actions),
1266
+ ]),
1267
+ el("div", { class: "account-card-badges" }, [firstBadge(account.id), el("span", { class: `badge ${unavailable ? "bad" : "ok"}`, text: unavailable ? t("providers.anthropicReauth") : t("pool.ready") })]),
1176
1268
  ]));
1269
+ if (currentRow && rows.length === 1 && preferredIndex >= 0) rows.push(currentRow);
1177
1270
  }
1178
- target.replaceChildren(...(rows.length ? rows : [el("div", { class: "empty-card", text: t("providers.anthropicNoAccounts") })]));
1179
- const count = (data.current ? 1 : 0) + (Array.isArray(data.accounts) ? data.accounts.length : 0);
1271
+ target.replaceChildren(...(rows.length ? rows : [el("div", { class: "account-empty", text: t("providers.anthropicNoAccounts") })]));
1272
+ const preferNote = $("#claude-account-prefer");
1273
+ if (preferNote) preferNote.hidden = !choosable;
1274
+ const count = (data.current ? 1 : 0) + stored.length;
1180
1275
  const countNode = $("#claude-account-count");
1181
1276
  if (countNode) countNode.textContent = t("providers.accountCount", { count });
1182
1277
  }
@@ -1193,24 +1288,32 @@ function anthropicAccountsPanel(name, provider) {
1193
1288
  const rotation = el("input", { type: "checkbox", checked: Boolean(provider.accountPool) });
1194
1289
  const rotationMessage = el("span", { class: "small" });
1195
1290
  rotation.addEventListener("change", () => void saveAnthropicRotation(name, provider, rotation.checked, rotation, rotationMessage));
1196
- const add = el("button", { class: "btn", type: "button", text: t("providers.addClaudeAccount") });
1291
+ const add = el("button", { class: "cell-button", type: "button", text: t("providers.addClaudeAccount") });
1197
1292
  add.addEventListener("click", () => openClaudeAccountConsent(name));
1198
- const removeAll = el("button", { class: "btn danger", type: "button", text: t("providers.anthropicLogoutAll") });
1293
+ const removeAll = el("button", { class: "cell-button destructive", type: "button", text: t("providers.anthropicLogoutAll") });
1199
1294
  removeAll.addEventListener("click", async () => {
1200
1295
  if (!confirm(t("providers.anthropicLogoutAllConfirm"))) return;
1201
1296
  try { await api("/api/claude-logout", { method: "POST" }); await refreshClaudeAccountPanel(name); }
1202
1297
  catch (error) { toast(t("common.actionFailed"), true, error.message); }
1203
1298
  });
1204
- const rows = el("div", { id: "claude-account-rows", class: "account-card-list" }, [el("div", { class: "small", text: t("providers.checking") })]);
1299
+ rotation.setAttribute("role", "switch");
1300
+ rotation.setAttribute("aria-label", t("providers.anthropicPool"));
1301
+ const rows = el("div", { id: "claude-account-rows", class: "group account-card-list" }, [el("div", { class: "small", text: t("providers.checking") })]);
1302
+ // The rotation switch first (it decides whether added accounts are used at all), then the
1303
+ // accounts with "Add" at the end of their list, then the one destructive act on its own.
1205
1304
  const panel = el("div", { class: "provider-panel" }, [
1206
- el("section", { class: "detail-section account-summary" }, [
1207
- el("div", { class: "section-heading" }, [el("div", {}, [el("h3", { text: t("providers.accountPoolTitle") }), el("p", { id: "claude-account-count", class: "account-count", text: t("providers.accountCount", { count: 0 }) }), hint(t("providers.accountCountHelp"))]), add]),
1208
- el("div", { class: "detail-setting-row rotation-row" }, [el("div", { class: "setting-copy" }, [el("strong", { text: t("providers.anthropicPool") }), el("span", { text: t("providers.accountPoolSubtitle") })]), el("label", { class: "switch" }, [rotation, el("span")])]),
1209
- !provider.accountPool ? el("div", { class: "notice warn" }, [el("strong", { text: t("providers.rotationOff") }), el("span", { text: t("providers.rotationRequired") })]) : null,
1210
- rotationMessage,
1305
+ el("section", { class: "section" }, [
1306
+ el("div", { class: "group" }, [cell(t("providers.anthropicPool"), el("label", { class: "switch" }, [rotation, el("span")]), [t("providers.accountPoolSubtitle"), rotationMessage])]),
1307
+ !provider.accountPool ? el("div", { class: "group-footer notice" }, [el("strong", { text: t("providers.rotationOff") }), el("span", { text: t("providers.rotationRequired") })]) : null,
1308
+ ]),
1309
+ el("section", { class: "section" }, [
1310
+ el("div", { class: "section-head" }, [el("h3", { class: "section-title", text: t("providers.accountPoolTitle") }), el("span", { id: "claude-account-count", class: "small", text: t("providers.accountCount", { count: 0 }) })]),
1311
+ rows,
1312
+ el("div", { class: "group stacked-add" }, [add]),
1313
+ el("p", { class: "group-footer", text: t("providers.accountCountHelp") }),
1314
+ el("p", { id: "claude-account-prefer", class: "group-footer", text: t("providers.preferHelp"), hidden: "" }),
1211
1315
  ]),
1212
- el("section", { class: "detail-section" }, [rows]),
1213
- el("section", { class: "detail-section danger-section" }, [el("h3", { text: t("providers.dangerZone") }), hint(t("providers.anthropicLogoutAllConfirm")), removeAll]),
1316
+ section(t("providers.dangerZone"), [removeAll], t("providers.anthropicLogoutAllConfirm")),
1214
1317
  ]);
1215
1318
  queueMicrotask(() => void refreshClaudeAccountPanel(name));
1216
1319
  return panel;
@@ -1294,18 +1397,18 @@ function renderChatgptAccountRows(target, data, name, generation) {
1294
1397
  const quota = chatgptQuotaText(account.quota);
1295
1398
  const detail = [account.email && account.email !== account.label ? account.email : null, account.planType || null].filter(Boolean).join(" · ");
1296
1399
  const help = account.state === "needs-login" || account.state === "quarantined" ? t("providers.chatgptReauthHelp") : own ? t("providers.chatgptOwnHelp") : t("providers.chatgptCodexHelp");
1297
- return el("article", { class: `account-card stacked${account.active ? " current" : ""}` }, [
1298
- el("div", { class: "account-card-copy" }, [
1400
+ return el("article", { class: `cell account-card${account.active ? " current" : ""}` }, [
1401
+ el("div", { class: "cell-text account-card-copy" }, [
1299
1402
  el("strong", { text: account.label }),
1300
1403
  detail ? el("span", { class: "small", text: detail }) : null,
1301
1404
  quota ? el("p", { class: "small account-quota", text: quota }) : null,
1302
1405
  hint(help),
1303
- ].filter(Boolean)),
1304
- chatgptStateBadge(account),
1305
- actions.length ? el("div", { class: "account-card-actions" }, actions) : null,
1306
- ].filter(Boolean));
1406
+ actions.length ? el("div", { class: "account-card-actions" }, actions) : null,
1407
+ ]),
1408
+ el("div", { class: "account-card-badges" }, [chatgptStateBadge(account)]),
1409
+ ]);
1307
1410
  });
1308
- target.replaceChildren(...(rows.length ? rows : [el("div", { class: "empty-card", text: t("providers.chatgptNoAccounts") })]));
1411
+ target.replaceChildren(...(rows.length ? rows : [el("div", { class: "account-empty", text: t("providers.chatgptNoAccounts") })]));
1309
1412
  const countNode = $("#chatgpt-account-count");
1310
1413
  if (countNode) countNode.textContent = t("providers.chatgptAccountCount", { count: accounts.length });
1311
1414
  }
@@ -1397,17 +1500,17 @@ function renderGoogleAccountRows(target, data, name, generation) {
1397
1500
  });
1398
1501
  actions.push(pause, rename, remove);
1399
1502
  const detail = [account.email && account.email !== account.label ? account.email : null, account.projectId ? t("providers.googleProject", { id: account.projectId }) : null].filter(Boolean).join(" · ");
1400
- return el("article", { class: `account-card stacked${account.active ? " current" : ""}` }, [
1401
- el("div", { class: "account-card-copy" }, [
1503
+ return el("article", { class: `cell account-card${account.active ? " current" : ""}` }, [
1504
+ el("div", { class: "cell-text account-card-copy" }, [
1402
1505
  el("strong", { text: account.label }),
1403
1506
  detail ? el("span", { class: "small", text: detail }) : null,
1404
1507
  hint(account.state === "needs-login" || account.state === "quarantined" ? t("providers.chatgptReauthHelp") : account.state === "needs-verification" ? t("providers.googleVerifyHelp") : t("providers.googleOwnHelp")),
1405
- ].filter(Boolean)),
1406
- chatgptStateBadge(account),
1407
- el("div", { class: "account-card-actions" }, actions),
1408
- ].filter(Boolean));
1508
+ el("div", { class: "account-card-actions" }, actions),
1509
+ ]),
1510
+ el("div", { class: "account-card-badges" }, [chatgptStateBadge(account)]),
1511
+ ]);
1409
1512
  });
1410
- target.replaceChildren(...(rows.length ? rows : [el("div", { class: "empty-card", text: t("providers.googleNoAccounts") })]));
1513
+ target.replaceChildren(...(rows.length ? rows : [el("div", { class: "account-empty", text: t("providers.googleNoAccounts") })]));
1411
1514
  const countNode = $("#google-account-count");
1412
1515
  if (countNode) countNode.textContent = t("providers.chatgptAccountCount", { count: accounts.length });
1413
1516
  }
@@ -1421,17 +1524,19 @@ async function refreshGoogleAccountPanel(name) {
1421
1524
  }
1422
1525
 
1423
1526
  function googleAccountsPanel(name) {
1424
- const add = el("button", { class: "btn", type: "button", text: t("providers.addGoogleAccount") });
1527
+ const add = el("button", { class: "cell-button", type: "button", text: t("providers.addGoogleAccount") });
1425
1528
  add.disabled = googleLoginBusy;
1426
1529
  add.addEventListener("click", () => void startGoogleLogin(() => { renderProviderDetail(); void refreshGoogleAccountPanel(name); }));
1427
- const rows = el("div", { id: "google-account-rows", class: "account-card-list" }, [el("div", { class: "small", text: t("providers.checking") })]);
1530
+ const rows = el("div", { id: "google-account-rows", class: "group account-card-list" }, [el("div", { class: "small", text: t("providers.checking") })]);
1428
1531
  const panel = el("div", { class: "provider-panel" }, [
1429
- el("section", { class: "detail-section account-summary" }, [
1430
- el("div", { class: "section-heading" }, [el("div", {}, [el("h3", { text: t("providers.googleAccountsTitle") }), el("p", { id: "google-account-count", class: "account-count", text: t("providers.chatgptAccountCount", { count: 0 }) }), hint(t("providers.googleAccountsSubtitle"))]), add]),
1431
- googleLoginBusy ? el("p", { class: "small", text: t("providers.googleLoginWaiting") }) : null,
1432
- el("p", { class: "small warn-text", text: t("providers.googleWarningShort") }),
1433
- ].filter(Boolean)),
1434
- el("section", { class: "detail-section" }, [rows]),
1532
+ el("section", { class: "section" }, [
1533
+ el("div", { class: "section-head" }, [el("h3", { class: "section-title", text: t("providers.googleAccountsTitle") }), el("span", { id: "google-account-count", class: "small", text: t("providers.chatgptAccountCount", { count: 0 }) })]),
1534
+ rows,
1535
+ el("div", { class: "group stacked-add" }, [add]),
1536
+ googleLoginBusy ? el("p", { class: "group-footer", text: t("providers.googleLoginWaiting") }) : null,
1537
+ el("p", { class: "group-footer", text: t("providers.googleAccountsSubtitle") }),
1538
+ el("div", { class: "group-footer notice" }, [el("strong", { text: t("providers.googleWarningShort") })]),
1539
+ ]),
1435
1540
  ]);
1436
1541
  queueMicrotask(() => void refreshGoogleAccountPanel(name));
1437
1542
  return panel;
@@ -1446,17 +1551,20 @@ async function refreshChatgptAccountPanel(name) {
1446
1551
  }
1447
1552
 
1448
1553
  function chatgptAccountsPanel(name) {
1449
- const add = el("button", { class: "btn", type: "button", text: t("providers.addChatgptAccount") });
1554
+ const add = el("button", { class: "cell-button", type: "button", text: t("providers.addChatgptAccount") });
1450
1555
  add.disabled = chatgptLoginBusy;
1451
- const waiting = el("p", { class: "small", text: chatgptLoginBusy ? t("providers.chatgptAddWaiting") : "" });
1556
+ const waiting = el("p", { class: "group-footer", text: chatgptLoginBusy ? t("providers.chatgptAddWaiting") : "" });
1557
+ waiting.hidden = !chatgptLoginBusy;
1452
1558
  add.addEventListener("click", () => void startChatgptLogin(() => { renderProviderDetail(); void refreshChatgptAccountPanel(name); }, true));
1453
- const rows = el("div", { id: "chatgpt-account-rows", class: "account-card-list" }, [el("div", { class: "small", text: t("providers.checking") })]);
1559
+ const rows = el("div", { id: "chatgpt-account-rows", class: "group account-card-list" }, [el("div", { class: "small", text: t("providers.checking") })]);
1454
1560
  const panel = el("div", { class: "provider-panel" }, [
1455
- el("section", { class: "detail-section account-summary" }, [
1456
- el("div", { class: "section-heading" }, [el("div", {}, [el("h3", { text: t("providers.chatgptAccountsTitle") }), el("p", { id: "chatgpt-account-count", class: "account-count", text: t("providers.chatgptAccountCount", { count: 0 }) }), hint(t("providers.chatgptAccountsSubtitle"))]), add]),
1561
+ el("section", { class: "section" }, [
1562
+ el("div", { class: "section-head" }, [el("h3", { class: "section-title", text: t("providers.chatgptAccountsTitle") }), el("span", { id: "chatgpt-account-count", class: "small", text: t("providers.chatgptAccountCount", { count: 0 }) })]),
1563
+ rows,
1564
+ el("div", { class: "group stacked-add" }, [add]),
1457
1565
  waiting,
1566
+ el("p", { class: "group-footer", text: t("providers.chatgptAccountsSubtitle") }),
1458
1567
  ]),
1459
- el("section", { class: "detail-section" }, [rows]),
1460
1568
  ]);
1461
1569
  queueMicrotask(() => void refreshChatgptAccountPanel(name));
1462
1570
  return panel;
@@ -1474,10 +1582,12 @@ function renderProviderDetail() {
1474
1582
  const check = el("button", { class: "btn secondary", type: "button", text: t("providers.check") });
1475
1583
  check.addEventListener("click", async () => { check.disabled = true; await probeProvider(name, provider); check.disabled = false; renderProviderWorkspace(); });
1476
1584
  const edit = el("button", { class: "btn secondary", type: "button", text: t("common.edit"), onclick: () => openProviderForm({ name, provider }) });
1477
- const remove = el("button", { class: "btn danger", type: "button", text: t("common.remove"), onclick: () => void removeProvider(name, provider) });
1585
+ // Removing is the one destructive act on this page, so it sits apart at the bottom, not beside Edit.
1586
+ const remove = el("button", { class: "cell-button destructive", type: "button", text: t("providers.remove"), onclick: () => void removeProvider(name, provider) });
1587
+ const back = el("button", { class: "provider-back", type: "button", onclick: closeProviderDetail }, [svgIcon("0 0 9 15", ["M7.5 1.5l-6 6 6 6"]), el("span", { text: t("nav.providers") })]);
1478
1588
  const header = el("header", { class: "provider-detail-header" }, [
1479
1589
  el("div", {}, [el("div", { class: "provider-title-line" }, [el("h2", { text: name }), providerState(name, provider)]), el("p", { class: "small", text: providerKind(name, provider) })]),
1480
- el("div", { class: "provider-detail-actions" }, [check, edit, remove]),
1590
+ el("div", { class: "provider-detail-actions" }, [check, edit]),
1481
1591
  ]);
1482
1592
  let content;
1483
1593
  if (providerDetailTab === "accounts" && provider.type === "anthropic" && provider.auth === "claude-code") content = anthropicAccountsPanel(name, provider);
@@ -1485,7 +1595,7 @@ function renderProviderDetail() {
1485
1595
  else if (providerDetailTab === "accounts" && provider.type === "google" && provider.auth === "antigravity") content = googleAccountsPanel(name);
1486
1596
  else if (providerDetailTab === "models") content = providerModelsPanel(name, provider);
1487
1597
  else { providerDetailTab = "overview"; content = providerOverview(name, provider); }
1488
- detail.replaceChildren(header, providerTabs(name, provider), content);
1598
+ detail.replaceChildren(back, header, providerTabs(name, provider), content, el("div", { class: "group provider-remove" }, [remove]));
1489
1599
  }
1490
1600
 
1491
1601
  function renderProviderWorkspace() {
@@ -1631,35 +1741,34 @@ async function openClaudeAccountSignIn(providerName, manual) {
1631
1741
  } catch (error) { showError({ ok: false, error: error.message }); }
1632
1742
  }
1633
1743
 
1744
+ // The services one can connect, as grouped lists: sign-in subscriptions first, then the API-key
1745
+ // presets by the format they speak (the "paste a key" note is said once per group, not per row),
1746
+ // then the manual entry on its own.
1634
1747
  function openProviderChooser() {
1635
1748
  const grid = el("div", { class: "chooser-grid" });
1636
- const anthropic = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: t("providers.anthropic") }), el("span", { text: t("providers.anthropicHelp") })]);
1637
- anthropic.addEventListener("click", () => openProviderForm({ kind: "anthropic" }));
1638
- grid.appendChild(anthropic);
1639
- const chatgpt = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: t("providers.chatgpt") }), el("span", { text: t("providers.chatgptHelp") })]);
1640
- chatgpt.addEventListener("click", () => openProviderForm({ kind: "chatgpt" }));
1641
- grid.appendChild(chatgpt);
1642
- const google = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: t("providers.google") }), el("span", { text: t("providers.googleHelp") })]);
1643
- google.addEventListener("click", () => openProviderForm({ kind: "google" }));
1644
- grid.appendChild(google);
1645
- const native = presets.filter((preset) => (preset.kind || "anthropic-compatible") === "anthropic-compatible");
1646
- for (const preset of native) {
1647
- const tile = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: preset.name }), el("span", { text: t("providers.presetHelp") })]);
1648
- tile.addEventListener("click", () => openProviderForm({ preset }));
1649
- grid.appendChild(tile);
1749
+ function tile(title, help, options) {
1750
+ const button = el("button", { class: "chooser-tile", type: "button" }, [el("span", { class: "cell-text" }, [el("strong", { text: title }), help ? el("span", { text: help }) : null]), chevronIcon()]);
1751
+ button.addEventListener("click", () => openProviderForm(options));
1752
+ return button;
1650
1753
  }
1651
- const openai = presets.filter((preset) => preset.kind === "openai-compatible");
1652
- if (openai.length) {
1653
- grid.appendChild(el("div", { class: "chooser-group" }, [el("strong", { text: t("providers.openaiGroup") }), el("span", { text: t("providers.openaiGroupHelp") })]));
1654
- for (const preset of openai) {
1655
- const tile = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: preset.name }), el("span", { text: t("providers.presetHelp") })]);
1656
- tile.addEventListener("click", () => openProviderForm({ preset }));
1657
- grid.appendChild(tile);
1658
- }
1754
+ function group(title, tiles, footer, single) {
1755
+ if (!tiles.length) return;
1756
+ grid.appendChild(el("section", { class: "chooser-section" }, [
1757
+ title ? el("div", { class: "section-head" }, [el("h3", { class: "section-title", text: title })]) : null,
1758
+ el("div", { class: `group chooser-group chooser-tiles${single ? " single" : ""}` }, tiles),
1759
+ footer ? el("p", { class: "group-footer", text: footer }) : null,
1760
+ ]));
1659
1761
  }
1660
- const custom = el("button", { class: "chooser-tile", type: "button" }, [el("strong", { text: t("providers.custom") }), el("span", { text: t("providers.customHelp") })]);
1661
- custom.addEventListener("click", () => openProviderForm({ kind: "custom" }));
1662
- grid.appendChild(custom);
1762
+ group(t("providers.subscriptionGroup"), [
1763
+ tile(t("providers.anthropic"), t("providers.anthropicHelp"), { kind: "anthropic" }),
1764
+ tile(t("providers.chatgpt"), t("providers.chatgptHelp"), { kind: "chatgpt" }),
1765
+ tile(t("providers.google"), t("providers.googleHelp"), { kind: "google" }),
1766
+ ], null, true);
1767
+ const native = presets.filter((preset) => (preset.kind || "anthropic-compatible") === "anthropic-compatible");
1768
+ group(t("providers.anthropicGroup"), native.map((preset) => tile(preset.name, null, { preset })), t("providers.presetHelp"));
1769
+ const openai = presets.filter((preset) => preset.kind === "openai-compatible");
1770
+ group(t("providers.openaiGroup"), openai.map((preset) => tile(preset.name, null, { preset })), `${t("providers.presetHelp")} ${t("providers.openaiGroupHelp")}`);
1771
+ group(null, [tile(t("providers.custom"), t("providers.customHelp"), { kind: "custom" })], null, true);
1663
1772
  showModal(el("div", {}, [el("h1", { id: "modal-title", text: t("providers.choose") }), hint(t("providers.chooseHelp")), grid]));
1664
1773
  }
1665
1774
  function inputRow(label, control, helpText) {
@@ -2241,6 +2350,619 @@ function openProviderForm(options) {
2241
2350
  showModal(form, () => { if (isChatgpt) formActive = false; });
2242
2351
  }
2243
2352
 
2353
+ // ---- ClaudeRipple mod ---------------------------------------------------------------
2354
+ //
2355
+ // The mod's settings live in the router (GET/PUT /api/mod-settings); the ⚙ panel in the bar edits
2356
+ // the same object. As in System Settings a change applies itself: it is saved after a short pause
2357
+ // with the revision it was made on. A 409 means another surface saved first — its settings are
2358
+ // shown and the change made here is dropped, rather than quietly overwriting theirs.
2359
+
2360
+ const MOD_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
2361
+ // The band's parts the person can turn off (`hide`), then the ones it shows only when turned on
2362
+ // (`extras`, as the mod's settings.ts EXTRAS): the judge's reason is in the effort's tip alone.
2363
+ const MOD_PARTS = ["timer", "route"];
2364
+ const MOD_EXTRAS = ["context", "limits"];
2365
+ // Named in themselves, as the mod's own Look card lists them.
2366
+ const MOD_LANGUAGES = [["ko", "한국어"], ["en", "English"], ["zh", "简体中文"], ["ja", "日本語"], ["es", "Español"], ["pt", "Português"]];
2367
+ // Claude Code's own agent with a model: what the mod offers besides the generated workers, smallest
2368
+ // first, and their current model ids (packages/mod/hooks/settings.ts CLAUDE_WORKERS).
2369
+ const MOD_CLAUDE_MODELS = ["haiku", "sonnet", "opus", "fable"];
2370
+ const MOD_CLAUDE_IDS = { haiku: "claude-haiku-5-5", sonnet: "claude-sonnet-5-5", opus: "claude-opus-5-5", fable: "claude-fable-5-1" };
2371
+ // The four workers, low to max (the mod's rung ids: light, standard, hard, plan).
2372
+ const MOD_RUNGS = ["light", "standard", "hard", "plan"];
2373
+ const modClaude = (model, effort) => ({ target: { agent: "general-purpose", model }, effort });
2374
+ /** The mod's defaults (packages/mod/hooks/settings.ts), for a router that has stored nothing yet. */
2375
+ const MOD_DEFAULTS = {
2376
+ effortAuto: true, judge: "haiku", judgeModel: "deepseek-v4.1-flash", jevSource: "typesafe",
2377
+ bias: 0, floor: "low", ceiling: "max", agentAuto: "auto",
2378
+ rules: {
2379
+ general: { mode: "ladder", rungs: {
2380
+ light: [{ target: { agent: "deepseek-v4-1-flash" }, effort: "max" }], standard: [{ target: { agent: "gpt-6-1-sol" }, effort: "medium" }],
2381
+ hard: [{ target: { agent: "gpt-6-astra" }, effort: "high" }], plan: [modClaude("opus", "high")],
2382
+ } },
2383
+ },
2384
+ allow: [], modes: [], providers: {},
2385
+ fallback: { agent: "general-purpose", model: "sonnet" },
2386
+ keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
2387
+ compactWith: "haiku", handoffSkill: "", handoffAfter: "continue", handoffButton: "advised",
2388
+ swampAt: 50, layout: "dashboard", appearance: "auto", language: "auto", hide: ["reason"], extras: [], setupDone: false,
2389
+ };
2390
+
2391
+ let modState = { settings: null, revision: 0, options: null, retiredNote: false };
2392
+ let modSaveTimer = null;
2393
+ let modSaving = false;
2394
+ let modSaveAgain = false;
2395
+
2396
+ /**
2397
+ * The router hands back the mod's full normalized settings; this only fills what an older router
2398
+ * (or an empty store) leaves out, so the page never draws a control over `undefined`. Fields this
2399
+ * page does not know are kept and sent back as they came.
2400
+ */
2401
+ function modNormalize(raw) {
2402
+ const value = raw && typeof raw === "object" && !Array.isArray(raw) ? clone(raw) : {};
2403
+ return {
2404
+ ...clone(MOD_DEFAULTS),
2405
+ ...value,
2406
+ rules: { ...clone(MOD_DEFAULTS.rules), ...(value.rules || {}) },
2407
+ allow: Array.isArray(value.allow) ? value.allow : [],
2408
+ modes: Array.isArray(value.modes) ? value.modes : [],
2409
+ providers: { ...(value.providers || {}) },
2410
+ hide: Array.isArray(value.hide) ? value.hide : clone(MOD_DEFAULTS.hide),
2411
+ };
2412
+ }
2413
+
2414
+ function modCap(text) { return text ? text.charAt(0).toLocaleUpperCase() + text.slice(1) : text; }
2415
+ function modEffortName(effort) { return t(`mod.effort.${effort}`); }
2416
+ function modSigned(n) { return n > 0 ? `+${n}` : n < 0 ? `−${-n}` : "0"; }
2417
+ function modTargetValue(target) { return target && target.agent ? (target.model ? `${target.agent}+${target.model}` : target.agent) : ""; }
2418
+ function modTargetOf(value) {
2419
+ const plus = value.indexOf("+");
2420
+ return plus < 0 ? { agent: value } : { agent: value.slice(0, plus), model: value.slice(plus + 1) };
2421
+ }
2422
+ function modAgents() { return (modState.options && modState.options.agents) || []; }
2423
+ function modProviderInfo(name) { return ((modState.options && modState.options.providers) || []).find((provider) => provider.name === name); }
2424
+ function modModels() {
2425
+ const seen = new Set();
2426
+ return ((modState.options && modState.options.models) || []).filter((model) => !model.judgeOnly && !seen.has(model.model) && seen.add(model.model));
2427
+ }
2428
+
2429
+ /** Rebuild a region in place, keeping keyboard focus on the control that had it. */
2430
+ function modRedraw(root, nodes) {
2431
+ const active = document.activeElement;
2432
+ const key = active && root.contains(active) ? active.dataset.key : null;
2433
+ root.replaceChildren(...nodes.filter(Boolean));
2434
+ if (key) {
2435
+ const again = root.querySelector(`[data-key="${CSS.escape(key)}"]`);
2436
+ if (again) again.focus();
2437
+ }
2438
+ }
2439
+ function modSwitch(key, checked, label, onChange) {
2440
+ const input = el("input", { type: "checkbox", role: "switch", "data-key": key, "aria-label": label, checked });
2441
+ input.addEventListener("change", () => onChange(input.checked));
2442
+ return el("span", { class: "switch" }, [input, el("span")]);
2443
+ }
2444
+ function modSegmented(key, choices, value, label, onPick) {
2445
+ return el("div", { class: "segmented", role: "radiogroup", "aria-label": label }, choices.map((choice) => {
2446
+ const on = String(choice.value) === String(value);
2447
+ const button = el("button", { type: "button", class: on ? "active" : "", role: "radio", "aria-checked": String(on), "data-key": `${key}:${choice.value}`, text: choice.label, ...(choice.disabled ? { disabled: "" } : {}) });
2448
+ button.addEventListener("click", () => { if (!on) onPick(choice.value); });
2449
+ return button;
2450
+ }));
2451
+ }
2452
+ /** A pop-up menu. Items are `{value, label}` or `{group, items}`; a saved value no longer offered stays visible. */
2453
+ function modSelect(key, items, value, label, onChange, cls) {
2454
+ const select = el("select", { "data-key": key, "aria-label": label, class: cls });
2455
+ const known = new Set();
2456
+ const add = (parent, item) => { known.add(String(item.value)); parent.appendChild(el("option", { value: item.value, text: item.label })); };
2457
+ for (const item of items) {
2458
+ if (!item.group) { add(select, item); continue; }
2459
+ if (!item.items.length) continue;
2460
+ const group = el("optgroup", { label: item.group });
2461
+ for (const entry of item.items) add(group, entry);
2462
+ select.appendChild(group);
2463
+ }
2464
+ if (!known.has(String(value))) select.appendChild(el("option", { value, text: `${value}${t("mod.unavailable")}` }));
2465
+ select.value = String(value);
2466
+ select.addEventListener("change", () => onChange(select.value));
2467
+ return select;
2468
+ }
2469
+ function modSection(title, rows, footers, action) {
2470
+ return el("section", { class: "section" }, [
2471
+ title || action ? el("div", { class: "section-head" }, [title ? el("h3", { class: "section-title", text: title }) : el("span"), action || null]) : null,
2472
+ el("div", { class: "group" }, rows.filter(Boolean)),
2473
+ ...(footers || []).filter(Boolean).map((footer) => typeof footer === "string" ? el("p", { class: "group-footer", text: footer }) : footer),
2474
+ ]);
2475
+ }
2476
+ function modCard(id, title, blurb, sections) {
2477
+ return el("section", { class: "mod-card", "aria-labelledby": `mod-card-${id}` }, [
2478
+ el("div", { class: "mod-card-head" }, [el("h2", { id: `mod-card-${id}`, text: title }), blurb ? el("p", { class: "hint", text: blurb }) : null]),
2479
+ ...sections.filter(Boolean),
2480
+ ]);
2481
+ }
2482
+ // ---- Saving
2483
+
2484
+ function modStatus(text, isError) {
2485
+ const box = $("#mod-status");
2486
+ box.textContent = text;
2487
+ box.classList.toggle("bad-text", Boolean(isError));
2488
+ }
2489
+ function modPatch(change) {
2490
+ modState.settings = { ...modState.settings, ...change };
2491
+ modRefresh();
2492
+ clearTimeout(modSaveTimer);
2493
+ modStatus(t("slots.saving"));
2494
+ modSaveTimer = setTimeout(() => { modSaveTimer = null; void saveMod(); }, 450);
2495
+ }
2496
+ function modSetRule(domain, rule) { modPatch({ rules: { ...modState.settings.rules, [domain]: rule } }); }
2497
+ async function saveMod() {
2498
+ if (modSaving) { modSaveAgain = true; return; }
2499
+ modSaving = true;
2500
+ try {
2501
+ 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 }) });
2502
+ modState.revision = Number(out.revision) || modState.revision + 1;
2503
+ // The router's copy is the normalized one; take it unless a newer change is already waiting.
2504
+ if (out.settings && !modSaveTimer && !modSaveAgain) { modState.settings = modNormalize(out.settings); modRefresh(); }
2505
+ modStatus(t("mod.saved"));
2506
+ } catch (error) {
2507
+ if (error.status === 409 && error.body && "settings" in error.body) {
2508
+ clearTimeout(modSaveTimer);
2509
+ modSaveTimer = null;
2510
+ modSaveAgain = false;
2511
+ modState.settings = modNormalize(error.body.settings);
2512
+ modState.revision = Number(error.body.revision) || 0;
2513
+ modRefresh();
2514
+ modStatus(t("mod.conflict"), true);
2515
+ toast(t("mod.conflict"), true);
2516
+ } else {
2517
+ modStatus(`${t("common.saveFailed")} ${error.message}`, true);
2518
+ }
2519
+ } finally {
2520
+ modSaving = false;
2521
+ if (modSaveAgain) { modSaveAgain = false; void saveMod(); }
2522
+ }
2523
+ }
2524
+ async function loadMod() {
2525
+ // A change still on its way would be overwritten by what the router holds now.
2526
+ if (modSaveTimer || modSaving) return;
2527
+ let retired = false;
2528
+ const [settingsResult, optionsResult] = await Promise.allSettled([api("/api/mod-settings"), api("/api/mod-options")]);
2529
+ if (settingsResult.status === "rejected") {
2530
+ if (!modState.settings) {
2531
+ $("#mod-content").replaceChildren(modSection(null, [el("div", { class: "cell-note bad-text", text: `${t("common.loadFailed")} ${settingsResult.reason.message}` })]));
2532
+ return;
2533
+ }
2534
+ } else {
2535
+ modState.settings = modNormalize(settingsResult.value.settings);
2536
+ modState.revision = Number(settingsResult.value.revision) || 0;
2537
+ retired = settingsResult.value.legacy === true;
2538
+ }
2539
+ modState.options = optionsResult.status === "fulfilled" ? optionsResult.value : null;
2540
+ await modLoadResets();
2541
+ if (retired) modState.retiredNote = true;
2542
+ modRefresh();
2543
+ // The settings came back without Auto and the field rules; saving them as they are keeps the note to this once.
2544
+ if (retired) modPatch({});
2545
+ }
2546
+ function modRefresh() {
2547
+ if (!modState.settings) return;
2548
+ renderModNotice();
2549
+ modRedraw($("#mod-content"), [modEffortCard(), modWorkersCard(), modJudgeCard(), modCacheCard(), modHandoffCard(), modLookCard()]);
2550
+ }
2551
+ /** Settings for a mod that is switched off do nothing until it is on; the switch lives in Clients. */
2552
+ function renderModNotice() {
2553
+ const box = $("#mod-notice");
2554
+ const off = Boolean(status) && !status.statusMod;
2555
+ if (box.dataset.state === String(off)) return;
2556
+ box.dataset.state = String(off);
2557
+ box.hidden = !off;
2558
+ box.replaceChildren(...(off ? [el("div", { class: "group" }, [cell(
2559
+ el("span", { class: "cell-label", text: t("mod.offNotice", { clients: t("nav.clients") }) }),
2560
+ el("button", { class: "btn secondary compact", type: "button", text: t("nav.clients"), onclick: () => showView("clients") }),
2561
+ )])] : []));
2562
+ }
2563
+
2564
+ // ---- Cards
2565
+
2566
+ function modEffortCard() {
2567
+ const s = modState.settings;
2568
+ const efforts = MOD_EFFORTS.map((effort) => ({ value: effort, label: modEffortName(effort) }));
2569
+ const range = el("input", { type: "range", class: "lean-range", min: "-2", max: "2", step: "1", value: String(s.bias), "data-key": "bias", "aria-label": t("mod.lean"), "aria-valuetext": modSigned(s.bias) });
2570
+ range.addEventListener("change", () => modPatch({ bias: Number(range.value) }));
2571
+ const lean = el("div", { class: "lean" }, [
2572
+ el("span", { class: "lean-end", text: t("mod.cheaper") }),
2573
+ el("div", { class: "lean-track" }, [range, el("div", { class: "lean-ticks", "aria-hidden": "true" }, [-2, -1, 0, 1, 2].map(() => el("span")))]),
2574
+ el("span", { class: "lean-end", text: t("mod.smarter") }),
2575
+ ]);
2576
+ const inverted = MOD_EFFORTS.indexOf(s.ceiling) < MOD_EFFORTS.indexOf(s.floor);
2577
+ return modCard("effort", t("mod.effort"), t("mod.effortBlurb"), [modSection(null, [
2578
+ cell(t("mod.auto"), modSwitch("effort-auto", s.effortAuto, t("mod.auto"), (on) => modPatch({ effortAuto: on })), modCap(t("mod.autoHint"))),
2579
+ cell(t("mod.lean"), lean, modCap(t("mod.leanHint")), "wide"),
2580
+ cell(t("mod.floor"), modSelect("floor", efforts, s.floor, t("mod.floor"), (floor) => modPatch({ floor }), "narrow")),
2581
+ cell(t("mod.ceiling"), modSelect("ceiling", efforts, s.ceiling, t("mod.ceiling"), (ceiling) => modPatch({ ceiling }), "narrow"), inverted ? el("span", { class: "cell-sub warn-text", text: modCap(t("mod.floorWins")) }) : null),
2582
+ ])]);
2583
+ }
2584
+
2585
+ // ---- The four workers (docs/specs/2026-10-12-worker-tiers.md)
2586
+
2587
+ /**
2588
+ * Everything a worker can run: the router's workers (each with its model's effort ladder and how it
2589
+ * is paid for), Claude Code's own agent on each Claude model, and the session's own model.
2590
+ */
2591
+ function modChoices() {
2592
+ const options = modState.options || {};
2593
+ const claudeEfforts = Array.isArray(options.claudeEfforts) && options.claudeEfforts.length ? options.claudeEfforts : ["low", "medium", "high", "max"];
2594
+ const workers = modAgents().map((agent) => {
2595
+ const info = modProviderInfo(agent.provider);
2596
+ return {
2597
+ value: agent.name, label: agent.model, model: agent.model, provider: agent.provider,
2598
+ billing: /(?::|-)free$/i.test(agent.model) ? "free" : agent.billing || (info && info.billing) || null,
2599
+ usable: !info || info.usable !== false,
2600
+ efforts: Array.isArray(agent.efforts) ? agent.efforts : effortLevelsFor(agent.provider, agent.model),
2601
+ };
2602
+ });
2603
+ const claude = MOD_CLAUDE_MODELS.map((alias) => ({
2604
+ value: `general-purpose+${alias}`, label: `Claude ${modCap(alias)}`, model: MOD_CLAUDE_IDS[alias], provider: "anthropic",
2605
+ billing: "subscription", usable: true, efforts: claudeEfforts, claude: true,
2606
+ }));
2607
+ const session = { value: "general-purpose", label: t("mod.sessionModel"), model: null, provider: null, billing: null, usable: true, efforts: claudeEfforts, claude: true };
2608
+ return [...workers, ...claude, session];
2609
+ }
2610
+ function modChoiceOf(target) { const value = modTargetValue(target); return modChoices().find((choice) => choice.value === value); }
2611
+ /** The efforts a model takes, in the mod's terms; an unknown ladder offers them all. */
2612
+ function modLadderOf(choice) {
2613
+ const levels = choice && Array.isArray(choice.efforts) ? choice.efforts.filter((effort) => MOD_EFFORTS.includes(effort)) : [];
2614
+ return levels.length ? levels : [...MOD_EFFORTS];
2615
+ }
2616
+ /** The nearest effort a model takes, ties going up: how the router clamps one (compat.ts). */
2617
+ function modNearest(effort, ladder) {
2618
+ if (ladder.includes(effort)) return effort;
2619
+ const at = MOD_EFFORTS.indexOf(effort);
2620
+ return ladder.reduce((best, next) => {
2621
+ const d = Math.abs(MOD_EFFORTS.indexOf(next) - at);
2622
+ const bd = Math.abs(MOD_EFFORTS.indexOf(best) - at);
2623
+ return d < bd || (d === bd && MOD_EFFORTS.indexOf(next) > MOD_EFFORTS.indexOf(best)) ? next : best;
2624
+ });
2625
+ }
2626
+ /** The group a model sits in: its provider, Claude's own together. */
2627
+ function modGroupName(choice) { return choice.claude ? "Claude" : choice.provider; }
2628
+ /** Choices in provider groups, each group tagged free / pay-per-token / subscription. */
2629
+ function modGroups(choices) {
2630
+ const groups = [];
2631
+ for (const choice of choices) {
2632
+ const name = modGroupName(choice);
2633
+ let group = groups.find((entry) => entry.name === name);
2634
+ if (!group) { group = { name, billing: choice.billing, items: [] }; groups.push(group); }
2635
+ group.items.push(choice);
2636
+ }
2637
+ return groups;
2638
+ }
2639
+ function modGroupLabel(group) { return group.billing ? `${group.name} · ${t(`mod.billing.${group.billing}`)}` : group.name; }
2640
+
2641
+ // ---- The models to use: what the four workers are filled from and may run on
2642
+
2643
+ /** An allow-list key: a worker's agent name, or `general-purpose:<model>` for Claude's own. */
2644
+ function modTickKey(choice) { return choice.claude ? choice.value.replace("+", ":") : choice.value; }
2645
+ /** The models that can be ticked: every worker and Claude's own; the session's model is always there. */
2646
+ function modTickable() { return modChoices().filter((choice) => choice.value !== "general-purpose"); }
2647
+ /** Whether a model is ticked: an empty list means every model, and an old provider name ticks that provider's. */
2648
+ function modTicked(choice) {
2649
+ const allow = modState.settings.allow || [];
2650
+ return !allow.length || allow.includes(modTickKey(choice)) || allow.includes(choice.provider);
2651
+ }
2652
+ /** Ticks one model; every model on again is stored as [], and the last one on cannot go off. */
2653
+ function modTick(choice, on) {
2654
+ const all = modTickable();
2655
+ const kept = new Set(all.filter(modTicked).map(modTickKey));
2656
+ if (on) kept.add(modTickKey(choice)); else kept.delete(modTickKey(choice));
2657
+ modPatch({ allow: kept.size === all.length ? [] : all.map(modTickKey).filter((key) => kept.has(key)) });
2658
+ }
2659
+ function modTickRows() {
2660
+ const choices = modTickable();
2661
+ if (!choices.length) return [el("div", { class: "cell-note", text: t("mod.noModels") })];
2662
+ const onCount = choices.filter(modTicked).length;
2663
+ return modGroups(choices).map((group) => {
2664
+ const boxes = group.items.map((choice) => {
2665
+ const on = modTicked(choice);
2666
+ const input = el("input", { type: "checkbox", "data-key": `tick:${modTickKey(choice)}`, "aria-label": `${choice.label} · ${group.name}`, checked: on });
2667
+ if (on && onCount === 1) input.disabled = true;
2668
+ input.addEventListener("change", () => modTick(choice, input.checked));
2669
+ // A model paid differently from its group (a :free model on a paid provider) says so beside its name.
2670
+ const other = choice.billing && choice.billing !== group.billing ? el("span", { class: "model-effort-tag", text: t(`mod.billing.${choice.billing}`) }) : null;
2671
+ const off = choice.usable ? null : el("span", { class: "model-effort-tag warn-text", text: t("mod.notUsable") });
2672
+ return el("label", { class: "model-check", title: choice.model || choice.label }, [input, el("span", { text: choice.label }), other, off]);
2673
+ });
2674
+ return el("div", { class: "tick-group" }, [el("div", { class: "tick-head", text: modGroupLabel(group) }), el("div", { class: "model-checklist" }, boxes)]);
2675
+ });
2676
+ }
2677
+
2678
+ // ---- The four workers: a meaning, the pick, and one menu to change it
2679
+
2680
+ /** The general ladder's rungs; the router fills any that are empty, so a missing one is only a failed load. */
2681
+ function modRungs() {
2682
+ const general = modState.settings.rules.general;
2683
+ return general && general.mode === "ladder" && general.rungs ? general.rungs : {};
2684
+ }
2685
+ /** One menu of `model · effort` for the ticked models, effort limited to what each model takes; the session's model too. */
2686
+ function modPickSelect(key, step, shown, label, onPick) {
2687
+ const choices = modChoices().filter((choice) => choice.value === "general-purpose" || modTicked(choice));
2688
+ const groups = modGroups(choices).map((group) => ({
2689
+ group: modGroupLabel(group),
2690
+ items: group.items.flatMap((choice) => modLadderOf(choice).map((effort) => ({ value: `${choice.value}|${effort}`, label: `${choice.label} · ${modEffortName(effort)}${choice.usable ? "" : t("mod.unavailableNow")}` }))),
2691
+ }));
2692
+ const current = step ? `${modTargetValue(step.target)}|${shown}` : "";
2693
+ const items = [...(step ? [] : [{ value: "", label: "—" }]), ...groups];
2694
+ // A pick that is not on offer now (its model unticked, or gone) keeps showing rather than a wrong neighbour.
2695
+ if (step && !groups.some((group) => group.items.some((item) => item.value === current))) {
2696
+ const choice = modChoiceOf(step.target);
2697
+ items.unshift({ value: current, label: `${choice ? choice.label : modTargetValue(step.target)} · ${modEffortName(shown)}${t("mod.unavailable")}` });
2698
+ }
2699
+ return modSelect(key, items, current, label, (value) => {
2700
+ if (!value) return;
2701
+ const bar = value.lastIndexOf("|");
2702
+ onPick(modTargetOf(value.slice(0, bar)), value.slice(bar + 1));
2703
+ }, "rung-model");
2704
+ }
2705
+ function modRungRows(mode) {
2706
+ const rungs = mode ? mode.rungs : modRungs();
2707
+ return MOD_RUNGS.map((rung) => {
2708
+ const steps = rungs[rung] || [];
2709
+ const name = t(`mod.rung.${rung}`);
2710
+ const save = (next) => {
2711
+ const updated = { ...rungs, [rung]: next };
2712
+ if (mode) modUpdateMode(mode.id, { rungs: updated });
2713
+ else modSetRule("general", { mode: "ladder", rungs: updated });
2714
+ };
2715
+ const rows = steps.map((step, index) => {
2716
+ const choice = modChoiceOf(step.target);
2717
+ const shown = modNearest(step.effort, modLadderOf(choice));
2718
+ const key = `worker:${mode ? mode.id : "base"}:${rung}:${index}`;
2719
+ const select = modPickSelect(key, step, shown, `${name} ${index + 1}`, (target, effort) => save(steps.map((item, at) => at === index ? { target, effort } : item)));
2720
+ const move = (delta) => { const next = [...steps]; [next[index], next[index + delta]] = [next[index + delta], next[index]]; save(next); };
2721
+ const button = (suffix, text, label, disabled, onclick) => el("button", { type: "button", class: "btn secondary compact", "data-key": `${key}:${suffix}`, text, "aria-label": label, ...(disabled ? { disabled: "" } : {}), onclick });
2722
+ return el("li", { class: "rung-item" }, [
2723
+ select,
2724
+ el("div", { class: "rung-actions" }, [
2725
+ button("up", "↑", t("mod.modeMoveUp"), index === 0, () => move(-1)),
2726
+ button("down", "↓", t("mod.modeMoveDown"), index === steps.length - 1, () => move(1)),
2727
+ button("remove", "✕", t("common.remove"), steps.length <= 1, () => save(steps.filter((_, at) => at !== index))),
2728
+ ]),
2729
+ choice && !choice.usable ? el("small", { class: "warn-text", text: t("mod.rungUnusable") }) : null,
2730
+ ]);
2731
+ });
2732
+ const add = el("button", { type: "button", class: "btn secondary compact", "data-key": `worker-add:${mode ? mode.id : "base"}:${rung}`, text: t("mod.modeAddModel"), onclick: () => {
2733
+ const choice = modChoices().find((item) => item.value !== "general-purpose" && modTicked(item)) || modChoices().at(-1);
2734
+ save([...steps, { target: modTargetOf(choice.value), effort: modNearest("medium", modLadderOf(choice)) }]);
2735
+ } });
2736
+ return cell(el("span", { class: "cell-label strong", text: name }), el("div", { class: "rung-list" }, [el("ol", {}, rows), add]), modCap(t(`mod.rungHint.${rung}`)), "wide rung-row");
2737
+ });
2738
+ }
2739
+ /** Said once after settings that still held Auto or field rules are read: they became the four workers. */
2740
+ function modRetiredNote() {
2741
+ const close = el("button", { class: "btn secondary compact", type: "button", "data-key": "retired-ok", text: t("common.close"), onclick: () => { modState.retiredNote = false; modRefresh(); } });
2742
+ return modSection(null, [cell(el("span", { class: "cell-label", text: t("mod.retired") }), close, null, "offer-row")]);
2743
+ }
2744
+ // ---- Operating modes (docs/specs/2026-10-12-worker-tiers.md "Operating modes")
2745
+ //
2746
+ // A tab edits a saved worker set; activation is always an explicit, separate action.
2747
+ let modModeTab = "";
2748
+ /** Providers a mode can end at the reset of, known now: ChatGPT ones, then Claude ('anthropic'). */
2749
+ let modResetProviders = [];
2750
+
2751
+ /** Only actual future reset timestamps enable the option, never a provider type alone. */
2752
+ function modQuotaKnown(accounts, binding = false) {
2753
+ return (accounts || []).some((account) => {
2754
+ if (account.state === "paused" || account.state === "needs-login") return false;
2755
+ const quota = account.quota || {};
2756
+ const windows = Object.values(quota.rate_limits || {}).filter(Boolean);
2757
+ const selected = binding ? windows.sort((a, b) => (b.used_percent || 0) - (a.used_percent || 0)).slice(0, 1) : windows;
2758
+ return selected.some((window) => {
2759
+ if (!window) return false;
2760
+ const at = typeof window.reset_at === "number" ? window.reset_at * (window.reset_at > 1e12 ? 1 : 1000)
2761
+ : typeof window.reset_after_seconds === "number" && typeof quota.at === "number" ? quota.at + window.reset_after_seconds * 1000 : NaN;
2762
+ return Number.isFinite(at) && at > Date.now();
2763
+ });
2764
+ });
2765
+ }
2766
+ async function modLoadResets() {
2767
+ const providers = (modState.options && modState.options.providers) || [];
2768
+ const chatgpt = await Promise.all(providers.filter((provider) => provider.type === "chatgpt").map(async (provider) => {
2769
+ try {
2770
+ const body = await api(`/api/chatgpt-accounts?provider=${encodeURIComponent(provider.name)}`);
2771
+ return body.provider === provider.name && modQuotaKnown(body.accounts) ? [provider.name] : [];
2772
+ } catch (error) { console.warn("Mode quota unavailable", provider.name, error.message); return []; }
2773
+ }));
2774
+ // The mod reads Claude's reset from the band's binding limit, under the name 'anthropic'.
2775
+ const claude = providers.some((provider) => provider.type === "anthropic") && modQuotaKnown(status && status.claude && status.claude.accounts, true);
2776
+ modResetProviders = [...chatgpt.flat(), ...(claude ? ["anthropic"] : [])];
2777
+ }
2778
+ /** The name a reset provider goes by: Claude, ChatGPT, or another ChatGPT provider's own name. */
2779
+ function modResetLabel(provider) {
2780
+ return provider === "anthropic" ? "Claude" : provider === "chatgpt" ? "ChatGPT" : provider;
2781
+ }
2782
+ /** Choosing reset starts on a known provider the mode's workers use, else the first known one. */
2783
+ function modResetDefault(rungs) {
2784
+ const used = new Set(Object.values(rungs).flatMap((steps) => steps.map((step) => { const choice = modChoiceOf(step.target); return choice && choice.provider; })));
2785
+ return modResetProviders.find((provider) => used.has(provider)) || modResetProviders[0];
2786
+ }
2787
+ function modUpdateMode(id, change) {
2788
+ modPatch({ modes: modState.settings.modes.map((mode) => mode.id === id ? { ...mode, ...change } : mode) });
2789
+ }
2790
+ function modSetActive(id) {
2791
+ // The start time is when a mode that runs until the quota resets counts its reset from.
2792
+ modPatch(id ? { activeMode: id, activeSince: Date.now() } : { activeMode: undefined, activeSince: undefined });
2793
+ }
2794
+ function modRemoveMode(mode) {
2795
+ if (!confirm(t("mod.modeRemoveConfirm", { name: mode.name }))) return;
2796
+ const s = modState.settings;
2797
+ modPatch({ modes: s.modes.filter((entry) => entry.id !== mode.id), ...(s.activeMode === mode.id ? { activeMode: undefined, activeSince: undefined } : {}) });
2798
+ }
2799
+ function modModeTabs() {
2800
+ const s = modState.settings;
2801
+ const tabs = [{ id: "", name: t("mod.modeNone") }, ...s.modes];
2802
+ if (!tabs.some((tab) => tab.id === modModeTab) && modModeTab !== "+") modModeTab = "";
2803
+ const buttons = tabs.map((tab) => {
2804
+ const on = (s.activeMode || "") === tab.id;
2805
+ return el("button", { type: "button", role: "tab", class: modModeTab === tab.id ? "active" : "", "aria-selected": String(modModeTab === tab.id), "aria-controls": "worker-tab-panel", "data-key": `mode-tab:${tab.id || "base"}`, onclick: () => { modModeTab = tab.id; modRefresh(); } }, [
2806
+ el("span", { text: tab.name }), on ? el("span", { class: "mode-on", text: t("mod.modeOn") }) : null,
2807
+ ]);
2808
+ });
2809
+ buttons.push(el("button", { type: "button", role: "tab", "aria-selected": String(modModeTab === "+"), "data-key": "mode-add", text: t("mod.modeAdd"), ...(s.modes.length >= 20 ? { disabled: "" } : {}), onclick: () => { modModeTab = "+"; modRefresh(); } }));
2810
+ return el("div", { class: "mode-tabs", role: "tablist", "aria-label": t("mod.modes") }, buttons);
2811
+ }
2812
+ function modNewMode() {
2813
+ const input = el("input", { type: "text", class: "text-field", maxlength: "40", "data-key": "mode-new-name", "aria-label": t("mod.modeName"), placeholder: t("mod.modeName") });
2814
+ const add = () => {
2815
+ const name = input.value.trim();
2816
+ if (!name) { toast(t("mod.modeNameRequired"), true); input.focus(); return; }
2817
+ const mode = { id: `mode-${crypto.randomUUID()}`, name, rungs: clone(modRungs()), delegation: "normal", until: "none" };
2818
+ modModeTab = mode.id;
2819
+ modPatch({ modes: [...modState.settings.modes, mode] });
2820
+ };
2821
+ input.addEventListener("keydown", (event) => { if (event.key === "Enter") add(); });
2822
+ return modSection(t("mod.modeAddTitle"), [cell(t("mod.modeName"), [input, el("button", { type: "button", class: "btn compact", text: t("common.save"), "data-key": "mode-create", onclick: add })], t("mod.modeNameHelp"), "wide")]);
2823
+ }
2824
+ /** A datetime-local value for an epoch-ms time, in this browser's zone. */
2825
+ function modLocalTime(ms) {
2826
+ const at = new Date(ms);
2827
+ const two = (n) => String(n).padStart(2, "0");
2828
+ return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())}T${two(at.getHours())}:${two(at.getMinutes())}`;
2829
+ }
2830
+ function modModeSection(mode) {
2831
+ const on = modState.settings.activeMode === mode.id;
2832
+ const name = el("input", { type: "text", class: "text-field", value: mode.name, maxlength: "40", "data-key": `mode-name:${mode.id}`, "aria-label": t("mod.modeName") });
2833
+ name.addEventListener("change", () => {
2834
+ if (!name.value.trim()) { name.value = mode.name; toast(t("mod.modeNameRequired"), true); return; }
2835
+ modUpdateMode(mode.id, { name: name.value.trim() });
2836
+ });
2837
+ const known = modResetProviders.length > 0;
2838
+ const kind = typeof mode.until === "number" ? "time" : mode.until && typeof mode.until === "object" ? "reset" : "none";
2839
+ const untilOf = (value) => value === "time" ? Date.now() + 3 * 3_600_000 : value === "reset" ? { reset: modResetDefault(mode.rungs) } : value;
2840
+ const until = modSegmented("mode-until", [
2841
+ { value: "none", label: t("mod.modeUntilNone") }, { value: "time", label: t("mod.modeUntilTime") },
2842
+ { value: "reset", label: t("mod.modeUntilResetChoice"), disabled: !known },
2843
+ ], kind, t("mod.modeUntil"), (value) => modUpdateMode(mode.id, { until: untilOf(value) }));
2844
+ const reset = until.querySelector('[data-key="mode-until:reset"]');
2845
+ if (!known) {
2846
+ reset.setAttribute("aria-describedby", "mode-reset-reason");
2847
+ until.appendChild(el("span", { id: "mode-reset-reason", class: "mode-reset-reason", text: t("mod.modeResetUnknown") }));
2848
+ }
2849
+ const endControls = [until];
2850
+ if (kind === "time") {
2851
+ const at = el("input", { type: "datetime-local", class: "text-field", "data-key": "mode-until-time", value: modLocalTime(mode.until), "aria-label": t("mod.modeUntilTime") });
2852
+ at.addEventListener("change", () => {
2853
+ const ms = new Date(at.value).getTime();
2854
+ if (!Number.isFinite(ms) || ms <= Date.now()) { at.value = modLocalTime(mode.until); toast(t("mod.modeTimePast"), true); return; }
2855
+ modUpdateMode(mode.id, { until: ms });
2856
+ });
2857
+ endControls.push(at);
2858
+ }
2859
+ if (kind === "reset") {
2860
+ // Only providers whose reset is known now; a saved one that is not stays shown as unavailable.
2861
+ const label = (provider) => t("mod.modeUntilResetOf", { provider: modResetLabel(provider) });
2862
+ const items = modResetProviders.map((provider) => ({ value: provider, label: label(provider) }));
2863
+ if (!modResetProviders.includes(mode.until.reset)) items.unshift({ value: mode.until.reset, label: `${label(mode.until.reset)}${t("mod.unavailable")}` });
2864
+ endControls.push(modSelect("mode-until-reset", items, mode.until.reset, t("mod.modeResetProvider"), (reset) => modUpdateMode(mode.id, { until: { reset } })));
2865
+ }
2866
+ const endHelp = kind === "reset" ? t("mod.modeResetHelp", { provider: modResetLabel(mode.until.reset) }) : null;
2867
+ return modSection(null, [
2868
+ cell(t("mod.modeName"), name),
2869
+ ...modRungRows(mode),
2870
+ cell(t("mod.modeDelegation"), modSegmented("mode-delegation", [{ value: "normal", label: t("mod.modeNormal") }, { value: "eager", label: t("mod.modeEager") }], mode.delegation, t("mod.modeDelegation"), (delegation) => modUpdateMode(mode.id, { delegation })), null, "wide"),
2871
+ cell(t("mod.modeUntil"), el("div", { class: "mode-until" }, endControls), endHelp, "wide"),
2872
+ cell(t("mod.modeActive"), [
2873
+ el("button", { type: "button", class: "btn compact", "data-key": "mode-toggle", text: t(on ? "mod.modeTurnOff" : "mod.modeTurnOn"), onclick: () => modSetActive(on ? "" : mode.id) }),
2874
+ el("button", { type: "button", class: "btn secondary compact", "data-key": `mode-remove:${mode.id}`, text: t("common.remove"), onclick: () => modRemoveMode(mode) }),
2875
+ ], on ? t("mod.modeOn") : null, "wide"),
2876
+ ]);
2877
+ }
2878
+
2879
+ function modWorkersCard() {
2880
+ const s = modState.settings;
2881
+ return modCard("workers", t("mod.workers"), t("mod.workersBlurb"), [
2882
+ modState.options ? null : modSection(null, [el("div", { class: "cell-note warn-text", text: t("mod.optionsFailed") })]),
2883
+ modState.retiredNote ? modRetiredNote() : null,
2884
+ modSection(null, [
2885
+ cell(t("mod.takesOver"), modSegmented("agent-auto", [{ value: "off", label: t("mod.off") }, { value: "auto", label: "ripple:auto" }, { value: "all", label: t("mod.allSpawns") }], s.agentAuto, t("mod.takesOver"), (agentAuto) => modPatch({ agentAuto })), null, "wide"),
2886
+ ], [t("mod.takesOverHelp"), t("mod.takesOverHelp2")]),
2887
+ modModeTabs(),
2888
+ el("div", { id: "worker-tab-panel", role: "tabpanel" }, modModeTab === "+" ? [modNewMode()] : modModeTab ? [modModeSection(s.modes.find((mode) => mode.id === modModeTab))] : [
2889
+ modSection(t("mod.models"), modTickRows(), [t("mod.modelsHelp")]),
2890
+ modSection(t("mod.fourWorkers"), modRungRows(), [t("mod.modeOrderHelp")]),
2891
+ s.activeMode ? modSection(null, [cell(t("mod.modeActive"), el("button", { type: "button", class: "btn secondary compact", "data-key": "mode-base-on", text: t("mod.modeUseBase"), onclick: () => modSetActive("") }))]) : null,
2892
+ ].filter(Boolean)),
2893
+ ]);
2894
+ }
2895
+
2896
+ function modJudgeCard() {
2897
+ const s = modState.settings;
2898
+ const zen = modState.options && modState.options.zen;
2899
+ const rows = [cell(t("mod.judge"), modSegmented("judge", [{ value: "haiku", label: t("mod.haiku") }, { value: "jev", label: t("mod.jev") }, { value: "model", label: t("mod.routerModel") }], s.judge, t("mod.judge"), (judge) => modPatch({ judge })), null, "wide")];
2900
+ const footers = [];
2901
+ if (s.judge === "haiku") rows.push(el("div", { class: "cell-note", text: t("mod.haikuHint") }));
2902
+ if (s.judge === "jev") {
2903
+ rows.push(cell(t("mod.jevSource"), modSegmented("jev-source", [{ value: "typesafe", label: t("mod.typesafe") }, { value: "zen", label: t("mod.zen") }], s.jevSource, t("mod.jevSource"), (jevSource) => modPatch({ jevSource })), null, "wide"));
2904
+ if (s.jevSource === "zen") {
2905
+ rows.push(zen && zen.configured
2906
+ ? cell(t("mod.model"), el("span", { class: "mono", text: zen.model }), modCap(t("mod.zenKey")))
2907
+ : cell(el("span", { class: "cell-label", text: modCap(t("mod.zenMissing")) }), el("button", { class: "btn secondary compact", type: "button", text: t("nav.providers"), onclick: () => showView("providers") }), t("mod.zenAdd")));
2908
+ } else {
2909
+ rows.push(el("div", { class: "cell-note", text: t("mod.jevKeyNote") }));
2910
+ }
2911
+ }
2912
+ if (s.judge === "model") {
2913
+ const models = modModels().map((model) => ({ value: model.model, label: `${model.name || model.model} · ${model.provider}` }));
2914
+ rows.push(cell(t("mod.model"), models.length || s.judgeModel
2915
+ ? modSelect("judge-model", models, s.judgeModel, t("mod.model"), (judgeModel) => modPatch({ judgeModel }))
2916
+ : el("span", { text: modCap(t("mod.noModels")) }), modCap(t("mod.haikuFallback"))));
2917
+ }
2918
+ return modCard("judge", t("mod.judge"), t("mod.judgeBlurb"), [modSection(null, rows, footers)]);
2919
+ }
2920
+
2921
+ function modCacheCard() {
2922
+ const s = modState.settings;
2923
+ return modCard("cache", t("mod.cache"), t("mod.cacheBlurb"), [modSection(null, [
2924
+ cell(t("mod.keepWarm"), modSwitch("keep-warm", s.keepWarm, t("mod.keepWarm"), (keepWarm) => modPatch({ keepWarm })), modCap(t("mod.keepWarmHint"))),
2925
+ cell(t("mod.for"), modSegmented("warm-hours", [1, 2, 4, 8].map((n) => ({ value: n, label: t("mod.hours", { n }) })), s.keepWarmHours, t("mod.for"), (n) => modPatch({ keepWarmHours: Number(n) })), modCap(t("mod.afterReply")), "wide"),
2926
+ cell(t("mod.chatsFrom"), modSegmented("warm-min", [10000, 30000, 60000, 100000].map((n) => ({ value: n, label: `${n / 1000}k` })), s.keepWarmMinTokens, t("mod.chatsFrom"), (n) => modPatch({ keepWarmMinTokens: Number(n) })), modCap(t("mod.tokens")), "wide"),
2927
+ ], [t("mod.warmCost")])]);
2928
+ }
2929
+
2930
+ function modHandoffCard() {
2931
+ const s = modState.settings;
2932
+ const skill = el("input", { type: "text", class: "text-field", value: s.handoffSkill, placeholder: t("mod.noSkill"), spellcheck: "false", autocomplete: "off", "data-key": "handoff-skill", "aria-label": t("mod.fullHandoff") });
2933
+ skill.addEventListener("change", () => modPatch({ handoffSkill: skill.value.trim() }));
2934
+ return modCard("handoff", t("mod.handoff"), t("mod.handoffBlurb"), [modSection(null, [
2935
+ cell(t("mod.compactWith"), modSegmented("compact-with", [{ value: "haiku", label: t("mod.haiku") }, { value: "session", label: t("mod.sessionCompactor") }], s.compactWith, t("mod.compactWith"), (compactWith) => modPatch({ compactWith })), null, "wide"),
2936
+ cell(t("mod.afterHandoff"), modSegmented("handoff-after", [{ value: "continue", label: t("mod.continue") }, { value: "confirm", label: t("mod.confirm") }, { value: "copy", label: t("mod.copy") }], s.handoffAfter, t("mod.afterHandoff"), (handoffAfter) => modPatch({ handoffAfter })), null, "wide"),
2937
+ cell(t("mod.handoffButton"), modSegmented("handoff-button", [{ value: "advised", label: t("mod.whenAdvised") }, { value: "always", label: t("mod.always") }], s.handoffButton, t("mod.handoffButton"), (handoffButton) => modPatch({ handoffButton })), null, "wide"),
2938
+ cell(t("mod.fullHandoff"), skill, null, "wide"),
2939
+ cell(t("mod.heavyAt"), modSegmented("swamp-at", [40, 50, 60, 70].map((n) => ({ value: n, label: `${n}%` })), s.swampAt, t("mod.heavyAt"), (n) => modPatch({ swampAt: Number(n) })), modCap(t("mod.context")), "wide"),
2940
+ ])]);
2941
+ }
2942
+
2943
+ function modLookCard() {
2944
+ const s = modState.settings;
2945
+ const languages = [{ value: "auto", label: t("mod.auto") }, ...MOD_LANGUAGES.map(([value, label]) => ({ value, label }))];
2946
+ return modCard("look", t("mod.look"), t("mod.lookBlurb"), [
2947
+ modSection(null, [
2948
+ 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"),
2949
+ 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"),
2950
+ cell(t("mod.language"), modSelect("language", languages, s.language, t("mod.language"), (language) => modPatch({ language }), "narrow")),
2951
+ ]),
2952
+ modSection(t("mod.show"), [
2953
+ ...MOD_PARTS.map((part) => {
2954
+ const shown = !s.hide.includes(part);
2955
+ return cell(t(`mod.part.${part}`), modSwitch(`part:${part}`, shown, t(`mod.part.${part}`), (on) => modPatch({ hide: on ? s.hide.filter((entry) => entry !== part) : [...s.hide, part] })));
2956
+ }),
2957
+ ...MOD_EXTRAS.map((part) => {
2958
+ const extras = s.extras || [];
2959
+ const shown = extras.includes(part);
2960
+ return cell(t(`mod.part.${part}`), modSwitch(`part:${part}`, shown, t(`mod.part.${part}`), (on) => modPatch({ extras: on ? [...extras, part] : extras.filter((entry) => entry !== part) })));
2961
+ }),
2962
+ ]),
2963
+ ]);
2964
+ }
2965
+
2244
2966
  // ---- Logs ---------------------------------------------------------------------------
2245
2967
 
2246
2968
  let logsPanel = "requests";
@@ -2340,14 +3062,15 @@ function requestRow(record) {
2340
3062
  const status = record.resent
2341
3063
  ? el("span", { class: "status-text resent", text: `${t("logs.status.resent")} ${record.status}`, title: t("logs.status.resentHelp") })
2342
3064
  : el("span", { class: `status-text ${record.ok ? "ok" : "bad"}`, text: `${record.ok ? t("logs.status.ok") : t("logs.status.error")} ${record.status}` });
3065
+ // Column classes let a narrow window lay the same row out as a two-line list item.
2343
3066
  row.append(
2344
- el("td", { text: timeOf(record.at) }),
2345
- el("td", {}, [model]),
2346
- el("td", { text: record.effort || t("common.notAvailable") }),
2347
- el("td", {}, [input]),
2348
- el("td", { class: record.usage ? "" : "no-usage", text: record.usage ? formatNumber(record.usage.output) : t("common.notAvailable") }),
2349
- el("td", { text: formatSeconds(record.ms) }),
2350
- el("td", {}, [status]),
3067
+ el("td", { class: "c-time", text: timeOf(record.at) }),
3068
+ el("td", { class: "c-model" }, [model]),
3069
+ el("td", { class: "c-effort", text: record.effort || t("common.notAvailable") }),
3070
+ el("td", { class: "c-in" }, [input]),
3071
+ el("td", { class: record.usage ? "c-out" : "c-out no-usage", text: record.usage ? formatNumber(record.usage.output) : t("common.notAvailable") }),
3072
+ el("td", { class: "c-dur", text: formatSeconds(record.ms) }),
3073
+ el("td", { class: "c-status" }, [status]),
2351
3074
  );
2352
3075
  return row;
2353
3076
  }