micro-models-agent 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@ import { S, setPath, getPath, THEME_KEY, hooks } from "./store.js";
11
11
  import { contextLabel } from "./format.js";
12
12
  import { closeAllMenus, setSessOpen } from "./tabs.js";
13
13
  import { setIndicator, closeConfirm, confirmOverlay, trapTab } from "./dialog.js";
14
+ import { controlFor, PANEL_SECTIONS, keyToPanel } from "./settings-controls.js";
14
15
 
15
16
  function setSelButton(btn, label, value) {
16
17
  btn.textContent = label + ":";
@@ -210,26 +211,9 @@ export function setConnStatus(disconnected) {
210
211
  connStatus.textContent = disconnected ? "⋯ " + t("web.conn.reconnecting") : "";
211
212
  }
212
213
 
213
- const SECTION_KEYS = {
214
- general: ["locale", "ui.verbosity", "maxToolIterations", "stuckThreshold"],
215
- context: ["contextWindow", "contextBudget.systemPrompt", "contextBudget.responseReserve"],
216
- reasoning: ["showReasoning", "reasoning.mode"],
217
- security: [
218
- "security.enabled",
219
- "security.paths.enabled",
220
- "security.contentScan.enabled",
221
- "security.sessionEncryption.enabled",
222
- ],
223
- advanced: ["logLevel", "version"],
224
- agent: [
225
- "moe.enabled",
226
- "moe.maxReplanCycles",
227
- "critic.enabled",
228
- "critic.maxCalls",
229
- "critic.maxRepairCycles",
230
- "critic.timeoutMs",
231
- ],
232
- };
214
+ // Panel → named sections lives in `settings-controls.js` (PANEL_SECTIONS).
215
+ // Custom panels (model/ui/browser…) render a bespoke body first and then their
216
+ // sections; purely generic panels (general/context/…) render sections only.
233
217
 
234
218
  // Localized label for a registry key: `config.key.<canonical>`, falling back
235
219
  // to the raw key when no translation exists (so a new key is never blank).
@@ -273,66 +257,65 @@ function descFor(key) {
273
257
  return d === k ? "" : d;
274
258
  }
275
259
 
260
+ // Generic per-type control (`settings-controls.js`): bool toggle, enum
261
+ // select, int/share/list/json inputs. `post` funnels through `postConfig`.
276
262
  function configControl(def, key) {
277
- const value = S.state.config ? getPath(S.state.config, key) : undefined;
278
- if (def.valueType === "bool") {
279
- return makeToggle(!!value, (next) => {
280
- postConfig(key, next, false);
281
- return true;
282
- });
283
- }
284
- if (def.valueType === "enum") {
285
- const seg = el("span", "seg");
286
- (def.enum || []).forEach((v) => {
287
- const b = el("button", v === value ? "on" : "", enumLabel(v));
288
- b.type = "button";
289
- b.setAttribute("aria-pressed", v === value ? "true" : "false");
290
- b.addEventListener("click", () => {
291
- seg.querySelectorAll("button").forEach((x) => {
292
- x.classList.remove("on");
293
- x.setAttribute("aria-pressed", "false");
294
- });
295
- b.classList.add("on");
296
- b.setAttribute("aria-pressed", "true");
297
- postConfig(key, v, false);
298
- });
299
- seg.appendChild(b);
300
- });
301
- return seg;
302
- }
303
- if (def.readOnly) {
304
- const ro = el("span", "val dim", value == null ? "—" : String(value));
305
- if (value != null) ro.title = String(value);
306
- return ro;
307
- }
308
- const input = el("input");
309
- input.setAttribute("aria-label", configLabel(def));
310
- if (def.valueType === "int") {
311
- input.type = "number";
312
- if (def.min != null) input.min = String(def.min);
313
- if (def.max != null) input.max = String(def.max);
314
- if (def.step != null) input.step = String(def.step);
315
- input.value = value == null ? "" : String(value);
316
- } else if (def.valueType === "share") {
317
- input.value = value == null ? "" : Math.round(Number(value) * 100) + "%";
318
- } else {
319
- input.value = value == null ? "" : String(value);
320
- }
321
- input.addEventListener("change", () => {
322
- postConfig(key, input.value, false);
263
+ // Read-only keys carry their resolved value in the registry entry: the getter
264
+ // is authoritative and may differ from the raw config field (`version`).
265
+ const value =
266
+ def.readOnly && def.value !== undefined
267
+ ? def.value
268
+ : S.state.config
269
+ ? getPath(S.state.config, key)
270
+ : undefined;
271
+ return controlFor(def, value, {
272
+ label: configLabel(def),
273
+ enumLabel,
274
+ emptyLabel: t("config.enum.none"),
275
+ fieldLabel: (k) => {
276
+ const kk = "config.field." + k;
277
+ const tr = t(kk);
278
+ return tr === kk ? k : tr;
279
+ },
280
+ post: (v) => postConfig(key, v, false),
323
281
  });
324
- return input;
325
282
  }
326
283
 
327
- function renderGenericPanel(name, keys) {
328
- const container = document.querySelector('[data-panel="' + name + '"]');
329
- if (!container) return;
330
- container.textContent = "";
331
- keys.forEach((key) => {
284
+ // One titled section card: uppercase title + optional one-line explanation,
285
+ // then its setting rows. Titles come from PANEL_SECTIONS so a section name can
286
+ // never drift from the keys under it.
287
+ function sectionCard(section) {
288
+ const card = el("div", "settings-section");
289
+ const head = el("div", "sec-head");
290
+ head.appendChild(el("span", "sec-title", t(section.titleKey)));
291
+ card.appendChild(head);
292
+ if (section.descKey) {
293
+ const d = t(section.descKey);
294
+ if (d !== section.descKey) card.appendChild(el("div", "sec-desc", d));
295
+ }
296
+ const body = el("div", "sec-body");
297
+ section.keys.forEach((key) => {
332
298
  const def = defFor(key);
333
299
  if (!def) return;
334
- container.appendChild(row(configLabel(def), configControl(def, key), descFor(def.key)));
300
+ body.appendChild(row(configLabel(def), configControl(def, key), descFor(def.key)));
335
301
  });
302
+ card.appendChild(body);
303
+ return card;
304
+ }
305
+
306
+ // Render the PANEL_SECTIONS table for a panel. Panels with custom bodies call
307
+ // this after their own content so bespoke widgets stay on top.
308
+ function renderSections(panel, sections) {
309
+ const container = document.querySelector('[data-panel="' + panel + '"]');
310
+ if (!container) return;
311
+ sections.forEach((section) => container.appendChild(sectionCard(section)));
312
+ }
313
+
314
+ function renderPanel(panel) {
315
+ const container = document.querySelector('[data-panel="' + panel + '"]');
316
+ if (!container) return;
317
+ container.textContent = "";
318
+ renderSections(panel, PANEL_SECTIONS[panel] || []);
336
319
  }
337
320
 
338
321
  function providerEntries() {
@@ -389,6 +372,37 @@ function providerInput(value, type, placeholder) {
389
372
  return input;
390
373
  }
391
374
 
375
+ function providerSelect(options, value) {
376
+ const sel = el("select");
377
+ const none = el("option", "", "—");
378
+ none.value = "";
379
+ sel.appendChild(none);
380
+ options.forEach((v) => {
381
+ const o = el("option", "", v);
382
+ o.value = v;
383
+ if (v === value) o.selected = true;
384
+ sel.appendChild(o);
385
+ });
386
+ if (value == null) sel.value = "";
387
+ return sel;
388
+ }
389
+
390
+ function providerCheck(value) {
391
+ const input = el("input");
392
+ input.type = "checkbox";
393
+ input.checked = value === true;
394
+ return input;
395
+ }
396
+
397
+ const CACHE_FLAG_KEYS = [
398
+ "sessionHeader",
399
+ "requestCachePrompt",
400
+ "requestPromptCacheKey",
401
+ "requestSessionId",
402
+ "requestCacheControl",
403
+ "requestStreamUsage",
404
+ ];
405
+
392
406
  // A titled group inside a settings panel (models / providers).
393
407
  function settingsSection(titleKey) {
394
408
  const sec = el("div", "settings-section");
@@ -481,17 +495,49 @@ function providerAdvancedFields(editing) {
481
495
  const adv = el("details", "pform-adv");
482
496
  adv.appendChild(el("summary", "", t("web.settings.provider_advanced")));
483
497
  const advGrid = el("div", "pform-grid");
498
+ const cache = editing.cache || {};
499
+ const retry = editing.retry || {};
484
500
  const f = {
485
501
  ctx: providerInput(editing.contextWindow, "number", "32768"),
486
502
  prio: providerInput(editing.priority, "number", "0"),
487
503
  rpm: providerInput(editing.rpm, "number", "60"),
488
504
  par: providerInput(editing.parallel, "number", "1"),
505
+ maxTokens: providerInput(editing.maxCompletionTokens, "number", "8192"),
506
+ cacheMech: providerSelect(["auto", "explicit", "local", "none"], cache.mechanism),
507
+ cacheReport: providerSelect(["openai", "deepseek", "anthropic", "ollama", "none"], cache.report),
508
+ retryMax: providerInput(retry.maxRetries, "number", "3"),
509
+ retryBase: providerInput(retry.baseDelay, "number", "1000"),
510
+ retryMaxD: providerInput(retry.maxDelay, "number", "30000"),
489
511
  };
512
+ f.cacheFlags = CACHE_FLAG_KEYS.map((key) => {
513
+ const box = providerCheck(cache[key]);
514
+ const wrap = el("label", "pform-check");
515
+ wrap.appendChild(box);
516
+ wrap.appendChild(el("span", "", key));
517
+ return { key, box, node: wrap };
518
+ });
490
519
  advGrid.appendChild(pfield("web.settings.provider_context", f.ctx));
491
520
  advGrid.appendChild(pfield("web.settings.provider_priority", f.prio));
492
521
  advGrid.appendChild(pfield("web.settings.provider_rpm", f.rpm));
493
522
  advGrid.appendChild(pfield("web.settings.provider_parallel", f.par));
523
+ advGrid.appendChild(pfield("web.settings.provider_max_tokens", f.maxTokens));
524
+ advGrid.appendChild(pfield("web.settings.provider_cache_mechanism", f.cacheMech));
525
+ advGrid.appendChild(pfield("web.settings.provider_cache_report", f.cacheReport));
494
526
  adv.appendChild(advGrid);
527
+ const retryGroup = el("div", "pform-checks");
528
+ retryGroup.appendChild(el("span", "pform-checks-title", t("web.settings.provider_retry")));
529
+ [["maxRetries", f.retryMax], ["baseDelay", f.retryBase], ["maxDelay", f.retryMaxD]].forEach(([key, input]) => {
530
+ const wrap = el("label", "pform-check");
531
+ wrap.appendChild(el("span", "", key));
532
+ input.setAttribute("aria-label", key);
533
+ wrap.appendChild(input);
534
+ retryGroup.appendChild(wrap);
535
+ });
536
+ adv.appendChild(retryGroup);
537
+ const flagGroup = el("div", "pform-checks");
538
+ flagGroup.appendChild(el("span", "pform-checks-title", t("web.settings.provider_cache_flags")));
539
+ f.cacheFlags.forEach(({ node }) => flagGroup.appendChild(node));
540
+ adv.appendChild(flagGroup);
495
541
  return { node: adv, ...f };
496
542
  }
497
543
 
@@ -519,23 +565,41 @@ async function submitProviderForm(f, mode, editing) {
519
565
  ["priority", f.prio, 0],
520
566
  ["rpm", f.rpm, 1],
521
567
  ["parallel", f.par, 1],
568
+ ["maxCompletionTokens", f.maxTokens, 512, 128000],
569
+ ["maxRetries", f.retryMax, 0, 10],
570
+ ["baseDelay", f.retryBase, 0, 60000],
571
+ ["maxDelay", f.retryMaxD, 0, 300000],
522
572
  ];
573
+ const retry = {};
523
574
  for (const entry of nums) {
524
575
  const raw = entry[1].value.trim();
525
576
  if (!raw) continue;
526
577
  const n = Number(raw);
527
- if (!Number.isInteger(n) || n < entry[2]) {
578
+ const max = entry[3] === undefined ? Number.MAX_SAFE_INTEGER : entry[3];
579
+ if (!Number.isInteger(n) || n < entry[2] || n > max) {
528
580
  setIndicator(t("web.settings.provider_invalid"), true);
529
581
  return;
530
582
  }
531
- body[entry[0]] = n;
583
+ if (entry[0] === "maxRetries" || entry[0] === "baseDelay" || entry[0] === "maxDelay") {
584
+ retry[entry[0]] = n;
585
+ } else {
586
+ body[entry[0]] = n;
587
+ }
532
588
  }
589
+ if (Object.keys(retry).length) body.retry = retry;
590
+ const cache = {};
591
+ if (f.cacheMech.value) cache.mechanism = f.cacheMech.value;
592
+ if (f.cacheReport.value) cache.report = f.cacheReport.value;
593
+ const prevCache = (editing && editing.cache) || {};
594
+ f.cacheFlags.forEach(({ key, box }) => {
595
+ if (box.checked !== (prevCache[key] === true)) cache[key] = box.checked;
596
+ });
597
+ if (Object.keys(cache).length) body.cache = cache;
533
598
  const r = await api("/api/providers", { method: "POST", body });
534
599
  if (r.ok && r.data && r.data.ok) {
535
600
  const persisted = r.data.persisted !== false;
536
- S.state.providerForm = null;
537
601
  setIndicator(persisted ? t("web.settings.saved") : r.data.warning || "not persisted", !persisted);
538
- hideProviderOverlay();
602
+ closeSubDialog();
539
603
  await loadProviders();
540
604
  await loadConfig();
541
605
  } else {
@@ -676,6 +740,7 @@ function renderModelPanel() {
676
740
  if (!entries.length) provList.appendChild(el("div", "placeholder", t("web.settings.providers_empty")));
677
741
  provSec.body.appendChild(provList);
678
742
  container.appendChild(provSec.el);
743
+ renderSections("model", PANEL_SECTIONS.model || []);
679
744
  }
680
745
 
681
746
  function renderUiPanel() {
@@ -698,25 +763,40 @@ function renderUiPanel() {
698
763
  });
699
764
  seg.appendChild(b);
700
765
  });
701
- container.appendChild(row(t("web.settings.theme"), seg));
766
+ // Theme is a browser-local preference (localStorage), not a config key, so it
767
+ // gets its own card before the generated sections.
768
+ const card = el("div", "settings-section");
769
+ const head = el("div", "sec-head");
770
+ head.appendChild(el("span", "sec-title", t("web.settings.sec.ui.theme")));
771
+ card.appendChild(head);
772
+ const desc = t("web.settings.sec.ui.theme.desc");
773
+ if (desc !== "web.settings.sec.ui.theme.desc") card.appendChild(el("div", "sec-desc", desc));
774
+ const body = el("div", "sec-body");
775
+ body.appendChild(row(t("web.settings.theme"), seg));
776
+ card.appendChild(body);
777
+ container.appendChild(card);
778
+ renderSections("ui", PANEL_SECTIONS.ui || []);
702
779
  }
703
780
 
704
781
  function renderSecurityPanel() {
705
782
  const container = document.querySelector('[data-panel="security"]');
706
783
  if (!container) return;
707
784
  container.textContent = "";
708
- SECTION_KEYS.security.forEach((key) => {
709
- const def = defFor(key);
710
- if (!def) return;
711
- container.appendChild(row(configLabel(def), configControl(def, key), descFor(def.key)));
712
- });
713
- container.appendChild(
785
+ renderSections("security", PANEL_SECTIONS.security || []);
786
+ const access = el("div", "settings-section");
787
+ const head = el("div", "sec-head");
788
+ head.appendChild(el("span", "sec-title", t("web.settings.sec.security.access")));
789
+ access.appendChild(head);
790
+ const body = el("div", "sec-body");
791
+ body.appendChild(
714
792
  row(t("web.settings.web_token"), el("span", "val dim", S.state.webToken || t("web.settings.web_token_none")), t("config.desc.webToken")),
715
793
  );
716
794
  const note = el("div", "placeholder");
717
795
  note.appendChild(el("span", "tag", t("web.settings.terminal_tag")));
718
796
  note.appendChild(document.createTextNode(" " + t("web.settings.security_note")));
719
- container.appendChild(note);
797
+ body.appendChild(note);
798
+ access.appendChild(body);
799
+ container.appendChild(access);
720
800
  }
721
801
 
722
802
  function placeholderFor(data, empty) {
@@ -735,40 +815,66 @@ function groupHeader(label, count) {
735
815
 
736
816
  const MEMORY_CATEGORIES = ["conventions", "decisions", "errors", "facts"];
737
817
 
738
- function renderMemoryPanel() {
739
- const container = document.querySelector('[data-panel="memory"]');
740
- if (!container) return;
741
- container.textContent = "";
742
- if (S.state.memory === undefined || S.state.memory === null) {
743
- container.appendChild(placeholderFor(S.state.memory, t("web.settings.memory_unavailable")));
744
- return;
745
- }
746
- const data = S.state.memory;
747
-
748
- const addRow = el("div", "mem-add");
818
+ /** Nested dialog: pick a memory category and write the note. */
819
+ function openMemoryForm() {
820
+ const box = el("div", "pform");
821
+ const grid = el("div", "pform-grid");
749
822
  const catSel = el("select");
750
823
  catSel.setAttribute("aria-label", t("web.settings.memory_category"));
751
824
  MEMORY_CATEGORIES.forEach((c) => catSel.appendChild(el("option", "", c)));
752
825
  catSel.value = "facts";
753
- addRow.appendChild(catSel);
754
- const textIn = el("input");
755
- textIn.placeholder = t("web.settings.new_memory");
756
- textIn.setAttribute("aria-label", t("web.settings.new_memory"));
757
- addRow.appendChild(textIn);
758
- addRow.appendChild(
759
- link(t("web.settings.add"), "", async () => {
760
- const text = textIn.value.trim();
761
- if (!text) return;
826
+ grid.appendChild(mcpLiteralField(t("web.settings.memory_category"), catSel));
827
+ box.appendChild(grid);
828
+ const area = el("textarea");
829
+ area.rows = 5;
830
+ area.placeholder = t("web.settings.new_memory");
831
+ area.value = "";
832
+ box.appendChild(mcpLiteralField(t("web.settings.new_memory"), area));
833
+ const actions = el("div", "pform-actions");
834
+ actions.appendChild(
835
+ link(t("web.settings.save"), "", async () => {
836
+ const text = area.value.trim();
837
+ if (!text) {
838
+ area.focus();
839
+ return;
840
+ }
762
841
  const r = await api("/api/memory", { method: "POST", body: { category: catSel.value, text } });
763
842
  if (r.ok && r.data && r.data.ok) {
764
- textIn.value = "";
843
+ closeSubDialog();
765
844
  await loadMemory();
766
845
  } else {
767
846
  setIndicator(t("web.settings.add_failed"), true);
768
847
  }
769
848
  }),
770
849
  );
771
- container.appendChild(addRow);
850
+ actions.appendChild(link(t("web.settings.cancel"), "dim", () => closeSubDialog()));
851
+ box.appendChild(actions);
852
+ openSubDialog(t("web.settings.memory_add_title"), box, () => renderMemoryPanel());
853
+ }
854
+
855
+ function renderMemoryPanel() {
856
+ const container = document.querySelector('[data-panel="memory"]');
857
+ if (!container) return;
858
+ container.textContent = "";
859
+ if (S.state.memory === undefined || S.state.memory === null) {
860
+ container.appendChild(placeholderFor(S.state.memory, t("web.settings.memory_unavailable")));
861
+ return;
862
+ }
863
+ const data = S.state.memory;
864
+
865
+ // Adding an entry opens the shared nested dialog: a category + a multi-line
866
+ // note with room to write, instead of a cramped inline field.
867
+ const addBtn = el("button", "sec-add");
868
+ addBtn.type = "button";
869
+ addBtn.appendChild(icon("plus"));
870
+ addBtn.appendChild(el("span", "", t("web.settings.add")));
871
+ addBtn.addEventListener("click", () => openMemoryForm());
872
+ const addHead = el("div", "sec-head");
873
+ addHead.appendChild(el("span", "sec-title", t("web.settings.sec.memory.add")));
874
+ addHead.appendChild(addBtn);
875
+ const addSec = el("div", "settings-section");
876
+ addSec.appendChild(addHead);
877
+ container.appendChild(addSec);
772
878
 
773
879
  const search = el("input", "searchbox");
774
880
  search.placeholder = t("web.settings.search_memory");
@@ -907,6 +1013,12 @@ function renderPluginsPanel() {
907
1013
  list.appendChild(pnote);
908
1014
 
909
1015
  list.appendChild(groupHeader(t("web.settings.mcp_servers"), (data.mcp || []).length));
1016
+ const mcpAdd = el("button", "sec-add");
1017
+ mcpAdd.type = "button";
1018
+ mcpAdd.appendChild(icon("plus"));
1019
+ mcpAdd.appendChild(el("span", "", t("web.settings.mcp_add")));
1020
+ mcpAdd.addEventListener("click", () => openMcpForm("add", null));
1021
+ list.appendChild(mcpAdd);
910
1022
  if (!(data.mcp || []).length) {
911
1023
  const empty = el("div", "placeholder");
912
1024
  empty.textContent = t("web.settings.no_mcp");
@@ -935,16 +1047,267 @@ function renderPluginsPanel() {
935
1047
  nameEl.title = m.name;
936
1048
  line.appendChild(nameEl);
937
1049
  line.appendChild(el("span", "st", m.status || "—"));
1050
+ line.appendChild(chip(t("web.settings.edit"), "", () => openMcpForm("edit", m)));
1051
+ line.appendChild(chip(t("web.settings.delete"), "danger", async () => {
1052
+ const r = await api("/api/mcp/" + encodeURIComponent(m.id || m.name), { method: "DELETE" });
1053
+ if (r.ok && r.data && r.data.ok) {
1054
+ setIndicator(t("web.settings.mcp_saved"), false);
1055
+ await loadPlugins();
1056
+ } else {
1057
+ setIndicator((r.data && r.data.error) || t("web.settings.delete_failed"), true);
1058
+ }
1059
+ }));
938
1060
  list.appendChild(line);
939
1061
  });
1062
+ if (S.state.mcpForm) list.appendChild(mcpForm());
940
1063
  if ((data.mcp || []).length) {
941
1064
  const mnote = el("div", "settings-note");
942
1065
  mnote.textContent = t("web.settings.mcp_note");
943
1066
  list.appendChild(mnote);
944
1067
  }
1068
+ renderLspSection(list);
945
1069
  container.appendChild(list);
946
1070
  }
947
1071
 
1072
+ // Inline MCP add/edit form. Secrets are never echoed by the API, so env/headers
1073
+ // start blank — leaving them blank on edit keeps the stored values (patch
1074
+ // semantics: absent keys are not touched).
1075
+ function mcpLiteralField(label, input) {
1076
+ const f = el("div", "pform-field");
1077
+ const lab = el("label", "", label);
1078
+ f.appendChild(lab);
1079
+ input.setAttribute("aria-label", label);
1080
+ f.appendChild(input);
1081
+ return f;
1082
+ }
1083
+
1084
+ function mcpForm() {
1085
+ const mode = (S.state.mcpForm && S.state.mcpForm.mode) || "add";
1086
+ const editing = (S.state.mcpForm && S.state.mcpForm.entry) || {};
1087
+ const box = el("div", "pform");
1088
+ const grid = el("div", "pform-grid");
1089
+ const fName = providerInput(editing.name || "", "text", "my-server");
1090
+ const fTransport = providerSelect(["stdio", "sse", "http"], editing.transport || "stdio");
1091
+ const fCommand = providerInput(editing.command || "", "text", "npx");
1092
+ const fUrl = providerInput(editing.url || "", "text", "https://…");
1093
+ grid.appendChild(mcpLiteralField("name", fName));
1094
+ grid.appendChild(mcpLiteralField("transport", fTransport));
1095
+ grid.appendChild(mcpLiteralField("command", fCommand));
1096
+ grid.appendChild(mcpLiteralField("url", fUrl));
1097
+ box.appendChild(grid);
1098
+ const adv = el("details", "pform-adv");
1099
+ adv.appendChild(el("summary", "", t("web.settings.provider_advanced")));
1100
+ const fArgs = providerInput(editing.args ? JSON.stringify(editing.args) : "", "text", '["--stdio"]');
1101
+ const fTimeout = providerInput(editing.timeout != null ? String(editing.timeout) : "", "number", "30000");
1102
+ const fEnabled = providerCheck(editing.enabled !== false);
1103
+ adv.appendChild(mcpLiteralField("args (JSON)", fArgs));
1104
+ adv.appendChild(mcpLiteralField("timeout (ms)", fTimeout));
1105
+ const envKeys = Array.isArray(editing.envKeys) ? editing.envKeys : [];
1106
+ const headerKeys = Array.isArray(editing.headerKeys) ? editing.headerKeys : [];
1107
+ const fEnv = el("textarea");
1108
+ fEnv.placeholder = envKeys.length ? "stored: " + envKeys.join(", ") + " — blank keeps" : '{"TOKEN": "…"}';
1109
+ const fHeaders = el("textarea");
1110
+ fHeaders.placeholder = headerKeys.length ? "stored: " + headerKeys.join(", ") + " — blank keeps" : '{"Authorization": "…"}';
1111
+ adv.appendChild(mcpLiteralField("env (JSON)", fEnv));
1112
+ adv.appendChild(mcpLiteralField("headers (JSON)", fHeaders));
1113
+ adv.appendChild(mcpLiteralField("enabled", fEnabled));
1114
+ box.appendChild(adv);
1115
+ const f = { name: fName, transport: fTransport, command: fCommand, url: fUrl, args: fArgs, timeout: fTimeout, env: fEnv, headers: fHeaders, enabled: fEnabled };
1116
+ const actions = el("div", "pform-actions");
1117
+ actions.appendChild(link(t("web.settings.save"), "", () => submitMcpForm(f, mode, editing)));
1118
+ actions.appendChild(link(t("web.settings.cancel"), "dim", () => closeSubDialog()));
1119
+ box.appendChild(actions);
1120
+ return box;
1121
+ }
1122
+
1123
+ /** Open the MCP add/edit nested dialog. */
1124
+ function openMcpForm(mode, entry) {
1125
+ S.state.mcpForm = { mode, entry: entry || null };
1126
+ openSubDialog(
1127
+ t(mode === "edit" ? "web.settings.mcp_edit_title" : "web.settings.mcp_add"),
1128
+ mcpForm(),
1129
+ () => {
1130
+ S.state.mcpForm = null;
1131
+ renderPluginsPanel();
1132
+ void loadPlugins();
1133
+ },
1134
+ );
1135
+ }
1136
+
1137
+ function parseJsonMap(raw) {
1138
+ const text = raw.trim();
1139
+ if (!text) return { present: false, value: null };
1140
+ let parsed = null;
1141
+ try {
1142
+ parsed = JSON.parse(text);
1143
+ } catch {
1144
+ return { present: true, value: null };
1145
+ }
1146
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { present: true, value: null };
1147
+ if (!Object.values(parsed).every((v) => typeof v === "string")) return { present: true, value: null };
1148
+ return { present: true, value: parsed };
1149
+ }
1150
+
1151
+ async function submitMcpForm(f, mode, editing) {
1152
+ const name = f.name.value.trim();
1153
+ const transport = f.transport.value || "stdio";
1154
+ const command = f.command.value.trim();
1155
+ const url = f.url.value.trim();
1156
+ if (!name || (transport === "stdio" ? !command : !url)) {
1157
+ setIndicator(t("web.settings.mcp_required"), true);
1158
+ return;
1159
+ }
1160
+ const body = { name, transport };
1161
+ if (command) body.command = command;
1162
+ if (url) body.url = url;
1163
+ const argsRaw = f.args.value.trim();
1164
+ if (argsRaw) {
1165
+ let args = null;
1166
+ try {
1167
+ args = JSON.parse(argsRaw);
1168
+ } catch {
1169
+ args = null;
1170
+ }
1171
+ if (!Array.isArray(args) || !args.every((x) => typeof x === "string")) {
1172
+ setIndicator(t("web.settings.mcp_invalid"), true);
1173
+ return;
1174
+ }
1175
+ body.args = args;
1176
+ }
1177
+ for (const key of ["env", "headers"]) {
1178
+ const parsed = parseJsonMap(f[key].value);
1179
+ if (parsed.present && !parsed.value) {
1180
+ setIndicator(t("web.settings.mcp_invalid"), true);
1181
+ return;
1182
+ }
1183
+ if (parsed.present) body[key] = parsed.value;
1184
+ }
1185
+ const timeoutRaw = f.timeout.value.trim();
1186
+ if (timeoutRaw) {
1187
+ const n = Number(timeoutRaw);
1188
+ if (!Number.isInteger(n) || n < 1000 || n > 300000) {
1189
+ setIndicator(t("web.settings.mcp_invalid"), true);
1190
+ return;
1191
+ }
1192
+ body.timeout = n;
1193
+ }
1194
+ body.enabled = f.enabled.checked;
1195
+ const target = mode === "edit" ? "/api/mcp/" + encodeURIComponent(editing.id || editing.name) : "/api/mcp";
1196
+ const r = await api(target, { method: mode === "edit" ? "PUT" : "POST", body });
1197
+ if (r.ok && r.data && r.data.ok) {
1198
+ setIndicator(t("web.settings.mcp_saved"), false);
1199
+ closeSubDialog();
1200
+ } else {
1201
+ setIndicator((r.data && r.data.error) || t("web.settings.save_failed"), true);
1202
+ }
1203
+ }
1204
+
1205
+ function renderLspSection(list) {
1206
+ const servers = S.state.lsp;
1207
+ list.appendChild(groupHeader(t("web.settings.lsp_servers"), Array.isArray(servers) ? servers.length : null));
1208
+ if (servers === undefined || servers === null) {
1209
+ list.appendChild(placeholderFor(servers, t("web.settings.lsp_unavailable")));
1210
+ return;
1211
+ }
1212
+ if (!servers.length) {
1213
+ const empty = el("div", "placeholder");
1214
+ empty.textContent = t("web.settings.no_lsp");
1215
+ list.appendChild(empty);
1216
+ }
1217
+ servers.forEach((entry) => {
1218
+ const line = el("div", "list-line");
1219
+ line.appendChild(el("span", entry.enabled ? "g" : "dim", entry.enabled ? "✓" : "○"));
1220
+ const nameEl = el("span", "name", entry.lang);
1221
+ nameEl.title = entry.lang;
1222
+ line.appendChild(nameEl);
1223
+ const meta = [entry.command, entry.timeout != null ? entry.timeout + " ms" : null].filter(Boolean).join(" · ");
1224
+ if (meta) line.appendChild(el("span", "src", meta));
1225
+ line.appendChild(el("span", "st", entry.enabled ? t("web.settings.enabled") : t("web.settings.disabled")));
1226
+ line.appendChild(chip(t("web.settings.edit"), "", () => openLspForm(entry)));
1227
+ list.appendChild(line);
1228
+ });
1229
+ if (servers.length) {
1230
+ const note = el("div", "settings-note");
1231
+ note.textContent = t("web.settings.lsp_note");
1232
+ list.appendChild(note);
1233
+ }
1234
+ }
1235
+
1236
+ /** Open the LSP server edit nested dialog. */
1237
+ function openLspForm(entry) {
1238
+ S.state.lspForm = { lang: entry.lang, entry };
1239
+ openSubDialog(entry.lang, lspForm(), () => {
1240
+ S.state.lspForm = null;
1241
+ renderPluginsPanel();
1242
+ void loadLsp();
1243
+ });
1244
+ }
1245
+
1246
+ // LSP server edit form (enabled toggle, timeout, command, args JSON).
1247
+ function lspForm() {
1248
+ const editing = (S.state.lspForm && S.state.lspForm.entry) || {};
1249
+ const lang = (S.state.lspForm && S.state.lspForm.lang) || editing.lang || "";
1250
+ const box = el("div", "pform");
1251
+ const grid = el("div", "pform-grid");
1252
+ const fCommand = providerInput(editing.command || "", "text", "npx");
1253
+ const fTimeout = providerInput(editing.timeout != null ? String(editing.timeout) : "", "number", "30000");
1254
+ const fEnabled = providerCheck(editing.enabled !== false);
1255
+ grid.appendChild(mcpLiteralField("command", fCommand));
1256
+ grid.appendChild(mcpLiteralField("timeout (ms)", fTimeout));
1257
+ grid.appendChild(mcpLiteralField("enabled", fEnabled));
1258
+ box.appendChild(grid);
1259
+ const fArgs = el("textarea");
1260
+ fArgs.value = editing.args ? JSON.stringify(editing.args) : "";
1261
+ fArgs.placeholder = '["--stdio"]';
1262
+ box.appendChild(mcpLiteralField("args (JSON)", fArgs));
1263
+ const actions = el("div", "pform-actions");
1264
+ actions.appendChild(link(t("web.settings.save"), "", async () => {
1265
+ const body = {};
1266
+ const command = fCommand.value.trim();
1267
+ if (command) body.command = command;
1268
+ const timeoutRaw = fTimeout.value.trim();
1269
+ if (timeoutRaw) {
1270
+ const n = Number(timeoutRaw);
1271
+ if (!Number.isInteger(n) || n < 1000 || n > 300000) {
1272
+ setIndicator(t("web.settings.provider_invalid"), true);
1273
+ return;
1274
+ }
1275
+ body.timeout = n;
1276
+ }
1277
+ const argsRaw = fArgs.value.trim();
1278
+ if (argsRaw) {
1279
+ let args = null;
1280
+ try {
1281
+ args = JSON.parse(argsRaw);
1282
+ } catch {
1283
+ args = null;
1284
+ }
1285
+ if (!Array.isArray(args) || !args.every((x) => typeof x === "string")) {
1286
+ setIndicator(t("web.settings.provider_invalid"), true);
1287
+ return;
1288
+ }
1289
+ body.args = args;
1290
+ }
1291
+ body.enabled = fEnabled.checked;
1292
+ const r = await api("/api/lsp/" + encodeURIComponent(lang), { method: "PUT", body });
1293
+ if (r.ok && r.data && r.data.ok) {
1294
+ setIndicator(t("web.settings.mcp_saved"), false);
1295
+ closeSubDialog();
1296
+ } else {
1297
+ setIndicator((r.data && r.data.error) || t("web.settings.save_failed"), true);
1298
+ }
1299
+ }));
1300
+ actions.appendChild(link(t("web.settings.cancel"), "dim", () => closeSubDialog()));
1301
+ box.appendChild(actions);
1302
+ return box;
1303
+ }
1304
+
1305
+ export async function loadLsp() {
1306
+ const r = await api("/api/lsp");
1307
+ S.state.lsp = r.ok && r.data && Array.isArray(r.data.servers) ? r.data.servers : null;
1308
+ renderPluginsPanel();
1309
+ }
1310
+
948
1311
  export async function loadMemory() {
949
1312
  const r = await api("/api/memory");
950
1313
  S.state.memory = r.ok && r.data ? r.data : null;
@@ -967,17 +1330,18 @@ export async function loadPlugins() {
967
1330
  }
968
1331
 
969
1332
  export function renderSettings() {
970
- renderGenericPanel("general", SECTION_KEYS.general);
1333
+ // Generic panels render straight from PANEL_SECTIONS; custom panels
1334
+ // (model/ui/memory/skills/plugins/security) append their sections themselves
1335
+ // or render bespoke widgets alongside.
1336
+ for (const panel of ["general", "context", "reasoning", "agent", "network", "browser", "sessions", "advanced"]) {
1337
+ renderPanel(panel);
1338
+ }
971
1339
  renderModelPanel();
972
- renderGenericPanel("context", SECTION_KEYS.context);
973
- renderGenericPanel("reasoning", SECTION_KEYS.reasoning);
974
- renderGenericPanel("agent", SECTION_KEYS.agent);
975
1340
  renderUiPanel();
976
1341
  renderMemoryPanel();
977
1342
  renderSkillsPanel();
978
1343
  renderPluginsPanel();
979
1344
  renderSecurityPanel();
980
- renderGenericPanel("advanced", SECTION_KEYS.advanced);
981
1345
  }
982
1346
 
983
1347
  const overlay = document.getElementById("overlay");
@@ -1025,6 +1389,135 @@ document.querySelectorAll(".content-col .panel").forEach((panel) => {
1025
1389
  panel.setAttribute("aria-labelledby", "tab-" + panel.id.replace(/^p-/, ""));
1026
1390
  });
1027
1391
 
1392
+ // ---- settings search -------------------------------------------------------
1393
+ // With 100+ keys, scanning a sidebar is slower than typing. The index is the
1394
+ // same PANEL_SECTIONS table that renders the panels (plus each key's i18n
1395
+ // label/description), so search can never disagree with the layout.
1396
+ const setMenuEl = document.getElementById("setMenu");
1397
+ const setSearch = document.getElementById("setSearch");
1398
+ const setSearchClear = document.getElementById("setSearchClear");
1399
+ const setSearchIcon = document.getElementById("setSearchIcon");
1400
+ const setResults = document.getElementById("setResults");
1401
+ if (setSearchIcon) setSearchIcon.appendChild(icon("search", "set-search-svg"));
1402
+
1403
+ function searchIndex() {
1404
+ const owner = keyToPanel();
1405
+ return (S.state.registry || [])
1406
+ .filter((def) => def && typeof def.key === "string")
1407
+ .map((def) => {
1408
+ const label = configLabel(def);
1409
+ return {
1410
+ def,
1411
+ panel: owner.get(def.key) || null,
1412
+ label,
1413
+ desc: descFor(def.key),
1414
+ haystack: `${label} ${def.key} ${descFor(def.key)}`.toLowerCase(),
1415
+ };
1416
+ })
1417
+ .filter((e) => e.panel);
1418
+ }
1419
+
1420
+ let searchRows = [];
1421
+ let searchCursor = -1;
1422
+
1423
+ function closeSearch() {
1424
+ if (setSearch) setSearch.value = "";
1425
+ if (setResults) {
1426
+ setResults.hidden = true;
1427
+ setResults.textContent = "";
1428
+ }
1429
+ if (setMenuEl) setMenuEl.hidden = false;
1430
+ if (setSearchClear) setSearchClear.hidden = true;
1431
+ searchRows = [];
1432
+ searchCursor = -1;
1433
+ }
1434
+
1435
+ function flashRow(panel, key) {
1436
+ const panelEl = document.getElementById("p-" + panel);
1437
+ if (!panelEl) return;
1438
+ const rows = Array.from(panelEl.querySelectorAll(".row"));
1439
+ const def = defFor(key);
1440
+ if (!def) return;
1441
+ const label = configLabel(def);
1442
+ const target = rows.find((r) => {
1443
+ const nameEl = r.querySelector(".row-name");
1444
+ return nameEl && nameEl.textContent === label;
1445
+ });
1446
+ if (!target) return;
1447
+ target.scrollIntoView({ block: "center" });
1448
+ target.classList.add("row-flash");
1449
+ setTimeout(() => target.classList.remove("row-flash"), 1200);
1450
+ }
1451
+
1452
+ function pickResult(entry) {
1453
+ if (!entry || !entry.panel) return;
1454
+ closeSearch();
1455
+ selectSettingsPanel(entry.panel);
1456
+ flashRow(entry.panel, entry.def.key);
1457
+ }
1458
+
1459
+ function renderResults(query) {
1460
+ if (!setResults) return;
1461
+ const q = query.trim().toLowerCase();
1462
+ if (!q) {
1463
+ closeSearch();
1464
+ return;
1465
+ }
1466
+ const matches = searchIndex().filter((e) => e.haystack.includes(q));
1467
+ setResults.textContent = "";
1468
+ if (!matches.length) {
1469
+ const empty = el("div", "set-results-empty", t("web.settings.search_empty"));
1470
+ setResults.appendChild(empty);
1471
+ }
1472
+ searchRows = matches.slice(0, 40);
1473
+ searchCursor = searchRows.length ? 0 : -1;
1474
+ searchRows.forEach((entry, i) => {
1475
+ const item = el("div", "set-result" + (i === searchCursor ? " on" : ""));
1476
+ item.setAttribute("role", "option");
1477
+ item.tabIndex = -1;
1478
+ item.appendChild(el("span", "sr-label", entry.label));
1479
+ const panelName = t("web.settings." + entry.panel);
1480
+ item.appendChild(el("span", "sr-panel", panelName === "web.settings." + entry.panel ? entry.panel : panelName));
1481
+ const keyEl = el("span", "sr-key", entry.def.key);
1482
+ keyEl.title = entry.def.key;
1483
+ item.appendChild(keyEl);
1484
+ item.addEventListener("click", () => pickResult(entry));
1485
+ setResults.appendChild(item);
1486
+ });
1487
+ setResults.hidden = false;
1488
+ setMenuEl.hidden = true;
1489
+ setSearchClear.hidden = false;
1490
+ }
1491
+
1492
+ function moveSearchCursor(delta) {
1493
+ if (!searchRows.length) return;
1494
+ searchCursor = (searchCursor + delta + searchRows.length) % searchRows.length;
1495
+ Array.from(setResults.children).forEach((node, i) => node.classList.toggle("on", i === searchCursor));
1496
+ const active = setResults.children[searchCursor];
1497
+ if (active) active.scrollIntoView({ block: "nearest" });
1498
+ }
1499
+
1500
+ if (setSearch) {
1501
+ setSearch.addEventListener("input", () => renderResults(setSearch.value));
1502
+ setSearch.addEventListener("keydown", (e) => {
1503
+ if (e.key === "ArrowDown") {
1504
+ e.preventDefault();
1505
+ moveSearchCursor(1);
1506
+ } else if (e.key === "ArrowUp") {
1507
+ e.preventDefault();
1508
+ moveSearchCursor(-1);
1509
+ } else if (e.key === "Enter") {
1510
+ e.preventDefault();
1511
+ pickResult(searchRows[searchCursor]);
1512
+ } else if (e.key === "Escape" && setSearch.value) {
1513
+ // First Esc clears the query; a second one is handled globally (close).
1514
+ e.stopPropagation();
1515
+ closeSearch();
1516
+ }
1517
+ });
1518
+ }
1519
+ if (setSearchClear) setSearchClear.addEventListener("click", closeSearch);
1520
+
1028
1521
  function openSettings() {
1029
1522
  settingsReturnFocus = document.activeElement;
1030
1523
  overlay.classList.add("open");
@@ -1032,12 +1525,15 @@ function openSettings() {
1032
1525
  void loadMemory();
1033
1526
  void loadSkills();
1034
1527
  void loadPlugins();
1528
+ void loadLsp();
1035
1529
  const active = document.querySelector("#setMenu .mi.on") || settingsTabs[0];
1036
1530
  if (active) setTimeout(() => active.focus(), 0);
1037
1531
  }
1038
1532
  function closeSettings() {
1039
- hideProviderOverlay();
1533
+ closeSubDialog();
1040
1534
  S.state.providerForm = null;
1535
+ S.state.mcpForm = null;
1536
+ S.state.lspForm = null;
1041
1537
  overlay.classList.remove("open");
1042
1538
  settingsBtn.setAttribute("aria-expanded", "false");
1043
1539
  if (settingsReturnFocus && settingsReturnFocus.focus) settingsReturnFocus.focus();
@@ -1050,60 +1546,71 @@ overlay.addEventListener("click", (e) => {
1050
1546
  });
1051
1547
  // Trap Tab inside the dialog while it is open.
1052
1548
  overlay.addEventListener("keydown", (e) => trapTab(overlay, e));
1053
-
1054
- const providerOverlay = document.getElementById("providerOverlay");
1055
- const providerFormHost = document.getElementById("providerFormHost");
1056
- const providerFormTitle = document.getElementById("providerFormTitle");
1057
- const closeProviderBtn = document.getElementById("closeProvider");
1058
- let providerReturnFocus = null;
1059
-
1060
- function isProviderOpen() {
1061
- return !!providerOverlay && providerOverlay.classList.contains("open");
1549
+ // One nested dialog for every "edit a single thing" flow (provider, MCP
1550
+ // server, LSP server). Keeps the settings modal visible behind it, traps Tab
1551
+ // and returns focus to the control that opened it.
1552
+ const subOverlay = document.getElementById("subOverlay");
1553
+ const subHost = document.getElementById("subHost");
1554
+ const subTitle = document.getElementById("subTitle");
1555
+ const closeSubBtn = document.getElementById("closeSub");
1556
+ let subReturnFocus = null;
1557
+ let subOnClose = null;
1558
+
1559
+ export function isSubDialogOpen() {
1560
+ return !!subOverlay && subOverlay.classList.contains("open");
1062
1561
  }
1063
1562
 
1064
- function openProviderForm(mode, entry) {
1065
- if (!providerOverlay || !providerFormHost) return;
1066
- S.state.providerForm = { mode, entry: entry || null };
1067
- providerReturnFocus = document.activeElement;
1068
- providerFormTitle.textContent = t(
1069
- mode === "edit" ? "web.settings.provider_edit_title" : "web.settings.provider_add_title",
1070
- );
1071
- providerFormHost.textContent = "";
1072
- providerFormHost.appendChild(providerForm());
1073
- providerOverlay.classList.add("open");
1563
+ /** Open the nested dialog with a built content node. `onClose` runs after. */
1564
+ export function openSubDialog(title, node, onClose) {
1565
+ if (!subOverlay || !subHost) return;
1566
+ subReturnFocus = document.activeElement;
1567
+ subOnClose = onClose || null;
1568
+ subTitle.textContent = title;
1569
+ subHost.textContent = "";
1570
+ subHost.appendChild(node);
1571
+ subOverlay.classList.add("open");
1074
1572
  setTimeout(() => {
1075
- const first = providerFormHost.querySelector("input, select");
1573
+ const first = subHost.querySelector("input, select, textarea");
1076
1574
  if (first) first.focus();
1077
1575
  }, 0);
1078
1576
  }
1079
1577
 
1080
- function hideProviderOverlay() {
1081
- if (!providerOverlay) return;
1082
- providerOverlay.classList.remove("open");
1083
- if (providerReturnFocus && providerReturnFocus.focus) providerReturnFocus.focus();
1084
- providerReturnFocus = null;
1085
- }
1086
-
1087
- function closeProviderForm() {
1088
- if (!isProviderOpen()) return;
1089
- S.state.providerForm = null;
1090
- hideProviderOverlay();
1091
- renderModelPanel();
1578
+ export function closeSubDialog() {
1579
+ if (!isSubDialogOpen()) return;
1580
+ subOverlay.classList.remove("open");
1581
+ const after = subOnClose;
1582
+ subOnClose = null;
1583
+ if (subReturnFocus && subReturnFocus.focus) subReturnFocus.focus();
1584
+ subReturnFocus = null;
1585
+ if (after) after();
1092
1586
  }
1093
1587
 
1094
- if (providerOverlay) {
1095
- if (closeProviderBtn) closeProviderBtn.addEventListener("click", closeProviderForm);
1096
- providerOverlay.addEventListener("click", (e) => {
1588
+ if (subOverlay) {
1589
+ if (closeSubBtn) closeSubBtn.addEventListener("click", closeSubDialog);
1590
+ subOverlay.addEventListener("click", (e) => {
1097
1591
  e.stopPropagation();
1098
- if (e.target === providerOverlay) closeProviderForm();
1592
+ if (e.target === subOverlay) closeSubDialog();
1099
1593
  });
1100
- // Keep Tab inside the nested dialog while it is open.
1101
- providerOverlay.addEventListener("keydown", (e) => trapTab(providerOverlay, e));
1594
+ subOverlay.addEventListener("keydown", (e) => trapTab(subOverlay, e));
1595
+ }
1596
+
1597
+ function openProviderForm(mode, entry) {
1598
+ S.state.providerForm = { mode, entry: entry || null };
1599
+ openSubDialog(
1600
+ t(mode === "edit" ? "web.settings.provider_edit_title" : "web.settings.provider_add_title"),
1601
+ providerForm(),
1602
+ () => renderModelPanel(),
1603
+ );
1604
+ }
1605
+
1606
+ function closeProviderForm() {
1607
+ S.state.providerForm = null;
1608
+ closeSubDialog();
1102
1609
  }
1103
1610
 
1104
1611
  document.addEventListener("keydown", (e) => {
1105
1612
  if (e.key !== "Escape") return;
1106
- if (isProviderOpen()) closeProviderForm();
1613
+ if (isSubDialogOpen()) closeSubDialog();
1107
1614
  else if (confirmOverlay && confirmOverlay.classList.contains("open")) closeConfirm();
1108
1615
  else if (overlay.classList.contains("open")) closeSettings();
1109
1616
  });