PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/settings.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:29 UTC 282,692 bytes, 5,235 lines download raw bytes

1// static/js/settings.js — Settings panel module (ES6)
2// User-facing preferences: AI models, search, appearance
3
4import uiModule from './ui.js';
5import searchModule from './search.js';
6import { makeWindowDraggable } from './windowDrag.js';
7import { clearDockSide } from './modalSnap.js';
8import { sortModelIds } from './modelSort.js';
9import { isAltGrEvent } from './platform.js';
10
11let initialized = false;
12let modalEl = null;
13
14function el(id) { return document.getElementById(id); }
15function esc(s) { return uiModule.esc(s); }
16function safeRasterDataUrl(raw) {
17  const value = String(raw || '').trim();
18  return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
19}
20
21/* ── Tab switching ── */
22const ADMIN_TABS = new Set(['services', 'integrations', 'tools', 'users', 'system']);
23
24function initTabs() {
25  modalEl.querySelectorAll('[data-settings-tab]').forEach(btn => {
26    btn.addEventListener('click', () => {
27      const tab = btn.dataset.settingsTab;
28      // Lazy-init admin when first clicking an admin tab
29      if (ADMIN_TABS.has(tab) && window.adminModule && typeof window.adminModule.open === 'function') {
30        window.adminModule.open(tab);
31        return;
32      }
33      modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab));
34      modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab));
35      // Mark when the Appearance tab is open so the modal can go
36      // semi-transparent — lets the user see the rest of the UI react as
37      // they flip toggles instead of having to close + reopen the modal.
38      document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
39      syncAppearanceOpacity(tab === 'appearance');
40      if (tab === 'ai') refreshAiModelEndpoints();
41    });
42  });
43}
44
45/* ── Dragging ── */
46function initDrag() {
47  const header = modalEl.querySelector('.modal-header');
48  const content = modalEl.querySelector('.settings-modal-content');
49  if (!header || !content) return;
50  // Skip interactive controls in the header (e.g. the opacity slider) so
51  // grabbing them doesn't start a window-drag.
52  makeWindowDraggable(modalEl, {
53    content,
54    header,
55    skipSelector: 'button, input, select, .theme-opacity-wrap',
56    enableDock: true,
57  });
58}
59
60function resetWindowPlacement() {
61  const content = modalEl && modalEl.querySelector('.settings-modal-content');
62  if (!content) return;
63  const hadLeft = modalEl.classList.contains('modal-left-docked');
64  const hadRight = modalEl.classList.contains('modal-right-docked');
65  modalEl.classList.remove('modal-left-docked', 'modal-right-docked');
66  if (hadLeft) clearDockSide('left', modalEl);
67  if (hadRight) clearDockSide('right', modalEl);
68  if (content._leftDockNavObs) {
69    try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {}
70    try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {}
71    delete content._leftDockNavObs;
72  }
73  delete content._preDockSnapshot;
74  delete content._dockSide;
75  delete content._dockSuspended;
76  delete content.dataset._tilePreSnap;
77  delete content.dataset._tileZone;
78  [
79    'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform',
80    'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition',
81  ].forEach(prop => content.style.removeProperty(prop));
82}
83
84/* ── Close on backdrop / X ── */
85function initClose() {
86  modalEl.querySelector('.close-btn').addEventListener('click', close);
87  modalEl.addEventListener('mousedown', e => {
88    if (uiModule.isTouchInsideModal()) return;
89    if (e.target === modalEl) close();
90  });
91  document.addEventListener('keydown', e => {
92    if (e.key !== 'Escape' || !modalEl || modalEl.classList.contains('hidden')) return;
93    // If an integration edit/add form is open inside the modal, close
94    // just that — don't dismiss the whole settings modal. (Pressing
95    // ESC mid-edit and losing the modal was a fast-typing footgun.)
96    const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form');
97    if (innerForm && innerForm.style.display !== 'none' && innerForm.children.length > 0) {
98      e.preventDefault();
99      e.stopPropagation();
100      innerForm.style.display = 'none';
101      innerForm.innerHTML = '';
102      return;
103    }
104    e.preventDefault();
105    e.stopPropagation();
106    close();
107  });
108}
109
110/* ── Appearance-tab opacity slider ──
111   Mirrors the Theme customizer's slider: fades the settings modal's
112   background (and inner cards) via color-mix so the user can watch the
113   rest of the UI react to toggles, while keeping text/controls crisp
114   (no element opacity). Only shown/active on the Appearance tab. */
115const _SETTINGS_PEEK = 55; // % opacity when the Peek toggle is on
116function _applySettingsOpacity(on) {
117  const content = modalEl && modalEl.querySelector('.settings-modal-content, .modal-content');
118  if (!content) return;
119  const cards = content.querySelectorAll('.admin-card');
120  if (on) {
121    const bgMix = `color-mix(in srgb, var(--bg) ${_SETTINGS_PEEK}%, transparent)`;
122    const panelMix = `color-mix(in srgb, var(--panel) ${_SETTINGS_PEEK}%, transparent)`;
123    content.style.setProperty('background', bgMix, 'important');
124    content.style.setProperty('backdrop-filter', 'none', 'important');
125    content.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
126    cards.forEach(c => {
127      c.style.setProperty('background', panelMix, 'important');
128      c.style.setProperty('backdrop-filter', 'none', 'important');
129      c.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
130    });
131  } else {
132    content.style.removeProperty('background');
133    content.style.removeProperty('backdrop-filter');
134    content.style.removeProperty('-webkit-backdrop-filter');
135    cards.forEach(c => {
136      c.style.removeProperty('background');
137      c.style.removeProperty('backdrop-filter');
138      c.style.removeProperty('-webkit-backdrop-filter');
139    });
140  }
141}
142
143// Show/hide the Peek toggle for the Appearance tab and apply or clear the fade.
144function syncAppearanceOpacity(active) {
145  const toggle = el('settings-opacity-wrap');
146  if (toggle) toggle.classList.toggle('hidden', !active);
147  if (active) {
148    _applySettingsOpacity(toggle ? toggle.classList.contains('active') : false);
149  } else {
150    _applySettingsOpacity(false); // clear the fade off the Appearance tab
151  }
152}
153
154function initOpacityToggle() {
155  const toggle = el('settings-opacity-wrap');
156  if (!toggle || toggle.dataset.bound === '1') return;
157  toggle.dataset.bound = '1';
158  toggle.addEventListener('click', () => {
159    const on = !toggle.classList.contains('active');
160    toggle.classList.toggle('active', on);
161    toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
162    _applySettingsOpacity(on);
163  });
164}
165
166/* ═══════════════════════════════════════════
167   AI TAB
168   ═══════════════════════════════════════════ */
169
170const _aiEndpointRefreshers = new Set();
171let _aiEndpointRefreshInFlight = null;
172
173async function _fetchModelEndpoints() {
174  const epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
175  const endpoints = await epRes.json();
176  return Array.isArray(endpoints) ? endpoints : [];
177}
178
179function _endpointLabel(ep) {
180  return ep.name + (ep.online ? '' : ' (offline)');
181}
182
183function _fillEndpointSelect(selectEl, endpoints, selected, keepBlank) {
184  if (!selectEl) return;
185  const previous = selected !== undefined ? selected : selectEl.value;
186  const blankText = keepBlank && selectEl.options[0] && selectEl.options[0].value === ''
187    ? selectEl.options[0].textContent
188    : null;
189  while (selectEl.options.length) selectEl.remove(0);
190  if (blankText !== null) {
191    const blank = document.createElement('option');
192    blank.value = '';
193    blank.textContent = blankText;
194    selectEl.appendChild(blank);
195  }
196  (endpoints || []).forEach(function(ep) {
197    if (!ep.is_enabled) return;
198    const opt = document.createElement('option');
199    opt.value = ep.id;
200    opt.textContent = _endpointLabel(ep);
201    selectEl.appendChild(opt);
202  });
203  if (previous && Array.from(selectEl.options).some(function(o) { return o.value === previous; })) {
204    selectEl.value = previous;
205  } else if (blankText !== null) {
206    selectEl.value = '';
207  }
208}
209
210function _fillModelSelect(selectEl, models, selected, keepBlank) {
211  if (!selectEl) return;
212  const previous = selected !== undefined ? selected : selectEl.value;
213  const blankText = keepBlank && selectEl.options[0] && selectEl.options[0].value === ''
214    ? selectEl.options[0].textContent
215    : null;
216  while (selectEl.options.length) selectEl.remove(0);
217  if (blankText !== null) {
218    const blank = document.createElement('option');
219    blank.value = '';
220    blank.textContent = blankText;
221    selectEl.appendChild(blank);
222  }
223  sortModelIds(models).forEach(function(m) {
224    const opt = document.createElement('option');
225    opt.value = m;
226    opt.textContent = String(m).split('/').pop();
227    selectEl.appendChild(opt);
228  });
229  if (previous && Array.from(selectEl.options).some(function(o) { return o.value === previous; })) {
230    selectEl.value = previous;
231  } else if (blankText !== null) {
232    selectEl.value = '';
233  }
234}
235
236function _registerAiEndpointRefresh(fn) {
237  _aiEndpointRefreshers.add(fn);
238}
239
240export async function refreshAiModelEndpoints() {
241  if (_aiEndpointRefreshInFlight) return _aiEndpointRefreshInFlight;
242  _aiEndpointRefreshInFlight = (async function() {
243    try {
244      const endpoints = await _fetchModelEndpoints();
245      _aiEndpointRefreshers.forEach(function(fn) {
246        try { fn(endpoints); } catch (e) { console.warn('[settings] endpoint refresh handler failed', e); }
247      });
248    } catch (e) {
249      console.warn('[settings] failed to refresh model endpoints', e);
250    } finally {
251      _aiEndpointRefreshInFlight = null;
252    }
253  })();
254  return _aiEndpointRefreshInFlight;
255}
256
257/* Shared fallback-chain widget — mirrors the Default Chat Model fallback UI
258 * for other model cards (Utility, Vision, …). Pass in the container/button
259 * IDs, the endpoints list, the settings key to persist under, and the
260 * model-filter (for Vision we exclude non-chat-capable models).
261 */
262function _bindFallbackWidget(opts) {
263  var fbContainer = el(opts.containerId);
264  var addBtn = el(opts.addBtnId);
265  var endpointsRef = opts.endpoints;       // mutable list reference
266  var modelsFilter = opts.modelsFilter || function() { return true; };
267  var settingKey = opts.settingKey;
268  var current = opts.initial || [];        // [{endpoint_id, model}]
269
270  if (!fbContainer || !addBtn) return { setEndpoints: function() {}, setInitial: function() {} };
271
272  function enabledEps() { return (endpointsRef() || []).filter(function(e) { return e.is_enabled; }); }
273
274  function fillModels(selectEl, epId, selected) {
275    while (selectEl.options.length) selectEl.remove(0);
276    var ep = (endpointsRef() || []).find(function(e) { return e.id === epId; });
277    if (ep && ep.models) {
278      sortModelIds(ep.models).forEach(function(m) {
279        if (!modelsFilter(m, ep)) return;
280        var o = document.createElement('option');
281        o.value = m;
282        o.textContent = m.split('/').pop();
283        selectEl.appendChild(o);
284      });
285    }
286    if (selected) selectEl.value = selected;
287  }
288
289  async function save() {
290    var clean = current.filter(function(f) { return f.endpoint_id && f.model; });
291    var body = {};
292    body[settingKey] = clean;
293    try {
294      await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
295        headers: { 'Content-Type': 'application/json' },
296        body: JSON.stringify(body)
297      });
298    } catch (e) { console.warn('[fallback] save failed for ' + settingKey, e); }
299  }
300
301  function render() {
302    fbContainer.innerHTML = '';
303    current.forEach(function(fb, idx) {
304      var row = document.createElement('div');
305      row.className = 'settings-fallback-row';
306
307      var num = document.createElement('span');
308      num.className = 'settings-fallback-num';
309      num.textContent = (idx + 1) + '.';
310
311      var epS = document.createElement('select');
312      epS.className = 'settings-select';
313      enabledEps().forEach(function(ep) {
314        var o = document.createElement('option');
315        o.value = ep.id;
316        o.textContent = ep.name + (ep.online ? '' : ' (offline)');
317        epS.appendChild(o);
318      });
319      var first = enabledEps()[0];
320      epS.value = fb.endpoint_id || (first ? first.id : '');
321
322      var mS = document.createElement('select');
323      mS.className = 'settings-select';
324      fillModels(mS, epS.value, fb.model);
325
326      fb.endpoint_id = epS.value;
327      fb.model = mS.value;
328
329      epS.addEventListener('change', function() {
330        fb.endpoint_id = epS.value;
331        fillModels(mS, epS.value, '');
332        fb.model = mS.value;
333        save();
334      });
335      mS.addEventListener('change', function() { fb.model = mS.value; save(); });
336
337      var rm = document.createElement('button');
338      rm.type = 'button';
339      rm.className = 'settings-fallback-remove';
340      rm.title = 'Remove fallback';
341      rm.innerHTML = '×';
342      rm.addEventListener('click', function() {
343        current.splice(idx, 1);
344        render();
345        save();
346      });
347
348      row.appendChild(num);
349      row.appendChild(epS);
350      row.appendChild(mS);
351      row.appendChild(rm);
352      fbContainer.appendChild(row);
353    });
354  }
355
356  addBtn.addEventListener('click', function() {
357    var first = enabledEps()[0];
358    current.push({ endpoint_id: first ? first.id : '', model: '' });
359    render();
360    save();
361  });
362
363  render();
364
365  return {
366    setInitial: function(list) { current = (list || []).slice(); render(); },
367    refresh: render,
368  };
369}
370
371/* ── Default Chat Model ── */
372async function initDefaultChat() {
373  var epSel = el('set-defaultEpSelect');
374  var modelSel = el('set-defaultModelSelect');
375  var msg = el('set-defaultChatMsg');
376  var fbContainer = el('set-defaultFallbacks');
377  var addFbBtn = el('set-defaultAddFallback');
378  var _endpoints = [];
379  var _fallbacks = []; // [{endpoint_id, model}] — tried in order if primary fails
380
381  function enabledEndpoints() {
382    return _endpoints.filter(function(e) { return e.is_enabled; });
383  }
384
385  // Fill any <select> with the models for a given endpoint id.
386  function fillModels(selectEl, epId, selected) {
387    var ep = _endpoints.find(function(e) { return e.id === epId; });
388    _fillModelSelect(selectEl, ep ? ep.models : [], selected, false);
389  }
390
391  try {
392    _endpoints = await _fetchModelEndpoints();
393    _fillEndpointSelect(epSel, _endpoints, epSel.value, false);
394  } catch (e) { console.warn('Failed to load endpoints for default chat', e); }
395
396  function refreshModels(selectedModel) { fillModels(modelSel, epSel.value, selectedModel); }
397  function refreshEndpointOptions(selectedEndpoint, selectedModel) {
398    _fillEndpointSelect(epSel, _endpoints, selectedEndpoint !== undefined ? selectedEndpoint : epSel.value, false);
399    refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value);
400    renderFallbacks();
401  }
402
403  // Render the fallback chain. Each row is endpoint + model + remove.
404  function renderFallbacks() {
405    fbContainer.innerHTML = '';
406    _fallbacks.forEach(function(fb, idx) {
407      var row = document.createElement('div');
408      row.className = 'settings-fallback-row';
409
410      var num = document.createElement('span');
411      num.className = 'settings-fallback-num';
412      num.textContent = (idx + 1) + '.';
413
414      var epS = document.createElement('select');
415      epS.className = 'settings-select';
416      enabledEndpoints().forEach(function(ep) {
417        var o = document.createElement('option');
418        o.value = ep.id;
419        o.textContent = ep.name + (ep.online ? '' : ' (offline)');
420        epS.appendChild(o);
421      });
422      var first = enabledEndpoints()[0];
423      epS.value = fb.endpoint_id || (first ? first.id : '');
424
425      var mS = document.createElement('select');
426      mS.className = 'settings-select';
427      fillModels(mS, epS.value, fb.model);
428
429      // Keep the model in sync with the values actually shown.
430      fb.endpoint_id = epS.value;
431      fb.model = mS.value;
432
433      epS.addEventListener('change', function() {
434        fb.endpoint_id = epS.value;
435        fillModels(mS, epS.value, '');
436        fb.model = mS.value;
437        saveDefault();
438      });
439      mS.addEventListener('change', function() { fb.model = mS.value; saveDefault(); });
440
441      var rm = document.createElement('button');
442      rm.type = 'button';
443      rm.className = 'settings-fallback-remove';
444      rm.title = 'Remove fallback';
445      rm.innerHTML = '&times;';
446      rm.addEventListener('click', function() {
447        _fallbacks.splice(idx, 1);
448        renderFallbacks();
449        saveDefault();
450      });
451
452      row.appendChild(num);
453      row.appendChild(epS);
454      row.appendChild(mS);
455      row.appendChild(rm);
456      fbContainer.appendChild(row);
457    });
458  }
459
460  try {
461    var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
462    var settings = await res.json();
463    if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
464    refreshModels(settings.default_model || '');
465    _fallbacks = Array.isArray(settings.default_model_fallbacks)
466      ? settings.default_model_fallbacks.map(function(f) {
467          return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' };
468        })
469      : [];
470    renderFallbacks();
471  } catch (e) { console.warn('Failed to load default chat settings', e); }
472
473  async function saveDefault() {
474    try {
475      var clean = _fallbacks.filter(function(f) { return f.endpoint_id && f.model; });
476      await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
477        headers: { 'Content-Type': 'application/json' },
478        body: JSON.stringify({
479          default_endpoint_id: epSel.value,
480          default_model: modelSel.value,
481          default_model_fallbacks: clean
482        })
483      });
484      msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
485      setTimeout(function() { msg.textContent = ''; }, 2000);
486    } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
487  }
488
489  epSel.addEventListener('change', function() { refreshModels(''); saveDefault(); });
490  modelSel.addEventListener('change', saveDefault);
491  if (addFbBtn) addFbBtn.addEventListener('click', function() {
492    var first = enabledEndpoints()[0];
493    _fallbacks.push({ endpoint_id: first ? first.id : '', model: '' });
494    renderFallbacks();
495    saveDefault();
496  });
497
498  _registerAiEndpointRefresh(function(endpoints) {
499    _endpoints = endpoints;
500    refreshEndpointOptions(epSel.value, modelSel.value);
501  });
502}
503
504/* ── Utility Model ── */
505async function initUtilityModel() {
506  var epSel = el('set-utilityEpSelect');
507  var modelSel = el('set-utilityModelSelect');
508  var msg = el('set-utilityChatMsg');
509  var _endpoints = [];
510  var fallbackWidget = null;
511  if (epSel && epSel.options[0]) epSel.options[0].textContent = 'Same as chat';
512  if (modelSel && modelSel.options[0]) modelSel.options[0].textContent = 'Same as chat';
513
514  try {
515    _endpoints = await _fetchModelEndpoints();
516    _fillEndpointSelect(epSel, _endpoints, epSel.value, true);
517  } catch (e) { console.warn('Failed to load endpoints for utility model', e); }
518
519  function refreshModels(selectedModel) {
520    var epId = epSel.value;
521    var ep = _endpoints.find(function(e) { return e.id === epId; });
522    _fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true);
523  }
524
525  try {
526    var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
527    var settings = await res.json();
528    if (settings.utility_endpoint_id) epSel.value = settings.utility_endpoint_id;
529    refreshModels(settings.utility_model || '');
530    fallbackWidget = _bindFallbackWidget({
531      containerId: 'set-utilityFallbacks',
532      addBtnId: 'set-utilityAddFallback',
533      endpoints: function() { return _endpoints; },
534      settingKey: 'utility_model_fallbacks',
535      initial: Array.isArray(settings.utility_model_fallbacks)
536        ? settings.utility_model_fallbacks.map(function(f) { return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' }; })
537        : [],
538    });
539  } catch (e) { console.warn('Failed to load utility model settings', e); }
540
541  // Persist whatever's currently selected. Empty endpoint or model → backend
542  // transparently falls back to the chat model (mirrors the teacher panel:
543  // no toggle, "—" means "unset, use chat").
544  async function saveUtility() {
545    try {
546      await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
547        headers: { 'Content-Type': 'application/json' },
548        body: JSON.stringify({
549          utility_endpoint_id: epSel.value || '',
550          utility_model: modelSel.value || ''
551        })
552      });
553      msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
554      setTimeout(function() { msg.textContent = ''; }, 1500);
555    } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
556  }
557
558  epSel.addEventListener('change', function() { refreshModels(''); saveUtility(); });
559  modelSel.addEventListener('change', saveUtility);
560
561  _registerAiEndpointRefresh(function(endpoints) {
562    _endpoints = endpoints;
563    _fillEndpointSelect(epSel, _endpoints, epSel.value, true);
564    refreshModels(modelSel.value);
565    if (fallbackWidget && fallbackWidget.refresh) fallbackWidget.refresh();
566  });
567}
568
569/* ── Teacher Model ── */
570// SOTA model called automatically when a self-hosted student model
571// fails an agent-mode task. Stored as a single `teacher_model` string
572// in the form `model@endpoint_name` so the backend's _resolve_model
573// can dispatch directly. Master toggle is the separate
574// `teacher_enabled` flag so the user can pause the feature without
575// losing their endpoint+model selection.
576async function initTeacherModel() {
577  var enabledToggle = el('set-teacherEnabledToggle');
578  var epSel = el('set-teacherEpSelect');
579  var modelSel = el('set-teacherModelSelect');
580  var msg = el('set-teacherChatMsg');
581  if (!epSel || !modelSel) return;
582  var _endpoints = [];
583
584  try {
585    _endpoints = await _fetchModelEndpoints();
586    _fillEndpointSelect(epSel, _endpoints, epSel.value, true);
587  } catch (e) { console.warn('Failed to load endpoints for teacher model', e); }
588
589  function refreshModels(selectedModel) {
590    var epId = epSel.value;
591    var ep = _endpoints.find(function(e) { return e.id === epId; });
592    _fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true);
593  }
594
595  // Disable / enable the endpoint+model dropdowns based on the
596  // master switch. Greys them out so users see at a glance that the
597  // selection is dormant.
598  function syncEnabled() {
599    var off = enabledToggle ? !enabledToggle.checked : true;
600    // Dim the card when off as a "dormant" cue, but keep the endpoint+model
601    // dropdowns INTERACTIVE — the toggle gates whether escalation runs, not
602    // whether you can configure it. (Previously the config was inert when off,
603    // so users couldn't pick an endpoint until they'd already enabled it.)
604    var card = enabledToggle ? enabledToggle.closest('.admin-card') : null;
605    if (card) card.style.opacity = off ? '0.7' : '';
606    var wrap = card ? card.querySelector('.settings-col') : null;
607    if (wrap) wrap.style.pointerEvents = '';
608    epSel.disabled = false;
609    modelSel.disabled = false;
610  }
611
612  try {
613    var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
614    var settings = await res.json();
615    if (enabledToggle) enabledToggle.checked = !!settings.teacher_enabled;
616    // teacher_model is stored as "model@endpoint_name". Split on the
617    // LAST `@` so model ids that contain @ aren't mangled.
618    var spec = settings.teacher_model || '';
619    var savedModel = spec;
620    var savedEpName = '';
621    var at = spec.lastIndexOf('@');
622    if (at >= 0) {
623      savedModel = spec.slice(0, at);
624      savedEpName = spec.slice(at + 1);
625    }
626    if (savedEpName) {
627      var match = _endpoints.find(function(ep) {
628        return ep.name && ep.name.toLowerCase().indexOf(savedEpName.toLowerCase()) >= 0;
629      });
630      if (match) epSel.value = match.id;
631    }
632    refreshModels(savedModel);
633    syncEnabled();
634  } catch (e) { console.warn('Failed to load teacher model settings', e); }
635
636  async function saveTeacher() {
637    try {
638      var spec = '';
639      if (epSel.value && modelSel.value) {
640        var ep = _endpoints.find(function(e) { return e.id === epSel.value; });
641        spec = ep ? (modelSel.value + '@' + ep.name) : modelSel.value;
642      }
643      var enabled = enabledToggle ? !!enabledToggle.checked : false;
644      await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
645        headers: { 'Content-Type': 'application/json' },
646        body: JSON.stringify({ teacher_enabled: enabled, teacher_model: spec })
647      });
648      msg.textContent = enabled ? (spec ? 'Saved' : 'Pick an endpoint + model') : 'Disabled';
649      msg.style.color = enabled && !spec ? 'var(--red)' : 'var(--fg)';
650      setTimeout(function() { msg.textContent = ''; }, 2000);
651    } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
652  }
653
654  if (enabledToggle) {
655    enabledToggle.addEventListener('change', function() {
656      syncEnabled();
657      saveTeacher();
658    });
659  }
660  epSel.addEventListener('change', function() { refreshModels(''); saveTeacher(); });
661  modelSel.addEventListener('change', saveTeacher);
662
663  _registerAiEndpointRefresh(function(endpoints) {
664    _endpoints = endpoints;
665    _fillEndpointSelect(epSel, _endpoints, epSel.value, true);
666    refreshModels(modelSel.value);
667  });
668}
669
670/* ── Image Generation ── */
671async function initImageSettings() {
672  const modelSel = el('set-imgModelSelect');
673  const qualSel = el('set-imgQualitySelect');
674  const msg = el('set-imgSettingsMsg');
675  const enabledToggle = el('set-imgEnabledToggle');
676  const configWrap = modelSel ? modelSel.closest('div[style*="flex-direction"]') : null;
677  try {
678    const modelsRes = await fetch('/api/models', { credentials: 'same-origin' });
679    const modelsData = await modelsRes.json();
680    // Inpaint-compat allowlist — image gen here is scoped to inpainting only,
681    // so DALL-E / GPT-Image-1 (no inpaint API) are excluded. Currently:
682    //   - any model with 'inpaint' in the id
683    //   - Stable Diffusion 3.5 Medium (inpaint via diffusers pipeline)
684    const _isInpaintModel = (mid) => {
685      const lower = String(mid || '').toLowerCase();
686      return lower.includes('inpaint')
687        || lower.includes('3.5-medium')
688        || lower.includes('3-5-medium')
689        || lower.includes('sd-3.5-med');
690    };
691    const imageModels = [];
692    (modelsData.items || []).forEach(item => {
693      (item.models || []).forEach(mid => {
694        if (_isInpaintModel(mid)) imageModels.push(mid);
695      });
696    });
697    sortModelIds(imageModels).forEach(mid => { const opt = document.createElement('option'); opt.value = mid; opt.textContent = mid; modelSel.appendChild(opt); });
698    // Hardcoded fallbacks shown as "(not detected)" so users know what to
699    // download/serve to enable inpaint here.
700    ['stable-diffusion-3.5-medium', 'stable-diffusion-inpainting'].forEach(mid => {
701      if (!imageModels.includes(mid)) { const opt = document.createElement('option'); opt.value = mid; opt.textContent = mid + ' (not detected)'; modelSel.appendChild(opt); }
702    });
703  } catch (e) { console.warn('Failed to load models for image settings', e); }
704  try {
705    const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
706    const settings = await settingsRes.json();
707    if (settings.image_model) modelSel.value = settings.image_model;
708    if (settings.image_quality) qualSel.value = settings.image_quality;
709    if (enabledToggle) enabledToggle.checked = settings.image_gen_enabled !== false;
710  } catch (e) { console.warn('Failed to load settings', e); }
711
712  function syncImgDisabled() {
713    var off = enabledToggle && !enabledToggle.checked;
714    var card = enabledToggle ? enabledToggle.closest('.admin-card') : null;
715    if (card) card.style.opacity = off ? '0.45' : '';
716    if (configWrap) configWrap.style.pointerEvents = off ? 'none' : '';
717  }
718  syncImgDisabled();
719
720  async function saveSettings() {
721    try {
722      await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
723        body: JSON.stringify({ image_gen_enabled: enabledToggle ? enabledToggle.checked : true, image_model: modelSel.value, image_quality: qualSel.value }) });
724      msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
725    } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
726  }
727  modelSel.addEventListener('change', saveSettings);
728  qualSel.addEventListener('change', saveSettings);
729  if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); });
730}
731
732/* ── Vision ── */
733async function initVisionSettings() {
734  const vlSel = el('set-vlModelSelect');
735  const msg = el('set-visionSettingsMsg');
736  const enabledToggle = el('set-visionEnabledToggle');
737  const configWrap = vlSel ? vlSel.closest('div[style*="flex-direction"]') : null;
738  var _visionEndpoints = [];
739  var visionFallbackWidget = null;
740  var _vlExclude = ['audio', 'realtime', 'tts', 'dall-e', 'embedding', 'search', 'whisper'];
741  function _isVisionModel(mid) {
742    var lower = String(mid || '').toLowerCase();
743    return !_vlExclude.some(function(kw) { return lower.includes(kw); });
744  }
745  try {
746    const modelsRes = await fetch('/api/models', { credentials: 'same-origin' });
747    const modelsData = await modelsRes.json();
748    const visionModels = [];
749    (modelsData.items || []).forEach(item => {
750      if (item.offline) return;
751      (item.models || []).forEach(mid => {
752        if (_isVisionModel(mid)) {
753          visionModels.push(mid);
754        }
755      });
756    });
757    sortModelIds(visionModels).forEach(mid => {
758      var opt = document.createElement('option'); opt.value = mid; opt.textContent = mid; vlSel.appendChild(opt);
759    });
760  } catch (e) { console.warn('Failed to load models for vision settings', e); }
761  // Also pull the raw endpoint list so the fallback widget can resolve
762  // endpoint-id → models the same way the other cards do.
763  try {
764    _visionEndpoints = await _fetchModelEndpoints();
765  } catch (e) { console.warn('Failed to load endpoints for vision fallback', e); }
766  try {
767    const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
768    const settings = await settingsRes.json();
769    if (settings.vision_model) vlSel.value = settings.vision_model;
770    if (enabledToggle) enabledToggle.checked = settings.vision_enabled !== false;
771    visionFallbackWidget = _bindFallbackWidget({
772      containerId: 'set-visionFallbacks',
773      addBtnId: 'set-visionAddFallback',
774      endpoints: function() { return _visionEndpoints; },
775      // Vision fallback list filters to vision-capable models (same heuristic
776      // as the primary select above — exclude audio/tts/embedding/etc.).
777      modelsFilter: function(mid) { return _isVisionModel(mid); },
778      settingKey: 'vision_model_fallbacks',
779      initial: Array.isArray(settings.vision_model_fallbacks)
780        ? settings.vision_model_fallbacks.map(function(f) { return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' }; })
781        : [],
782    });
783  } catch (e) { console.warn('Failed to load vision settings', e); }
784
785  function syncVisionDisabled() {
786    var off = enabledToggle && !enabledToggle.checked;
787    var card = enabledToggle ? enabledToggle.closest('.admin-card') : null;
788    if (card) card.style.opacity = off ? '0.45' : '';
789    if (configWrap) configWrap.style.pointerEvents = off ? 'none' : '';
790  }
791  syncVisionDisabled();
792
793  async function saveSettings() {
794    try {
795      await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
796        body: JSON.stringify({ vision_enabled: enabledToggle ? enabledToggle.checked : true, vision_model: vlSel.value }) });
797      msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
798    } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
799  }
800  vlSel.addEventListener('change', saveSettings);
801  if (enabledToggle) enabledToggle.addEventListener('change', function() { syncVisionDisabled(); saveSettings(); });
802
803  _registerAiEndpointRefresh(function(endpoints) {
804    _visionEndpoints = endpoints;
805    if (visionFallbackWidget && visionFallbackWidget.refresh) visionFallbackWidget.refresh();
806  });
807}
808
809/* ── Face Recognition ── */
810
811/* ── Text to Speech ── */
812async function initTtsSettings() {
813  var provSel = el('set-ttsProviderSelect');
814  var modelSelect = el('set-ttsModelSelect');
815  var modelInput = el('set-ttsModelInput');
816  var voiceSelect = el('set-ttsVoiceSelect');
817  var voiceInput = el('set-ttsVoiceInput');
818  var modelRow = el('set-ttsModelRow');
819  var voiceRow = el('set-ttsVoiceRow');
820  var speedSelect = el('set-ttsSpeedSelect');
821  var speedRow = el('set-ttsSpeedRow');
822  var ttsMsg = el('set-ttsSettingsMsg');
823  var ttsEnabledToggle = el('set-ttsEnabledToggle');
824  var ttsConfigWrap = provSel ? provSel.closest('div[style*="flex-direction"]') : null;
825
826  function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
827  function getModel() { return isEndpoint() ? modelSelect.value : modelInput.value; }
828  function getVoice() { return isEndpoint() ? voiceSelect.value : voiceInput.value; }
829
830  function updateVisibility() {
831    var prov = provSel.value;
832    modelRow.style.display = prov.startsWith('endpoint:') ? 'flex' : 'none';
833    voiceRow.style.display = prov === 'disabled' ? 'none' : 'flex';
834    speedRow.style.display = prov === 'disabled' ? 'none' : 'flex';
835    if (isEndpoint()) {
836      modelSelect.style.display = ''; modelInput.style.display = 'none';
837      voiceSelect.style.display = ''; voiceInput.style.display = 'none';
838    } else {
839      modelSelect.style.display = 'none'; modelInput.style.display = '';
840      voiceSelect.style.display = 'none'; voiceInput.style.display = prov === 'disabled' ? 'none' : '';
841    }
842  }
843
844  var ttsKeywords = ['tts', 'audio'];
845  try {
846    var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
847    var endpoints = await epRes.json();
848    endpoints.forEach(function(ep) {
849      if (!ep.is_enabled) return;
850      var hasTTS = (ep.models || []).some(m => ttsKeywords.some(kw => m.toLowerCase().includes(kw)));
851      if (!hasTTS) return;
852      var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
853    });
854  } catch (e) { console.warn('Failed to load endpoints for TTS', e); }
855
856  try {
857    var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
858    var settings = await settingsRes.json();
859    if (settings.tts_provider) provSel.value = settings.tts_provider;
860    if (settings.tts_model) { modelSelect.value = settings.tts_model; modelInput.value = settings.tts_model; }
861    if (settings.tts_voice) { voiceSelect.value = settings.tts_voice; voiceInput.value = settings.tts_voice; }
862    if (settings.tts_speed) { speedSelect.value = settings.tts_speed; }
863    if (ttsEnabledToggle) ttsEnabledToggle.checked = settings.tts_enabled !== false;
864  } catch (e) { console.warn('Failed to load TTS settings', e); }
865
866  function syncTtsDisabled() {
867    var off = ttsEnabledToggle && !ttsEnabledToggle.checked;
868    var card = ttsEnabledToggle ? ttsEnabledToggle.closest('.admin-card') : null;
869    if (card) card.style.opacity = off ? '0.45' : '';
870    if (ttsConfigWrap) ttsConfigWrap.style.pointerEvents = off ? 'none' : '';
871  }
872  syncTtsDisabled();
873  updateVisibility();
874
875  async function saveTTS() {
876    try {
877      await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
878        body: JSON.stringify({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' }) });
879      ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000);
880      if (window.aiTTSManager) window.aiTTSManager.checkAvailability();
881    } catch (e) { ttsMsg.textContent = 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
882  }
883
884  async function saveAndClearCache() {
885    await saveTTS();
886    fetch('/api/tts/clear-cache', { method: 'POST', credentials: 'same-origin' }).catch(function(){});
887  }
888
889  provSel.addEventListener('change', function() {
890    var prov = provSel.value;
891    if (prov === 'local') voiceInput.value = 'af_heart';
892    else if (isEndpoint()) { voiceSelect.value = 'alloy'; modelSelect.value = 'tts-1'; }
893    else if (prov === 'browser') { voiceInput.value = ''; voiceInput.placeholder = 'OS default voice'; }
894    updateVisibility();
895    saveTTS();
896  });
897  modelSelect.addEventListener('change', saveAndClearCache);
898  modelInput.addEventListener('change', saveTTS);
899  voiceSelect.addEventListener('change', saveAndClearCache);
900  voiceInput.addEventListener('change', saveTTS);
901  speedSelect.addEventListener('change', saveAndClearCache);
902  if (ttsEnabledToggle) ttsEnabledToggle.addEventListener('change', function() { syncTtsDisabled(); saveTTS(); });
903
904  // Preview / test button
905  var previewBtn = el('set-ttsPreviewBtn');
906  if (previewBtn) {
907    var previewAudio = null;
908    var previewPlaying = false;
909    function resetPreview() { previewPlaying = false; previewBtn.textContent = 'Preview'; previewBtn.style.borderColor = ''; }
910
911    previewBtn.addEventListener('click', async function() {
912      if (previewPlaying) {
913        if (previewAudio) { previewAudio.pause(); previewAudio = null; }
914        window.speechSynthesis.cancel();
915        resetPreview(); return;
916      }
917      var prov = provSel.value;
918      if (prov === 'disabled') {
919        ttsMsg.textContent = 'Select a provider first'; ttsMsg.style.color = 'var(--red, #e55)';
920        setTimeout(function() { ttsMsg.textContent = ''; }, 2000); return;
921      }
922      var testText = 'Hello, this is a test of text to speech.';
923      previewPlaying = true; previewBtn.textContent = 'Loading...';
924      try {
925        if (prov === 'browser') {
926          if (!('speechSynthesis' in window)) throw new Error('Browser TTS not supported');
927          var utt = new SpeechSynthesisUtterance(testText);
928          var voiceVal = getVoice();
929          if (voiceVal) {
930            var voices = window.speechSynthesis.getVoices();
931            var target = voiceVal.toLowerCase();
932            var match = voices.find(function(v) { return v.name.toLowerCase() === target; }) ||
933                        voices.find(function(v) { return v.name.toLowerCase().includes(target); });
934            if (match) utt.voice = match;
935          }
936          utt.rate = parseFloat(speedSelect.value) || 1;
937          previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
938          await new Promise(function(resolve, reject) {
939            utt.onend = resolve;
940            utt.onerror = function(e) { reject(new Error('Browser TTS: ' + e.error)); };
941            window.speechSynthesis.speak(utt);
942          });
943        } else {
944          var res = await fetch('/api/tts/synthesize', {
945            method: 'POST', credentials: 'same-origin',
946            headers: { 'Content-Type': 'application/json' },
947            body: JSON.stringify({ text: testText, format: 'audio' })
948          });
949          if (!res.ok) { var err = await res.json().catch(function() { return {}; }); throw new Error(err.detail?.message || 'Synthesis failed'); }
950          var blob = await res.blob();
951          var url = URL.createObjectURL(blob);
952          previewAudio = new Audio(url);
953          previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
954          await new Promise(function(resolve, reject) {
955            previewAudio.onended = function() { URL.revokeObjectURL(url); previewAudio = null; resolve(); };
956            previewAudio.onerror = function() { URL.revokeObjectURL(url); previewAudio = null; reject(new Error('Playback failed')); };
957            previewAudio.play().catch(reject);
958          });
959        }
960      } catch (e) {
961        ttsMsg.textContent = 'Preview failed: ' + e.message; ttsMsg.style.color = 'var(--red, #e55)';
962        setTimeout(function() { ttsMsg.textContent = ''; }, 3000);
963      } finally {
964        resetPreview();
965      }
966    });
967  }
968}
969
970/* ── Speech to Text ── */
971async function initSttSettings() {
972  var provSel = el('set-sttProviderSelect');
973  var modelSelect = el('set-sttModelSelect');
974  var modelInput = el('set-sttModelInput');
975  var modelRow = el('set-sttModelRow');
976  var langRow = el('set-sttLangRow');
977  var langInput = el('set-sttLangInput');
978  var sttMsg = el('set-sttSettingsMsg');
979  var sttEnabledToggle = el('set-sttEnabledToggle');
980  var sttConfigWrap = el('set-sttConfigWrap');
981  // STT was removed from AI Defaults — bail if the UI isn't present.
982  if (!provSel) return;
983
984  function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
985  function getModel() { return isEndpoint() ? modelInput.value : modelSelect.value; }
986
987  function updateVisibility() {
988    var prov = provSel.value;
989    var showModel = prov === 'local' || prov.startsWith('endpoint:');
990    var showLang = prov !== 'disabled';
991    modelRow.style.display = showModel ? 'flex' : 'none';
992    langRow.style.display = showLang ? 'flex' : 'none';
993    if (isEndpoint()) {
994      modelSelect.style.display = 'none'; modelInput.style.display = '';
995    } else {
996      modelSelect.style.display = ''; modelInput.style.display = 'none';
997    }
998  }
999
1000  function syncSttDisabled() {
1001    var off = sttEnabledToggle && !sttEnabledToggle.checked;
1002    var card = sttEnabledToggle ? sttEnabledToggle.closest('.admin-card') : null;
1003    if (card) card.style.opacity = off ? '0.45' : '';
1004    if (sttConfigWrap) sttConfigWrap.style.pointerEvents = off ? 'none' : '';
1005  }
1006
1007  // Effective provider: if toggle is off, treat as disabled regardless of provider select
1008  function effectiveProvider() {
1009    if (sttEnabledToggle && !sttEnabledToggle.checked) return 'disabled';
1010    return provSel.value;
1011  }
1012
1013  // Add API endpoints that might support STT
1014  try {
1015    var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
1016    var endpoints = await epRes.json();
1017    endpoints.forEach(function(ep) {
1018      if (!ep.is_enabled) return;
1019      var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
1020    });
1021  } catch (e) { console.warn('Failed to load endpoints for STT', e); }
1022
1023  // Load saved settings
1024  try {
1025    var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
1026    var settings = await settingsRes.json();
1027    if (settings.stt_provider) provSel.value = settings.stt_provider;
1028    if (settings.stt_model) { modelSelect.value = settings.stt_model; modelInput.value = settings.stt_model; }
1029    if (settings.stt_language) langInput.value = settings.stt_language;
1030    if (sttEnabledToggle) sttEnabledToggle.checked = settings.stt_enabled !== false;
1031  } catch (e) { console.warn('Failed to load STT settings', e); }
1032
1033  syncSttDisabled();
1034  updateVisibility();
1035
1036  async function saveSTT() {
1037    try {
1038      var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false;
1039      await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
1040        headers: { 'Content-Type': 'application/json' },
1041        body: JSON.stringify({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() }) });
1042      sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000);
1043      // Notify voiceRecorder of effective provider and update send button icon
1044      if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider();
1045      if (window._updateSendBtnIcon) window._updateSendBtnIcon();
1046    } catch (e) { sttMsg.textContent = 'Failed to save'; sttMsg.style.color = 'var(--red)'; }
1047  }
1048
1049  provSel.addEventListener('change', function() { updateVisibility(); saveSTT(); });
1050  modelSelect.addEventListener('change', saveSTT);
1051  modelInput.addEventListener('change', saveSTT);
1052  langInput.addEventListener('change', saveSTT);
1053  if (sttEnabledToggle) sttEnabledToggle.addEventListener('change', function() { syncSttDisabled(); saveSTT(); });
1054}
1055
1056/* ═══════════════════════════════════════════
1057   SEARCH TAB
1058   ═══════════════════════════════════════════ */
1059
1060var _searchProviderHints = {
1061  searxng: 'Self-hosted SearXNG instance. Leave URL empty to use the SEARXNG_INSTANCE env var.',
1062  duckduckgo: 'Free search — no API key required. Works out of the box.',
1063  brave: 'Get your API key from brave.com/search/api',
1064  google_pse: 'Requires a Google API key and a Programmable Search Engine ID (CX). Create one at programmablesearchengine.google.com',
1065  tavily: 'AI-optimized search. 1,000 free credits/month at tavily.com',
1066  serper: 'Google results via API. 2,500 free queries at serper.dev',
1067  disabled: 'Web search and deep research tools will be unavailable.',
1068};
1069var _searchNeedsKey = { brave: 1, google_pse: 1, tavily: 1, serper: 1 };
1070var _searchLabels = {
1071  searxng: 'SearXNG', duckduckgo: 'DuckDuckGo', brave: 'Brave Search',
1072  google_pse: 'Google PSE', tavily: 'Tavily', serper: 'Serper', disabled: 'Disabled',
1073};
1074var _searchKeyFields = {
1075  brave: 'brave_api_key', google_pse: 'google_pse_key',
1076  tavily: 'tavily_api_key', serper: 'serper_api_key',
1077};
1078
1079async function initSearchSettings() {
1080  var provSel = el('set-searchProvider');
1081  var countSel = el('set-searchResultCount');
1082  var countCustomInput = el('set-searchResultCountCustom');
1083  var urlInput = el('set-searchUrl');
1084  var urlRow = el('set-searchUrlRow');
1085  var keyInput = el('set-searchApiKey');
1086  var keyRow = el('set-searchKeyRow');
1087  var cxInput = el('set-searchCx');
1088  var cxRow = el('set-searchCxRow');
1089  var hint = el('set-searchHint');
1090  var msg = el('set-searchMsg');
1091  var _settings = {};
1092
1093  function keyFieldFor(prov) { return _searchKeyFields[prov] || ''; }
1094
1095  function loadKeyForProvider(prov) {
1096    var field = keyFieldFor(prov);
1097    keyInput.value = field ? (_settings[field] || _settings.search_api_key || '') : '';
1098  }
1099
1100  function updateVisibility() {
1101    var prov = provSel.value;
1102    urlRow.style.display = prov === 'searxng' ? 'flex' : 'none';
1103    keyRow.style.display = _searchNeedsKey[prov] ? 'flex' : 'none';
1104    cxRow.style.display = prov === 'google_pse' ? 'flex' : 'none';
1105    hint.textContent = _searchProviderHints[prov] || '';
1106    if (prov === 'brave') keyInput.placeholder = 'Brave API key';
1107    else if (prov === 'google_pse') keyInput.placeholder = 'Google API key';
1108    else if (prov === 'tavily') keyInput.placeholder = 'Tavily API key';
1109    else if (prov === 'serper') keyInput.placeholder = 'Serper API key';
1110    else keyInput.placeholder = 'API key';
1111    loadKeyForProvider(prov);
1112  }
1113
1114  function updateCountDisplay() {
1115    var val = _settings.search_result_count || 5;
1116    var presets = ['3', '5', '10', '20'];
1117    if (presets.includes(String(val))) {
1118      countSel.value = String(val);
1119      countCustomInput.style.display = 'none';
1120    } else {
1121      countSel.value = 'custom';
1122      countCustomInput.value = Math.max(1, Math.min(100, val));
1123      countCustomInput.style.display = 'block';
1124    }
1125  }
1126
1127  try {
1128    var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
1129    _settings = await res.json();
1130    if (_settings.search_provider) provSel.value = _settings.search_provider;
1131    updateCountDisplay();
1132    if (_settings.search_url) urlInput.value = _settings.search_url;
1133    if (_settings.google_pse_cx) cxInput.value = _settings.google_pse_cx;
1134  } catch (e) { console.warn('Failed to load search settings', e); }
1135
1136  countSel.addEventListener('change', function() {
1137    if (this.value === 'custom') {
1138      countCustomInput.style.display = 'block';
1139      countCustomInput.focus();
1140    } else {
1141      countCustomInput.style.display = 'none';
1142    }
1143  });
1144
1145  updateVisibility();
1146
1147  async function refreshStatus() {
1148    try {
1149      var sRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
1150      var s = await sRes.json();
1151      _settings = s;
1152      var active = s.search_provider || 'searxng';
1153      var label = _searchLabels[active] || active;
1154      var extra = '';
1155      var kf = keyFieldFor(active);
1156      var hasKey = kf ? ((s[kf] || '').trim() || (s.search_api_key || '').trim()) : false;
1157      if (_searchNeedsKey[active]) {
1158        extra = hasKey ? ' (key set)' : ' (no key)';
1159      } else if (active === 'searxng' && (s.search_url || '').trim()) {
1160        extra = ' (' + s.search_url + ')';
1161      }
1162      var count = s.search_result_count || 5;
1163      msg.textContent = 'Active: ' + label + extra + ' \u00b7 ' + count + ' results';
1164      msg.style.color = active === 'disabled' ? 'var(--red)' : (_searchNeedsKey[active] && !hasKey) ? 'var(--red)' : 'var(--fg)';
1165    } catch (e) { /* ignore */ }
1166  }
1167  refreshStatus();
1168
1169  async function saveSearch() {
1170    try {
1171      var prov = provSel.value;
1172      var resultCount;
1173      if (countSel.value === 'custom') {
1174        var customVal = parseInt(countCustomInput.value, 10);
1175        if (isNaN(customVal) || customVal < 1 || customVal > 100) {
1176          resultCount = _settings.search_result_count || 5;
1177        } else {
1178          resultCount = customVal;
1179        }
1180      } else {
1181        resultCount = parseInt(countSel.value, 10);
1182      }
1183      var payload = {
1184        search_provider: prov,
1185        search_result_count: resultCount,
1186        search_url: urlInput.value.trim(),
1187        google_pse_cx: cxInput.value.trim(),
1188      };
1189      var kf = keyFieldFor(prov);
1190      if (kf) {
1191        payload[kf] = keyInput.value.trim();
1192        _settings[kf] = keyInput.value.trim();
1193      }
1194      await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
1195        headers: { 'Content-Type': 'application/json' },
1196        body: JSON.stringify(payload)
1197      });
1198      msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
1199      setTimeout(refreshStatus, 2000);
1200      if (searchModule && searchModule.refresh) searchModule.refresh();
1201    } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
1202  }
1203
1204  provSel.addEventListener('change', function() { updateVisibility(); saveSearch(); _syncSearchPicker(); });
1205  countSel.addEventListener('change', saveSearch);
1206  urlInput.addEventListener('change', saveSearch);
1207  keyInput.addEventListener('change', saveSearch);
1208  cxInput.addEventListener('change', saveSearch);
1209
1210  // ── Provider picker with logos (mirrors the hidden <select>) ──
1211  var picker = el('search-provider-picker');
1212  var pickerBtn = el('search-provider-btn');
1213  var pickerMenu = el('search-provider-menu');
1214  var pickerCurrent = picker ? picker.querySelector('.adm-provider-current') : null;
1215  function _searchProviderLogoSvg(key) {
1216    return _SEARCH_PROVIDER_LOGOS[key] || '';
1217  }
1218  function _renderSearchPickerMenu() {
1219    if (!pickerMenu) return;
1220    pickerMenu.innerHTML = Array.from(provSel.options).map(function(o) {
1221      var logo = _searchProviderLogoSvg(o.dataset.searchLogo);
1222      var active = o.value === provSel.value ? ' active' : '';
1223      return '<div class="adm-provider-item' + active + '" role="option" data-value="' + o.value.replace(/"/g, '&quot;') + '">' +
1224        '<span class="adm-provider-logo">' + logo + '</span>' +
1225        '<span>' + o.textContent + '</span>' +
1226      '</div>';
1227    }).join('');
1228  }
1229  function _syncSearchPicker() {
1230    if (!pickerCurrent) return;
1231    var opt = provSel.selectedOptions[0] || provSel.options[0];
1232    var logo = _searchProviderLogoSvg(opt.dataset.searchLogo);
1233    pickerCurrent.querySelector('.adm-provider-logo').innerHTML = logo;
1234    pickerCurrent.querySelector('.adm-provider-name').textContent = opt.textContent;
1235  }
1236  if (picker && pickerBtn && pickerMenu && pickerCurrent) {
1237    _renderSearchPickerMenu();
1238    _syncSearchPicker();
1239    pickerBtn.addEventListener('click', function(e) {
1240      e.stopPropagation();
1241      pickerMenu.classList.toggle('hidden');
1242    });
1243    pickerMenu.addEventListener('click', function(e) {
1244      var item = e.target.closest('.adm-provider-item');
1245      if (!item) return;
1246      provSel.value = item.dataset.value;
1247      provSel.dispatchEvent(new Event('change', { bubbles: true }));
1248      pickerMenu.classList.add('hidden');
1249      _renderSearchPickerMenu();
1250    });
1251    document.addEventListener('click', function(e) {
1252      if (!picker.contains(e.target)) pickerMenu.classList.add('hidden');
1253    });
1254  }
1255
1256  // ── Fallback chain ──
1257  // Stored as an ordered array of provider IDs (primary not included).
1258  // When the primary fails or hits rate-limit, the backend walks this
1259  // list in order trying each one.
1260  var fbWrap = el('set-searchFallbackChain');
1261  function _availableFallbackOptions() {
1262    var primary = provSel.value;
1263    var chain = _settings.search_fallback_chain || [];
1264    var inChain = new Set(chain.concat([primary, 'disabled']));
1265    return Array.from(provSel.options)
1266      .map(function(o) { return { value: o.value, label: o.textContent, logo: o.dataset.searchLogo }; })
1267      .filter(function(o) { return !inChain.has(o.value); });
1268  }
1269  function _renderFallbackChain() {
1270    if (!fbWrap) return;
1271    var chain = (_settings.search_fallback_chain || []).slice();
1272    var esc = function(s) { return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;'); };
1273    var chipsHtml = chain.map(function(p, i) {
1274      var label = _searchLabels[p] || p;
1275      var logo = _SEARCH_PROVIDER_LOGOS[p] || '';
1276      return '<span class="search-fb-chip" draggable="true" data-idx="' + i + '" data-value="' + esc(p) + '">' +
1277        '<span class="search-fb-grip" title="Drag to reorder">⋮⋮</span>' +
1278        '<span class="search-fb-logo">' + logo + '</span>' +
1279        '<span>' + esc(label) + '</span>' +
1280        '<button type="button" class="search-fb-remove" data-value="' + esc(p) + '" title="Remove">&times;</button>' +
1281      '</span>';
1282    }).join('');
1283    var addOptions = _availableFallbackOptions();
1284    var addSelect = addOptions.length
1285      ? '<select class="search-fb-add" id="search-fb-add"><option value="">+ Add</option>' +
1286          addOptions.map(function(o) { return '<option value="' + esc(o.value) + '">' + esc(o.label) + '</option>'; }).join('') +
1287        '</select>'
1288      : '';
1289    fbWrap.innerHTML = chipsHtml + addSelect;
1290    // Wire chip remove + drag-reorder + add
1291    fbWrap.querySelectorAll('.search-fb-remove').forEach(function(btn) {
1292      btn.addEventListener('click', function() {
1293        var next = (_settings.search_fallback_chain || []).filter(function(p) { return p !== btn.dataset.value; });
1294        _saveFallbackChain(next);
1295      });
1296    });
1297    var addSel = el('search-fb-add');
1298    if (addSel) {
1299      addSel.addEventListener('change', function() {
1300        if (!addSel.value) return;
1301        var next = (_settings.search_fallback_chain || []).slice();
1302        if (!next.includes(addSel.value)) next.push(addSel.value);
1303        _saveFallbackChain(next);
1304      });
1305    }
1306    // Drag-reorder
1307    var dragging = null;
1308    fbWrap.querySelectorAll('.search-fb-chip').forEach(function(chip) {
1309      chip.addEventListener('dragstart', function() {
1310        dragging = chip; chip.classList.add('dragging');
1311      });
1312      chip.addEventListener('dragend', function() {
1313        if (dragging) dragging.classList.remove('dragging');
1314        dragging = null;
1315        // Persist new order
1316        var order = Array.from(fbWrap.querySelectorAll('.search-fb-chip')).map(function(c) { return c.dataset.value; });
1317        _saveFallbackChain(order);
1318      });
1319      chip.addEventListener('dragover', function(e) {
1320        e.preventDefault();
1321        if (!dragging || dragging === chip) return;
1322        var rect = chip.getBoundingClientRect();
1323        var after = (e.clientX - rect.left) > rect.width / 2;
1324        chip.parentNode.insertBefore(dragging, after ? chip.nextSibling : chip);
1325      });
1326    });
1327  }
1328  async function _saveFallbackChain(chain) {
1329    _settings.search_fallback_chain = chain;
1330    try {
1331      await fetch('/api/auth/settings', {
1332        method: 'POST', credentials: 'same-origin',
1333        headers: { 'Content-Type': 'application/json' },
1334        body: JSON.stringify({ search_fallback_chain: chain }),
1335      });
1336      msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
1337      setTimeout(refreshStatus, 2000);
1338    } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
1339    _renderFallbackChain();
1340  }
1341  _renderFallbackChain();
1342  // Re-render whenever the primary changes (it gets filtered out of "Add").
1343  provSel.addEventListener('change', _renderFallbackChain);
1344
1345  // ── Test button ── runs a one-off query against the configured provider.
1346  var testBtn = el('set-searchTestBtn');
1347  if (testBtn) {
1348    testBtn.addEventListener('click', async function() {
1349      var prov = provSel.value;
1350      if (!prov || prov === 'disabled') {
1351        msg.textContent = 'Pick a provider first';
1352        msg.style.color = 'var(--red)';
1353        return;
1354      }
1355      // Persist current form values first so the test uses what's on screen.
1356      await saveSearch();
1357      testBtn.disabled = true;
1358      var orig = testBtn.textContent;
1359      testBtn.textContent = 'Testing...';
1360      msg.textContent = '';
1361      var t0 = performance.now();
1362      try {
1363        var fd = new FormData();
1364        fd.append('query', 'hello world');
1365        fd.append('provider', prov);
1366        fd.append('count', '3');
1367        var r = await fetch('/api/search/query', { method: 'POST', body: fd, credentials: 'same-origin' });
1368        var d = await r.json();
1369        var ms = Math.round(performance.now() - t0);
1370        if (d.error) {
1371          msg.textContent = '✗ ' + d.error + ' (' + ms + 'ms)';
1372          msg.style.color = 'var(--red)';
1373        } else if (!d.results || !d.results.length) {
1374          msg.textContent = '⚠ No results returned (' + ms + 'ms)';
1375          msg.style.color = 'var(--red)';
1376        } else {
1377          var topTitle = (d.results[0].title || d.results[0].url || '').slice(0, 60);
1378          msg.textContent = '✓ ' + d.results.length + ' result' + (d.results.length === 1 ? '' : 's') + ' · ' + ms + 'ms · top: ' + topTitle;
1379          msg.style.color = 'var(--fg)';
1380        }
1381      } catch (e) {
1382        msg.textContent = '✗ Test failed: ' + (e && e.message ? e.message : e);
1383        msg.style.color = 'var(--red)';
1384      } finally {
1385        testBtn.disabled = false; testBtn.textContent = orig;
1386      }
1387    });
1388  }
1389}
1390
1391// SVG logos for each search provider (16×16 viewBox normalised to 24×24).
1392var _SEARCH_PROVIDER_LOGOS = {
1393  searxng:   '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 4a6 6 0 1 0 0 12 6 6 0 0 0 0-12zm0-2a8 8 0 1 1-4.93 14.32l-3.4 3.4a1 1 0 1 1-1.4-1.4l3.4-3.4A8 8 0 0 1 10 2zM13 8.5L11.5 10 13 11.5l-1 1L10.5 11 9 12.5l-1-1L9.5 10 8 8.5l1-1L10.5 9 12 7.5z"/></svg>',
1394  duckduckgo:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-1.5 5.5a1.2 1.2 0 1 1 0 2.4 1.2 1.2 0 0 1 0-2.4zm5 0a1.2 1.2 0 1 1 0 2.4 1.2 1.2 0 0 1 0-2.4zM12 13c-1.5 0-3.6.8-3.6 2.5C8.4 17.2 10.4 18 12 18s3.6-.8 3.6-2.5C15.6 13.8 13.5 13 12 13z"/></svg>',
1395  brave:     '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 4l-1.5 1L15 3l-3 .5L9 3 6.5 5 5 4 3 7l1.5 2L4 12l3 5 4 3 1 1 1-1 4-3 3-5-.5-3L21 7l-2-3zM12 17l-2.5-2 .5-3-2-1.5 2-1.5L11 7l3-1 3 1-.5 2 2 1.5-2 1.5.5 3L14.5 17 12 17z"/></svg>',
1396  google_pse:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21.35 11.1H12v3.2h5.35c-.5 2.4-2.55 4-5.35 4-3.25 0-5.9-2.65-5.9-5.9s2.65-5.9 5.9-5.9c1.55 0 2.95.55 4.05 1.55l2.4-2.4C16.85 4.05 14.55 3 12 3 7 3 3 7 3 12s4 9 9 9c5.2 0 8.65-3.65 8.65-8.8 0-.4-.05-.7-.3-1.1z"/></svg>',
1397  tavily:    '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L2 8.5l4 2.5v6l6 3.5 6-3.5v-6l4-2.5L12 2zm-4 9.5L12 14l4-2.5V16l-4 2.5L8 16v-4.5z"/></svg>',
1398  serper:    '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11 4a7 7 0 1 0 4.2 12.6l4.5 4.5 1.4-1.4-4.5-4.5A7 7 0 0 0 11 4zm0 2a5 5 0 1 1 0 10 5 5 0 0 1 0-10zm-1 2v2H8v2h2v2h2v-2h2V10h-2V8h-2z"/></svg>',
1399  disabled:  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
1400};
1401
1402/* ── Deep Research Model (AI tab) ── */
1403async function initResearchSettings() {
1404  var epSel = el('set-researchEndpoint');
1405  var modelSel = el('set-researchModel');
1406  var tokensInput = el('set-researchMaxTokens');
1407  var extractTimeoutInput = el('set-researchExtractTimeout');
1408  var extractConcurrencyInput = el('set-researchExtractConcurrency');
1409  var runTimeoutInput = el('set-researchRunTimeout');
1410  var msg = el('set-researchMsg');
1411  var endpoints = [];
1412
1413  try {
1414    endpoints = await _fetchModelEndpoints();
1415    _fillEndpointSelect(epSel, endpoints, epSel.value, true);
1416  } catch (e) { console.warn('Failed to load endpoints for research', e); }
1417
1418  function refreshModels(selectedModel) {
1419    var epId = epSel.value;
1420    var ep = endpoints.find(function(e) { return e.id === epId; });
1421    _fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true);
1422  }
1423
1424  try {
1425    var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
1426    var settings = await res.json();
1427    if (settings.research_endpoint_id) epSel.value = settings.research_endpoint_id;
1428    refreshModels(settings.research_model || '');
1429    if (settings.research_max_tokens) tokensInput.value = settings.research_max_tokens;
1430    if (settings.research_extraction_timeout_seconds) extractTimeoutInput.value = settings.research_extraction_timeout_seconds;
1431    if (settings.research_extraction_concurrency) extractConcurrencyInput.value = settings.research_extraction_concurrency;
1432    if (settings.research_run_timeout_seconds !== undefined && settings.research_run_timeout_seconds !== null) {
1433      runTimeoutInput.value = settings.research_run_timeout_seconds;
1434    }
1435  } catch (e) { console.warn('Failed to load research settings', e); }
1436
1437  function showStatus() {
1438    var parts = [];
1439    if (epSel.value) {
1440      var epName = epSel.options[epSel.selectedIndex].textContent;
1441      var mName = modelSel.value ? modelSel.value.split('/').pop() : 'auto';
1442      parts.push(epName + ' / ' + mName);
1443    }
1444    if (tokensInput.value) {
1445      parts.push('Max tokens: ' + tokensInput.value);
1446    }
1447    if (extractTimeoutInput.value) {
1448      parts.push('Extract: ' + extractTimeoutInput.value + 's');
1449    }
1450    if (extractConcurrencyInput.value) {
1451      parts.push('Parallel: ' + extractConcurrencyInput.value);
1452    }
1453    if (runTimeoutInput.value !== '') {
1454      var rtv = parseInt(runTimeoutInput.value, 10);
1455      if (!isNaN(rtv)) {
1456        parts.push(rtv === 0 ? 'Max time: no limit' : 'Max time: ' + rtv + 's');
1457      }
1458    }
1459    if (parts.length) {
1460      msg.textContent = parts.join(' · ');
1461      msg.style.color = 'var(--fg)';
1462    } else {
1463      msg.textContent = 'Using chat defaults';
1464      msg.style.color = 'var(--fg)';
1465    }
1466  }
1467  showStatus();
1468
1469  async function saveResearch() {
1470    var payload = {
1471      research_endpoint_id: epSel.value,
1472      research_model: modelSel.value,
1473    };
1474    var tv = parseInt(tokensInput.value, 10);
1475    if (tv && tv >= 1024) payload.research_max_tokens = tv;
1476    var et = parseInt(extractTimeoutInput.value, 10);
1477    if (et && et >
1477= 15 && et <= 3600) payload.research_extraction_timeout_seconds = et;
1478    var ec = parseInt(extractConcurrencyInput.value, 10);
1479    if (ec && ec >= 1 && ec <= 12) payload.research_extraction_concurrency = ec;
1480    if (runTimeoutInput.value !== '') {
1481      var rt = parseInt(runTimeoutInput.value, 10);
1482      // 0 = no limit (disables the hard timeout); otherwise 60s..86400s (24h)
1483      if (!isNaN(rt) && (rt === 0 || (rt >= 60 && rt <= 86400))) {
1484        payload.research_run_timeout_seconds = rt;
1485      }
1486    }
1487    try {
1488      await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
1489        headers: { 'Content-Type': 'application/json' },
1490        body: JSON.stringify(payload)
1491      });
1492      msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
1493      setTimeout(showStatus, 2000);
1494    } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
1495  }
1496
1497  epSel.addEventListener('change', async function() {
1498    refreshModels('');
1499    saveResearch();
1500  });
1501  modelSel.addEventListener('change', saveResearch);
1502  tokensInput.addEventListener('change', saveResearch);
1503  extractTimeoutInput.addEventListener('change', saveResearch);
1504  extractConcurrencyInput.addEventListener('change', saveResearch);
1505  runTimeoutInput.addEventListener('change', saveResearch);
1506
1507  _registerAiEndpointRefresh(function(nextEndpoints) {
1508    endpoints = nextEndpoints;
1509    _fillEndpointSelect(epSel, endpoints, epSel.value, true);
1510    refreshModels(modelSel.value);
1511  });
1512}
1513
1514/* ── Deep Research Search (Search tab) ── */
1515async function initResearchSearchSettings() {
1516  var searchSel = el('set-researchSearch');
1517  var msg = el('set-researchSearchMsg');
1518
1519  function updateSearchOptions(settings) {
1520    var options = searchSel.querySelectorAll('option');
1521    options.forEach(function(opt) {
1522      var prov = opt.value;
1523      if (!prov) return;
1524      var kf = _searchKeyFields[prov];
1525      if (!kf) return;
1526      var hasKey = ((settings[kf] || '').trim() || (settings.search_api_key || '').trim());
1527      if (!hasKey) {
1528        opt.textContent = (_searchLabels[prov] || prov) + ' (no key)';
1529        opt.style.color = 'var(--red)';
1530      } else {
1531        opt.textContent = _searchLabels[prov] || prov;
1532        opt.style.color = '';
1533      }
1534    });
1535  }
1536
1537  try {
1538    var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
1539    var settings = await res.json();
1540    if (settings.research_search_provider) searchSel.value = settings.research_search_provider;
1541    updateSearchOptions(settings);
1542  } catch (e) { console.warn('Failed to load research search settings', e); }
1543
1544  async function saveResearchSearch() {
1545    try {
1546      await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
1547        headers: { 'Content-Type': 'application/json' },
1548        body: JSON.stringify({ research_search_provider: searchSel.value })
1549      });
1550      msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
1551      setTimeout(function() { msg.textContent = ''; }, 2000);
1552    } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
1553  }
1554
1555  searchSel.addEventListener('change', saveResearchSearch);
1556}
1557
1558/* ── Agent Settings (AI tab) ── */
1559async function initAgentSettings() {
1560  var toolsInput = el('set-agentMaxTools');
1561  var roundsInput = el('set-agentMaxRounds');
1562  var msg = el('set-agentMsg');
1563  if (!toolsInput) return;
1564
1565  try {
1566    var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
1567    var settings = await res.json();
1568    if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
1569    if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
1570  } catch (e) {}
1571
1572  // Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
1573  // when blank/non-numeric. Mirrors the server-side validation.
1574  function clampInt(raw, lo, hi, dflt) {
1575    var n = parseInt(raw, 10);
1576    if (isNaN(n)) return dflt;
1577    return Math.max(lo, Math.min(n, hi));
1578  }
1579
1580  async function save() {
1581    var tools = clampInt(toolsInput.value, 0, 1000, 0);
1582    var rounds = roundsInput ? clampInt(roundsInput.value, 1, 200, 20) : null;
1583    toolsInput.value = tools;                       // reflect the clamped value
1584    if (roundsInput) roundsInput.value = rounds;
1585    var payload = { agent_max_tool_calls: tools };
1586    if (rounds != null) payload.agent_max_rounds = rounds;
1587    try {
1588      await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
1589        headers: { 'Content-Type': 'application/json' },
1590        body: JSON.stringify(payload)
1591      });
1592      msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
1593        (rounds != null ? ' · ' + rounds + ' steps/message' : '');
1594      msg.style.color = 'var(--fg)';
1595    } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
1596  }
1597
1598  toolsInput.addEventListener('change', save);
1599  if (roundsInput) roundsInput.addEventListener('change', save);
1600  var cur = parseInt(toolsInput.value, 10) || 0;
1601  var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
1602  msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
1603    (curR != null ? ' · ' + curR + ' steps/message' : '');
1604}
1605
1606/* ═══════════════════════════════════════════
1607   APPEARANCE TAB
1608   ═══════════════════════════════════════════ */
1609function initAppearance() {
1610  syncAppearanceCheckboxes();
1611  syncPrivacyCheckboxes();
1612
1613  modalEl.querySelectorAll('[data-ui-key]').forEach(function(chk) {
1614    chk.addEventListener('change', async function() {
1615      var key = chk.dataset.uiKey;
1616
1617      if (window.UI_VIS_ADMIN_ONLY && window.UI_VIS_ADMIN_ONLY.has(key) && !chk.checked && !window._isAdmin) {
1618        chk.checked = true;
1619        if (uiModule && uiModule.showToast) {
1620          uiModule.showToast('Only admins can hide Settings.');
1621        }
1622        return;
1623      }
1624
1625      // Hiding the Settings cog removes the only visible way to re-open this
1626      // panel. Warn the user and remind them about the `/settings` slash
1627      // command so they don't lock themselves out.
1628      if (key === 'sidebar-settings-btn' && !chk.checked) {
1629        var ok = true;
1630        try {
1631          ok = await (uiModule && uiModule.styledConfirm
1632            ? uiModule.styledConfirm(
1633                'Hide the Settings cog?\n\nYou can re-open this panel any time by typing /settings in the chat input.',
1634                { confirmText: 'Hide', cancelText: 'Cancel' }
1635              )
1636            : Promise.resolve(window.confirm('Hide the Settings cog?\n\nYou can re-open this panel any time by typing /settings in the chat input.')));
1637        } catch (_) { ok = false; }
1638        if (!ok) {
1639          chk.checked = true;
1640          return;
1641        }
1642        if (uiModule && uiModule.showToast) {
1643          uiModule.showToast('Settings cog hidden — type /settings to bring it back.', 5000);
1644        }
1645      }
1646
1647      var s = window.loadUIVis();
1648      s[key] = chk.checked;
1649      window.saveUIVis(s);
1650      window.applyUIVis(s);
1651    });
1652  });
1653
1654  modalEl.querySelectorAll('[data-privacy-key]').forEach(function(chk) {
1655    chk.addEventListener('change', function() {
1656      if (chk.dataset.privacyKey !== 'sensitive-blur') return;
1657      localStorage.setItem('odysseus-sensitive-blur', chk.checked ? 'on' : 'off');
1658      window.dispatchEvent(new CustomEvent('odysseus-sensitive-blur-change', {
1659        detail: { enabled: chk.checked }
1660      }));
1661    });
1662  });
1663
1664  var resetBtn = el('set-uiVisResetBtn');
1665  if (resetBtn) {
1666    resetBtn.addEventListener('click', function() {
1667      localStorage.removeItem('odysseus-ui-visibility');
1668      syncAppearanceCheckboxes();
1669      syncPrivacyCheckboxes();
1670      window.applyUIVis({});
1671    });
1672  }
1673}
1674
1675function syncAppearanceCheckboxes() {
1676  var s = window.loadUIVis ? window.loadUIVis() : {};
1677  var defaultOff = window.UI_VIS_DEFAULT_OFF || new Set();
1678  modalEl.querySelectorAll('[data-ui-key]').forEach(function(chk) {
1679    var key = chk.dataset.uiKey;
1680    chk.checked = key in s ? s[key] !== false : !defaultOff.has(key);
1681  });
1682}
1683
1684function syncPrivacyCheckboxes() {
1685  modalEl.querySelectorAll('[data-privacy-key="sensitive-blur"]').forEach(function(chk) {
1686    chk.checked = localStorage.getItem('odysseus-sensitive-blur') === 'on';
1687  });
1688}
1689
1690/* ═══════════════════════════════════════════
1691   SHORTCUTS TAB
1692   ═══════════════════════════════════════════ */
1693
1694const SHORTCUT_DEFAULTS = {
1695  search:         'ctrl+k',
1696  toggle_sidebar: 'ctrl+b',
1697  new_session:    'ctrl+alt+n',
1698  fav_session:    'ctrl+alt+f',
1699  delete_session: 'ctrl+alt+d',
1700  cancel:         'escape',
1701  tts:            'alt+shift+t',
1702  incognito:      'ctrl+alt+i',
1703  settings:       'ctrl+,',
1704  focus_input:    'ctrl+/',
1705  // Open-tool shortcuts. Calendar is bound by default; the rest are
1706  // unbound (empty) so the user can assign their own in the panel.
1707  open_calendar:  'ctrl+alt+c',
1708  open_compare:   '',
1709  open_cookbook:  '',
1710  open_research:  '',
1711  open_gallery:   '',
1712  open_library:   '',
1713  open_memory:    '',
1714  open_notes:     '',
1715  open_tasks:     '',
1716  open_theme:     '',
1717};
1718
1719const SHORTCUT_ICONS = {
1720  search:         '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="10" cy="10" r="7"/><path d="M21 21l-4.35-4.35"/></svg>',
1721  toggle_sidebar: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>',
1722  new_session:    '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
1723  fav_session:    '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
1724  delete_session: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
1725  cancel:         '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
1726  tts:            '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>',
1727  incognito:      '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/>
1727<line x1="8" y1="8" x2="16" y2="16"/></svg>',
1728  settings:       '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
1729  focus_input:    '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
1730  open_calendar:  '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
1731  open_compare:   '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>',
1732  open_cookbook:  '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
1733  open_research:  '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
1734  open_gallery:   '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>',
1735  open_library:   '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
1736  open_memory:    '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 0 1 7 7c0 2.4-1.2 4.5-3 5.7V17a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2.3C6.2 13.5 5 11.4 5 9a7 7 0 0 1 7-7z"/><line x1="10" y1="22" x2="14" y2="22"/></svg>',
1737  open_notes:     '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h10l4 4v14H5z"/><path d="M15 3v5h5"/><path d="M8 17.5 15.5 10l2.5 2.5L10.5 20H8z"/></svg>',
1738  open_tasks:     '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M9 16l2 2 4-4"/></svg>',
1739  open_theme:     '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 0 0 20 5 5 0 0 0 5-5 3 3 0 0 0-3-3h-2a3 3 0 0 1-3-3 5 5 0 0 1 5-5"/></svg>',
1740};
1741
1742const SHORTCUT_LABELS = {
1743  search:         'Search conversations',
1744  toggle_sidebar: 'Toggle sidebar',
1745  new_session:    'New session',
1746  fav_session:    'Favorite session',
1747  delete_session: 'Delete session',
1748  cancel:         'Cancel / close',
1749  tts:            'Play/stop TTS',
1750  incognito:      'Toggle incognito',
1751  settings:       'Toggle Window',
1752  focus_input:    'Focus chat input',
1753  open_calendar:  'Open Calendar',
1754  open_compare:   'Open Compare',
1755  open_cookbook:  'Open Cookbook',
1756  open_research:  'Open Deep Research',
1757  open_gallery:   'Open Gallery',
1758  open_library:   'Open Library',
1759  open_memory:    'Open Memory',
1760  open_notes:     'Open Notes',
1761  open_tasks:     'Open Tasks',
1762  open_theme:     'Open Theme',
1763};
1764
1765const SHORTCUT_CATEGORIES = [
1766  { name: 'Navigation', keys: ['search', 'toggle_sidebar', 'focus_input', 'settings'] },
1767  { name: 'Sessions', keys: ['new_session', 'fav_session', 'delete_session'] },
1768  { name: 'Tools', keys: ['incognito', 'tts', 'cancel'] },
1769  { name: 'Open Tools', keys: ['open_calendar', 'open_compare', 'open_cookbook', 'open_research', 'open_gallery', 'open_library', 'open_memory', 'open_notes', 'open_tasks', 'open_theme'] },
1770];
1771
1772function _formatKeyCaps(combo) {
1773  return combo.split('+').map(p => {
1774    let label;
1775    if (p === 'ctrl') label = 'Ctrl';
1776    else if (p === 'alt') label = 'Alt';
1777    else if (p === 'shift') label = 'Shift';
1778    else if (p === 'meta') label = 'Cmd';
1779    else if (p === 'escape') label = 'Esc';
1780    else if (p === ',') label = ',';
1781    else if (p === '/') label = '/';
1782    else if (p === 'space') label = 'Space';
1783    else label = p.charAt(0).toUpperCase() + p.slice(1);
1784    return `<kbd>${label}</kbd>`;
1785  }).join('');
1786}
1787
1788function _comboFromEvent(e) {
1789  // Drop a stray AltGr keystroke (e.g. AltGr+E to type €) so it isn't recorded
1790  // as a bogus ctrl+alt+<char> binding — onKey ignores empty combos. See
1791  // platform.js for the macOS carve-out and Windows trade-off.
1792  if (isAltGrEvent(e)) return '';
1793  const parts = [];
1794  if (e.ctrlKey || e.metaKey) parts.push('ctrl');
1795  if (e.altKey) parts.push('alt');
1796  if (e.shiftKey) parts.push('shift');
1797  const key = e.key.toLowerCase();
1798  if (!['control', 'alt', 'shift', 'meta'].includes(key)) {
1799    parts.push(key === ' ' ? 'space' : key);
1800  }
1801  return parts.join('+');
1802}
1803
1804async function initShortcuts() {
1805  const listEl = el('shortcuts-list');
1806  const resetBtn = el('shortcuts-reset-btn');
1807  if (!listEl) return;
1808
1809  // Load saved keybinds
1810  let keybinds = { ...SHORTCUT_DEFAULTS };
1811  try {
1812    const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
1813    const settings = await res.json();
1814    if (settings.keybinds) keybinds = { ...keybinds, ...settings.keybinds };
1815  } catch (e) {}
1816
1817  function _findConflicts() {
1818    const comboMap = {};
1819    for (const [action, combo] of Object.entries(keybinds)) {
1820      if (!comboMap[combo]) comboMap[combo] = [];
1821      comboMap[combo].push(action);
1822    }
1823    const conflicts = new Set();
1824    for (const actions of Object.values(comboMap)) {
1825      if (actions.length > 1) actions.forEach(a => conflicts.add(a));
1826    }
1827    return conflicts;
1828  }
1829
1830  function render() {
1831    listEl.innerHTML = '';
1832    const conflicts = _findConflicts();
1833
1834    for (const cat of SHORTCUT_CATEGORIES) {
1835      const catHeader = document.createElement('div');
1836      catHeader.className = 'shortcut-category';
1837      catHeader.textContent = cat.name;
1838      listEl.appendChild(catHeader);
1839
1840      for (const action of cat.keys) {
1841        if (!(action in keybinds)) continue;
1842        const combo = keybinds[action];
1843        // Unbound shortcuts (empty combo) still render so the user can
1844        // assign one \u2014 they show a "Set" affordance instead of keycaps.
1845        const label = SHORTCUT_LABELS[action] || action;
1846        const icon = SHORTCUT_ICONS[action] || '';
1847        const isCustom = combo !== (SHORTCUT_DEFAULTS[action] || '');
1848        const hasConflict = combo && conflicts.has(action);
1849        const row = document.createElement('div');
1850        row.className = 'shortcut-row' + (hasConflict ? ' shortcut-conflict' : '');
1851        row.dataset.action = action;
1852        const keyContent = combo ? _formatKeyCaps(combo) : '<span class="shortcut-unset">Set</span>';
1853        row.innerHTML = `
1854          <span class="shortcut-label"><span class="shortcut-icon">${icon}</span>${esc(label)}${hasConflict ? '<span class="shortcut-warn" title="Duplicate shortcut">!</span>' : ''}</span>
1855          <div class="shortcut-controls">
1856            <span class="shortcut-hint" hidden></span>
1857            <button class="shortcut-key${combo ? '' : ' shortcut-key-unset'}" data-action="${action}" title="Click to rebind">${keyContent}</button>
1858            <button class="shortcut-action-btn ${isCustom ? 'is-reset' : ''}" data-action="${action}" title="${isCustom ? 'Reset to default' : 'Confirm'}" style="${isCustom ? '' : 'visibility:hidden'}">
1859              ${isCustom ? '\u21A9' : '\u2713'}
1860            </button>
1861          </div>
1862        `;
1863        listEl.appendChild(row);
1864      }
1865    }
1866
1867    listEl.querySelectorAll('.shortcut-key').forEach(btn => {
1868      btn.addEventListener('click', () => startRebind(btn));
1869    });
1870
1871    listEl.querySelectorAll('.shortcut-action-btn.is-reset').forEach(btn => {
1872      btn.addEventListener('click', () => {
1873        const action = btn.dataset.action;
1874        keybinds[action] = SHORTCUT_DEFAULTS[action];
1875        saveKeybinds();
1876        render();
1877      });
1878    });
1879  }
1880
1881  function startRebind(btn) {
1882    const action = btn.dataset.action;
1883    const row = btn.closest('.shortcut-row');
1884    const actionBtn = row.querySelector('.shortcut-action-btn');
1885    const hintEl = row.querySelector('.shortcut-hint');
1886
1887    // Remove any other active rebind
1888    listEl.querySelectorAll('.shortcut-key.listening').forEach(b => {
1889      b.classList.remove('listening');
1890      b.innerHTML = _formatKeyCaps(keybinds[b.dataset.action]);
1891      const otherRow = b.closest('.shortcut-row');
1892      const otherAction = otherRow.querySelector('.shortcut-action-btn');
1893      if (otherAction && !otherAction.classList.contains('is-reset')) otherAction.style.visibility = 'hidden';
1894    });
1895
1896    btn.classList.add('listening');
1897    btn.textContent = 'Press keys...';
1898    // Show confirm button
1899    actionBtn.textContent = '\u2713';
1900    actionBtn.classList.remove('is-reset');
1901    actionBtn.style.visibility = 'visible';
1902    actionBtn.title = 'Confirm';
1903    // Hint: tell the user how to commit / cancel the rebind.
1904    if (hintEl) {
1905      hintEl.hidden = false;
1906      hintEl.textContent = 'press a key';
1907    }
1908
1909    let pendingCombo = null;
1910
1911    // Wire confirm button
1912    const confirmHandler = () => {
1913      if (pendingCombo) {
1914        keybinds[action] = pendingCombo;
1915        saveKeybinds();
1916      }
1917      cleanup();
1918      render();
1919    };
1920    actionBtn.addEventListener('click', confirmHandler, { once: true });
1921
1922    function onKey(e) {
1923      e.preventDefault();
1924      e.stopPropagation();
1925
1926      if (e.key === 'Escape') {
1927        cleanup();
1928        btn.innerHTML = _formatKeyCaps(keybinds[action]);
1929        const isCustom = keybinds[action] !== SHORTCUT_DEFAULTS[action];
1930        if (isCustom) {
1931          actionBtn.textContent = '\u21A9';
1932          actionBtn.classList.add('is-reset');
1933          actionBtn.title = 'Reset to default';
1934        } else {
1935          actionBtn.style.visibility = 'hidden';
1936        }
1937        return;
1938      }
1939
1940      // Enter commits the previewed combo (same as clicking \u2713). Only acts
1941      // as commit once a combo has been captured \u2014 otherwise it would just
1942      // try to bind Enter itself.
1943      if (e.key === 'Enter' && pendingCombo) {
1944        confirmHandler();
1945        return;
1946      }
1947
1948      const combo = _comboFromEvent(e);
1949      if (!combo || combo === 'ctrl' || combo === 'alt' || combo === 'shift' || combo === 'ctrl+alt' || combo === 'ctrl+shift' || combo === 'alt+shift' || combo === 'ctrl+alt+shift') return;
1950
1951      // Preview the combo, wait for confirm
1952      pendingCombo = combo;
1953      btn.innerHTML = _formatKeyCaps(combo);
1954      // Now that a combo is captured, prompt to commit with Enter.
1955      if (hintEl) hintEl.textContent = '\u21B5 Enter to save';
1956    }
1957
1958    function cleanup() {
1959      btn.classList.remove('listening');
1960      if (hintEl) { hintEl.hidden = true; hintEl.textContent = ''; }
1961      document.removeEventListener('keydown', onKey, true);
1962      actionBtn.removeEventListener('click', confirmHandler);
1963    }
1964
1965    document.addEventListener('keydown', onKey, true);
1966  }
1967
1968  async function saveKeybinds() {
1969    try {
1970      await fetch('/api/auth/settings', {
1971        method: 'POST', credentials: 'same-origin',
1972        headers: { 'Content-Type': 'application/json' },
1973        body: JSON.stringify({ keybinds }),
1974      });
1975      // Update global keybinds so they take effect immediately
1976      window._odysseusKeybinds = keybinds;
1977      if (uiModule && uiModule.showToast) uiModule.showToast('Shortcut saved');
1978    } catch (e) {
1979      console.error('Failed to save keybinds:', e);
1980    }
1981  }
1982
1983  if (resetBtn) {
1984    resetBtn.addEventListener('click', async () => {
1985      keybinds = { ...SHORTCUT_DEFAULTS };
1986      render();
1987      await saveKeybinds();
1988      if (uiModule && uiModule.showToast) uiModule.showToast('Shortcuts reset to defaults');
1989    });
1990  }
1991
1992  render();
1993}
1994
1995/* ═══════════════════════════════════════════
1996   INIT & REFRESH
1997   ═══════════════════════════════════════════ */
1998function initAccount() {
1999  // Populate user info
2000  fetch('/api/auth/status', { credentials: 'same-origin' })
2001    .then(r => r.json())
2002    .then(d => {
2003      const nameEl = el('settings-account-username');
2004      const roleEl = el('settings-account-role');
2005      const avatarEl = el('settings-account-avatar');
2006      if (nameEl) nameEl.textContent = d.username || 'Unknown';
2007      if (roleEl) roleEl.textContent = d.is_admin ? 'Admin' : 'User';
2008      if (avatarEl) {
2009        const initial = (d.username || '?')[0].toUpperCase();
2010        avatarEl.textContent = initial;
2011      }
2012    }).catch(() => {});
2013
2014  // Change password
2015  const saveBtn = el('settings-pw-save');
2016  const msgEl = el('settings-pw-msg');
2017  if (saveBtn) {
2018    saveBtn.addEventListener('click', async () => {
2019      const cur = el('settings-pw-current').value;
2020      const nw = el('settings-pw-new').value;
2021      const conf = el('settings-pw-confirm').value;
2022      msgEl.style.color = '';
2023      if (!cur || !nw) { msgEl.textContent = 'Fill in all fields'; msgEl.style.color = 'var(--red)'; return; }
2024      if (nw.length < 8) { msgEl.textContent = 'Min 8 characters'; msgEl.style.color = 'var(--red)'; return; }
2025      if (nw !== conf) { msgEl.textContent = 'Passwords don\'t match'; msgEl.style.color = 'var(--red)'; return; }
2026      saveBtn.disabled = true;
2027      try {
2028        const res = await fetch('/api/auth/change-password', {
2029          method: 'POST', credentials: 'same-origin',
2030          headers: { 'Content-Type': 'application/json' },
2031          body: JSON.stringify({ current_password: cur, new_password: nw })
2032        });
2033        if (!res.ok) { const d = await res.json(); throw new Error(d.detail || 'Failed'); }
2034        msgEl.style.color = 'var(--green)';
2035        msgEl.textContent = 'Password updated';
2036        el('settings-pw-current').value = '';
2037        el('settings-pw-new').value = '';
2038        el('settings-pw-confirm').value = '';
2039      } catch (e) {
2040        msgEl.style.color = 'var(--red)';
2041        msgEl.textContent = e.message;
2042      } finally {
2043        saveBtn.disabled = false;
2044      }
2045    });
2046  }
2047
2048  // ── Two-Factor Authentication ──
2049  const tfaContent = el('settings-2fa-content');
2050  if (tfaContent) {
2051    async function render2FA() {
2052      try {
2053        const res = await fetch('/api/auth/2fa/status', { credentials: 'same-origin' });
2054        const data = await res.json();
2055        if (data.enabled) {
2056          // 2FA is ON — show disable option
2057          tfaContent.innerHTML = `
2058            <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
2059              <span style="color:var(--color-save-green, #4caf50);font-size:12px;font-weight:600;">&#x2713; Enabled</span>
2060              <span style="font-size:11px;opacity:0.5;">Authenticator app required on login</span>
2061            </div>
2062            <input id="tfa-disable-pw" type="password" placeholder="Enter password to disable" autocomplete="current-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;width:100%;box-sizing:border-box;margin-bottom:6px;">
2063            <div class="settings-row" style="justify-content:flex-end;">
2064              <span id="tfa-msg" style="font-size:11px;margin-right:auto;"></span>
2065              <button class="admin-btn-add" id="tfa-disable-btn" style="opacity:0.7;">Disable 2FA</button>
2066            </div>`;
2067          el('tfa-disable-btn').addEventListener('click', async () => {
2068            const pw = el('tfa-disable-pw').value;
2069            const msg = el('tfa-msg');
2070            if (!pw) { msg.textContent = 'Enter your password'; msg.style.color = 'var(--red)'; return; }
2071            try {
2072              const r = await fetch('/api/auth/2fa/disable', {
2073                method: 'POST', credentials: 'same-origin',
2074                headers: { 'Content-Type': 'application/json' },
2075                body: JSON.stringify({ password: pw })
2076              });
2077              if (!r.ok) { const d = await r.json(); throw new Error(d.detail || 'Failed'); }
2078              render2FA();
2079            } catch (e) { msg.textContent = e.message; msg.style.color = 'var(--red)'; }
2080          });
2081        } else {
2082          // 2FA is OFF — show setup button
2083          tfaContent.innerHTML = `
2084            <div style="font-size:12px;opacity:0.6;margin-bottom:8px;">Add an extra layer of security with an authenticator app (Aegis, Google Authenticator, etc.)</div>
2085            <div class="settings-row" style="justify-content:flex-end;">
2086              <span id="tfa-msg" style="font-size:11px;margin-right:auto;"></span>
2087              <button class="admin-btn-add" id="tfa-setup-btn">Set Up 2FA</button>
2088            </div>`;
2089          el('tfa-setup-btn').addEventListener('click', async () => {
2090            const msg = el('tfa-msg');
2091            try {
2092              const r = await fetch('/api/auth/2fa/setup', { method: 'POST', credentials: 'same-origin' });
2093              if (!r.ok) { const d = await r.json(); throw new Error(d.detail || 'Failed'); }
2094              const setup = await r.json();
2095              const qrCode = safeRasterDataUrl(setup.qr_code);
2096              // Show QR code + manual secret + verify input
2097              tfaContent.innerHTML = `
2098                <div style="text-align:center;margin-bottom:12px;">
2099                  ${qrCode ? `<img src="${esc(qrCode)}" alt="QR Code" style="border-radius:8px;max-width:200px;">` : ''}
2100                </div>
2101                <div style="font-size:11px;opacity:0.5;text-align:center;margin-bottom:8px;">
2102                  Scan with your authenticator app, or enter manually:
2103                </div>
2104                <div style="font-family:monospace;font-size:12px;text-align:center;padding:6px;background:var(--bg);border:1px solid var(--border);border-radius:4px;margin-bottom:12px;word-break:break-all;user-select:all;cursor:text;">${esc(setup.secret)}</div>
2105                <input id="tfa-verify-code" type="text" placeholder="Enter 6-digit code to verify" autocomplete="one-time-code" inputmode="numeric" maxlength="8" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:13px;box-sizing:border-box;text-align:center;letter-spacing:3px;margin-bottom:6px;">
2106                <div class="settings-row" style="justify-content:flex-end;">
2107                  <span id="tfa-msg" style="font-size:11px;margin-right:auto;"></span>
2108                  <button class="admin-btn-add" id="tfa-cancel-btn" style="opacity:0.5;">Cancel</button>
2109                  <button class="admin-btn-add" id="tfa-verify-btn">Verify & Enable</button>
2110                </div>`;
2111              el('tfa-verify-code').focus();
2112              el('tfa-cancel-btn').addEventListener('click', () => render2FA());
2113              el('tfa-verify-btn').addEventListener('click', async () => {
2114                const code = el('tfa-verify-code').value.trim();
2115                const vmsg = el('tfa-msg');
2116                if (!code) { vmsg.textContent = 'Enter the code'; vmsg.style.color = 'var(--red)'; return; }
2117                try {
2118                  const vr = await fetch('/api/auth/2fa/confirm', {
2119                    method: 'POST', credentials: 'same-origin',
2120                    headers: { 'Content-Type': 'application/json' },
2121                    body: JSON.stringify({ code })
2122                  });
2123                  if (!vr.ok) { const d = await vr.json(); throw new Error(d.detail || 'Invalid code'); }
2124                  const result = await vr.json();
2125                  // Show backup codes
2126                  const codes = result.backup_codes || [];
2127                  tfaContent.innerHTML = `
2128                    <div style="color:var(--color-save-green, #4caf50);font-size:13px;font-weight:600;margin-bottom:8px;">&#x2713; 2FA Enabled!</div>
2129                    <div style="font-size:12px;opacity:0.7;margin-bottom:8px;">Save these backup codes somewhere safe. Each can be used once if you lose your authenticator:</div>
2130                    <div style="font-family:monospace;font-size:12px;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;columns:2;column-gap:16px;margin-bottom:8px;">${codes.map(c => '<div style="margin-bottom:2px;">' + c + '</div>').join('')}</div>
2131                    <button class="admin-btn-add" id="tfa-done-btn">Done</button>`;
2132                  el('tfa-done-btn').addEventListener('click', () => render2FA());
2133                } catch (e) { vmsg.textContent = e.message; vmsg.style.color = 'var(--red)'; }
2134              });
2135            } catch (e) { msg.textContent = e.message; msg.style.color = 'var(--red)'; }
2136          });
2137        }
2138      } catch (_) {
2139        tfaContent.innerHTML = '<div style="font-size:11px;opacity:0.4;">Could not load 2FA status</div>';
2140      }
2141    }
2142    render2FA();
2143  }
2144
2145  // Logout
2146  const logoutBtn = el('settings-logout-btn');
2147  if (logoutBtn) {
2148    logoutBtn.addEventListener('mouseenter', () => { logoutBtn.style.opacity = '1'; logoutBtn.style.borderColor = 'var(--red)'; logoutBtn.style.color = 'var(--red)'; });
2149    logoutBtn.addEventListener('mouseleave', () => { logoutBtn.style.opacity = ''; logoutBtn.style.borderColor = ''; logoutBtn.style.color = ''; });
2150    logoutBtn.addEventListener('click', async () => {
2151      try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (_) {}
2152      // SECURITY: wipe all client-side state on logout so the next user that
2153      // signs in on this browser doesn't inherit the previous account's
2154      // session id, last-used model, draft chat input, or any cached lists.
2155      // Keep "odysseus-last-user" so the login form remembers the username
2156      // (if "Remember me" was on). Without this the chat composer pre-loaded
2157      // the previous user's last model into a fresh session, which read as
2158      // cross-account leakage.
2159      try {
2160        const _keepKeys = new Set(['odysseus-last-user']);
2161        const _toRemove = [];
2162        for (let i = 0; i < localStorage.length; i++) {
2163          const k = localStorage.key(i);
2164          if (k && !_keepKeys.has(k)) _toRemove.push(k);
2165        }
2166        _toRemove.forEach(k => localStorage.removeItem(k));
2167        sessionStorage.clear();
2168      } catch (_) {}
2169      window.location.href = '/login';
2170    });
2171  }
2172}
2173
2174function initAll() {
2175  modalEl = el('settings-modal');
2176  initTabs();
2177  initDrag();
2178  initClose();
2179  initOpacityToggle();
2180  initialized = true;
2181  initDefaultChat();
2182  initTeacherModel();
2183  initUtilityModel();
2184  initImageSettings();
2185  initVisionSettings();
2186  initTtsSettings();
2187  initSttSettings();
2188  initSearchSettings();
2189  initResearchSettings();
2190  initResearchSearchSettings();
2191  initAgentSettings();
2192  initAppearance();
2193  initShortcuts();
2194  initAccount();
2195  initIntegrations();
2196  initEmailSettings();
2197  initEmailAccountsSettings();
2198  initReminderSettings();
2199  initUnifiedIntegrations();
2200  // HF Cloud Storage bucket management
2201  if (window.initBucketSettings) window.initBucketSettings();
2202  // HF Secrets & Environment management
2203  if (window.initHfSecrets) window.initHfSecrets();
2204  if (window.initGithubRepos) window.initGithubRepos();
2205}
2206
2207function notifyIntegrationsChanged() {
2208  try {
2209    window.dispatchEvent(new CustomEvent('odysseus-integrations-changed'));
2210  } catch (_) {}
2211}
2212
2213async function initReminderSettings() {
2214  const root = el('settings-modal');
2215  if (!root || !root.querySelector('[data-settings-panel="reminders"]')) return;
2216
2217  // Public URL field (used for deep-links in outgoing alert emails)
2218  const pubUrlIn = el('set-app-public-url');
2219  const pubUrlMsg = el('set-app-public-url-msg');
2220  if (pubUrlIn) {
2221    try {
2222      const r = await fetch('/api/auth/settings', { credentials: 'same-origin' });
2223      const s = await r.json();
2224      pubUrlIn.value = s.app_public_url || '';
2225    } catch (_) {}
2226    let pubDebounce;
2227    pubUrlIn.addEventListener('input', () => {
2228      clearTimeout(pubDebounce);
2229      pubDebounce = setTimeout(async () => {
2230        try {
2231          const val = pubUrlIn.value.trim().replace(/\/+$/, '');
2232          await fetch('/api/auth/settings', {
2233            method: 'POST', credentials: 'same-origin',
2234            headers: { 'Content-Type': 'application/json' },
2235            body: JSON.stringify({ app_public_url: val }),
2236          });
2237          if (pubUrlMsg) {
2238            pubUrlMsg.textContent = val ? 'Saved' : 'Cleared (deep-links disabled)';
2239            pubUrlMsg.style.color = 'var(--green,#50fa7b)';
2240            setTimeout(() => { pubUrlMsg.textContent = ''; }, 2000);
2241          }
2242        } catch (_) {
2243          if (pubUrlMsg) { pubUrlMsg.textContent = 'Save failed'; pubUrlMsg.style.color = 'var(--red)'; }
2244        }
2245      }, 600);
2246    });
2247  }
2248
2249  const channelSel = el('set-reminder-channel');
2250  const emailOpt = el('set-reminder-channel-email-opt');
2251  const ntfyOpt = el('set-reminder-channel-ntfy-opt');
2252  const webhookOpt = el('set-reminder-channel-webhook-opt');
2253  const hint = el('set-reminder-channel-hint');
2254  const llmToggle = el('set-reminder-llm-toggle');
2255  // "Integrations" link in the channel-hint copy. Jumps to the
2256  // Integrations tab so the user can configure the underlying accounts
2257  // (email, ntfy server) the channel dropdown depends on. Idempotent.
2258  const openIntgBtn = el('set-reminders-open-integrations');
2259  if (openIntgBtn && !openIntgBtn.dataset.wired) {
2260    openIntgBtn.dataset.wired = '1';
2261    openIntgBtn.addEventListener('click', (e) => {
2262      e.preventDefault();
2263      const target = modalEl?.querySelector('[data-settings-tab="integrations"]');
2264      if (target) target.click();
2265    });
2266  }
2267  if (!channelSel || !llmToggle) return;
2268
2269  // Detect configured email accounts. The legacy single-account
2270  // `/api/email/config` endpoint was a no-op stub for most installs;
2271  // the real per-account list lives at `/api/email/accounts` and is
2272  // what the Integrations panel manages. Treat the email channel as
2273  // configured if there's at least one account with SMTP set.
2274  let emailAccounts = [];
2275  try {
2276    const res = await fetch('/api/email/accounts', { credentials: 'same-origin' });
2277    if (res.ok) {
2278      const d = await res.json();
2279      emailAccounts = (d.accounts || []).filter(a => a.smtp_host && a.smtp_user && a.has_smtp_password);
2280    }
2281  } catch (_) {}
2282  let smtpConfigured = emailAccounts.length > 0;
2283
2284  if (!smtpConfigured && emailOpt) {
2285    emailOpt.disabled = true;
2286    emailOpt.textContent = 'Email (add an account in Integrations)';
2287  }
2288
2289  // Detect whether ntfy integration exists — try admin endpoint, fall back to
2290  // checking if an ntfy integration was saved in settings (non-admin users).
2291  let ntfyConfigured = false;
2292  try {
2293    const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' });
2294    if (res.ok) {
2295      const data = await res.json();
2296      ntfyConfigured = (data.integrations || []).some(
2297        i => (i.preset === 'ntfy' || (i.name || '').toLowerCase() === 'ntfy') && i.enabled !== false && i.base_url
2298      );
2299    }
2300  } catch (_) {}
2301  // If admin check failed, check if ntfy was previously selected (trust the saved setting)
2302  if (!ntfyConfigured) {
2303    try {
2304      const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
2305      const s = await res.json();
2306      if (s.reminder_channel === 'ntfy') ntfyConfigured = true;
2307    } catch (_) {}
2308  }
2309
2310  if (!ntfyConfigured && ntfyOpt) {
2311    ntfyOpt.disabled = true;
2312    ntfyOpt.textContent = 'ntfy (add in Integrations first)';
2313  }
2314
2315  // Webhook: available whenever at least one integration with a base_url exists.
2316  // The user picks which integration to target and supplies a payload template.
2317  let allIntegrations = [];
2318  let webhookConfigured = false;
2319  try {
2320    const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' });
2321    if (res.ok) {
2322      const data = await res.json();
2323      allIntegrations = (data.integrations || []).filter(i => i.base_url && i.enabled !== false);
2324      webhookConfigured = allIntegrations.length > 0;
2325    }
2326  } catch (_) {}
2327  if (!webhookConfigured && webhookOpt) {
2328    webhookOpt.disabled = true;
2329    webhookOpt.textContent = 'Webhook (add an Integration first)';
2330  }
2331
2332  const emailFromRow = el('set-reminder-email-from-row');
2333  const emailAcctSel = el('set-reminder-email-account');
2334  const emailToRow = el('set-reminder-email-to-row');
2335  const emailToIn = el('set-reminder-email-to');
2336  const ntfyTopicRow = el('set-reminder-ntfy-topic-row');
2337  const ntfyTopicIn = el('set-reminder-ntfy-topic');
2338  const webhookIntgRow = el('set-reminder-webhook-intg-row');
2339  const webhookIntgSel = el('set-reminder-webhook-intg');
2340  const webhookTemplateRow = el('set-reminder-webhook-template-row');
2341  const webhookTemplateIn = el('set-reminder-webhook-template');
2342
2343  function populateReminderEmailAccounts(selectedId = '') {
2344    if (!emailAcctSel) return;
2345    emailAcctSel.innerHTML = emailAccounts.map(a =>
2346      `<option value="${a.id}">${esc(a.name || a.from_address || a.imap_user || 'Unnamed')}${a.is_default ? ' (default)' : ''}</option>`
2347    ).join('');
2348    const fallback = (emailAccounts.find(a => a.is_default) || emailAccounts[0] || {}).id || '';
2349    emailAcctSel.value = (selectedId && emailAccounts.some(a => a.id === selectedId)) ? selectedId : fallback;
2350  }
2351
2352  function populateWebhookIntegrations(selectedId = '') {
2353    if (!webhookIntgSel) return;
2354    webhookIntgSel.innerHTML = allIntegrations.length
2355      ? allIntegrations.map(i => `<option value="${esc(i.id)}">${esc(i.name || i.id)}</option>`).join('')
2356      : '<option value="">No integrations configured</option>';
2357    if (selectedId && allIntegrations.some(i => i.id === selectedId)) webhookIntgSel.value = selectedId;
2358  }
2359
2360  function applyReminderChannelAvailability() {
2361    if (emailOpt) {
2362      emailOpt.disabled = !smtpConfigured;
2363      emailOpt.textContent = smtpConfigured ? 'Email' : 'Email (add an account in Integrations)';
2364    }
2365    if (ntfyOpt) {
2366      ntfyOpt.disabled = !ntfyConfigured;
2367      ntfyOpt.textContent = ntfyConfigured ? 'ntfy' : 'ntfy (add in Integrations first)';
2368    }
2369    if (webhookOpt) {
2370      webhookOpt.disabled = !webhookConfigured;
2371      webhookOpt.textContent = webhookConfigured ? 'Webhook' : 'Webhook (add an Integration first)';
2372    }
2373  }
2374
2375  async function refreshReminderChannelAvailability() {
2376    const currentChannel = channelSel.value || 'browser';
2377    const currentEmailAccount = emailAcctSel?.value || '';
2378    const currentWebhookIntg = webhookIntgSel?.value || '';
2379    try {
2380      const res = await fetch('/api/email/accounts', { credentials: 'same-origin' });
2381      if (res.ok) {
2382        const d = await res.json();
2383        emailAccounts = (d.accounts || []).filter(a => a.smtp_host && a.smtp_user && a.has_smtp_password);
2384      }
2385    } catch (_) {}
2386    smtpConfigured = emailAccounts.length > 0;
2387
2388    ntfyConfigured = false;
2389    try {
2390      const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' });
2391      if (res.ok) {
2392        const data = await res.json();
2393        ntfyConfigured = (data.integrations || []).some(
2394          i => (i.preset === 'ntfy' || (i.name || '').toLowerCase() === 'ntfy') && i.enabled !== false && i.base_url
2395        );
2396        allIntegrations = (data.integrations || []).filter(i => i.base_url && i.enabled !== false);
2397        webhookConfigured = allIntegrations.length > 0;
2398      }
2399    } catch (_) {}
2400    if (!ntfyConfigured) {
2401      try {
2402        const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
2403        const s = await res.json();
2404        if (s.reminder_channel === 'ntfy') ntfyConfigured = true;
2405      } catch (_) {}
2406    }
2407
2408    applyReminderChannelAvailability();
2409    populateReminderEmailAccounts(currentEmailAccount);
2410    populateWebhookIntegrations(currentWebhookIntg);
2411    if (currentChannel === 'email' && !smtpConfigured) channelSel.value = 'browser';
2412    else if (currentChannel === 'ntfy' && !ntfyConfigured) channelSel.value = 'browser';
2413    else if (currentChannel === 'webhook' && !webhookConfigured) channelSel.value = 'browser';
2414    else channelSel.value = currentChannel;
2415    if (hint) hint.textContent = CHANNEL_HINTS[channelSel.value] || '';
2416    syncChannelRows();
2417  }
2418
2419  // Populate the "Send from" picker with all configured email accounts.
2420  populateReminderEmailAccounts();
2421
2422  function syncChannelRows() {
2423    const isEmail = channelSel.value === 'email';
2424    const isWebhook = channelSel.value === 'webhook';
2425    if (emailFromRow) emailFromRow.style.display = (isEmail && emailAccounts.length > 1) ? 'flex' : 'none';
2426    if (emailToRow) emailToRow.style.display = isEmail ? 'flex' : 'none';
2427    if (ntfyTopicRow) ntfyTopicRow.style.display = channelSel.value === 'ntfy' ? 'flex' : 'none';
2428    if (webhookIntgRow) webhookIntgRow.style.display = isWebhook ? 'flex' : 'none';
2429    if (webhookTemplateRow) webhookTemplateRow.style.display = isWebhook ? 'flex' : 'none';
2430  }
2431
2432  // Browser notifications fire on EVERY reminder (see
2433  // routes/note_routes.py — the in-app notif is always queued
2434  // regardless of channel). The hint should make that clear so
2435  // users don't think they have to choose between channels.
2436  const CHANNEL_HINTS = {
2437    browser: 'Reminders appear as browser notifications inside Odysseus.',
2438    email: 'Reminders are emailed AND shown as a browser notification.',
2439    ntfy: 'Reminders are pushed via ntfy AND shown as a browser notification.',
2440    webhook: 'Reminders are POSTed to the selected integration AND shown as a browser notification. Use {{title}} and {{message}} in the payload template.',
2441  };
2442
2443  applyReminderChannelAvailability();
2444  if (!channelSel.dataset.integrationRefreshWired) {
2445    channelSel.dataset.integrationRefreshWired = '1';
2446    window.addEventListener('odysseus-integrations-changed', () => {
2447      refreshReminderChannelAvailability().catch(e => console.warn('Failed to refresh reminder channels', e));
2448    });
2449  }
2450
2451  // Default payload templates for known presets — auto-filled when the user
2452  // picks a matching integration so they don't have to write JSON from scratch.
2453  // Defined here (before the load block) so both the load path and the change
2454  // handler can reference it.
2455  const WEBHOOK_PRESET_TEMPLATES = {
2456    discord_webhook: '{"embeds": [{"title": "{{title}}", "description": "{{message}}", "color": 5793266}]}',
2457  };
2458
2459  try {
2460    const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
2461    const s = await res.json();
2462    let savedChannel = s.reminder_channel || 'browser';
2463    if (savedChannel === 'email' && !smtpConfigured) savedChannel = 'browser';
2464    if (savedChannel === 'ntfy' && !ntfyConfigured) savedChannel = 'browser';
2465    if (savedChannel === 'webhook' && !webhookConfigured) savedChannel = 'browser';
2466    channelSel.value = savedChannel;
2467    llmToggle.checked = !!s.reminder_llm_synthesis;
2468    if (emailToIn) emailToIn.value = s.reminder_email_to || '';
2469    if (ntfyTopicIn) ntfyTopicIn.value = s.reminder_ntfy_topic || 'Reminders';
2470    populateWebhookIntegrations(s.reminder_webhook_integration_id || '');
2471    if (webhookTemplateIn) {
2472      webhookTemplateIn.value = s.reminder_webhook_payload_template || '';
2473      // If an integration is already selected but no template was ever saved,
2474      // auto-fill with the preset default so the first test works out of the box.
2475      if (!webhookTemplateIn.value && webhookIntgSel?.value) {
2476        const intg = allIntegrations.find(i => i.id === webhookIntgSel.value);
2477        const tpl = WEBHOOK_PRESET_TEMPLATES[intg?.preset] || '';
2478        if (tpl) { webhookTemplateIn.value = tpl; save({ reminder_webhook_payload_template: tpl }); }
2479      }
2480    }
2481    // Restore the previously-picked email account (if any), otherwise
2482    // default to the account flagged is_default in the integrations
2483    // list. Falls through to the first option if neither exists.
2484    if (emailAcctSel) {
2485      const savedId = s.reminder_email_account_id;
2486      populateReminderEmailAccounts(savedId || '');
2487      if (emailAcctSel.value && emailAcctSel.value !== (savedId || '')) {
2488        save({ reminder_email_account_id: emailAcctSel.value || null });
2489      }
2490    }
2491    if (hint) hint.textContent = CHANNEL_HINTS[channelSel.value] || '';
2492    syncChannelRows();
2493  } catch (e) { console.warn('Failed to load reminder settings', e); }
2494
2495  async function save(patch) {
2496    try {
2497      await fetch('/api/auth/settings', {
2498        method: 'POST',
2499        credentials: 'same-origin',
2500        headers: { 'Content-Type': 'application/json' },
2501        body: JSON.stringify(patch),
2502      });
2503    } catch (e) { console.warn('Failed to save reminder settings', e); }
2504  }
2505
2506  channelSel.addEventListener('change', () => {
2507    if (hint) hint.textContent = CHANNEL_HINTS[channelSel.value] || '';
2508    syncChannelRows();
2509    save({ reminder_channel: channelSel.value });
2510  });
2511  if (emailToIn) {
2512    let emailDebounce;
2513    emailToIn.addEventListener('input', () => {
2514      clearTimeout(emailDebounce);
2515      emailDebounce = setTimeout(() => save({ reminder_email_to: emailToIn.value.trim() }), 600);
2516    });
2517  }
2518  if (emailAcctSel) {
2519    emailAcctSel.addEventListener('change', () => {
2520      save({ reminder_email_account_id: emailAcctSel.value || null });
2521    });
2522  }
2523  if (ntfyTopicIn) {
2524    let topicDebounce;
2525    ntfyTopicIn.addEventListener('input', () => {
2526      clearTimeout(topicDebounce);
2527      topicDebounce = setTimeout(() => save({ reminder_ntfy_topic: ntfyTopicIn.value.trim() || 'reminders' }), 600);
2528    });
2529  }
2530  if (webhookIntgSel) {
2531    webhookIntgSel.addEventListener('change', () => {
2532      save({ reminder_webhook_integration_id: webhookIntgSel.value || '' });
2533      // If the template is empty and we recognise the integration's preset,
2534      // pre-fill with a sensible default so users can test immediately.
2535      if (webhookTemplateIn && !webhookTemplateIn.value.trim()) {
2536        const intg = allIntegrations.find(i => i.id === webhookIntgSel.value);
2537        const tpl = WEBHOOK_PRESET_TEMPLATES[intg?.preset] || '';
2538        if (tpl) {
2539          webhookTemplateIn.value = tpl;
2540          save({ reminder_webhook_payload_template: tpl });
2541        }
2542      }
2543    });
2544  }
2545  if (webhookTemplateIn) {
2546    let templateDebounce;
2547    webhookTemplateIn.addEventListener('input', () => {
2548      clearTimeout(templateDebounce);
2549      templateDebounce = setTimeout(() => save({ reminder_webhook_payload_template: webhookTemplateIn.value.trim() }), 600);
2550    });
2551  }
2552  // Dim the whole AI Synthesis card when off (matches Vision/Utility/etc.).
2553  function syncSynthesisDim() {
2554    const card = llmToggle.closest('.admin-card');
2555    if (card) card.style.opacity = llmToggle.checked ? '' : '0.45';
2556  }
2557  syncSynthesisDim();
2558  llmToggle.addEventListener('change', () => {
2559    syncSynthesisDim();
2560    save({ reminder_llm_synthesis: llmToggle.checked });
2561  });
2562
2563  // Test button
2564  const testBtn = el('set-reminder-test-btn');
2565  const testMsg = el('set-reminder-test-msg');
2566  if (testBtn) {
2567    testBtn.addEventListener('click', async () => {
2568      testBtn.disabled = true;
2569      if (testMsg) { testMsg.textContent = 'Sending…'; testMsg.style.color = 'var(--fg)'; }
2570      // Whirlpool loader right next to the "Sending…" text while it sends.
2571      let _testSpin = null;
2572      try {
2573        const _sp = (await import('./spinner.js')).default;
2574        _testSpin = _sp.createWhirlpool(14);
2575        _testSpin.element.style.cssText = 'width:14px;height:14px;margin:0 0 0 7px;display:inline-block;vertical-align:middle;';
2576        (testMsg || testBtn).insertAdjacentElement('afterend', _testSpin.element);
2577      } catch (_) {}
2578      const _stopTestSpin = () => { try { _testSpin && _testSpin.stop(); _testSpin && _testSpin.element.remove(); } catch (_) {} };
2579      try {
2580        const res = await fetch('/api/notes/fire-reminder', {
2581          method: 'POST',
2582          credentials: 'same-origin',
2583          headers: { 'Content-Type': 'application/json' },
2584          body: JSON.stringify({
2585            note_id: 'test-' + Date.now(),
2586            title: 'Test Reminder',
2587            body: 'This is a test reminder to verify your settings are working.',
2588            channel: channelSel.value,
2589            ...(channelSel.value === 'webhook' ? {
2590              webhook_integration_id: webhookIntgSel?.value || '',
2591              webhook_payload_template: webhookTemplateIn?.value.trim() || '',
2592            } : {}),
2593          }),
2594        });
2595        const data = await res.json();
2596        if (!res.ok) throw new Error(data.detail || 'Server error');
2597        if (channelSel.value === 'email' && !data.email_sent) {
2598          throw new Error(data.email_error || 'Email reminder was not sent');
2599        }
2600        if (channelSel.value === 'ntfy' && !data.ntfy_sent) {
2601          throw new Error(data.ntfy_error || 'ntfy reminder was not sent');
2602        }
2603        if (channelSel.value === 'webhook' && !data.webhook_sent) {
2604          const activeChannel = data.channel ? ` (server used channel: "${data.channel}")` : '';
2605          throw new Error((data.webhook_error || 'Webhook reminder was not sent') + activeChannel);
2606        }
2607        let status = 'Delivered via ' + channelSel.value;
2608        if (data.synthesis) status += ' (AI: "' + data.synthesis.slice(0, 60) + '...")';
2609        if (data.email_sent) status += ' — email sent';
2610        if (data.ntfy_sent) status += ' — ntfy sent';
2611        if (data.webhook_sent) status += ' — webhook sent';
2612        if (testMsg) { testMsg.textContent = status; testMsg.style.color = 'var(--green, #50fa7b)'; }
2613        // Also fire a browser notification so user can see it
2614        if ('Notification' in window && Notification.permission === 'granted') {
2615          try {
2616            new Notification('Test Reminder', {
2617              body: data.synthesis || 'This is a test reminder.',
2618              tag: 'reminder-test',
2619              icon: '/static/favicon.ico',
2620            });
2621          } catch {}
2622        }
2623      } catch (e) {
2624        if (testMsg) { testMsg.textContent = 'Failed: ' + e.message; testMsg.style.color = 'var(--red)'; }
2625      } finally {
2626        _stopTestSpin();
2627        testBtn.disabled = false;
2628      }
2629    });
2630  }
2631}
2632
2633async function initEmailAccountsSettings() {
2634  const root = el('settings-modal');
2635  if (!root || !root.querySelector('[data-settings-panel="email"]')) return;
2636  const manageBtn = el('set-email-open-integrations');
2637  if (manageBtn && manageBtn.dataset.bound !== '1') {
2638    manageBtn.dataset.bound = '1';
2639    manageBtn.addEventListener('click', () => open('integrations'));
2640  }
2641  const tasksBtn = el('set-email-open-tasks');
2642  if (tasksBtn && tasksBtn.dataset.bound !== '1') {
2643    tasksBtn.dataset.bound = '1';
2644    tasksBtn.addEventListener('click', async () => {
2645      try {
2646        const mod = await import('./tasks.js');
2647        const openTasks = mod.openTasks || (mod.default && mod.default.openTasks);
2648        if (typeof openTasks === 'function') openTasks();
2649        else document.getElementById('tool-tasks-btn')?.click();
2650      } catch (_) {
2651        document.getElementById('tool-tasks-btn')?.click();
2652      }
2653    });
2654  }
2655  const listEl = el('set-email-accounts-list');
2656  const msgEl = el('set-email-accounts-msg');
2657  const formEl = el('set-email-accounts-form');
2658  const addBtn = el('set-email-accounts-add-btn');
2659  if (!listEl || !addBtn || !formEl) return;
2660
2661  const esc = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
2662
2663  async function fetchAccounts() {
2664    const r = await fetch('/api/email/accounts', { credentials: 'same-origin' });
2665    const d = await r.json();
2666    return d.accounts || [];
2667  }
2668
2669  function renderRow(a) {
2670    const imap = a.imap_host ? `${a.imap_host}:${a.imap_port}` : '<no IMAP>';
2671    const badge = a.is_default
2672      ? '<span style="font-size:9px;text-transform:uppercase;letter-spacing:0.5px;padding:1px 6px;border-radius:3px;background:color-mix(in srgb, var(--accent,#50fa7b) 15%, transparent);color:var(--accent,#50fa7b)">Default</span>'
2673      : (a.enabled ? '' : '<span style="font-size:9px;text-transform:uppercase;letter-spacing:0.5px;padding:1px 6px;border-radius:3px;opacity:0.4">Disabled</span>');
2674    return `<div class="email-account-row" data-acc-id="${esc(a.id)}" style="display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--border);border-radius:6px">
2675      <div style="flex:1;min-width:0">
2676        <div style="font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px">${esc(a.name)} ${badge}</div>
2677        <div style="font-size:11px;opacity:0.6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.imap_user || a.from_address || '')} — ${esc(imap)}</div>
2678      </div>
2679      ${a.is_default ? '' : `<button class="admin-btn-sm email-acc-default-btn" style="font-size:10px">Make Default</button>`}
2680      <button class="admin-btn-sm email-acc-edit-btn" style="font-size:10px">Edit</button>
2681      <button class="admin-btn-sm email-acc-del-btn" style="font-size:10px;opacity:0.6">Delete</button>
2682    </div>`;
2683  }
2684
2685  async function renderList() {
2686    const accs = await fetchAccounts();
2687    if (!accs.length) {
2688      listEl.innerHTML = '<div style="padding:12px;opacity:0.5;font-size:12px;text-align:center">No email accounts configured</div>';
2689      return;
2690    }
2691    listEl.innerHTML = accs.map(renderRow).join('');
2692    listEl.querySelectorAll('.email-account-row').forEach(row => {
2693      const id = row.dataset.accId;
2694      row.querySelector('.email-acc-default-btn')?.addEventListener('click', async (e) => {
2695        e.stopPropagation();
2696        await fetch(`/api/email/accounts/${id}/set-default`, { method: 'POST', credentials: 'same-origin' });
2697        renderList();
2698      });
2699      row.querySelector('.email-acc-edit-btn')?.addEventListener('click', (e) => {
2700        e.stopPropagation();
2701        showForm(accs.find(a => a.id === id));
2702      });
2703      row.querySelector('.email-acc-del-btn')?.addEventListener('click', async (e) => {
2704        e.stopPropagation();
2705        if (!await window.styledConfirm(`Delete account "${accs.find(a => a.id === id)?.name}"?`, { confirmText: 'Delete', danger: true })) return;
2706        await fetch(`/api/email/accounts/${id}`, { method: 'DELETE', credentials: 'same-origin' });
2707        renderList();
2708      });
2709    });
2710  }
2711
2712  function showForm(existing) {
2713    const a = existing || {};
2714    const isEdit = !!existing;
2715    formEl.style.display = '';
2716    // Small `?` indicator next to each label. Hover/focus to read the
2717    // hint via the native `title` tooltip. tabindex makes it
2718    // keyboard-focusable too.
2719    const _hint = (tip) =>
2720      `<span class="eaf-hint" title="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0" `
2721      + `style="display:inline-block;width:13px;height:13px;border-radius:50%;`
2722      + `border:1px solid currentColor;font-size:9px;line-height:11px;text-align:center;`
2723      + `opacity:0.45;margin-left:5px;cursor:help;vertical-align:1px;font-weight:600;">?</span>`;
2724    // Provider presets — picking one fills host/port/STARTTLS for both
2725    // IMAP and SMTP. Dovecot is IMAP-only here; the host is intentionally
2726    // blank because it may live on another machine (DNS, LAN, Tailscale).
2727    const PROVIDERS = {
2728      gmail:    { label: 'Gmail',                  imap: { host: 'imap.gmail.com',           port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com',            port: 465 } },
2729      migadu:   { label: 'Migadu',                 imap: { host: 'imap.migadu.com',          port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com',           port: 465 } },
2730      icloud:   { label: 'iCloud',                 imap: { host: 'imap.mail.me.com',         port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com',          port: 587 } },
2731      outlook:  { label: 'Outlook / Office 365',   imap: { host: 'outlook.office365.com',    port: 993, starttls: false }, smtp: { host: 'smtp.office365.com',        port: 587 } },
2732      fastmail: { label: 'Fastmail',               imap: { host: 'imap.fastmail.com',        port: 993, starttls: false }, smtp: { host: 'smtp.fastmail.com',         port: 465 } },
2733      yahoo:    { label: 'Yahoo',                  imap: { host: 'imap.mail.yahoo.com',      port: 993, starttls: false }, smtp: { host: 'smtp.mail.yahoo.com',       port: 465 } },
2734      dovecot:  { label: 'Dovecot IMAP (no SMTP)',  imap: { host: '',                        port: 31143, starttls: false }, smtp: { host: '',                          port: 465 } },
2735    };
2736    const _providerOptions = Object.entries(PROVIDERS)
2737      .map(([k, v]) => `<option value="${k}">${esc(v.label)}</option>`)
2738      .join('');
2739    const _smtpSecurity = (acct) => acct?.smtp_security || ((parseInt(acct?.smtp_port || 465) === 587) ? 'starttls' : 'ssl');
2740    formEl.innerHTML = `
2741      <h3 style="font-size:12px;margin:0 0 8px">${isEdit ? 'Edit Account' : 'New Account'}</h3>
2742      <div class="settings-col">
2743        <div class="settings-row"><label class="settings-label">Provider${_hint('Pick a known provider to auto-fill the IMAP and SMTP host/port. Choose Custom to type your own.')}</label><select id="eaf-provider" class="settings-select"><option value="">Custom…</option>${_providerOptions}</select></div>
2744        <div class="settings-row"><label class="settings-label">Name${_hint('Optional label for this account (e.g. “Work” or “Personal”). Leave blank to use the email address.')}</label><input id="eaf-name" class="settings-input" placeholder="(optional — leave blank to use email)" value="${esc(a.name || '')}"></div>
2745        <div class="settings-row"><label class="settings-label">Email${_hint('Your email address. Used as the From: header on outgoing mail and as the display label when Name is blank.')}</label><input id="eaf-from" class="settings-input" placeholder="[email protected]" value="${esc(a.from_address || '')}"></div>
2746        <div style="font-size:11px;font-weight:600;opacity:0.6;margin:6px 0 2px">IMAP (Receiving)</div>
2747        <div class="settings-row"><label class="settings-label">Host${_hint('Your IMAP server, e.g. imap.gmail.com, imap.migadu.com, a LAN host, or a Tailscale IP for Dovecot.')}</label><input id="eaf-imap-host" class="settings-input" value="${esc(a.imap_host || '')}"></div>
2748        <div class="settings-row"><label class="settings-label">Port${_hint('993 for IMAPS (most providers), 143 for plain or STARTTLS. Local servers often use a custom port like 31143.')}</label><input id="eaf-imap-port" class="settings-input" type="number" value="${esc(a.imap_port || 993)}" style="max-width:100px"></div>
2749        <div class="settings-row"><label class="settings-label">Username${_hint('Usually your full email address.')}</label><input id="eaf-imap-user" class="settings-input" value="${esc(a.imap_user || '')}"></div>
2750        <div class="settings-row"><label class="settings-label">Password${_hint('Your IMAP login password. Use an app-specific password if your provider requires 2FA (Gmail, iCloud, etc.).')}</label><input id="eaf-imap-pass" class="settings-input" type="password" placeholder="${isEdit && a.has_imap_password ? '(unchanged)' : ''}"></div>
2751        <div class="settings-row"><label class="settings-label">STARTTLS${_hint('Turn ON for port 143/587 to upgrade plain to TLS. Tur
2751n OFF for port 993 (IMAPS — already encrypted) or a local server with no TLS configured.')}</label><label class="admin-switch"><input type="checkbox" id="eaf-imap-starttls" ${a.imap_starttls !== false ? 'checked' : ''}><span class="admin-slider"></span></label></div>
2752        <div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px">SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
2753        <div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com, smtp.migadu.com. Leave blank to make this account read-only.')}</label><input id="eaf-smtp-host" class="settings-input" value="${esc(a.smtp_host || '')}"></div>
2754        <div class="settings-row"><label class="settings-label">Port${_hint('465 for SSL/SMTPS, 587 for STARTTLS. 25 is usually blocked by ISPs.')}</label><input id="eaf-smtp-port" class="settings-input" type="number" value="${esc(a.smtp_port || 465)}" style="max-width:100px"></div>
2755        <div class="settings-row"><label class="settings-label">Security${_hint('SSL for port 465, STARTTLS for port 587, or None for local SMTP bridges such as Proton Mail Bridge.')}</label><select id="eaf-smtp-security" class="settings-select"><option value="ssl">SSL</option><option value="starttls">STARTTLS</option><option value="none">None</option></select></div>
2756        <div class="settings-row"><label class="settings-label">Same as IMAP${_hint('Use the IMAP username and password for SMTP too (this is right for almost every provider). Turn off to enter separate SMTP credentials.')}</label><label class="admin-switch"><input type="checkbox" id="eaf-smtp-same" ${(!isEdit || (a.smtp_user && a.imap_user && a.smtp_user === a.imap_user)) ? 'checked' : ''}>
2756<span class="admin-slider"></span></label></div>
2757        <div class="settings-row eaf-smtp-creds"><label class="settings-label">Username${_hint('Usually the same as your IMAP username (your email address).')}</label><input id="eaf-smtp-user" class="settings-input" value="${esc(a.smtp_user || '')}"></div>
2758        <div class="settings-row eaf-smtp-creds"><label class="settings-label">Password${_hint('Your SMTP password — often the same as your IMAP password.')}</label><input id="eaf-smtp-pass" class="settings-input" type="password" placeholder="${isEdit && a.has_smtp_password ? '(unchanged)' : ''}"></div>
2759        <div class="settings-row" style="margin-top:10px;align-items:center;">
2760          <button class="admin-btn-add" id="eaf-save" style="background:var(--red);border-color:var(--red);color:#fff;display:inline-flex;align-items:center;gap:5px;font-weight:600;">
2761            <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
2762            ${isEdit ? 'Save' : 'Create'}
2763          </button>
2764          <span id="eaf-msg" style="font-size:11px;flex:1;margin-left:8px;"></span>
2765          <button class="admin-btn-add" id="eaf-cancel" style="opacity:0.7;display:inline-flex;align-items:center;gap:5px;position:relative;top:1px;margin-left:auto;">
2766            <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
2767            Cancel
2768          </button>
2769        </div>
2770      </div>
2771    `;
2772
2773    // Provider preset → autofill host/port/STARTTLS for both halves.
2774    el('eaf-provider').addEventListener('change', (e) => {
2775      const p = PROVIDERS[e.target.value];
2776      if (!p) return;
2777      el('eaf-imap-host').value = p.imap.host;
2778      el('eaf-imap-port').value = p.imap.port;
2779      el('eaf-imap-starttls').checked = !!p.imap.starttls;
2780      el('eaf-smtp-host').value = p.smtp.host;
2781      el('eaf-smtp-port').value = p.smtp.port;
2782      el('eaf-smtp-security').value = p.smtp.security || ((parseInt(p.smtp.port || 465) === 587) ? 'starttls' : 'ssl');
2783    });
2784    el('eaf-smtp-security').value = _smtpSecurity(a);
2785
2786    // "Same as IMAP" toggle — hide the SMTP creds rows when on. The save
2787    // handler copies the IMAP user/password into SMTP at submit time.
2788    const _syncSmtpSame = () => {
2789      const same = el('eaf-smtp-same').checked;
2790      formEl.querySelectorAll('.eaf-smtp-creds').forEach(r => {
2791        r.style.display = same ? 'none' : '';
2792      });
2793    };
2794    el('eaf-smtp-same').addEventListener('change', _syncSmtpSame);
2795    _syncSmtpSame();
2796
2797    el('eaf-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
2798    el('eaf-save').addEventListener('click', async () => {
2799      const body = {
2800        name: el('eaf-name').value.trim(),
2801        from_address: el('eaf-from').value.trim(),
2802        imap_host: el('eaf-imap-host').value.trim(),
2803        imap_port: parseInt(el('eaf-imap-port').value) || 993,
2804        imap_user: el('eaf-imap-user').value.trim(),
2805        imap_starttls: el('eaf-imap-starttls').checked,
2806        smtp_host: el('eaf-smtp-host').value.trim(),
2807        smtp_port: parseInt(el('eaf-smtp-port').value) || 465,
2808        smtp_security: el('eaf-smtp-security').value,
2809        smtp_user: el('eaf-smtp-user').value.trim(),
2810      };
2811      if (el('eaf-imap-pass').value) body.imap_password = el('eaf-imap-pass').value;
2812      if (el('eaf-smtp-pass').value) body.smtp_password = el('eaf-smtp-pass').value;
2813      // "Same as IMAP" toggle — copy IMAP username/password into SMTP at
2814      // save time, so the hidden SMTP-creds rows don't matter. We only
2815      // mirror the password if the user actually typed an IMAP one
2816      // (otherwise SMTP keeps whatever it already had on the server).
2817      if (el('eaf-smtp-same').checked) {
2818        body.smtp_user = body.imap_user;
2819        if (body.imap_password) body.smtp_password = body.imap_password;
2820      }
2821      // Name is optional — fall back to the From address so the list view
2822      // still has a label to render. Only refuse if both are blank.
2823      if (!body.name) body.name = body.from_address;
2824      if (!body.name) { el('eaf-msg').textContent = 'Need at least a Name or Email'; el('eaf-msg').style.color = 'var(--red)'; return; }
2825
2826      try {
2827        const url = isEdit ? `/api/email/accounts/${a.id}` : '/api/email/accounts';
2828        const method = isEdit ? 'PUT' : 'POST';
2829        const r = await fetch(url, {
2830          method, credentials: 'same-origin',
2831          headers: { 'Content-Type': 'application/json' },
2832          body: JSON.stringify(body),
2833        });
2834        const d = await r.json();
2835        if (d.ok || d.id) {
2836          el('eaf-msg').textContent = 'Saved';
2837          el('eaf-msg').style.color = 'var(--green,#50fa7b)';
2838          setTimeout(() => { formEl.style.display = 'none'; renderList(); }, 400);
2839        } else {
2840          el('eaf-msg').textContent = d.error || 'Save failed';
2841          el('eaf-msg').style.color = 'var(--red)';
2842        }
2843      } catch (e) {
2844        el('eaf-msg').textContent = 'Error: ' + e.message;
2845        el('eaf-msg').style.color = 'var(--red)';
2846      }
2847    });
2848  }
2849
2850  addBtn.addEventListener('click', () => showForm(null));
2851  await renderList();
2852}
2853
2854async function initEmailSettings() {
2855  const root = el('settings-modal');
2856  if (!root || !root.querySelector('[data-settings-panel="email"]')) return;
2857
2858  // Load current email config
2859  try {
2860    const res = await fetch('/api/email/config');
2861    const cfg = await res.json();
2862    if (el('set-email-imap-host')) el('set-email-imap-host').value = cfg.imap_host || '';
2863    if (el('set-email-imap-port')) el('set-email-imap-port').value = cfg.imap_port || '';
2864    if (el('set-email-imap-user')) el('set-email-imap-user').value = cfg.imap_user || '';
2865    if (el('set-email-imap-pass')) el('set-email-imap-pass').value = ''; // never prefill
2866    if (el('set-email-smtp-host')) el('set-email-smtp-host').value = cfg.smtp_host || '';
2867    if (el('set-email-smtp-port')) el('set-email-smtp-port').value = cfg.smtp_port || '';
2868    if (el('set-email-smtp-user')) el('set-email-smtp-user').value = cfg.smtp_user || '';
2869    if (el('set-email-smtp-pass')) el('set-email-smtp-pass').value = '';
2870    if (el('set-email-from')) el('set-email-from').value = cfg.from_address || '';
2871  } catch (_) {}
2872
2873  // Load contacts config
2874  try {
2875    const res = await fetch('/api/contacts/config');
2876    const cfg = await res.json();
2877    if (el('set-carddav-url')) el('set-carddav-url').value = cfg.url || '';
2878    if (el('set-carddav-user')) el('set-carddav-user').value = cfg.username || '';
2879    if (el('set-carddav-pass')) el('set-carddav-pass').value = '';
2880  } catch (_) {}
2881
2882  // Load writing style
2883  try {
2884    const res = await fetch('/api/email/style');
2885    const data = await res.json();
2886    if (el('set-email-style')) el('set-email-style').value = data.style || '';
2887  } catch (_) {}
2888
2889  // Save email config
2890  el('set-email-save')?.addEventListener('click', async () => {
2891    const msg = el('set-email-msg');
2892    if (msg) msg.textContent = 'Saving...';
2893    const data = {
2894      imap_host: el('set-email-imap-host').value,
2895      imap_port: parseInt(el('set-email-imap-port').value) || 0,
2896      imap_user: el('set-email-imap-user').value,
2897      smtp_host: el('set-email-smtp-host').value,
2898      smtp_port: parseInt(el('set-email-smtp-port').value) || 0,
2899      smtp_user: el('set-email-smtp-user').value,
2900      email_from: el('set-email-from').value,
2901    };
2902    const imapPass = el('set-email-imap-pass').value;
2903    const smtpPass = el('set-email-smtp-pass').value;
2904    if (imapPass) data.imap_password = imapPass;
2905    if (smtpPass) data.smtp_password = smtpPass;
2906    try {
2907      const res = await fetch('/api/email/config', {
2908        method: 'PUT',
2909        headers: { 'Content-Type': 'application/json' },
2910        body: JSON.stringify(data),
2911      });
2912      const result = await res.json();
2913      if (msg) msg.textContent = result.success ? '✓ Saved' : (result.error || 'Failed');
2914      setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
2915    } catch (e) {
2916      if (msg) msg.textContent = 'Failed';
2917    }
2918  });
2919
2920  // Save CardDAV config
2921  el('set-carddav-save')?.addEventListener('click', async () => {
2922    const msg = el('set-carddav-msg');
2923    if (msg) msg.textContent = 'Saving...';
2924    const data = {
2925      carddav_url: el('set-carddav-url').value,
2926      carddav_username: el('set-carddav-user').value,
2927    };
2928    const pass = el('set-carddav-pass').value;
2929    if (pass) data.carddav_password = pass;
2930    try {
2931      const res = await fetch('/api/contacts/config', {
2932        method: 'PUT',
2933        headers: { 'Content-Type': 'application/json' },
2934        body: JSON.stringify(data),
2935      });
2936      const result = await res.json();
2937      if (msg) msg.textContent = result.success ? '✓ Saved' : (result.error || 'Failed');
2938      setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
2939    } catch (e) {
2940      if (msg) msg.textContent = 'Failed';
2941    }
2942  });
2943
2944  // Extract writing style
2945  el('set-email-style-extract')?.addEventListener('click', async () => {
2946    const btn = el('set-email-style-extract');
2947    const msg = el('set-email-style-msg');
2948    btn.disabled = true;
2949    // Render whirlpool + label inside the status area (same pattern as
2950    // the "Find" / network-discover button in Add Models).
2951    let wp = null;
2952    if (msg) {
2953      msg.className = '';
2954      msg.innerHTML = '';
2955      try {
2956        const sp = window.spinnerModule || (await import('./spinner.js')).default;
2957        wp = sp.createWhirlpool(16);
2958        wp.element.style.cssText = 'display:inline-block;vertical-align:middle;margin:0 8px 0 0;';
2959        const wrap = document.createElement('span');
2960        wrap.style.cssText = 'display:inline-flex;align-items:center;';
2961        wrap.appendChild(wp.element);
2962        const txt = document.createElement('span');
2963        txt.textContent = 'Analyzing your sent emails…';
2964        txt.style.cssText = 'font-size:12px;opacity:0.7;';
2965        wrap.appendChild(txt);
2966        msg.appendChild(wrap);
2967      } catch (_) {
2968        msg.textContent = 'Analyzing your sent emails…';
2969      }
2970    }
2971    try {
2972      const res = await fetch('/api/email/extract-style', {
2973        method: 'POST',
2974        headers: { 'Content-Type': 'application/json' },
2975        body: JSON.stringify({ sample_count: 15 }),
2976      });
2977      const data = await res.json();
2978      if (data.success && data.style) {
2979        if (el('set-email-style')) el('set-email-style').value = data.style;
2980        if (msg) msg.textContent = '✓ Style extracted';
2981      } else {
2982        if (msg) msg.textContent = data.error || 'Failed';
2983      }
2984    } catch (e) {
2985      if (msg) msg.textContent = 'Failed to extract';
2986    } finally {
2987      if (wp && wp.destroy) { try { wp.destroy(); } catch (_) {} }
2988      btn.disabled = false;
2989      setTimeout(() => { if (msg) msg.textContent = ''; }, 5000);
2990    }
2991  });
2992
2993  // Save writing style manually
2994  el('set-email-style-save')?.addEventListener('click', async () => {
2995    const msg = el('set-email-style-msg');
2996    if (msg) msg.textContent = 'Saving...';
2997    try {
2998      const res = await fetch('/api/email/style', {
2999        method: 'PUT',
3000        headers: { 'Content-Type': 'application/json' },
3001        body: JSON.stringify({ style: el('set-email-style').value }),
3002      });
3003      const result = await res.json();
3004      if (msg) msg.textContent = result.success ? '✓ Saved' : 'Failed';
3005      setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
3006    } catch (e) {
3007      if (msg) msg.textContent = 'Failed';
3008    }
3009  });
3010}
3011
3012async function initIntegrations() {
3013  const listEl = el('integrations-list');
3014  const formCard = el('integration-form-card');
3015  const addBtn = el('intg-add-btn');
3016  if (!listEl || !formCard) return;
3017
3018  const presetSel = el('intg-preset');
3019  const nameIn = el('intg-name');
3020  const urlIn = el('intg-url');
3021  const authTypeSel = el('intg-auth-type');
3022  const authHeaderRow = el('intg-auth-header-row');
3023  const authHeaderIn = el('intg-auth-header');
3024  const keyIn = el('intg-key');
3025  const descIn = el('intg-description');
3026  const saveBtn = el('intg-save-btn');
3027  const cancelBtn = el('intg-cancel-btn');
3028  const testBtn = el('intg-test-btn');
3029  const statusEl = el('intg-status');
3030  const formTitle = el('integration-form-title');
3031
3032  let editingId = null;
3033  let presets = {};
3034
3035  // Presets where the secret is embedded in the URL — no separate key or
3036  // auth header is used, so hiding those fields avoids confusion.
3037  const URL_AUTH_PRESETS = ['discord_webhook'];
3038
3039  // Toggle auth header + key row visibility based on auth type and preset.
3040  function syncAuthRow() {
3041    const v = authTypeSel.value;
3042    authHeaderRow.style.display = (v === 'header' || v === 'query') ? 'flex' : 'none';
3043    if (v === 'query') authHeaderIn.placeholder = 'api_key';
3044    else authHeaderIn.placeholder = 'X-Auth-Token';
3045    const keyRow = keyIn?.closest('.settings-row');
3046    if (keyRow) keyRow.style.display = URL_AUTH_PRESETS.includes(presetSel?.value) ? 'none' : '';
3047  }
3048  authTypeSel.addEventListener('change', syncAuthRow);
3049
3050  // Load presets
3051  try {
3052    const res = await fetch('/api/auth/integrations/presets', { credentials: 'same-origin' });
3053    if (res.ok) {
3054      const data = await res.json();
3055      presets = data.presets || {};
3056      for (const [key, preset] of Object.entries(presets)) {
3057        const opt = document.createElement('option');
3058        opt.value = key;
3059        opt.textContent = preset.name || key;
3060        presetSel.appendChild(opt);
3061      }
3062    }
3063  } catch (e) {}
3064
3065  // Preset auto-fill
3066  presetSel.addEventListener('change', () => {
3067    const p = presets[presetSel.value];
3068    if (!p) return;
3069    nameIn.value = p.name || '';
3070    authTypeSel.value = p.auth_type || 'none';
3071    authHeaderIn.value = p.auth_header || '';
3072    descIn.value = p.description || '';
3073    syncAuthRow();
3074  });
3075
3076  // Render list
3077  async function renderList() {
3078    try {
3079      const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' });
3080      if (!res.ok) { listEl.innerHTML = '<div style="padding:12px;opacity:0.5;font-size:12px;">Admin access required</div>'; return; }
3081      const data = await res.json();
3082      const items = data.integrations || [];
3083      if (!items.length) {
3084        listEl.innerHTML = '<div style="padding:12px;opacity:0.5;font-size:12px;text-align:center;">No integrations configured</div>';
3085        return;
3086      }
3087      listEl.innerHTML = items.map(i => `
3088        <div class="admin-card" style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
3089          <div style="flex:1;min-width:0;">
3090            <div style="font-size:13px;font-weight:600;">${_esc(i.name || i.id)}</div>
3091            <div style="font-size:11px;opacity:0.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(i.base_url || '')}</div>
3092          </div>
3093          <div style="display:flex;gap:4px;flex-shrink:0;">
3094            <button class="admin-btn-sm intg-edit-btn" data-id="${i.id}" style="font-size:11px;">Edit</button>
3095            <button class="admin-btn-sm intg-del-btn" data-id="${i.id}" style="font-size:11px;opacity:0.6;">Del</button>
3096          </div>
3097        </div>
3098      `).join('');
3099      listEl.querySelectorAll('.intg-edit-btn').forEach(b => b.addEventListener('click', () => startEdit(b.dataset.id)));
3100      listEl.querySelectorAll('.intg-del-btn').forEach(b => b.addEventListener('click', () => doDelete(b.dataset.id)));
3101    } catch (e) { listEl.innerHTML = '<div style="padding:12px;color:var(--red);font-size:12px;">Failed to load</div>'; }
3102  }
3103
3104  function _esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
3105
3106  // Start editing
3107  async function startEdit(id) {
3108    editingId = id;
3109    formTitle.textContent = 'Edit Integration';
3110    // Fetch full data (with unmasked key from a dedicated edit fetch — we'll just load what we have)
3111    try {
3112      const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' });
3113      const data = await res.json();
3114      const item = (data.integrations || []).find(i => i.id === id);
3115      if (!item) return;
3116      presetSel.value = item.preset || '';
3117      nameIn.value = item.name || '';
3118      urlIn.value = item.base_url || '';
3119      authTypeSel.value = item.auth_type || 'none';
3120      authHeaderIn.value = item.auth_header || '';
3121      keyIn.value = ''; // masked — user re-enters if changing
3122      keyIn.placeholder = item.api_key ? 'Leave blank to keep current' : 'API key or token';
3123      descIn.value = item.description || '';
3124      syncAuthRow();
3125      formCard.style.display = '';
3126    } catch (e) {}
3127  }
3128
3129  // Show add form
3130  addBtn.addEventListener('click', () => {
3131    editingId = null;
3132    formTitle.textContent = 'Add Integration';
3133    presetSel.value = '';
3134    nameIn.value = '';
3135    urlIn.value = '';
3136    authTypeSel.value = 'header';
3137    authHeaderIn.value = '';
3138    keyIn.value = '';
3139    keyIn.placeholder = 'API key or token';
3140    descIn.value = '';
3141    statusEl.textContent = '';
3142    syncAuthRow();
3143    formCard.style.display = '';
3144  });
3145
3146  cancelBtn.addEventListener('click', () => {
3147    formCard.style.display = 'none';
3148    statusEl.textContent = '';
3149  });
3150
3151  // Save
3152  saveBtn.addEventListener('click', async () => {
3153    const payload = {
3154      name: nameIn.value.trim(),
3155      base_url: urlIn.value.trim().replace(/\/+$/, ''),
3156      auth_type: authTypeSel.value,
3157      auth_header: authHeaderIn.value.trim(),
3158      description: descIn.value.trim(),
3159    };
3160    if (presetSel.value) payload.preset = presetSel.value;
3161    if (keyIn.value.trim()) payload.api_key = keyIn.value.trim();
3162    if (!payload.name) { statusEl.textContent = 'Name required'; statusEl.style.color = 'var(--red)'; return; }
3163    if (!payload.base_url) { statusEl.textContent = 'URL required'; statusEl.style.color = 'var(--red)'; return; }
3164
3165    try {
3166      const url = editingId ? `/api/auth/integrations/${editingId}` : '/api/auth/integrations';
3167      const method = editingId ? 'PUT' : 'POST';
3168      const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), credentials: 'same-origin' });
3169      if (res.ok) {
3170        statusEl.textContent = 'Saved';
3171        statusEl.style.color = 'var(--green, #98c379)';
3172        formCard.style.display = 'none';
3173        await renderList();
3174        notifyIntegrationsChanged();
3175      } else {
3176        const err = await res.json().catch(() => ({}));
3177        statusEl.textContent = err.detail || 'Save failed';
3178        statusEl.style.color = 'var(--red)';
3179      }
3180    } catch (e) {
3181      statusEl.textContent = 'Error saving';
3182      statusEl.style.color = 'var(--red)';
3183    }
3184  });
3185
3186  // Test
3187  testBtn.addEventListener('click', async () => {
3188    if (!editingId) { statusEl.textContent = 'Save first, then test'; statusEl.style.color = 'var(--fg)'; return; }
3189    statusEl.textContent = 'Testing...';
3190    statusEl.style.color = 'var(--fg)';
3191    try {
3192      const res = await fetch(`/api/auth/integrations/${editingId}/test`, { method: 'POST', credentials: 'same-origin' });
3193      const data = await res.json();
3194      statusEl.textContent = data.message || (data.ok ? 'OK' : 'Failed');
3195      statusEl.style.color = data.ok ? 'var(--green, #98c379)' : 'var(--red)';
3196    } catch (e) {
3197      statusEl.textContent = 'Connection failed';
3198      statusEl.style.color = 'var(--red)';
3199    }
3200  });
3201
3202  // Delete
3203  async function doDelete(id) {
3204    if (!await window.styledConfirm('Delete this integration?', { confirmText: 'Delete', danger: true })) return;
3205    try {
3206      await fetch(`/api/auth/integrations/${id}`, { method: 'DELETE', credentials: 'same-origin' });
3207      if (editingId === id) { formCard.style.display = 'none'; editingId = null; }
3208      await renderList();
3209      notifyIntegrationsChanged();
3210    } catch (e) {}
3211  }
3212
3213  syncAuthRow();
3214  renderList();
3215}
3216
3217/* ══ Unified Integrations ══ */
3218
3219const INTG_TYPES = {
3220  api:     { label: 'API',     icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>' },
3221  caldav:  { label: 'CalDAV',  icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>' },
3222  contacts: { label: 'Contacts', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' },
3223  carddav: { label: 'CardDAV', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' },
3224  email:   { label: 'Email',   icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>' },
3225  mcp:     { label: 'MCP',     icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>' },
3226  codex:   { label: 'Codex',   icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 10.696.453a6.023 6.023 0 0 0-5.75 4.172 6.061 6.061 0 0 0-3.946 2.945 6.024 6.024 0 0 0 .742 7.099 5.98 5.98 0 0 0 .516 4.911 6.046 6.046 0 0 0 6.51 2.9A5.996 5.996 0 0 0 13.26 23.547a6.023 6.023 0 0 0 5.75-4.172 6.061 6.061 0 0 0 3.946-2.945 6.024 6.024 0 0 0-.674-6.609zM13.26 21.047a4.508 4.508 0 0 1-2.886-1.041l.143-.082 4.793-2.769a.777.777 0 0 0 .391-.676V10.34l2.026 1.17a.072.072 0 0 1 .039.061v5.596a4.532 4.532 0 0 1-4.506 4.48zM3.968 17.64a4.473 4.473 0 0 1-.537-3.018l.143.086 4.793 2.769a.79.79 0 0 0 .782 0l5.852-3.379v2.34a.072.072 0 0 1-.029.062l-4.845 2.796a4.532 4.532 0 0 1-6.159-1.656zM2.804 7.922a4.49 4.49 0 0 1 2.348-1.973V11.6a.778.778 0 0 0 .391.676l5.852 3.378-2.026 1.17a.072.072 0 0 1-.068 0L4.456 14.03a4.532 4.532 0 0 1-1.652-6.108zm16.423 3.823L13.375 8.367l2.026-1.17a.072.072 0 0 1 .068 0l4.845 2.796a4.525 4.525 0 0 1-.7 8.08V12.42a.778.778 0 0 0-.387-.676zm2.015-3.025l-.143-.086-4.793-2.769a.79.79 0 0 0-.782 0L9.672 9.243V6.903a.072.072 0 0 1 .029-.062l4.845-2.796a4.525 4.525 0 0 1 6.696 4.675zM8.598 12.66L6.57 11.49a.072.072 0 0 1-.039-.061V5.833a4.525 4.525 0 0 1 7.413-3.48l-.143.082-4.793 2.769a.777.777 0 0 0-.391.676l-.019 6.78zm1.1-2.379l2.607-1.505 2.607 1.505v3.01l-2.607 1.505-2.607-1.505z"/></svg>' },
3227  claude:  { label: 'Claude',  icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"/></svg>' },
3228  vault:   { label: 'Vault',   icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' },
3229};
3230
3231// Config shared by the Codex Agent and Claude Agent forms. Both use the same
3232// scope-gated /api/codex/* backend; this just parameterizes the UI label,
3233// default token name, and the per-agent install commands.
3234const AGENT_CONFIGS = {
3235  codex: {
3236    label: 'Codex Agent',
3237    word: 'Codex',
3238    namePrefix: 'codex agent',
3239    defaultName: 'Codex Agent',
3240    pluginPath: '/api/codex/plugin.zip',
3241    setupDescription: 'Downloads the plugin bundle and registers it with Codex. Sets <code>ODYSSEUS_URL</code> + <code>ODYSSEUS_API_TOKEN</code>, fetches the plugin from <a href="/api/codex/plugin.zip" style="color:var(--accent,var(--red));">this Odysseus instance</a>, and runs <code>codex plugin add odysseus@personal</code>.',
3242    buildSetup: (origin, token) => `export ODYSSEUS_URL=${origin}
3243export ODYSSEUS_API_TOKEN='${token}'
3244mkdir -p ~/plugins
3245curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/codex/plugin.zip" -o /tmp/odysseus-codex-plugin.zip
3246python3 -m zipfile -e /tmp/odysseus-codex-plugin.zip ~/plugins
3247python3 - <<'PY'
3248import json
3249from pathlib import Path
3250
3251p = Path.home() / ".agents" / "plugins" / "marketplace.json"
3252p.parent.mkdir(parents=True, exist_ok=True)
3253if p.exists():
3254    data = json.loads(p.read_text())
3255else:
3256    data = {"name": "personal", "interface": {"displayName": "Personal"}, "plugins": []}
3257
3258data.setdefault("name", "personal")
3259data.setdefault("interface", {}).setdefault("displayName", "Personal")
3260plugins = data.setdefault("plugins", [])
3261entry = {
3262    "name": "odysseus",
3263    "source": {"source": "local", "path": "./plugins/odysseus"},
3264    "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
3265    "category": "Productivity",
3266}
3267data["plugins"] = [item for item in plugins if item.get("name") != "odysseus"] + [entry]
3268p.write_text(json.dumps(data, indent=2) + "\\n")
3269PY
3270codex plugin add odysseus@personal
3271python3 ~/plugins/odysseus/scripts/odysseus_api.py capabilities`,
3272  },
3273  claude: {
3274    label: 'Claude Agent',
3275    word: 'Claude',
3276    namePrefix: 'claude agent',
3277    defaultName: 'Claude Agent',
3278    pluginPath: '/api/claude/plugin.zip',
3279    setupDescription: 'Downloads the skill bundle into <code>~/.claude/skills/odysseus/</code>. Sets <code>ODYSSEUS_URL</code> + <code>ODYSSEUS_API_TOKEN</code>, fetches the skill from <a href="/api/claude/plugin.zip" style="color:var(--accent,var(--red));">this Odysseus instance</a>. Claude Code auto-loads the skill on next start.',
3280    buildSetup: (origin, token) => `export ODYSSEUS_URL=${origin}
3281export ODYSSEUS_API_TOKEN='${token}'
3282mkdir -p ~/.claude
3283curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/claude/plugin.zip" -o /tmp/odysseus-claude-skill.zip
3284python3 -m zipfile -e /tmp/odysseus-claude-skill.zip ~/.claude/
3285python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py capabilities`,
3286  },
3287};
3288
3289let _unifiedInited = false;
3290
3291async function initUnifiedIntegrations() {
3292  if (_unifiedInited) return;
3293  _unifiedInited = true;
3294
3295  const listEl = el('unified-integrations-list');
3296  const formEl = el('unified-intg-form');
3297  const addBtn = el('unified-intg-add-btn');
3298  if (!listEl) return;
3299  let integrationNotice = '';
3300
3301  function _openEmailSettings() {
3302    open('email');
3303  }
3304
3305  async function fetchAll() {
3306    const [apiRes, calRes, cardRes, contactsRes, emailAccountsRes, mcpRes, vaultRes, tokenRes, calendarsRes] = await Promise.all([
3307      fetch('/api/auth/integrations', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { integrations: [] }).catch(() => ({ integrations: [] })),
3308      fetch('/api/calendar/config/accounts', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { accounts: [] }).catch(() => ({ accounts: [] })),
3309      fetch('/api/contacts/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})),
3310      fetch('/api/contacts/list', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { contacts: [], count: 0 }).catch(() => ({ contacts: [], count: 0 })),
3311      fetch('/api/email/accounts', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { accounts: [] }).catch(() => ({ accounts: [] })),
3312      fetch('/api/mcp/servers', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : []).catch(() => []),
3313      fetch('/api/vault/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})),
3314      fetch('/api/tokens', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : []).catch(() => []),
3315      fetch('/api/calendar/calendars', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { calendars: [] }).catch(() => ({ calendars: [] })),
3316    ]);
3317    const items = [];
3318    // API integrations
3319    for (const intg of (apiRes.integrations || [])) {
3320      items.push({ type: 'api', id: intg.id, name: intg.name || 'Unnamed', detail: intg.base_url || '', enabled: intg.enabled !== false, data: intg });
3321    }
3322    // CalDAV — one card per account
3323    for (const acc of (calRes.accounts || [])) {
3324      items.push({ type: 'caldav', id: acc.id, name: acc.label || 'Calendar (CalDAV)', detail: acc.url, enabled: true, data: acc });
3325    }
3326    // Contacts import first, then the optional CardDAV sync account.
3327    const contactCount = Number(contactsRes.count || (contactsRes.contacts || []).length || 0);
3328    if (contactCount > 0) {
3329      items.push({
3330        type: 'contacts',
3331        id: '__contacts__',
3332        name: 'Contacts Import',
3333        detail: `${contactCount} contact${contactCount === 1 ? '' : 's'}`,
3334        enabled: true,
3335        data: contactsRes,
3336      });
3337    }
3338    if (cardRes.url) {
3339      items.push({
3340        type: 'carddav',
3341        id: '__carddav__',
3342        name: 'Contacts (CardDAV)',
3343        detail: cardRes.url,
3344        enabled: true,
3345        data: cardRes,
3346      });
3347    }
3348    // Email — one entry per EmailAccount row
3349    for (const acc of (emailAccountsRes.accounts || [])) {
3350      const label = acc.name + (acc.is_default ? ' (default)' : '');
3351      const detail = [acc.from_address || acc.imap_user, acc.imap_host].filter(
3351Boolean).join(' — ');
3352      items.push({ type: 'email', id: acc.id, name: label, detail, enabled: acc.enabled !== false, data: acc });
3353    }
3354    // MCP servers
3355    const mcpList = Array.isArray(mcpRes) ? mcpRes : (mcpRes.servers || []);
3356    for (const srv of mcpList) {
3357      const statusText = srv.needs_oauth ? 'needs auth' : srv.status === 'connected' ? `${srv.enabled_tool_count}/${srv.tool_count} tools` : srv.status === 'error' ? 'error' : 'disconnected';
3358      items.push({ type: 'mcp', id: srv.id || srv.name, name: srv.name || 'MCP Server', detail: statusText, enabled: srv.is_enabled !== false, data: srv });
3359    }
3360    for (const tok of (Array.isArray(tokenRes) ? tokenRes : [])) {
3361      const scopes = tok.scopes || [];
3362      const lowerName = (tok.name || '').toLowerCase();
3363      let agentType = null;
3364      if (lowerName.startsWith('claude agent')) agentType = 'claude';
3365      else if (lowerName.startsWith('codex agent')) agentType = 'codex';
3366      else if (scopes.some(s => String(s || '').startsWith('todos:') || String(s || '').startsWith('email:') || String(s || '').startsWith('documents:'))) {
3367        // Legacy / un-prefixed scoped tokens fall back to Codex for backwards compat.
3368        agentType = 'codex';
3369      }
3370      if (!agentType) continue;
3371      const detail = `${tok.token_prefix || 'token'}... - ${scopes.join(', ') || 'chat'}`;
3372      items.push({ type: agentType, id: tok.id, name: tok.name || (agentType === 'claude' ? 'Claude Agent' : 'Codex Agent'), detail, enabled: true, data: tok });
3373    }
3374    // Vaultwarden removed as an integration option.
3375    return items;
3376  }
3377
3378  function renderCard(item) {
3379    const t = INTG_TYPES[item.type] || INTG_TYPES.api;
3380    // Static enabled/disabled indicator — same dot every integration
3381    // type gets. (The clickable glow-on-test variant for email was
3382    // removed earlier; this matches the API/CalDAV/MCP pattern.)
3383    const statusDot = item.enabled
3384      ? '<span style="width:8px;height:8px;border-radius:50%;background:var(--color-success,#50fa7b);flex-shrink:0;--notif-glow:var(--color-success,#50fa7b);animation:cookbook-notif-pulse 2s ease-in-out infinite;" title="Active"></span>'
3385      : '<span style="width:8px;height:8px;border-radius:50%;background:var(--fg);opacity:0.3;flex-shrink:0" title="Disabled"></span>';
3386    return `<div class="intg-card" data-intg-id="${item.id}" data-intg-type="${item.type}" style="display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:color-mix(in srgb, var(--fg) 3%, transparent);margin-bottom:6px;cursor:pointer;transition:all 0.15s;" title="Click to edit">
3387      <span style="opacity:0.6;flex-shrink:0">${t.icon}</span>
3388      <div style="flex:1;min-width:0">
3389        <div style="font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px">${item.name} <span style="font-size:9px;text-transform:uppercase;letter-spacing:0.5px;padding:1px 5px;border:1px solid color-mix(in srgb, var(--accent, var(--red)) 50%, transparent);border-radius:3px;color:var(--accent, var(--red));background:color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);">${t.label}</span></div>
3390        <div style="font-size:11px;opacity:0.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${item.detail || ''}</div>
3391      </div>
3392      ${statusDot}
3393      <button class="admin-btn-sm intg-del-btn" data-intg-id="${item.id}" data-intg-type="${item.type}" data-intg-name="${(item.name || '').replace(/"/g, '&quot;')}" title="Remove" style="background:none;border:none;padding:4px;cursor:pointer;color:var(--red);opacity:0.55;display:inline-flex;align-items:center;justify-content:center;">
3394        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
3395      </button>
3396    </div>`;
3397  }
3398
3399  async function renderList() {
3400    const items = await fetchAll();
3401    const noticeHtml = integrationNotice ? `
3402      <div class="intg-followup-note" style="display:flex;align-items:center;gap:8px;padding:8px 10px;margin-bottom:8px;border:1px solid color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);border-left:3px solid var(--accent, var(--red));border-radius:5px;background:color-mix(in srgb, var(--accent, var(--red)) 8%, transparent);font-size:11px;">
3403        <span style="flex:1;line-height:1.35">${integrationNotice}</span>
3404        <button type="button" class="admin-btn-sm intg-open-email-settings" style="white-space:nowrap;">Email settings</button>
3405      </div>` : '';
3406    if (items.length === 0) {
3407      listEl.innerHTML = noticeHtml + '<div style="padding:12px;opacity:0.5;font-size:12px;text-align:center">No integrations configured</div>';
3408    } else {
3409      listEl.innerHTML = noticeHtml + items.map(renderCard).join('');
3410    }
3411    listEl.querySelector('.intg-open-email-settings')?.addEventListener('click', (e) => {
3412      e.stopPropagation();
3413      _openEmailSettings();
3414    });
3415    // Wire edit clicks
3416    listEl.querySelectorAll('.intg-card').forEach(card => {
3417      card.addEventListener('click', (e) => {
3418        if (e.target.closest('.intg-del-btn')) return;
3419        const type = card.dataset.intgType;
3420        const id = card.dataset.intgId;
3421        const items2 = listEl.querySelectorAll('.intg-card');
3422        items2.forEach(c => c.style.borderColor = '');
3423        card.style.borderColor = 'var(--accent)';
3424        showForm(type, id);
3425      });
3426    });
3427    // Wire delete
3428    listEl.querySelectorAll('.intg-del-btn').forEach(btn => {
3429      btn.addEventListener('click', async (e) => {
3430        e.stopPropagation();
3431        const intgName = btn.dataset.intgName || 'this integration';
3432        if (!await window.styledConfirm(`Remove "${intgName}"?`, { confirmText: 'Remove', danger: true })) return;
3433        const type = btn.dataset.intgType;
3434        const id = btn.dataset.intgId;
3435        try {
3436          if (type === 'api') await fetch(`/api/auth/integrations/${id}`, { method: 'DELETE', credentials: 'same-origin' });
3437          else if (type === 'caldav') await fetch(`/api/calendar/config/accounts/${id}`, { method: 'DELETE', credentials: 'same-origin' });
3438          else if (type === 'contacts') {
3439            await fetch('/api/contacts/clear', { method: 'DELETE', credentials: 'same-origin' });
3440          }
3441          else if (type === 'carddav') {
3442            await fetch('/api/contacts/config', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ carddav_url: '', carddav_username: '', carddav_password: '' }) });
3443          }
3444          else if (type === 'email') await fetch(`/api/email/accounts/${id}`, { method: 'DELETE', credentials: 'same-origin' });
3445          else if (type === 'mcp') await fetch(`/api/mcp/servers/${id}`, { method: 'DELETE', credentials: 'same-origin' });
3446          else if (type === 'codex' || type === 'claude') await fetch(`/api/tokens/${id}`, { method: 'DELETE', credentials: 'same-origin' });
3447          else if (type === 'vault') await fetch('/api/vault/logout', { method: 'POST', credentials: 'same-origin' });
3448        } catch (_) {}
3449        formEl.style.display = 'none';
3450        await renderList();
3451        notifyIntegrationsChanged();
3452      });
3453    });
3454  }
3455
3456  function showForm(type, editId) {
3457    formEl.style.display = '';
3458    if (type === 'api') showApiForm(editId);
3459    else if (type === 'caldav') showCalDavForm(editId);
3460    else if (type === 'contacts' || type === 'carddav') showCardDavForm();
3461    else if (type === 'email') showEmailForm(editId);
3462    else if (type === 'mcp') showMcpForm(editId);
3463    else if (type === 'codex') showAgentForm('codex', editId);
3464    else if (type === 'claude') showAgentForm('claude', editId);
3465    else if (type === 'vault') showVaultForm();
3466  }
3467
3468  // ── API form ──
3469  async function showApiForm(editId) {
3470    let presets = {};
3471    try {
3472      const r = await fetch('/api/auth/integrations/presets', { credentials: 'same-origin' });
3473      if (r.ok) { const d = await r.json(); presets = d.presets || {}; }
3474    } catch (_) {}
3475    const presetEntries = Object.entries(presets);
3476    // Same `?` hint helper as the email form. Native title tooltip,
3477    // tabbable for keyboard users. Inline-styled so it doesn't need
3478    // a CSS dependency.
3479    const _apiHint = (tip) =>
3480      `<span class="uf-hint" title="${esc(tip.replace(/<[^>]+>/g, ''))}" aria-label="${esc(tip.replace(/<[^>]+>/g, ''))}" tabindex="0" `
3481      + `style="display:inline-block;width:13px;height:13px;border-radius:50%;`
3482      + `border:1px solid currentColor;font-size:9px;line-height:11px;text-align:center;`
3483      + `opacity:0.45;margin-left:5px;cursor:help;vertical-align:1px;font-weight:600;">?</span>`;
3484    // Real <select> instead of <datalist>: datalists are silently
3485    // suppressed in Firefox when autocomplete="off" is on the input,
3486    // and they're patchy on mobile browsers. A native select renders
3487    // the same everywhere and makes the available options visible
3488    // without needing the user to type.
3489    const sortedPresets = presetEntries.sort((a, b) => (a[1].name || a[0]).localeCompare(b[1].name || b[0]));
3490    const selectOpts = sortedPresets
3491      .map(([k, p]) => `<option value="${k}">${esc(p.name || k)}</option>`)
3492      .join('');
3493    // Letter-in-brand-color logo for each API preset; outline plug icon for
3494    // "Custom (no preset)". Matches the email-provider dropdown pattern.
3495    const _apiLetter = (letter, bg) => `<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="flex-shrink:0"><circle cx="12" cy="12" r="11" fill="${bg}"/><text x="12" y="16.5" font-size="13" font-weight="700" text-anchor="middle" fill="#fff" font-family="system-ui,sans-serif">${letter}</text></svg>`;
3496    const _apiCustomIco = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;opacity:0.7"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
3497    const API_PRESET_LOGO = {
3498      miniflux:        _apiLetter('M', '#214c87'),
3499      gitea:           _apiLetter('G', '#609926'),
3500      linkding:        _apiLetter('L', '#1f2937'),
3501      home_assistant:  _apiLetter('H', '#41bdf5'),
3502      ntfy:            _apiLetter('n', '#317f43'),
3503      vaultwarden:     _apiLetter('V', '#175ddc'),
3504      freshrss:        _apiLetter('R', '#ef6c00'),
3505    };
3506    const _apiIconFor = (k) => {
3507      if (!k) return _apiCustomIco;
3508      if (API_PRESET_LOGO[k]) return API_PRESET_LOGO[k];
3509      const first = (presets[k]?.name || k).trim().charAt(0).toUpperCase() || '?';
3510      return _apiLetter(first, '#6b7280');
3511    };
3512    const _apiRows = [['', 'Custom (no preset)'], ...sortedPresets.map(([k, p]) => [k, p.name || k])]
3513      .map(([k, label]) => `<button type="button" class="ufapi-option" data-value="${esc(k)}" style="display:flex;align-items:center;gap:10px;width:100%;padding:8px 10px;background:transparent;border:0;color:var(--fg);font:inherit;cursor:pointer;text-align:left;">${_apiIconFor(k)}<span>${esc(label)}</span></button>`).join('');
3514    formEl.innerHTML = `
3515      <div class="admin-card" style="margin-top:8px">
3516        <h2 style="font-size:13px;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>API Integration</h2>
3517        <div class="settings-col">
3518          <div class="settings-row"><label class="settings-label">Preset</label>
3519            <div style="position:relative;flex:1;min-width:0;">
3520              <select id="uf-api-preset" tabindex="-1" aria-hidden="true" style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;"><option value="">Custom (no preset)</option>${selectOpts}</select>
3521              <button type="button" id="uf-api-preset-trigger" class="settings-select" style="display:flex;align-items:center;gap:10px;cursor:pointer;text-align:left;width:100%;padding-right:24px;position:relative;">
3522                <span class="ufapi-icon" style="display:inline-flex;align-items:center;">${_apiCustomIco}</span>
3523                <span class="ufapi-label" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Custom (no preset)</span>
3524                <span aria-hidden="true" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0.5;font-size:10px;pointer-events:none;">▾</span>
3525              </button>
3526              <div id="uf-api-preset-menu" style="display:none;position:absolute;top:calc(100% + 2px);left:0;right:0;z-index:1000;background:var(--panel);border:1px solid var(--border);border-radius:6px;max-height:340px;overflow-y:auto;box-shadow:0 6px 18px rgba(0,0,0,0.25);">${_apiRows}</div>
3527            </div>
3528          </div>
3529          <div class="settings-row"><label class="settings-label">
3529Name</label><input id="uf-api-name" class="settings-input" placeholder="My Service"></div>
3530          <div class="settings-row"><label class="settings-label">Base URL</label><input id="uf-api-url" class="settings-input" placeholder="http://localhost:8080"></div>
3531          <div id="uf-api-ntfy-hint" style="display:none;font-size:11px;line-height:1.35;opacity:0.68;margin:-2px 0 2px 106px;"></div>
3532          <div class="settings-row"><label class="settings-label">Auth${_apiHint('How this service expects the credential to be sent. <b>Bearer</b> = sends "Authorization: Bearer YOUR_KEY" (most modern APIs, ntfy, OpenAI-style). <b>Header</b> = sends YOUR_KEY verbatim under a header name you choose (Miniflux uses X-Auth-Token). <b>Basic</b> = HTTP basic auth (user:pass). <b>None</b> = the API is open / no auth.')}</label><select id="uf-api-auth" class="settings-input"><option value="bearer">Bearer (most common)</option><option value="header">Header</option><option value="basic">Basic</option><option value="none">None</option></select></div>
3533          <div class="settings-row" id="uf-api-header-row"><label class="settings-label">Header${_apiHint('The HTTP header name the key goes under (Miniflux: X-Auth-Token; most others: Authorization). Only used when Auth = Header.')}</label><input id="uf-api-header" class="settings-input" placeholder="X-Auth-Token"></div>
3534          <div class="settings-row"><label class="settings-label">API Key${_apiHint('The secret token the service issued you (generated in its admin panel / settings). Used to prove your identity on each request. Required for any Auth mode except None.')}</label><input id="uf-api-key" class="settings-input" type="password" placeholder="Token/key"></div>
3535          <div class="settings-row" style="margin-top:4px"><button class="admin-btn-sm" id="uf-api-save">Save</button><button class="admin-btn-sm" id="uf-api-test" style="opacity:0.7">Test</button><button class="admin-btn-sm" id="uf-api-cancel" style="opacity:0.7">Cancel</button><span id="uf-api-msg" style="font-size:11px"></span></div>
3536        </div>
3537      </div>`;
3538    // Custom preset dropdown wire-up (hidden select stays as data source).
3539    (() => {
3540      const trig = el('uf-api-preset-trigger');
3541      const menu = el('uf-api-preset-menu');
3542      const sel = el('uf-api-preset');
3543      if (!trig || !menu || !sel) return;
3544      const lbl = trig.querySelector('.ufapi-label');
3545      const ico = trig.querySelector('.ufapi-icon');
3546      const _setFromKey = (k) => {
3547        const row = menu.querySelector(`.ufapi-option[data-value="${k}"]`);
3548        const text = row?.querySelector('span')?.textContent || 'Custom (no preset)';
3549        if (lbl) lbl.textContent = text;
3550        if (ico) ico.innerHTML = _apiIconFor(k);
3551      };
3552      const _close = () => { menu.style.display = 'none'; };
3553      const _open = () => {
3554        menu.style.display = 'block';
3555        const tRect = trig.getBoundingClientRect();
3556        const mRect = menu.getBoundingClientRect();
3557        const below = window.innerHeight - tRect.bottom;
3558        const above = tRect.top;
3559        if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; }
3560        else { menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; }
3561        const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trig) { _close(); document.removeEventListener('click', onDoc, true); } };
3562        setTimeout(() => document.addEventListener('click', onDoc, true), 0);
3563      };
3564      trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); });
3565      menu.querySelectorAll('.ufapi-option').forEach(btn => {
3566        btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
3567        btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
3568        btn.addEventListener('click', (e) => {
3569          e.stopPropagation();
3570          const k = btn.dataset.value || '';
3571          sel.value = k;
3572          _setFromKey(k);
3573          _close();
3574          sel.dispatchEvent(new Event('change', { bubbles: true }));
3575        });
3576      });
3577      _setFromKey(sel.value || '');
3578    })();
3579
3580    const preset = el('uf-api-preset'), name = el('uf-api-name'), url = el('uf-api-url'), auth = el('uf-api-auth'), header = el('uf-api-header'), key = el('uf-api-key'), ntfyHint = el('uf-api-ntfy-hint');
3581    let _editId = editId && editId !== 'new' ? editId : null;
3582    // Load existing
3583    if (_editId) {
3584      try {
3585        const r = await fetch('/api/auth/integrations', { credentials: 'same-origin' });
3586        const d = await r.json();
3587        const item = (d.integrations || []).find(i => i.id === _editId);
3588        if (item) { name.value = item.name || ''; url.value = item.base_url || ''; auth.value = item.auth_type || 'none'; header.value = item.auth_header || ''; }
3589      } catch (_) {}
3590    }
3591    // Native <select>: the option `value` is the preset key directly, so
3592    // no typed-name → key lookup is needed (datalist-era leftover).
3593    const _applyPreset = () => {
3594      const p = presets[preset.value];
3595      const isNtfy = preset.value === 'ntfy' || (p && (p.name || '').toLowerCase() === 'ntfy');
3596      const isUrlAuth = preset.value === 'discord_webhook'; // secret embedded in URL — no key/auth fields needed
3597      if (ntfyHint) {
3598        ntfyHint.style.display = isNtfy ? 'block' : 'none';
3599        if (isNtfy) {
3600          ntfyHint.innerHTML = 'Enter the ntfy server URL Odysseus can reach. Examples: <code>http://127.0.0.1:8091</code>, <code>http://100.x.y.z:8091</code>, or <code>https://ntfy.example.com</code>.';
3601        }
3602      }
3603      if (url) {
3604        url.placeholder = isNtfy ? 'http://127.0.0.1:8091' : isUrlAuth ? 'https://discord.com/api/webhooks/...' : 'http://localhost:8080';
3605      }
3606      // For presets that embed the secret in the URL, hide auth/key/header rows
3607      // so users aren't confused into thinking they need to fill them in.
3608      const keyRow = key?.closest('.settings-row');
3609      const authRow = auth?.closest('.settings-row');
3610      const headerRow = el('uf-api-header-row');
3611      if (keyRow) keyRow.style.display = isUrlAuth ? 'none' : '';
3612      if (authRow) authRow.style.display = isUrlAuth ? 'none' : '';
3613      if (headerRow) headerRow.style.display = isUrlAuth ? 'none' : '';
3614      if (!p) return;
3615      name.value = p.name || '';
3616      auth.value = p.auth_type || 'none';
3617      header.value = p.auth_header || '';
3618    };
3619    preset.addEventListener('change', _applyPreset);
3620    _applyPreset();
3621    el('uf-api-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
3622    el('uf-api-save').addEventListener('click', async () => {
3623      const presetKey = preset.value || undefined;
3624      const body = { name: name.value, base_url: url.value, auth_type: auth.value, auth_header: header.value, preset: presetKey };
3625      if (key.value) body.api_key = key.value;
3626      try {
3627        const u = _editId ? `/api/auth/integrations/${_editId}` : '/api/auth/integrations';
3628        const m = _editId ? 'PUT' : 'POST';
3629        const r = await fetch(u, { method: m, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
3630        if (!r.ok) throw new Error();
3631        const saved = await r.json().catch(() => null);
3632        // If this was a create, capture the new ID so Test works
3633        // immediately without needing a form reopen. The POST response
3634        // shape is {ok, integration: {id, ...}} — saved.id at the top
3635        // level would silently miss, leaving Test perpetually stuck on
3636        // "Save first" until the form was reopened.
3637        if (!_editId && saved) _editId = saved.integration?.id || saved.id;
3638        el('uf-api-msg').textContent = 'Saved'; el('uf-api-msg').style.color = 'var(--green,#50fa7b)';
3639        await renderList();
3640        notifyIntegrationsChanged();
3641      } catch (_) { el('uf-api-msg').textContent = 'Failed'; el('uf-api-msg').style.color = 'var(--red)'; }
3642    });
3643    el('uf-api-test').addEventListener('click', async () => {
3644      if (!_editId) { el('uf-api-msg').textContent = 'Save first'; return; }
3645      try {
3646        const r = await fetch(`/api/auth/integrations/${_editId}/test`, { method: 'POST', credentials: 'same-origin' });
3647        const d = await r.json();
3648        // Backend returns {ok: bool, message: str}
3649        if (d.ok) {
3650          el('uf-api-msg').textContent = d.message || 'Connected';
3651          el('uf-api-msg').style.color = 'var(--green,#50fa7b)';
3652        } else {
3653          el('uf-api-msg').textContent = (d.message || d.error || d.detail || `HTTP ${r.status}`).slice(0, 360);
3654          el('uf-api-msg').style.color = 'var(--red)';
3655        }
3656      } catch (e) { el('uf-api-msg').textContent = 'Error: ' + e.message; el('uf-api-msg').style.color = 'var(--red)'; }
3657    });
3658  }
3659
3660  // ── CalDAV form (supports add + edit per account) ──
3661  async function showCalDavForm(editId) {
3662    const isNew = !editId || editId === 'new';
3663    formEl.innerHTML = `
3664      <div class="admin-card" style="margin-top:8px">
3665        <h2 style="font-size:13px;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>${isNew ? 'Add CalDAV Calendar' : 'Edit CalDAV Calendar'}</h2>
3666        <div class="settings-col">
3667          <div class="settings-row"><label class="settings-label">Label</label><input id="uf-caldav-label" class="settings-input" placeholder="e.g. Work, Personal"></div>
3668          <div class="settings-row"><label class="settings-label">Server URL</label><input id="uf-caldav-url" class="settings-input" placeholder="https://www.google.com/calendar/dav/[email protected]/user/"></div>
3669          <div class="settings-row"><label class="settings-label">Username</label><input id="uf-caldav-user" class="settings-input" placeholder="[email protected]"></div>
3670          <div class="settings-row"><label class="settings-label">Password</label><input id="uf-caldav-pass" class="settings-input" type="password" placeholder="${isNew ? '' : 'Leave blank to keep existing'}"></div>
3671          <div class="settings-row" style="margin-top:4px"><button class="admin-btn-sm" id="uf-caldav-save">Save</button><button class="admin-btn-sm" id="uf-caldav-test" style="opacity:0.7">Test</button><button class="admin-btn-sm" id="uf-caldav-cancel" style="opacity:0.7">Cancel</button><span id="uf-caldav-msg" style="font-size:11px;margin-left:6px"></span></div>
3672        </div>
3673      </div>`;
3674
3675    if (!isNew) {
3676      try {
3677        const r = await fetch('/api/calendar/config/accounts', { credentials: 'same-origin' });
3678        const d = await r.json();
3679        const acc = (d.accounts || []).find(a => a.id === editId);
3680        if (acc) {
3681          el('uf-caldav-label').value = acc.label || '';
3682          el('uf-caldav-url').value = acc.url || '';
3683          el('uf-caldav-user').value = acc.username || '';
3684        }
3685      } catch (_) {}
3686    }
3687
3688    el('uf-caldav-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
3689
3690    const _runCalDavTest = async () => {
3691      const body = {
3692        url: el('uf-caldav-url').value.trim(),
3693        username: el('uf-caldav-user').value.trim(),
3694        password: el('uf-caldav-pass').value,
3695      };
3696      if (!isNew && !body.password) body.account_id = editId;
3697      try {
3698        const r = await fetch('/api/calendar/test', {
3699          method: 'POST', credentials: 'same-origin',
3700          headers: { 'Content-Type': 'application/json' },
3701          body: JSON.stringify(body),
3702        });
3703        return await r.json();
3704      } catch (e) {
3705        return { ok: false, error: 'Network error: ' + e.message };
3706      }
3707    };
3708
3709    const _setCalDavMsg = (text, ok) => {
3710      const msg = el('uf-caldav-msg');
3711      msg.textContent = text;
3712      msg.style.color = ok ? 'var(--green, #50fa7b)' : 'var(--red)';
3713    };
3714
3715    el('uf-caldav-save').addEventListener('click', async () => {
3716      _setCalDavMsg('Testing…', true);
3717      el('uf-caldav-msg').style.color = '';
3718      const d = await _runCalDavTest();
3719      if (!d.ok) {
3720        _setCalDavMsg(d.error || 'Connection failed — not saved', false);
3721        return;
3722      }
3723      try {
3724        const payload = {
3725          label: el('uf-caldav-label').value.trim(),
3726          url: el('uf-caldav-url').value.trim(),
3727          username: el('uf-caldav-user').value.trim(),
3728          password: el('uf-caldav-pass').value,
3729        };
3730        let resp;
3731        if (isNew) {
3732          resp = await fetch('/api/calendar/config/accounts', {
3733            method: 'POST', credentials: 'same-origin',
3734            headers: { 'Content-Type': 'application/json' },
3735            body: JSON.stringify(payload),
3736          });
3737        } else {
3738          resp = await fetch(`/api/calendar/config/accounts/${editId}`, {
3739            method: 'PUT', credentials: 'same-origin',
3740            headers: { 'Content-Type': 'application/json' },
3741            body: JSON.stringify(payload),
3742          });
3743        }
3744        if (!resp.ok) {
3745          const err = await resp.json().catch(() => ({}));
3746          _setCalDavMsg(err.detail || 'Save failed', false);
3747          return;
3748        }
3749        _setCalDavMsg('Saved', true);
3750        formEl.style.display = 'none';
3751        await renderList();
3752        notifyIntegrationsChanged();
3753      } catch (_) {
3754        _setCalDavMsg('Save failed', false);
3755      }
3756    });
3757
3758    el('uf-caldav-test').addEventListener('click', async () => {
3759      _setCalDavMsg('Testing…', true);
3760      el('uf-caldav-msg').style.color = '';
3761      const d = await _runCalDavTest();
3762      _setCalDavMsg(d.ok ? 'Connected' : (d.error || 'Failed'), d.ok);
3763    });
3764  }
3765
3766  // ── CardDAV form + contacts manager ──
3767  async function showCardDavForm() {
3768    formEl.innerHTML = `
3769      <div class="admin-card" style="margin-top:8px">
3770        <h2 style="font-size:13px;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Contacts (CardDAV)</h2>
3771        <div class="settings-col">
3772          <div class="settings-row"><label class="settings-label">URL</label><input id="uf-carddav-url" class="settings-input" placeholder="http://localhost:5232/user/contacts/"></div>
3773          <div class="settings-row"><label class="settings-label">Username</label><input id="uf-carddav-user" class="settings-input"></div>
3774          <div class="settings-row"><label class="settings-label">Password</label><input id="uf-carddav-pass" class="settings-input" type="password"></div>
3775          <div class="settings-row" style="margin-top:8px;align-items:center;">
3776            <button class="admin-btn-add" id="uf-carddav-save" style="background:var(--red);border-color:var(--red);color:#fff;display:inline-flex;align-items:center;gap:5px;font-weight:600;">
3777              <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
3778              Save
3779            </button>
3780            <span id="uf-carddav-msg" style="font-size:11px;flex:1;margin-left:8px"></span>
3781            <button class="admin-btn-add" id="uf-carddav-cancel" style="opacity:0.7;display:inline-flex;align-items:center;gap:5px;position:relative;top:1px;margin-left:auto;">
3782              <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
3783              Cancel
3784            </button>
3785          </div>
3786        </div>
3787      </div>
3788      <div class="admin-card contacts-manager" style="margin-top:8px">
3789        <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
3790          <h2 style="font-size:13px;margin:0;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Contacts Import <span id="cm-count" style="opacity:0.5;font-weight:normal;font-size:11px;"></span></h2>
3791          <button class="admin-btn-sm" id="cm-import-btn" style="margin-left:auto;">Import</button>
3792          <button class="admin-btn-sm" id="cm-export-vcf-btn">Export .vcf</button>
3793          <button class="admin-btn-sm" id="cm-export-csv-btn">Export .csv</button>
3794          <button class="admin-btn-sm" id="cm-add-toggle">+ Add</button>
3795          <input type="file" id="cm-import-file" accept=".vcf,.csv,text/vcard,text/csv" multiple style="display:none">
3796        </div>
3797        <div id="cm-add-row" class="contacts-add-row" style="display:none;">
3798          <input id="cm-add-name" class="settings-input" placeholder="Name" style="flex:1;min-width:0;">
3799          <input id="cm-add-email" class="settings-input" placeholder="[email protected]" style="flex:1;min-width:0;">
3800          <button class="admin-btn-sm" id="cm-add-save">Save</button>
3801        </div>
3802        <div id="cm-list" class="contacts-list"><div style="opacity:0.4;font-size:11px;padding:8px 2px;">Loading…</div></div>
3803      </div>`;
3804    try {
3805      const r = await fetch('/api/contacts/config', { credentials: 'same-origin' }); const d = await r.json();
3806      el('uf-carddav-url').value = d.url || ''; el('uf-carddav-user').value = d.username || '';
3807    } catch (_) {}
3808    el('uf-carddav-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
3809    el('uf-carddav-save').addEventListener('click', async () => {
3810      const body = { carddav_url: el('uf-carddav-url').value, carddav_username: el('uf-carddav-user').value };
3811      if (el('uf-carddav-pass').value) body.carddav_password = el('uf-carddav-pass').value;
3812      try {
3813        await fetch('/api/contacts/config', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
3814        el('uf-carddav-msg').textContent = 'Saved';
3815        el('uf-carddav-msg').style.color = 'var(--green, #50fa7b)';
3816        // Refresh both the sub-panel (contacts manager) AND the
3817        // outer integrations list so the CardDAV row appears
3818        // immediately instead of waiting for a page reload.
3819        await _renderContactsManager();
3820        await renderList();
3821        notifyIntegrationsChanged();
3822      } catch (_) {
3823        el('uf-carddav-msg').textContent = 'Failed';
3824        el('uf-carddav-msg').style.color = 'var(--red)';
3825      }
3826    });
3827    // Add-row toggle + save
3828    el('cm-add-toggle')?.addEventListener('click', () => {
3829      const row = el('cm-add-row');
3830      const open = row.style.display !== 'none';
3831      row.style.display = open ? 'none' : 'flex';
3832      if (!open) el('cm-add-name')?.focus();
3833    });
3834    el('cm-add-save')?.addEventListener('click', async () => {
3835      const name = el('cm-add-name').value.trim();
3836      const email = el('cm-add-email').value.trim();
3837      if (!email) { el('cm-add-email').focus(); return; }
3838      try {
3839        await fetch('/api/contacts/add', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email }) });
3840      } catch (_) {}
3841      el('cm-add-name').value = ''; el('cm-add-email').value = '';
3842      el('cm-add-row').style.display = 'none';
3843      await _renderContactsManager();
3844    });
3845    const _downloadContacts = async (format) => {
3846      const btn = el(format === 'csv' ? 'cm-export-csv-btn' : 'cm-export-vcf-btn');
3847      const orig = btn ? btn.textContent : '';
3848      if (btn) { btn.textContent = 'Exporting...'; btn.disabled = true; }
3849      try {
3850        const res = await fetch(`/api/contacts/export?format=${encodeURIComponent(format)}`, { credentials: 'same-origin' });
3851        if (!res.ok) throw new Error('Export failed');
3852        const blob = await res.blob();
3853        const url = URL.createObjectURL(blob);
3854        const a = document.createElement('a');
3855        a.href = url;
3856        a.download = format === 'csv' ? 'odysseus-contacts.csv' : 'odysseus-contacts.vcf';
3857        document.body.appendChild(a);
3858        a.click();
3859        a.remove();
3860        setTimeout(() => URL.revokeObjectURL(url), 1000);
3861      } catch (_) {
3862        uiModule.showError ? uiModule.showError('Export failed') : alert('Export failed');
3863      } finally {
3864        if (btn) { btn.textContent = orig; btn.disabled = false; }
3865      }
3866    };
3867    el('cm-export-vcf-btn')?.addEventListener('click', () => _downloadContacts('vcf'));
3868    el('cm-export-csv-btn')?.addEventListener('click', () => _downloadContacts('csv'));
3869
3870    // Import .vcf/.csv — read each selected file as text, concatenate by type,
3871    // then POST. Imported CardDAV contacts immediately feed email autocomplete
3872    // because compose searches /api/contacts/search.
3873    el('cm-import-btn')?.addEventListener('click', () => el('cm-import-file')?.click());
3874    el('cm-import-file')?.addEventListener('change', async (e) => {
3875      const files = Array.from(e.target.files || []);
3876      if (!files.length) return;
3877      const btn = el('cm-import-btn');
3878      const orig = btn ? btn.textContent : '';
3879      if (btn) { btn.textContent = 'Importing…'; btn.disabled = true; }
3880      try {
3881        const texts = await Promise.all(files.map(f => f.text()));
3882        const vcfParts = [];
3883        const csvParts = [];
3884        texts.forEach((text, idx) => {
3885          const name = (files[idx]?.name || '').toLowerCase();
3886          if (name.endsWith('.csv') || !String(text || '').toUpperCase().includes('BEGIN:VCARD')) csvParts.push(text);
3887          else vcfParts.push(text);
3888        });
3889        let imported = 0, total = 0, failed = 0;
3890        const _postImport = async (body) => {
3891          const r = await fetch('/api/contacts/import', {
3892            method: 'POST', credentials: 'same-origin',
3893            headers: { 'Content-Type': 'application/json' },
3894            body: JSON.stringify(body),
3895          });
3896          const d = await r.json();
3897          if (d.error) throw new Error(d.error);
3898          imported += Number(d.imported || 0);
3899          total += Number(d.total || 0);
3900          failed += Number(d.failed || 0);
3901        };
3902        if (vcfParts.length) await _postImport({ vcf: vcfParts.join('\n') });
3903        if (csvParts.length) await _postImport({ csv: csvParts.join('\n') });
3904        if (!vcfParts.length && !csvParts.length) throw new Error('No contact data found');
3905        const msg = `Imported ${imported}/${total}` + (failed ? ` (${failed} failed)` : '');
3906        uiModule.showToast ? uiModule.showToast(msg) : null;
3907      } catch (err) {
3908        uiModule.showError ? uiModule.showError(err?.message || 'Import failed') : alert(err?.message || 'Import failed');
3909      } finally {
3910        if (btn) { btn.textContent = orig; btn.disabled = false; }
3911        e.target.value = '';
3912        await _renderContactsManager();
3913      }
3914    });
3915    await _renderContactsManager();
3916  }
3917
3918  // Render the contacts list inside the manager card with inline edit +
3919  // delete. Each row: name + emails; pencil flips to editable inputs.
3920  async function _renderContactsManager() {
3921    const list = el('cm-list');
3922    if (!list) return;
3923    let contacts = [];
3924    try {
3925      const r = await fetch('/api/contacts/list', { credentials: 'same-origin' });
3926      const d = await r.json();
3927      contacts = d.contacts || [];
3928    } catch (_) {
3929      list.innerHTML = '<div style="opacity:0.5;font-size:11px;padding:8px 2px;">Failed to load contacts (check CardDAV config above).</div>';
3930      return;
3931    }
3932    const cnt = el('cm-count');
3933    if (cnt) cnt.textContent = contacts.length ? `(${contacts.length})` : '';
3934    if (!contacts.length) {
3935      list.innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 2px;">No contacts yet.</div>';
3936      return;
3937    }
3938    // Sort by name for a stable list.
3939    contacts.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
3940    list.innerHTML = contacts.map(c => {
3941      const emails = (c.emails || []).join(', ');
3942      const phones = (c.phones || []).join(', ');
3943      const sub = [emails, phones].filter(Boolean).join(' · ');
3944      return `<div class="contact-row" data-uid="${esc(c.uid)}">
3945        <div class="contact-row-view" style="display:flex;align-items:center;gap:8px;">
3946          <div style="flex:1;min-width:0;">
3947            <div class="contact-name" style="font-size:12px;font-weight:600;">${esc(c.name || '(no name)')}</div>
3948            <div class="contact-sub" style="font-size:10px;opacity:0.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(sub)}</div>
3949          </div>
3950          <button class="admin-btn-sm contact-edit" title="Edit" style="display:inline-flex;align-items:center;gap:4px;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 35%, var(--border));">
3951            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
3952            Edit
3953          </button>
3954          <button class="admin-btn-sm contact-del" title="Delete" style="opacity:0.85;display:inline-flex;align-items:center;gap:4px;">
3955            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
3956            Delete
3957          </button>
3958        </div>
3959        <div class="contact-row-edit" style="display:none;flex-direction:column;gap:4px;">
3960          <input class="settings-input contact-edit-name" value="${esc(c.name || '')}" placeholder="Name">
3961          <input class="settings-input contact-edit-emails" value="${esc(emails)}" placeholder="email1, email2">
3962          <input class="settings-input contact-edit-phones" value="${esc(phones)}" placeholder="phone1, phone2">
3963          <div style="display:flex;gap:6px;"><button class="admin-btn-sm contact-save">Save</button><button class="admin-btn-sm contact-cancel" style="opacity:0.7;">Cancel</button></div>
3964        </div>
3965      </div>`;
3966    }).join('');
3967    // Wire each row's edit / delete / save / cancel.
3968    list.querySelectorAll('.contact-row').forEach(row => {
3969      const uid = row.dataset.uid;
3970      const view = row.querySelector('.contact-row-view');
3971      const editForm = row.querySelector('.contact-row-edit');
3972      row.querySelector('.contact-edit')?.addEventListener('click', () => {
3973        view.style.display = 'none';
3974        editForm.style.display = 'flex';
3975      });
3976      row.querySelector('.contact-cancel')?.addEventListener('click', () => {
3977        editForm.style.display = 'none';
3978        view.style.display = 'flex';
3979      });
3980      row.querySelector('.contact-save')?.addEventListener('click', async () => {
3981        const body = {
3982          name: row.querySelector('.contact-edit-name').value.trim(),
3983          emails: row.querySelector('.contact-edit-emails').value.split(',').map(s =>
3983 s.trim()).filter(Boolean),
3984          phones: row.querySelector('.contact-edit-phones').value.split(',').map(s => s.trim()).filter(Boolean),
3985        };
3986        try {
3987          await fetch('/api/contacts/' + encodeURIComponent(uid), { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
3988        } catch (_) {}
3989        await _renderContactsManager();
3990      });
3991      row.querySelector('.contact-del')?.addEventListener('click', async () => {
3992        const ok = uiModule.styledConfirm
3993          ? await uiModule.styledConfirm('Delete this contact?', { confirmText: 'Delete', danger: true })
3994          : window.confirm('Delete this contact?');
3995        if (!ok) return;
3996        try {
3997          await fetch('/api/contacts/' + encodeURIComponent(uid), { method: 'DELETE', credentials: 'same-origin' });
3998        } catch (_) {}
3999        await _renderContactsManager();
4000      });
4001    });
4002  }
4003
4004  // ── Email form (multi-account) ──
4005  // When editId is a real account id, edit that row. When editId is falsy or 'new',
4006  // create a fresh account. Posts to /api/email/accounts, never to the legacy
4007  // /api/email/config which would overwrite the default.
4008  async function showEmailForm(editId) {
4009    const isEdit = editId && editId !== 'new' && editId !== '__email__';
4010    let existing = null;
4011    if (isEdit) {
4012      try {
4013        const r = await fetch('/api/email/accounts', { credentials: 'same-origin' });
4014        const d = await r.json();
4015        existing = (d.accounts || []).find(a => a.id === editId) || null;
4016      } catch (_) {}
4017    }
4018    const placeholderPass = (isEdit && existing) ? '(leave blank to keep current)' : '';
4019    // Small `?` indicator next to each label (native title tooltip).
4020    const _hint = (tip) =>
4021      `<span class="uf-hint" title="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0" `
4022      + `style="display:inline-block;width:13px;height:13px;border-radius:50%;`
4023      + `border:1px solid currentColor;font-size:9px;line-height:11px;text-align:center;`
4024      + `opacity:0.45;margin-left:5px;cursor:help;vertical-align:1px;font-weight:600;">?</span>`;
4025    // Provider presets — picking one auto-fills IMAP + SMTP host/port.
4026    // Dovecot is IMAP-only here; the host is intentionally blank because
4027    // it may be remote (DNS, LAN, Tailscale), not localhost.
4028    const PROVIDERS = {
4029      gmail:    { label: 'Gmail',                   emailEx: '[email protected]',     imap: { host: 'imap.gmail.com',           port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com',     port: 465 } },
4030      migadu:   { label: 'Migadu',                  emailEx: '[email protected]', imap: { host: 'imap.migadu.com',          port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com',    port: 465 } },
4031      icloud:   { label: 'iCloud',                  emailEx: '[email protected]',    imap: { host: 'imap.mail.me.com',         port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com',   port: 587 } },
4032      outlook:  { label: 'Outlook / Office 365',    emailEx: '[email protected]',   imap: { host: 'outlook.office365.com',    port: 993, starttls: false }, smtp: { host: 'smtp.office365.com', port: 587 } },
4033      fastmail: { label: 'Fastmail',                emailEx: '[email protected]',  imap: { host: 'imap.fastmail.com',        port: 993, starttls: false }, smtp: { host: 'smtp.fastmail.com',  port: 465 } },
4034      yahoo:    { label: 'Yahoo',                   emailEx: '[email protected]',     imap: { host: 'imap.mail.yahoo.com',      port: 993, starttls: false }, smtp: { host: 'smtp.mail.yahoo.com', port: 465 } },
4035      dovecot:  { label: 'Dovecot IMAP (no SMTP)',  emailEx: '[email protected]',   imap: { host: '',                         port: 31143, starttls: false }, smtp: { host: '',                   port: 465 } },
4036    };
4037    const _providerOptions = Object.entries(PROVIDERS)
4038      .map(([k, v]) => `<option value="${k}">${esc(v.label)}</option>`).join('');
4039    // Provider logos — small SVGs the custom dropdown renders next to each
4040    // option. Letter-in-brand-color circle for known providers; outline
4041    // envelope for "Custom…". Inline SVG (no external assets, no emoji).
4042    const _letterLogo = (letter, bg) =>
4042 `<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="flex-shrink:0"><circle cx="12" cy="12" r="11" fill="${bg}"/><text x="12" y="16.5" font-size="13" font-weight="700" text-anchor="middle" fill="#fff" font-family="system-ui,sans-serif">${letter}</text></svg>`;
4043    const _customLogo = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;opacity:0.7"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>';
4044    const PROV_LOGO = {
4045      '':       _customLogo,
4046      gmail:    _letterLogo('G', '#ea4335'),
4047      migadu:   _letterLogo('M', '#3aa39d'),
4048      icloud:   _letterLogo('i', '#3693f3'),
4049      outlook:  _letterLogo('O', '#0078d4'),
4050      fastmail: _letterLogo('F', '#4a5fbb'),
4051      yahoo:    _letterLogo('Y', '#6001d2'),
4052      dovecot:  _letterLogo('D', '#6b7280'),
4053    };
4054    const _provOptionRows = [['', 'Custom…'], ...Object.entries(PROVIDERS).map(([k, v]) => [k, v.label])]
4055      .map(([k, label]) => `<button type="button" class="ufp-option" data-value="${esc(k)}" style="display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;background:transparent;border:0;color:var(--fg);font:inherit;cursor:pointer;text-align:left;">${PROV_LOGO[k] || _customLogo}<span>${esc(label)}</span></button>`).join('');
4056    const _smtpSecurity = (acct) => acct?.smtp_security || ((parseInt(acct?.smtp_port || 465) === 587) ? 'starttls' : 'ssl');
4057    formEl.innerHTML = `
4058      <div class="admin-card" style="margin-top:8px">
4059        <h2 style="font-size:13px">${isEdit ? 'Edit' : 'Add'} Email Account</h2>
4060        <div class="settings-col">
4061          <div class="settings-row"><label class="settings-label">Provider${_hint('Pick a known provider to auto-fill the IMAP and SMTP host/port. Choose Custom to type your own.')}</label>
4062            <div class="ufp-wrap" style="position:relative;flex:1;min-width:0;">
4063              <select id="uf-email-provider" tabindex="-1" aria-hidden="true" style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;"><option value="">Custom…</option>${_providerOptions}</select>
4064              <button type="button" id="uf-email-provider-trigger" class="settings-select" style="display:flex;align-items:center;gap:8px;cursor:pointer;text-align:left;width:100%;padding-right:24px;position:relative;">
4065                <span class="ufp-icon" style="display:inline-flex;align-items:center;">${PROV_LOGO['']}</span>
4066                <span class="ufp-label" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Custom…</span>
4067                <span aria-hidden="true" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0.5;font-size:10px;pointer-events:none;">▾</span>
4068              </button>
4069              <div id="uf-email-provider-menu" style="display:none;position:absolute;top:calc(100% + 2px);left:0;right:0;z-index:1000;background:var(--panel);border:1px solid var(--border);border-radius:6px;max-height:280px;overflow-y:auto;box-shadow:0 6px 18px rgba(0,0,0,0.25);">${_provOptionRows}</div>
4070            </div>
4071          </div>
4072          <div id="uf-email-provider-note" style="display:none;font-size:11px;line-height:1.5;padding:8px 10px;margin:2px 0 4px;border:1px solid color-mix(in srgb, var(--fg) 15%, transparent);border-left:3px solid var(--accent, var(--red));border-radius:4px;background:color-mix(in srgb, var(--fg) 4%, transparent);"></div>
4073          <div class="settings-row"><label class="settings-label">Name${_hint('Optional label for this account (e.g. “Work” or “Personal”). Leave blank to use the email address.')}</label><input id="uf-email-name" class="settings-input" placeholder="(optional — leave blank to use email)"></div>
4074          <div class="settings-row"><label class="settings-label">Email${_hint('Your email address. Used as the From: header on outgoing mail and as the display label when Name is blank.')}</label><input id="uf-email-from" class="settings-input" placeholder="[email protected]"></div>
4075          <div style="font-size:11px;font-weight:600;opacity:0.6;margin:4px 0 2px;display:flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;" aria-hidden="true"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>IMAP (Receiving)</div>
4076          <div class="settings-row"><label class="settings-label">Host${_hint('Your IMAP server, e.g. imap.gmail.com, imap.migadu.com, a LAN host, or a Tailscale IP for Dovecot.')}</label><input id="uf-imap-host" class="settings-input" placeholder="imap.example.com"></div>
4077          <div class="settings-row"><label class="settings-label">Port${_hint('993 for IMAPS (most providers), 143 for plain or STARTTLS. Local servers often use a custom port like 31143.')}</label><input id="uf-imap-port" class="settings-input" type="number" placeholder="993" style="max-width:100px"></div>
4078          <div class="settings-row"><label class="settings-label">Username${_hint('Yes — your full email address goes here too (e.g. [email protected]). Same as the Email field above for almost every provider.')}</label><input id="uf-imap-user" class="settings-input" placeholder="[email protected]"></div>
4079          <div class="settings-row"><label class="settings-label">Password${_hint('For Gmail, iCloud, and Yahoo: paste your App Password (NOT your normal account password — those are blocked for IMAP). For Migadu, Fastmail, Outlook, etc.: your regular mailbox password works.')}</label><input id="uf-imap-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div>
4080          <div class="settings-row"><label class="settings-label">STARTTLS${_hint('Turn ON for port 143/587 to upgrade plain to TLS. Tur
4080n OFF for port 993 (IMAPS — already encrypted) or a local server with no TLS configured.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-imap-starttls" checked><span class="admin-slider"></span></label></div>
4081          <div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px;display:flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
4082          <div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com. Leave blank to make this account read-only.')}</label><input id="uf-smtp-host" class="settings-input" placeholder="smtp.example.com"></div>
4083          <div class="settings-row"><label class="settings-label">Port${_hint('465 for SSL/SMTPS, 587 for STARTTLS. 25 is usually blocked by ISPs.')}</label><input id="uf-smtp-port" class="settings-input" type="number" placeholder="465" style="max-width:100px"></div>
4084          <div class="settings-row"><label class="settings-label">Security${_hint('SSL for port 465, STARTTLS for port 587, or None for local SMTP bridges such as Proton Mail Bridge.')}</label><select id="uf-smtp-security" class="settings-select"><option value="ssl">SSL</option><option value="starttls">STARTTLS</option><option value="none">None</option></select></div>
4085          <div class="settings-row"><label class="settings-label">Same as IMAP${_hint('Use the IMAP username and password for SMTP too (right for almost every provider). Turn off to enter separate SMTP credentials.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-smtp-same" checked><span class="admin-slider"></span></label></div>
4086          <div class="settings-row uf-smtp-creds"><label class="settings-label">Username${_hint('Usually the same as your IMAP username (your email address).')}</label><input id="uf-smtp-user" class="settings-input"></div>
4087          <div class="settings-row uf-smtp-creds"><label class="settings-label">Password${_hint('Your SMTP password — often the same as your IMAP password.')}</label><input id="uf-smtp-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div>
4088          <div class="settings-row" style="margin-top:4px"><label class="settings-label">Default${_hint('Use this account whenever no specific account is chosen.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-email-default"><span class="admin-slider"></span></label><span style="font-size:10px;opacity:0.5;margin-left:6px">Used when nothing else is selected</span></div>
4089          <div class="settings-row" style="margin-top:10px;align-items:center;">
4090            <button class="admin-btn-add" id="uf-email-save" style="background:var(--red);border-color:var(--red);color:#fff;display:inline-flex;align-items:center;gap:5px;font-weight:600;">
4091              <span class="uf-email-save-ico" style="display:inline-flex;width:11px;height:11px;align-items:center;justify-content:center;">
4092                <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
4093              </span>
4094              <span class="uf-email-save-label">${isEdit ? 'Save' : 'Create'}</span>
4095            </button>
4096            <button class="admin-btn-add" id="uf-email-test" style="display:inline-flex;align-items:center;gap:5px;opacity:0.85;">
4097              <span class="uf-email-test-ico" style="display:inline-flex;width:11px;height:11px;align-items:center;justify-content:center;">
4098                <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 4 12 14.01 9 11.01"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
4099              </span>
4100              Test
4101            </button>
4102            <span id="uf-email-msg" style="font-size:11px;flex:1;margin-left:8px"></span>
4103            <button class="admin-btn-add" id="uf-email-cancel" style="opacity:0.7;display:inline-flex;align-items:center;gap:5px;position:relative;top:1px;margin-left:auto;">
4104              <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
4105              Cancel
4106            </button>
4107          </div>
4108        </div>
4109      </div>`;
4110
4111    // Provider-specific helper notes — surfaces for providers that
4112    // require an app-specific password (Gmail killed basic IMAP auth
4113    // in 2022; iCloud + Yahoo follow the same model). The Generate
4114    // button opens the right page in a new tab and copies the URL for
4115    // mobile / cross-device flows.
4116    const PROVIDER_NOTES = {
4117      gmail: {
4118        title: 'Gmail needs an App Password',
4119        body: 'Your regular Google password won\'t work for IMAP. Generate a 16-character App Password (requires 2-Step Verification enabled) and paste it as the Password.',
4120        url: 'https://myaccount.google.com/apppasswords',
4121      },
4122      icloud: {
4123        title: 'iCloud needs an App-S
4123pecific Password',
4124        body: 'Sign in to your Apple ID, go to Sign-In and Security → App-Specific Passwords, and generate one (requires 2FA on your Apple ID).',
4125        url: 'https://account.apple.com/account/manage',
4126      },
4127      yahoo: {
4128        title: 'Yahoo needs an App Password',
4129        body: 'Generate an App Password from Yahoo Account Security (requires 2-Step Verification enabled) and paste it as the Password.',
4130        url: 'https://login.yahoo.com/account/security/app-passwords',
4131      },
4132    };
4133    const noteEl = el('uf-email-provider-note');
4134    const _copyProviderUrl = async (text) => {
4135      const value = String(text || '');
4136      if (!value) return false;
4137      if (navigator.clipboard && window.isSecureContext) {
4138        try {
4139          await navigator.clipboard.writeText(value);
4140          return true;
4141        } catch (_) {
4142          // Fall through to the textarea path below.
4143        }
4144      }
4145      const ta = document.createElement('textarea');
4146      ta.value = value;
4147      ta.setAttribute('readonly', 'readonly');
4148      ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;z-index:-1;';
4149      document.body.appendChild(ta);
4150      ta.focus();
4151      ta.select();
4152      ta.setSelectionRange(0, value.length);
4153      let ok = false;
4154      try { ok = document.execCommand('copy'); } catch (_) { ok = false; }
4155      ta.remove();
4156      return ok;
4157    };
4158    if (noteEl && !noteEl._ufProviderCopyWired) {
4159      noteEl._ufProviderCopyWired = true;
4160      noteEl.addEventListener('click', async (e) => {
4161        const copyBtn = e.target.closest?.('.uf-prov-copy');
4162        if (!copyBtn || !noteEl.contains(copyBtn)) return;
4163        e.preventDefault();
4164        e.stopPropagation();
4165        const url = copyBtn.dataset.url || '';
4166        const orig = copyBtn.innerHTML;
4167        const ok = await _copyProviderUrl(url);
4168        if (!ok) {
4169          uiModule.showError?.('Copy failed');
4170          return;
4171        }
4172        uiModule.showToast?.('Copied');
4173        copyBtn.innerHTML = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> Copied';
4174        setTimeout(() => {
4175          if (copyBtn.isConnected) copyBtn.innerHTML = orig;
4176        }, 1500);
4177      });
4178    }
4179    const _renderProviderNote = (key) => {
4180      const n = PROVIDER_NOTES[key];
4181      if (!n) { noteEl.style.display = 'none'; noteEl.innerHTML = ''; return; }
4182      noteEl.style.display = '';
4183      noteEl.innerHTML = `
4184        <div style="font-weight:600;margin-bottom:3px;">${esc(n.title)}</div>
4185        <div style="opacity:0.8;margin-bottom:6px;">${esc(n.body)}</div>
4186        <div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
4187          <a href="${esc(n.url)}" target="_blank" rel="noopener noreferrer" class="admin-btn-sm" style="background:var(--red);border-color:var(--red);color:#fff;text-decoration:none;display:inline-flex;align-items:center;gap:5px;font-weight:600;">
4188            <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
4189            Generate App Password
4190          </a>
4191          <button type="button" class="admin-btn-sm uf-prov-copy" data-url="${esc(n.url)}" style="opacity:0.7;display:inline-flex;align-items:center;gap:5px;">
4192            <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
4193            Copy link
4194          </button>
4195        </div>`;
4196    };
4197
4198    // Custom dropdown wire-up — the native <select> stays in the DOM as the
4199    // data source and accessibility target, but the visible UI is a button +
4200    // popup so each provider row can render with its SVG logo. Selecting an
4201    // option updates select.value and dispatches a `change` event so the
4202    // existing autofill handler below runs unchanged.
4203    (() => {
4204      const trigger = el('uf-email-provider-trigger');
4205      const menu = el('uf-email-provider-menu');
4206      const sel = el('uf-email-provider');
4207      if (!trigger || !menu || !sel) return;
4208      const labelEl = trigger.querySelector('.ufp-label');
4209      const iconEl = trigger.querySelector('.ufp-icon');
4210      const _setFromKey = (k) => {
4211        const row = menu.querySelector(`.ufp-option[data-value="${k}"]`);
4212        const lbl = row?.querySelector('span')?.textContent || 'Custom…';
4213        if (labelEl) labelEl.textContent = lbl;
4214        if (iconEl) iconEl.innerHTML = PROV_LOGO[k] || _customLogo;
4215      };
4216      const _closeMenu = () => { menu.style.display = 'none'; };
4217      const _openMenu = () => {
4218        menu.style.display = 'block';
4219        // Drop-up when there's not enough room below the trigger.
4220        const tRect = trigger.getBoundingClientRect();
4221        const mRect = menu.getBoundingClientRect();
4222        const below = window.innerHeight - tRect.bottom;
4223        const above = tRect.top;
4224        if (mRect.height > below && above > below) {
4225          menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)';
4226        } else {
4227          menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
4228        }
4229        const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trigger) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
4230        setTimeout(() => document.addEventListener('click', onDoc, true), 0);
4231      };
4232      trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
4233      menu.querySelectorAll('.ufp-option').forEach(btn => {
4234        btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
4235        btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
4236        btn.addEventListener('click', (e) => {
4237          e.stopPropagation();
4238          const k = btn.dataset.value || '';
4239          sel.value = k;
4240          _setFromKey(k);
4241          _closeMenu();
4242          sel.dispatchEvent(new Event('change', { bubbles: true }));
4243        });
4244      });
4245      _setFromKey(sel.value || '');
4246    })();
4247
4248    // Provider preset → autofill IMAP + SMTP host/port + STARTTLS, set the
4249    // helper note, and update the Email/Username placeholders to a
4250    // provider-specific example so users see the right format at a glance.
4251    el('uf-email-provider').addEventListener('change', (e) => {
4252      const key = e.target.value;
4253      _renderProviderNote(key);
4254      const p = PROVIDERS[key];
4255      if (!p) return;
4256      el('uf-imap-host').value = p.imap.host;
4257      el('uf-imap-port').value = p.imap.port;
4258      el('uf-imap-starttls').checked = !!p.imap.starttls;
4259      el('uf-smtp-host').value = p.smtp.host;
4260      el('uf-smtp-port').value = p.smtp.port;
4261      el('uf-smtp-security').value = p.smtp.security || ((parseInt(p.smtp.port || 465) === 587) ? 'starttls' : 'ssl');
4262      if (p.emailEx) {
4263        el('uf-email-from').placeholder = p.emailEx;
4264        el('uf-imap-user').placeholder = p.emailEx;
4265        el('uf-smtp-user').placeholder = p.emailEx;
4266      }
4267    });
4268
4269    // "Same as IMAP" toggle — hide the SMTP creds rows when on.
4270    const _syncSmtpSame = () => {
4271      const same = el('uf-smtp-same').checked;
4272      formEl.querySelectorAll('.uf-smtp-creds').forEach(r => {
4273        r.style.display = same ? 'none' : '';
4274      });
4275    };
4276    el('uf-smtp-same').addEventListener('change', _syncSmtpSame);
4277    _syncSmtpSame();
4278    if (existing) {
4279      el('uf-email-name').value = existing.name || '';
4280      el('uf-email-from').value = existing.from_address || '';
4281      el('uf-imap-host').value = existing.imap_host || '';
4282      el('uf-imap-port').value = existing.imap_port || 993;
4283      el('uf-imap-user').value = existing.imap_user || '';
4284      el('uf-imap-starttls').checked = existing.imap_starttls !== false;
4285      el('uf-smtp-host').value = existing.smtp_host || '';
4286      el('uf-smtp-port').value = existing.smtp_port || 465;
4287      el('uf-smtp-security').value = _smtpSecurity(existing);
4288      el('uf-smtp-user').value = existing.smtp_user || '';
4289      el('uf-email-default').checked = !!existing.is_default;
4290      // If the saved SMTP user matches the IMAP user, keep the "Same as
4291      // IMAP" toggle ON (and stay hidden). Otherwise turn it off so the
4292      // separate SMTP credentials are visible for editing.
4293      const sameCreds = !!(existing.imap_user && existing.smtp_user && existing.imap_user === existing.smtp_user);
4294      el('uf-smtp-same').checked = sameCreds || !existing.smtp_user;
4295      _syncSmtpSame();
4296    } else {
4297      el('uf-imap-port').value = 993;
4298      el('uf-smtp-port').value = 465;
4299      el('uf-smtp-security').value = 'ssl';
4300    }
4301    el('uf-email-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
4302
4303    // Reset the Test button to neutral when the user edits any field
4304    // after a test — stale green/red would imply the new values were
4305    // tested too.
4306    const _resetTestBtn = () => {
4307      const btn = el('uf-email-test');
4308      if (!btn) return;
4309      btn.style.background = '';
4310      btn.style.borderColor = '';
4311      btn.style.color = '';
4312      btn.style.boxShadow = '';
4313      btn.style.animation = '';
4314      const ico = btn.querySelector('.uf-email-test-ico');
4315      if (ico && btn.dataset.origIco) ico.innerHTML = btn.dataset.origIco;
4316    };
4317    formEl.querySelectorAll('input, select').forEach(inp => {
4318      if (inp.id === 'uf-email-msg') return;
4319      inp.addEventListener('input', _resetTestBtn);
4320      inp.addEventListener('change', _resetTestBtn);
4321    });
4322
4323    // Collect the current form values + apply the "Same as IMAP" mirror —
4324    // shared by both Save and Test so they agree on what's being sent.
4325    const _collectBody = () => {
4326      const body = {
4327        name: el('uf-email-name').value.trim(),
4328        from_address: el('uf-email-from').value.trim(),
4329        imap_host: el('uf-imap-host').value.trim(),
4330        imap_port: parseInt(el('uf-imap-port').value) || 993,
4331        imap_user: el('uf-imap-user').value.trim(),
4332        imap_starttls: el('uf-imap-starttls').checked,
4333        smtp_host: el('uf-smtp-host').value.trim(),
4334        smtp_port: parseInt(el('uf-smtp-port').value) || 465,
4335        smtp_security: el('uf-smtp-security').value,
4336        smtp_user: el('uf-smtp-user').value.trim(),
4337        is_default: el('uf-email-default').checked,
4338      };
4339      if (el('uf-imap-pass').value) body.imap_password = el('uf-imap-pass').value;
4340      if (el('uf-smtp-pass').value) body.smtp_password = el('uf-smtp-pass').value;
4341      if (el('uf-smtp-same').checked) {
4342        body.smtp_user = body.imap_user;
4343        if (body.imap_password) body.smtp_password = body.imap_password;
4344      }
4345      return body;
4346    };
4347
4348    // Spinner SVG kept inline so we can swap it back to the original
4349    // checkmark on completion. ~13px to match the button icon size.
4350    const _spinner = '<span style="display:inline-block;width:11px;height:11px;border-radius:50%;border:1.5px solid currentColor;border-top-color:transparent;animation:whirlpool-spin 0.7s linear infinite"></span>';
4351    const _checkIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
4352
4353    el('uf-email-test').addEventListener('click', async () => {
4354      const body = _collectBody();
4355      // Edit-mode + blank password = use the saved row's stored creds
4356      // via the account_id shortcut. Other overrides in the body still
4357      // win (server merges).
4358      if (isEdit && !body.imap_password) body.account_id = editId;
4359      const msg = el('uf-email-msg');
4360      const btn = el('uf-email-test');
4361      const ico = btn.querySelector('.uf-email-test-ico');
4362      btn.dataset.origIco = btn.dataset.origIco || ico.innerHTML;
4363      btn.disabled = true;
4364      // Clear any prior green/red while testing.
4365      btn.style.background = '';
4366      btn.style.borderColor = '';
4367      btn.style.color = '';
4368      btn.style.boxShadow = '';
4369      btn.style.animation = '';
4370      ico.innerHTML = _spinner;
4371      msg.textContent = '';
4372      msg.style.color = '';
4373      try {
4374        const r = await fetch('/api/email/accounts/test', {
4375          method: 'POST', credentials: 'same-origin',
4376          headers: { 'Content-Type': 'application/json' },
4377          body: JSON.stringify(body),
4378        });
4379        const d = await r.json();
4380        if (d.ok) {
4381          // Button becomes the indicator — green checkmark with the
4382          // cookbook-style halo + breathing animation. No status text;
4383          // the glow is the signal.
4384          btn.style.background = 'var(--green, #50fa7b)';
4385          btn.style.borderColor = 'var(--green, #50fa7b)';
4386          btn.style.color = '#0b0';
4387          btn.style.boxShadow =
4388            '0 0 0 2px color-mix(in srgb, var(--green, #50fa7b) 25%, transparent),'
4389          + ' 0 0 10px 2px color-mix(in srgb, var(--green, #50fa7b) 55%, transparent)';
4390          btn.style.animation = 'cookbook-srv-glow-ok 2.4s ease-in-out infinite';
4391          ico.innerHTML = _checkIcon;
4392        } else {
4393          // Failure — red glow, original icon, error detail in status
4394          // text so we can say WHICH half failed (IMAP vs SMTP).
4395          btn.style.background = 'var(--red)';
4396          btn.style.borderColor = 'var(--red)';
4397          btn.style.color = '#fff';
4398          btn.style.boxShadow =
4399            '0 0 0 2px color-mix(in srgb, var(--red) 25%, transparent),'
4400          + ' 0 0 10px 2px color-mix(in srgb, var(--red) 55%, transparent)';
4401          ico.innerHTML = btn.dataset.origIco;
4402          const imap = d.imap?.ok ? 'IMAP ok' : `IMAP: ${d.imap?.error || 'fail'}`;
4403          const smtp = d.smtp ? (d.smtp.ok ? ' · SMTP ok' : ` · SMTP: ${d.smtp.error || 'fail'}`) : '';
4404          msg.textContent = imap + smtp;
4405          msg.style.color = 'var(--red)';
4406        }
4407      } catch (e) {
4408        btn.style.background = 'var(--red)';
4409        btn.style.borderColor = 'var(--red)';
4410        btn.style.color = '#fff';
4411        ico.innerHTML = btn.dataset.origIco;
4412        msg.textContent = 'Test error: ' + e.message;
4413        msg.style.color = 'var(--red)';
4414      } finally {
4415        btn.disabled = false;
4416      }
4417    });
4418
4419    el('uf-email-save').addEventListener('click', async () => {
4420      const body = _collectBody();
4421      // Name is optional — fall back to Email so the list still has a label.
4422      if (!body.name) body.name = body.from_address;
4423      if (!body.name) { el('uf-email-msg').textContent = 'Need at least a Name or Email'; el('uf-email-msg').style.color = 'var(--red)'; return; }
4424      const saveBtn = el('uf-email-save');
4425      saveBtn.disabled = true;
4426      const saveIcoEl = saveBtn.querySelector('.uf-email-save-ico');
4427      const saveLblEl = saveBtn.querySelector('.uf-email-save-label');
4428      const prevIco = saveIcoEl.innerHTML;
4429      const prevLbl = saveLblEl.textContent;
4430      saveIcoEl.innerHTML = _spinner;
4431      saveLblEl.textContent = 'Saving…';
4432      try {
4433        const url = isEdit ? `/api/email/accounts/${editId}` : '/api/email/accounts';
4434        const method = isEdit ? 'PUT' : 'POST';
4435        const r = await fetch(url, {
4436          method, credentials: 'same-origin',
4437          headers: { 'Content-Type': 'application/json' },
4438          body: JSON.stringify(body),
4439        });
4440        const d = await r.json();
4441        if (!(d.ok || d.id)) {
4442          el('uf-email-msg').textContent = d.error || 'Failed';
4443          el('uf-email-msg').style.color = 'var(--red)';
4444          return;
4445        }
4446        el('uf-email-msg').textContent = 'Saved';
4447        el('uf-email-msg').style.color = 'var(--green,#50fa7b)';
4448        integrationNotice = 'Email account saved. For more settings, go to Settings > Email.';
4449        formEl.style.display = 'none';
4450        await renderList();
4451        notifyIntegrationsChanged();
4452      } catch (e) {
4453        el('uf-email-msg').textContent = 'Error: ' + e.message;
4454        el('uf-email-msg').style.color = 'var(--red)';
4455      } finally {
4456        saveBtn.disabled = false;
4457        saveIcoEl.innerHTML = prevIco;
4458        saveLblEl.textContent = prevLbl;
4459      }
4460    });
4461  }
4462
4463  // ── Vaultwarden form ──
4464  async function showVaultForm() {
4465    formEl.innerHTML = `
4466      <div class="admin-card" style="margin-top:8px">
4467        <h2 style="font-size:13px">Vaultwarden (Password Vault)</h2>
4468        <div id="uf-vault-status" style="font-size:11px;opacity:0.7;margin-bottom:8px">Loading...</div>
4469        <div class="settings-col">
4470          <div class="settings-row"><label class="settings-label">Server URL</label><input id="uf-vault-url" class="settings-input" placeholder="https://vault.example.com"></div>
4471          <div class="settings-row"><label class="settings-label">Email</label><input id="uf-vault-email" class="settings-input" placeholder="[email protected]"></div>
4472          <div class="settings-row"><label class="settings-label">Master Password</label><input id="uf-vault-pass" class="settings-input" type="password" placeholder="Only required for Login / Unlock"></div>
4473          <div class="settings-row" style="margin-top:4px;flex-wrap:wrap;gap:4px">
4474            <button class="admin-btn-sm" id="uf-vault-save">Save Config</button>
4475            <button class="admin-btn-sm" id="uf-vault-login">Login</button>
4476            <button class="admin-btn-sm" id="uf-vault-unlock">Unlock</button>
4477            <button class="admin-btn-sm" id="uf-vault-lock" style="opacity:0.7">Lock</button>
4478            <button class="admin-btn-sm" id="uf-vault-logout" style="opacity:0.7">Logout</button>
4479            <button class="admin-btn-sm" id="uf-vault-cancel" style="opacity:0.7">Cancel</button>
4480            <span id="uf-vault-msg" style="font-size:11px;margin-left:4px"></span>
4481          </div>
4482          <div style="font-size:10px;opacity:0.5;margin-top:6px;line-height:1.4">
4483            <strong>Login</strong> registers this device with your Vaultwarden account (once per account).<br>
4484            <strong>Unlock</strong> decrypts the vault — required after restart or Lock. Session is saved so the assistant can read passwords.
4485          </div>
4486        </div>
4487      </div>`;
4488
4489    const msg = (text, color) => {
4490      const m = el('uf-vault-msg');
4491      m.textContent = text || '';
4492      m.style.color = color || '';
4493    };
4494
4495    async function refreshStatus() {
4496      try {
4497        const r = await fetch('/api/vault/config', { credentials: 'same-origin' });
4498        const d = await r.json();
4499        el('uf-vault-url').value = d.server_url || '';
4500        el('uf-vault-email').value = d.email || '';
4501        const installed = d.bw_installed;
4502        const parts = [];
4503        parts.push(installed ? 'bw CLI: installed' : 'bw CLI: NOT installed (install nodejs-bitwarden-cli)');
4504        parts.push(d.unlocked ? 'Status: UNLOCKED' : 'Status: locked');
4505        if (d.unlocked_at) parts.push(`Last unlock: ${d.unlocked_at.replace('T',' ').slice(0,19)}`);
4506        const statusEl = el('uf-vault-status');
4507        statusEl.textContent = parts.join(' — ');
4508        statusEl.style.color = !installed ? 'var(--red)' : d.unlocked ? 'var(--green,#50fa7b)' : '';
4509      } catch (_) {
4510        el('uf-vault-status').textContent = 'Failed to load vault status';
4511      }
4512    }
4513    await refreshStatus();
4514
4515    el('uf-vault-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
4516
4517    el('uf-vault-save').addEventListener('click', async () => {
4518      msg('Saving...');
4519      try {
4520        const r = await fetch('/api/vault/config', {
4521          method: 'POST', credentials: 'same-origin',
4522          headers: { 'Content-Type': 'application/json' },
4523          body: JSON.stringify({ server_url: el('uf-vault-url').value, email: el('uf-vault-email').value }),
4524        });
4525        const d = await r.json();
4526        if (d.ok) { msg('Saved', 'var(--green,#50fa7b)'); await refreshStatus(); await renderList(); }
4527        else msg(d.error || 'Failed', 'var(--red)');
4528      } catch (e) { msg('Error: ' + e.message, 'var(--red)'); }
4529    });
4530
4531    el('uf-vault-login').addEventListener('click', async () => {
4532      const email = el('uf-vault-email').value.trim();
4533      const pass = el('uf-vault-pass').value;
4534      if (!email || !pass) { msg('Email + master password required', 'var(--red)'); return; }
4535      msg('Logging in...');
4536      try {
4537        const r = await fetch('/api/vault/login', {
4538          method: 'POST', credentials: 'same-origin',
4539          headers: { 'Content-Type': 'application/json' },
4540          body: JSON.stringify({ email, master_password: pass }),
4541        });
4542        const d = await r.json();
4543        if (d.ok) {
4544          msg(d.already ? 'Already logged in — use Unlock' : 'Logged in', 'var(--green,#50fa7b)');
4545          el('uf-vault-pass').value = '';
4546          await refreshStatus(); await renderList();
4547        } else msg(d.error || 'Login failed', 'var(--red)');
4548      } catch (e) { msg('Error: ' + e.message, 'var(--red)'); }
4549    });
4550
4551    el('uf-vault-unlock').addEventListener('click', async () => {
4552      const pass = el('uf-vault-pass').value;
4553      if (!pass) { msg('Master password required', 'var(--red)'); return; }
4554      msg('Unlocking...');
4555      try {
4556        const r = await fetch('/api/vault/unlock', {
4557          method: 'POST', credentials: 'same-origin',
4558          headers: { 'Content-Type': 'application/json' },
4559          body: JSON.stringify({ master_password: pass }),
4560        });
4561        const d = await r.json();
4562        if (d.ok) {
4563          msg('Vault unlocked', 'var(--green,#50fa7b)');
4564          el('uf-vault-pass').value = '';
4565          await refreshStatus(); await renderList();
4566        } else msg(d.error || 'Unlock failed', 'var(--red)');
4567      } catch (e) { msg('Error: ' + e.message, 'var(--red)'); }
4568    });
4569
4570    el('uf-vault-lock').addEventListener('click', async () => {
4571      msg('Locking...');
4572      try {
4573        await fetch('/api/vault/lock', { method: 'POST', credentials: 'same-origin' });
4574        msg('Locked', 'var(--green,#50fa7b)');
4575        await refreshStatus(); await renderList();
4576      } catch (e) { msg('Error: ' + e.message, 'var(--red)'); }
4577    });
4578
4579    el('uf-vault-logout').addEventListener('click', async () => {
4580      if (!await window.styledConfirm('Log out of Bitwarden CLI? You\'ll need to re-enter your master password to log back in.', { c
4580onfirmText: 'Log out' })) return;
4581      msg('Logging out...');
4582      try {
4583        await fetch('/api/vault/logout', { method: 'POST', credentials: 'same-origin' });
4584        msg('Logged out', 'var(--green,#50fa7b)');
4585        await refreshStatus(); await renderList();
4586      } catch (e) { msg('Error: ' + e.message, 'var(--red)'); }
4587    });
4588  }
4589
4590  // ── MCP form — full management view ──
4591  async function showMcpForm(editId) {
4592    // Toggle an in-flight loading state on a button (disabled + dimmed + label).
4593    function _setBtnLoading(btn, loading, label) {
4594      if (!btn) return;
4595      btn.disabled = loading;
4596      btn.style.opacity = loading ? '0.6' : '';
4597      btn.style.cursor = loading ? 'progress' : '';
4598      if (label != null) btn.textContent = label;
4599    }
4600    function _showMcpPasteback(id) {
4601      const msg = el('uf-mcp-msg'); if (!msg) return;
4602      if (el('uf-mcp-pasteback')) return;  // already shown
4603      msg.innerHTML =
4604        'Authorize in the opened tab. If the redirect fails (remote access), paste the resulting URL here: ' +
4605        '<input id="uf-mcp-pasteback" class="settings-input" placeholder="http://localhost:7000/api/mcp/oauth/callback?code=..." style="margin-top:4px">' +
4606        '<button class="admin-btn-sm" id="uf-mcp-paste-go" style="margin-top:4px">Submit</button>';
4607      const pasteGo = el('uf-mcp-paste-go');
4608      if (pasteGo) pasteGo.addEventListener('click', async () => {
4609        const cb = el('uf-mcp-pasteback').value.trim();
4610        if (!cb) return;
4611        const pf = new FormData(); pf.append('callback_url', cb);
4612        _setBtnLoading(pasteGo, true, 'Submitting…');
4613        try {
4614          await fetch(`/api/mcp/oauth/exchange/${id}`, { method: 'POST', credentials: 'same-origin', body: pf });
4615        } finally {
4616          _setBtnLoading(pasteGo, false, 'Submit');
4617        }
4618      });
4619    }
4620
4621    // Drives the OAuth flow: waits for the auth_url (discovery+DCR may lag),
4622    // opens it once, then resolves on connected/error.
4623    async function _handleMcpAuth(id, initialAuthUrl, tries = 90) {
4624      let opened = false;
4625      const openAuth = (u) => { if (!opened && u) { opened = true; window.open(u, '_blank', 'noopener'); _showMcpPasteback(id); } };
4626      openAuth(initialAuthUrl);
4627      const msg = el('uf-mcp-msg');
4628      let fails = 0;
4629      for (let i = 0; i < tries; i++) {
4630        await new Promise(res => setTimeout(res, 2000));
4631        try {
4632          const r = await fetch('/api/mcp/servers', { credentials: 'same-origin' });
4633          if (!r.ok) throw new Error('HTTP ' + r.status);
4634          const list = await r.json();
4635          fails = 0;
4636          const s = Array.isArray(list) ? list.find(x => x.id === id) : null;
4637          if (!s) continue;
4638          if (s.auth_url) openAuth(s.auth_url);
4639          if (s.status === 'connected') {
4640            if (msg) msg.textContent = `Connected (${s.tool_count || 0} tools)`;
4641            await renderList(); return;
4642          }
4643          if (s.status === 'error') {
4644            if (msg) msg.textContent = `Failed: ${s.error || 'unknown'}`; return;
4645          }
4646        } catch (e) {
4647          // Tolerate a single blip, but surface persistent failures instead of
4648          // silently polling until timeout.
4649          if (++fails >= 5 && msg) msg.textContent = `Status check failing (${e.message || 'network error'}) — still retrying…`;
4650        }
4651      }
4652      if (msg) msg.textContent = 'Authorization timed out. Reconnect from the server list to retry.';
4653    }
4654    if (editId && editId !== 'new') {
4655      // Show management view for existing server
4656      formEl.innerHTML = '<div class="admin-card" style="margin-top:8px"><span style="opacity:0.5;font-size:11px">Loading...</span></div>';
4657      try {
4658        const res = await fetch('/api/mcp/servers', { credentials: 'same-origin' });
4659        const servers = await res.json();
4660        const srv = servers.find(s => (s.id || s.name) === editId);
4661        if (!srv) { formEl.innerHTML = '<div class="admin-card" style="margin-top:8px">Server not found</div>'; return; }
4662        const esc = s => String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;');
4663        const statusColor = srv.needs_oauth ? '#e5a33a' : srv.status === 'connected' ? 'var(--green,#50fa7b)' : srv.status === 'error' ? 'var(--red)' : 'var(--fg)';
4664        const toolInfo = srv.status === 'connected' ? `${srv.enabled_tool_count}/${srv.tool_count} tools` : '';
4665        const statusText = srv.needs_oauth ? 'Needs authorization' : srv.status === 'connected' ? `Connected (${toolInfo})` : srv.status === 'error' ? `Error: ${esc(srv.error || 'unknown')}` : 'Disconnected';
4666        formEl.innerHTML = `
4667          <div class="admin-card" style="margin-top:8px">
4668            <h2 style="font-size:13px">${esc(srv.name)}</h2>
4669            <div style="display:flex;align-items:center;gap:6px;margin-bottom:8px">
4670              <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${statusColor}"></span>
4671              <span style="font-size:11px;opacity:0.7">${statusText}</span>
4672            </div>
4673            <div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px">
4674              ${srv.needs_oauth ? `<a href="/api/mcp/oauth/authorize/${srv.id}" target="_blank" class="admin-btn-sm" style="background:var(--red);color:#fff;text-decoration:none">Authorize</a>` : ''}
4675              <button class="admin-btn-sm" id="uf-mcp-reconnect">Reconnect</button>
4676              <button class="admin-btn-sm" id="uf-mcp-toggle">${srv.is_enabled ? 'Disable' : 'Enable'}</button>
4677              <button class="admin-btn-sm" id="uf-mcp-cancel" style="opacity:0.7">Close</button>
4678              <span id="uf-mcp-msg" style="font-size:11px"></span>
4679            </div>
4680            <div id="uf-mcp-tools-panel"></div>
4681          </div>`;
4682        // Reconnect
4683        el('uf-mcp-reconnect').addEventListener('click', async () => {
4684          const msg = el('uf-mcp-msg'); msg.textContent = 'Reconnecting...';
4685          try {
4686            const r = await fetch(`/api/mcp/servers/${srv.id}/reconnect`, { method: 'POST', credentials: 'same-origin' });
4687            const d = await r.json();
4688            msg.textContent = d.connected ? `Connected (${d.tool_count} tools)` : `Failed: ${d.error || 'unknown'}`;
4689            await renderList();
4690            showMcpForm(editId); // refresh this view
4691          } catch (e) { msg.textContent = 'Failed'; }
4692        });
4693        // Toggle enable/disable
4694        el('uf-mcp-toggle').addEventListener('click', async () => {
4695          const fd = new FormData(); fd.append('is_enabled', String(!srv.is_enabled));
4696          await fetch(`/api/mcp/servers/${srv.id}`, { method: 'PATCH', body: fd, credentials: 'same-origin' });
4697          await renderList();
4698          showMcpForm(editId);
4699        });
4700        el('uf-mcp-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
4701        // Load tools list
4702        if (srv.status === 'connected' && srv.tool_count > 0) {
4703          const panel = el('uf-mcp-tools-panel');
4704          try {
4705            const tr = await fetch(`/api/mcp/servers/${srv.id}/tools`, { credentials: 'same-origin' });
4706            const tools = await tr.json();
4707            if (tools.length) {
4708              const disabled = new Set(tools.filter(t => t.is_disabled).map(t => t.name));
4709              panel.innerHTML = `<div class="mcp-tools-header"><span>Tools</span><span style="display:flex;gap:8px;align-items:center"><span class="mcp-tools-count">${tools.length - disabled.size}/${tools.length} enabled</span><a href="#" id="uf-mcp-all">All</a> <a href="#" id="uf-mcp-none">None</a></span></div><div class="mcp-tools-list">${tools.map(t => `<label title="${esc(t.description)}"><input type="checkbox" data-mcp-tool-name="${esc(t.name)}" ${!t.is_disabled ? 'checked' : ''}>
4709<span><strong>${esc(t.name)}</strong> <span style="opacity:0.5">— ${esc((t.description||'').slice(0,80))}</span></span></label>`).join('')}</div>`;
4710              const saveFn = async () => {
4711                const dis = [];
4712                panel.querySelectorAll('input[type=checkbox]').forEach(cb => { if (!cb.checked) dis.push(cb.dataset.mcpToolName); });
4713                await fetch(`/api/mcp/servers/${srv.id}/tools`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ disabled: dis }) });
4714                const cnt = panel.querySelector('.mcp-tools-count');
4715                if (cnt) cnt.textContent = `${tools.length - dis.length}/${tools.length} enabled`;
4716              };
4717              panel.querySelectorAll('input[type=checkbox]').forEach(cb => cb.addEventListener('change', saveFn));
4718              el('uf-mcp-all')?.addEventListener('click', (e) => { e.preventDefault(); panel.querySelectorAll('input[type=checkbox]').forEach(cb => cb.checked = true); saveFn(); });
4719              el('uf-mcp-none')?.addEventListener('click', (e) => { e.preventDefault(); panel.querySelectorAll('input[type=checkbox]').forEach(cb => cb.checked = false); saveFn(); });
4720            }
4721          } catch (_) { panel.innerHTML = '<span style="opacity:0.5;font-size:11px">Failed to load tools</span>'; }
4722        }
4723      } catch (_) { formEl.innerHTML = '<div class="admin-card" style="margin-top:8px">Failed to load server</div>'; }
4724    } else {
4725      // Add new MCP server form
4726      formEl.innerHTML = `
4727        <div class="admin-card" style="margin-top:8px">
4728          <h2 style="font-size:13px">Add MCP Server</h2>
4729          <div class="settings-col">
4730            <div class="settings-row"><label class="settings-label">Name</label><input id="uf-mcp-name" class="settings-input" placeholder="Server name"></div>
4731            <div class="settings-row"><label class="settings-label">Transport</label><select id="uf-mcp-transport" class="settings-input"><option value="stdio">stdio</option><option value="sse">SSE</option><option value="http">Streamable HTTP</option></select></div>
4732            <div id="uf-mcp-stdio-fields" style="display:flex;flex-direction:column;gap:6px;">
4733              <div class="settings-row"><label class="settings-label">Command</label><input id="uf-mcp-cmd" class="settings-input" placeholder="npx"></div>
4734              <div class="settings-row"><label class="settings-label">Args</label><input id="uf-mcp-args" class="settings-input" placeholder='["-y", "@modelcontextprotocol/server-filesystem"]'></div>
4735              <div class="settings-row"><label class="settings-label">Env</label><input id="uf-mcp-env" class="settings-input" placeholder='{"KEY": "value"}'></div>
4736            </div>
4737            <div id="uf-mcp-sse-fields" style="display:none;flex-direction:column;gap:6px;">
4738              <div class="settings-row"><label class="settings-label">URL</label><input id="uf-mcp-url" class="settings-input" placeholder="http://localhost:3001/sse"></div>
4739            </div>
4740            <div class="settings-row" style="margin-top:4px"><button class="admin-btn-sm" id="uf-mcp-save">Save</button><button class="admin-btn-sm" id="uf-mcp-cancel" style="opacity:0.7">Cancel</button><span id="uf-mcp-msg" style="font-size:11px"></span></div>
4741          </div>
4742        </div>`;
4743      el('uf-mcp-transport').addEventListener('change', () => {
4744        const v = el('uf-mcp-transport').value;
4745        const isUrl = (v === 'sse' || v === 'http');
4746        el('uf-mcp-stdio-fields').style.display = isUrl ? 'none' : 'flex';
4747        el('uf-mcp-sse-fields').style.display = isUrl ? 'flex' : 'none';
4748        const urlInput = el('uf-mcp-url');
4749        if (urlInput) urlInput.placeholder = (v === 'http') ? 'https://mcp.example.com/mcp' : 'http://localhost:3001/sse';
4750      });
4751      el('uf-mcp-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
4752      el('uf-mcp-save').addEventListener('click', async () => {
4753        const transport = el('uf-mcp-transport').value;
4754        // routes/mcp_routes.py uses FastAPI Form(...) — send multipart, not JSON.
4755        const fd = new FormData();
4756        fd.append('name', el('uf-mcp-name').value);
4757        fd.append('transport', transport);
4758        if (transport === 'stdio') {
4759          fd.append('command', el('uf-mcp-cmd').value);
4760          let args = '[]'; try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); } catch (_) {}
4761          let env  = '{}'; try { env  = JSON.stringify(JSON.parse(el('uf-mcp-env').value  || '{}')); } catch (_) {}
4762          fd.append('args', args);
4763          fd.append('env', env);
4764        } else {
4765          fd.append('url', el('uf-mcp-url').value);
4766        }
4767        const saveBtn = el('uf-mcp-save'), cancelBtn = el('uf-mcp-cancel');
4768        const _origLabel = saveBtn.textContent;
4769        _setBtnLoading(saveBtn, true, 'Saving…'); if (cancelBtn) cancelBtn.disabled = true;
4770        try {
4771          const r = await fetch('/api/mcp/servers', { method: 'POST', credentials: 'same-origin', body: fd });
4772          const data = await r.json().catch(() => ({}));
4773          if (r.ok && data.needs_auth) {
4774            el('uf-mcp-msg').textContent = 'Preparing authorization…';
4775            _handleMcpAuth(data.id, data.auth_url);
4776          } else if (r.ok && (data.connected || data.status === 'connected')) {
4777            el('uf-mcp-msg').textContent = `Connected (${data.tool_count || 0} tools)`;
4778            formEl.style.display = 'none'; await renderList();
4779          } else if (r.ok) {
4780            el('uf-mcp-msg').textContent = 'Saved'; formEl.style.display = 'none'; await renderList();
4781          } else {
4782            el('uf-mcp-msg').textContent = `Failed (${r.status})`;
4783          }
4784        } catch (_) { el('uf-mcp-msg').textContent = 'Failed'; }
4785        finally { _setBtnLoading(saveBtn, false, _origLabel); if (cancelBtn) cancelBtn.disabled = false; }
4786      });
4787    }
4788  }
4789
4790  async function showAgentForm(kind, editId) {
4791    const cfg = AGENT_CONFIGS[kind] || AGENT_CONFIGS.codex;
4792    let tokens = [];
4793    try {
4794      const tokRes = await fetch('/api/tokens', { credentials: 'same-origin' });
4795      if (tokRes.ok) tokens = await tokRes.json();
4796    } catch (_) {}
4797
4798    const toolScopes = [
4799      { key: 'todos:read', label: 'Todos', detail: 'Read notes and checklists' },
4800      { key: 'todos:write', label: 'Todos write', detail: 'Create, update, delete, and toggle todo items' },
4801      { key: 'documents:read', label: 'Documents', detail: 'Read documents when a document API is enabled' },
4802      { key: 'documents:write', label: 'Documents write', detail: 'Create and update draft documents' },
4803      { key: 'email:read', label: 'Email', detail: 'Read email when an email API is enabled' },
4804      { key: 'email:draft', label: 'Email drafts', detail: 'Create email reply drafts without sending' },
4805      { key: 'email:send', label: 'Email send', detail: 'Send email directly' },
4806      { key: 'calendar:read', label: 'Calendar', detail: 'Read calendar events when enabled' },
4807      { key: 'calendar:write', label: 'Calendar write', detail: 'Create and update calendar events' },
4808      { key: 'memory:read', label: 'Memory', detail: 'Read memory when enabled' },
4809      { key: 'memory:write', label: 'Memory write', detail: 'Write memory when enabled' },
4810      { key: 'cookbook:read', label: 'Cookbook', detail: 'List cookbook tasks + tail the
4810ir tmux output (debug a model serve from outside the UI)' },
4811      { key: 'cookbook:launch', label: 'Cookbook launch', detail: 'Launch and stop cookbook serve tasks. Powerful: runs SSH commands on your configured servers, bounded by the same allowlist the UI uses (vllm/python3/sglang/llama-server/...)' },
4812    ];
4813    // Strict name-prefix match keeps Codex and Claude tokens in their own forms.
4814    const agentTokens = (Array.isArray(tokens) ? tokens : []).filter(tok =>
4815      (tok.name || '').toLowerCase().startsWith(cfg.namePrefix)
4816    );
4817    const current = agentTokens.find(t => String(t.id) === String(editId));
4818    const _scopeIcons = {
4819      todos: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><line x1="8" y1="9" x2="16" y2="9"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>',
4820      documents: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
4821      email: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2 6 12 13 22 6"/></svg>',
4822      calendar: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
4823      memory: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 2a2.5 2.5 0 0 0-2.5 2.5 2.5 2.5 0 0 0-2.5 2.5A2.5 2.5 0 0 0 2 9.5v3A2.5 2.5 0 0 0 4.5 15a2.5 2.5 0 0 0 2.5 2.5A2.5 2.5 0 0 0 9.5 20H10V2z"/><path d="M14.5 2a2.5 2.5 0 0 1 2.5 2.5 2.5 2.5 0 0 1 2.5 2.5A2.5 2.5 0 0 1 22 9.5v3A2.5 2.5 0 0 1 19.5 15a2.5 2.5 0 0 1-2.5 2.5A2.5 2.5 0 0 1 14.5 20H14V2z"/></svg>',
4824      cookbook: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
4825    };
4826    const _scopeNiceLabel = (label) => label.replace(/\s+(write|drafts?|send)$/i, '');
4827    const _scopeAction = (key) => (key.split(':')[1] || '').toLowerCase();
4828    const _pillStyle = (action) => {
4829      if (action === 'read') return 'background:rgba(150,150,150,0.18);color:var(--fg-muted,#888);';
4830      return 'background:color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);color:var(--accent, var(--red));';
4831    };
4832    const scopeToggles = (t) => {
4833      const scopes = new Set(t.scopes || []);
4834      return toolScopes.map(scope => {
4835        const tool = scope.key.split(':')[0];
4836        const action = _scopeAction(scope.key);
4837        const icon = _scopeIcons[tool] || '';
4838        const niceLabel = _scopeNiceLabel(scope.label);
4839        return `
4840        <label class="settings-row" style="align-items:center;gap:8px;display:flex;min-height:30px;padding:2px 0;">
4841          <span style="opacity:0.7;display:inline-flex;align-items:center;justify-content:center;width:16px;flex-shrink:0;">${icon}</span>
4842          <span class="settings-label" style="width:75px;flex-shrink:0;padding:0;">${esc(niceLabel)}</span>
4843          <span style="font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;padding:1px 7px;border-radius:999px;flex-shrink:0;min-width:44px;text-align:center;margin-left:-3px;box-sizing:border-box;${_pillStyle(action)}">${esc(action)}</span>
4844          <span style="font-size:11px;line-height:1.35;opacity:0.62;flex:1;min-width:0;">${esc(scope.detail)}</span>
4845          <label class="admin-switch" style="margin-left:auto;flex-shrink:0;"><input type="checkbox" class="uf-codex-scope" data-token-id="${esc(t.id)}" data-scope="${esc(scope.key)}" ${scopes.has(scope.key) ? 'checked' : ''}>
4845<span class="admin-slider"></span></label>
4846        </label>`;
4847      }).join('');
4848    };
4849    const tokenRows = agentTokens.length ? agentTokens.map(t => `
4850      <div class="uf-codex-token" data-token-id="${esc(t.id)}" style="border:1px solid var(--border);border-radius:6px;padding:9px 10px;margin-top:8px;">
4851        <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
4852          <div style="flex:1;min-width:0;">
4853            <input type="text" class="uf-codex-rename settings-input" data-token-id="${esc(t.id)}" value="${esc(t.name || cfg.defaultName)}" placeholder="${esc(cfg.defaultName)} (e.g. ${esc(cfg.word)} on laptop)" style="font-size:12px;font-weight:600;padding:3px 6px;width:100%;background:transparent;border:1px solid transparent;border-radius:4px;" title="Click to rename this agent">
4854            <div style="font-size:10px;opacity:0.52;margin-top:2px;">${esc(t.token_prefix || 'token')}...${t.last_used_at ? ` · Last used ${new Date(t.last_used_at).toLocaleDateString()}` : ' · Never used'}</div>
4855          </div>
4856          <button class="admin-btn-sm uf-codex-copy-prefix" data-token-prefix="${esc(t.token_prefix || '')}" title="Copy token prefix (full token only shown once, at creation)" style="opacity:0.7">Copy</button>
4857          <button class="admin-btn-delete uf-codex-revoke" data-token-id="${esc(t.id)}">Revoke</button>
4858        </div>
4859        <div style="font-size:11px;font-weight:600;opacity:0.62;margin-bottom:4px;">Tool access</div>
4860        ${scopeToggles(t)}
4861        <div class="uf-codex-scope-msg" data-token-id="${esc(t.id)}" style="font-size:11px;min-height:14px;"></div>
4862      </div>`).join('') : `<div style="opacity:0.45;font-size:11px;padding:8px 0;">No ${esc(cfg.word)} tokens yet.</div>`;
4863    const origin = window.location.origin || '';
4864    const setupForToken = (token) => cfg.buildSetup(origin, token);
4865
4866    formEl.innerHTML = `
4867      <div class="admin-card" style="margin-top:8px">
4868        <h2 style="font-size:13px">${esc(cfg.label)}</h2>
4869        <div style="font-size:11px;opacity:0.65;line-height:1.45;margin:-2px 0 8px;">Generates a scoped token + setup commands so ${esc(cfg.word)} on your own machine can read/write your Odysseus data (todos, email, calendar, etc.). The agent runs in your terminal — it isn't streamed inside Odysseus.</div>
4870        <div class="settings-col">
4871          <div id="uf-codex-pending" style="display:${current ? 'none' : 'block'};font-size:11px;opacity:0.6;padding:6px 0;">Creating agent...</div>
4872          <div id="uf-codex-reveal" style="display:none;padding:10px 12px;border:1px solid var(--border);border-left:3px solid var(--accent, var(--red));border-radius:6px;background:rgba(0,0,0,0.04);width:100%;box-sizing:border-box;">
4873            <div style="font-weight:600;font-size:12px;margin-bottom:6px;">${esc(cfg.word)} setup</div>
4874
4875            <div style="font-size:11px;opacity:0.62;margin-bottom:4px;">Copy this token now &mdash; it will not be shown again.</div>
4876            <code id="uf-codex-token" style="display:block;word-break:break-all;font-size:11px;padding:6px 8px;background:rgba(0,0,0,0.08);border-radius:4px;"></code>
4877            <div style="margin-top:6px;">
4878              <button class="admin-btn-sm" id="uf-codex-copy-token">Copy token</button>
4879            </div>
4880
4881            <div style="margin-top:14px;font-weight:600;font-size:11px;margin-bottom:4px;">Quickstart &mdash; or copy setup directly in your terminal</div>
4882            <div style="font-size:11px;opacity:0.62;margin-bottom:6px;">${cfg.setupDescription}</div>
4883            <pre style="margin:0;white-space:pre;overflow-x:auto;max-height:220px;overflow-y:auto;font-size:10px;line-height:1.45;padding:8px 10px;background:rgba(0,0,0,0.08);border-radius:4px;width:100%;box-sizing:border-box;"><code id="uf-codex-setup-code"></code></pre>
4884            <div style="margin-top:6px;">
4885              <button class="admin-btn-sm" id="uf-codex-copy-setup">Copy setup</button>
4886            </div>
4887
4888            <div style="margin-top:14px;font-weight:600;font-size:11px;margin-bottom:4px;">Configure access</div>
4889            <div style="font-size:11px;opacity:0.62;margin-bottom:6px;">Toggle which Odysseus tools this agent can use. New agents start with chat only.</div>
4890            <div id="uf-codex-inline-scopes"></div>
4891          </div>
4892          <div style="font-size:11px;font-weight:600;opacity:0.62;margin-top:10px;">${agentTokens.length ? 'Existing agents' : 'Agents'}</div>
4893          <div id="uf-codex-token-list">${tokenRows}</div>
4894          <div class="settings-row" style="margin-top:10px;align-items:center;">
4895            <button class="admin-btn-add" id="uf-codex-save" style="background:var(--red);border-color:var(--red);color:#fff;display:inline-flex;align-items:center;gap:5px;font-weight:600;">
4896              <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
4897              Save
4898            </button>
4899            <span id="uf-codex-msg" style="font-size:11px;flex:1;margin-left:8px"></span>
4900            <button class="admin-btn-add" id="uf-codex-cancel" style="opacity:0.7;display:inline-flex;align-items:center;gap:5px;position:relative;top:1px;margin-left:auto;">
4901              <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
4902              Cancel
4903            </button>
4904          </div>
4905        </div>
4906      </div>`;
4907
4908    el('uf-codex-cancel')?.addEventListener('click', () => { formEl.style.display = 'none'; });
4909    el('uf-codex-save')?.addEventListener('click', () => {
4910      const msg = el('uf-codex-msg');
4911      if (msg) { msg.textContent = 'Saved'; msg.style.color = 'var(--green, #50fa7b)'; }
4912      setTimeout(() => { formEl.style.display = 'none'; }, 350);
4913    });
4914
4915    const _autoCreateCodex = async () => {
4916      const msg = el('uf-codex-msg');
4917      const pending = el('uf-codex-pending');
4918      const existingNames = new Set(agentTokens.map(t => (t.name || '').trim()));
4919      let name = cfg.defaultName;
4920      let n = 2;
4921      while (existingNames.has(name)) { name = `${cfg.defaultName} ${n++}`; }
4922      const fd = new FormData();
4923      fd.append('name', name);
4924      fd.append('scopes', 'chat');
4925      try {
4926        const r = await fetch('/api/tokens', { method: 'POST', credentials: 'same-origin', body: fd });
4927        const d = await r.json();
4928        if (!r.ok) throw new Error(d.detail || 'Failed');
4929        if (pending) pending.style.display = 'none';
4930        el('uf-codex-token').textContent = d.token || '';
4931        el('uf-codex-reveal').style.display = '';
4932        const setupBtn = el('uf-codex-copy-setup');
4933        if (setupBtn) setupBtn.dataset.token = d.token || '';
4934        const setupCode = el('uf-codex-setup-code');
4935        if (setupCode) setupCode.textContent = setupForToken(d.token || '');
4936        // Populate inline scope toggles for the just-created token (Configure access already open)
4937        const newToken = { id: d.id, name, scopes: d.scopes || ['chat'] };
4938        const inlineEl = el('uf-codex-inline-scopes');
4939        if (inlineEl) {
4940          inlineEl.innerHTML = `
4941            <div class="uf-codex-token" data-token-id="${esc(newToken.id)}">
4942              ${scopeToggles(newToken)}
4943              <div class="uf-codex-scope-msg" data-token-id="${esc(newToken.id)}" style="font-size:11px;min-height:14px;"></div>
4944            </div>`;
4945          _wireScopeChange(inlineEl);
4946        }
4947        if (msg) {
4948          msg.textContent = `Created "${name}".`;
4949          msg.style.color = 'var(--green, #50fa7b)';
4950        }
4951        await renderList();
4952      } catch (err) {
4953        if (pending) pending.style.display = 'none';
4954        if (msg) {
4955          msg.textContent = err?.message || 'Failed';
4956          msg.style.color = 'var(--red)';
4957        }
4958      }
4959    };
4960    if (!current) _autoCreateCodex();
4961    const _copyCodexToken = async (text) => {
4962      const value = String(text || '');
4963      if (!value) return false;
4964      if (navigator.clipboard && window.isSecureContext) {
4965        try {
4966          await navigator.clipboard.writeText(value);
4967          return true;
4968        } catch (_) {}
4969      }
4970      const ta = document.createElement('textarea');
4971      ta.value = value;
4972      ta.setAttribute('readonly', 'readonly');
4973      ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;z-index:-1;';
4974      document.body.appendChild(ta);
4975      ta.focus();
4976      ta.select();
4977      ta.setSelectionRange(0, value.length);
4978      let ok = false;
4979      try { ok = document.execCommand('copy'); } catch (_) { ok = false; }
4980      ta.remove();
4981      return ok;
4982    };
4983    const _selectTextFallback = (text, containerId) => {
4984      const code = document.createElement('pre');
4985      code.textContent = text;
4986      code.style.cssText = 'white-space:pre-wrap;word-break:break-word;font-size:10px;margin:6px 0 0;';
4987      el(containerId)?.appendChild(code);
4988      const range = document.createRange();
4989      range.selectNodeContents(code);
4990      const selection = window.getSelection();
4991      selection.removeAllRanges();
4992      selection.addRange(range);
4993    };
4994    el('uf-codex-copy-setup')?.addEventListener('click', async () => {
4995      const token = el('uf-codex-copy-setup')?.dataset.token || '';
4996      const btn = el('uf-codex-copy-setup');
4997      if (!token) {
4998        if (btn) {
4999          btn.textContent = 'Add agent first';
5000          setTimeout(() => { const latest = el('uf-codex-copy-setup'); if (latest) latest.textContent = 'Copy setup'; }, 1600);
5001        }
5002        return;
5003      }
5004      const setup = setupForToken(token);
5005      const ok = await _copyCodexToken(setup);
5006      if (!btn) return;
5007      btn.textContent = ok ? 'Copied setup' : 'Select setup';
5008      if (!ok) _selectTextFallback(setup, 'uf-codex-reveal');
5009      setTimeout(() => { const latest = el('uf-codex-copy-setup'); if (latest) latest.textContent = 'Copy setup'; }, 1600);
5010    });
5011    el('uf-codex-copy-token')?.addEventListener('click', async () => {
5012      const token = el('uf-codex-token')?.textContent || '';
5013      const ok = await _copyCodexToken(token);
5014      const btn = el('uf-codex-copy-token');
5015      if (!btn) return;
5016      btn.textContent = ok ? 'Copied token' : 'Select token';
5017      if (!ok) _selectTextFallback(token, 'uf-codex-reveal');
5018      setTimeout(() => { const latest = el('uf-codex-copy-token'); if (latest) latest.textContent = 'Copy token'; }, 1600);
5019    });
5020    formEl.querySelectorAll('.uf-codex-revoke').forEach(btn => {
5021      btn.addEventListener('click', async () => {
5022        if (!await window.styledConfirm(`Revoke this ${cfg.word} token? Terminal agents using it will lose access.`, { confirmText: 'Revoke', danger: true })) return;
5023        await fetch(`/api/tokens/${btn.dataset.tokenId}`, { method: 'DELETE', credentials: 'same-origin' });
5024        formEl.style.display = 'none';
5025        await renderList();
5026      });
5027    });
5028    // Rename: PATCH the token's name when the user blurs the input (or hits Enter).
5029    formEl.querySelectorAll('.uf-codex-rename').forEach(input => {
5030      const original = input.value;
5031      const commit = async () => {
5032        const name = (input.value || '').trim();
5033        if (!name || name === original) return;
5034        try {
5035          const r = await fetch(`/api/tokens/${input.dataset.tokenId}`, {
5036            method: 'PATCH',
5037            credentials: 'same-origin',
5038            headers: { 'Content-Type': 'application/json' },
5039            body: JSON.stringify({ name }),
5040          });
5041          if (!r.ok) throw new Error('Save failed');
5042          input.style.borderColor = 'var(--green, #50fa7b)';
5043          setTimeout(() => { input.style.borderColor = 'transparent'; }, 800);
5044          await renderList();
5045        } catch (_) {
5046          input.value = original;
5047          input.style.borderColor = 'var(--red)';
5048          setTimeout(() => { input.style.borderColor = 'transparent'; }, 1200);
5049        }
5050      };
5051      input.addEventListener('blur', commit);
5052      input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } });
5053    });
5054    // Copy token prefix (full token irrecoverable after the one-time creation reveal).
5055    formEl.querySelectorAll('.uf-codex-copy-prefix').forEach(btn => {
5056      btn.addEventListener('click', async () => {
5057        const prefix = btn.dataset.tokenPrefix || '';
5058        if (!prefix) return;
5059        try {
5060          if (navigator.clipboard && window.isSecureContext) {
5061            await navigator.clipboard.writeText(prefix);
5062          } else {
5063            const ta = document.createElement('textarea');
5064            ta.value = prefix;
5065            ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;';
5066            document.body.appendChild(ta);
5067            ta.select();
5068            try { document.execCommand('copy'); } catch (_) {}
5069            ta.remove();
5070          }
5071          const label = btn.textContent;
5072          btn.textContent = 'Copied prefix';
5073          setTimeout(() => { btn.textContent = label; }, 1400);
5074        } catch (_) {}
5075      });
5076    });
5077    function _wireScopeChange(scope) {
5078      scope.querySelectorAll('.uf-codex-scope').forEach(cb => {
5079        if (cb.dataset.wired === '1') return;
5080        cb.dataset.wired = '1';
5081        cb.addEventListener('change', async () => {
5082          const tokenId = cb.dataset.tokenId;
5083          const panel = formEl.querySelector(`.uf-codex-token[data-token-id="${CSS.escape(tokenId)}"]`);
5084          const msg = formEl.querySelector(`.uf-codex-scope-msg[data-token-id="${CSS.escape(tokenId)}"]`);
5085          const scopes = Array.from(panel.querySelectorAll('.uf-codex-scope:checked')).map(input => input.dataset.scope);
5086          try {
5087            const r = await fetch(`/api/tokens/${tokenId}`, {
5088              method: 'PATCH',
5089              credentials: 'same-origin',
5090              headers: { 'Content-Type': 'application/json' },
5091              body: JSON.stringify({ scopes }),
5092            });
5093            const d = await r.json().catch(() => ({}));
5094            if (!r.ok) throw new Error(d.detail || 'Failed');
5095            if (msg) { msg.textContent = 'Saved'; msg.style.color = 'var(--green, #50fa7b)'; }
5096            await renderList();
5097          } catch (err) {
5098            cb.checked = !cb.checked;
5099            if (msg) { msg.textContent = err?.message || 'Failed'; msg.style.color = 'var(--red)'; }
5100          }
5101        });
5102      });
5103    }
5104    _wireScopeChange(formEl);
5105  }
5106
5107  // ── Add button with type picker ──
5108  if (addBtn) {
5109    addBtn.addEventListener('click', () => {
5110      formEl.style.display = '';
5111      const _typeOptions = [
5112        ['api', 'API Service'],
5113        ['caldav', 'CalDAV Calendar'],
5114        ['claude', 'Claude Agent'],
5115        ['codex', 'Codex Agent'],
5116        ['carddav', 'Contacts (CardDAV)'],
5117        ['contacts', 'Contacts Import'],
5118        ['email', 'Email (IMAP/SMTP)'],
5119        ['mcp', 'MCP Tool Server'],
5120      ];
5121      const _iconFor = (k) => (INTG_TYPES[k]?.icon || '').replace(/width="14"/, 'width="16"').replace(/height="14"/, 'height="16"');
5122      const _rowsHtml = _typeOptions.map(([k, label]) => `<button type="button" class="uf-type-option" data-value="${k}" style="display:flex;align-items:center;gap:10px;width:100%;padding:8px 10px;background:transparent;border:0;color:var(--fg);font:inherit;cursor:pointer;text-align:left;"><span style="display:inline-flex;color:var(--accent, var(--red));flex-shrink:0;">${_iconFor(k)}</span><span>${esc(label)}</span></button>`).join('');
5123      formEl.innerHTML = `
5124        <div class="admin-card" style="margin-top:8px">
5125          <h2 style="font-size:13px;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Add Integration</h2>
5126          <div class="settings-col">
5127            <div class="settings-row"><label class="settings-label">Type</label>
5128              <div style="position:relative;flex:1;min-width:0;">
5129                <button type="button" id="uf-type-trigger" class="settings-select" style="display:flex;align-items:center;gap:10px;cursor:pointer;text-align:left;width:100%;padding-right:24px;position:relative;">
5130                  <span class="uf-type-icon" style="display:inline-flex;color:var(--accent, var(--red));"></span>
5131                  <span class="uf-type-label" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:0.65;">Select...</span>
5132                  <span aria-hidden="true" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0.5;font-size:10px;pointer-events:none;">▾</span>
5133                </button>
5134                <div id="uf-type-menu" style="display:none;position:absolute;top:calc(100% + 2px);left:0;right:0;z-index:1000;background:var(--panel);border:1px solid var(--border);border-radius:6px;max-height:340px;overflow-y:auto;box-shadow:0 6px 18px rgba(0,0,0,0.25);">${_rowsHtml}</div>
5135              </div>
5136            </div>
5137          </div>
5138        </div>`;
5139      const trigger = el('uf-type-trigger');
5140      const menu = el('uf-type-menu');
5141      const labelEl = trigger.querySelector('.uf-type-label');
5142      const iconEl = trigger.querySelector('.uf-type-icon');
5143      const _closeMenu = () => { menu.style.display = 'none'; };
5144      const _openMenu = () => {
5145        menu.style.display = 'block';
5146        // Drop-up when there's not enough room below the trigger (mobile
5147        // landscape / docked keyboard / long lists near the bottom of screen).
5148        const tRect = trigger.getBoundingClientRect();
5149        const mRect = menu.getBoundingClientRect();
5150        const below = window.innerHeight - tRect.bottom;
5151        const above = tRect.top;
5152        if (mRect.height > below && above > below) {
5153          menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)';
5154        } else {
5155          menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
5156        }
5157        const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trigger) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
5158        setTimeout(() => document.addEventListener('click', onDoc, true), 0);
5159      };
5160      trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
5161      menu.querySelectorAll('.uf-type-option').forEach(btn => {
5162        btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
5163        btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
5164        btn.addEventListener('click', (e) => {
5165          e.stopPropagation();
5166          const k = btn.dataset.value;
5167          const lbl = btn.querySelector('span:last-child')?.textContent || '';
5168          if (labelEl) { labelEl.textContent = lbl; labelEl.style.opacity = '1'; }
5169          if (iconEl) iconEl.innerHTML = _iconFor(k);
5170          _closeMenu();
5171          showForm(k, 'new');
5172        });
5173      });
5174    });
5175  }
5176
5177  await renderList();
5178}
5179
5180/* ── Admin visibility sync ── */
5181function syncAdminVisibility() {
5182  if (!modalEl) return;
5183  const isAdmin = !!window._isAdmin;
5184  modalEl.querySelectorAll('.admin-only').forEach(el => {
5185    el.style.display = isAdmin ? '' : 'none';
5186  });
5187}
5188
5189/* ═══════════════════════════════════════════
5190   PUBLIC API
5191   ═══════════════════════════════════════════ */
5192export function open(tab) {
5193  if (!initialized) initAll();
5194  syncAppearanceCheckboxes();
5195  resetWindowPlacement();
5196  modalEl.classList.remove('hidden');
5197  syncAdminVisibility();
5198  const content = modalEl.querySelector('.settings-modal-content');
5199  if (tab) {
5200    modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab));
5201    modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab));
5202  }
5203  // Auto-init admin data if showing an admin tab
5204  const activeTab = tab || (modalEl.querySelector('[data-settings-tab].active') || {}).dataset?.settingsTab || 'services';
5205  document.body.classList.toggle('settings-appearance-open', activeTab === 'appearance');
5206  syncAppearanceOpacity(activeTab === 'appearance');
5207  if (activeTab === 'ai') refreshAiModelEndpoints();
5208  if (ADMIN_TABS.has(activeTab) && window.adminModule && !window.adminModule._initialized) {
5209    window.adminModule._initData();
5210  }
5211}
5212
5213export function close() {
5214  if (!modalEl) return;
5215  // Always clear the appearance-tab body class so the rest of the app
5216  // doesn't keep its dimmed state if the modal got closed mid-tab.
5217  document.body.classList.remove('settings-appearance-open');
5218  syncAppearanceOpacity(false); // clear any opacity-slider fade
5219  const content = modalEl.querySelector('.modal-content, .settings-modal-content');
5220  if (content && !content.classList.contains('modal-closing')) {
5221    content.classList.add('modal-closing');
5222    content.addEventListener('animationend', () => {
5223      modalEl.classList.add('hidden');
5224      content.classList.remove('modal-closing');
5225    }, { once: true });
5226    setTimeout(() => { if (!modalEl.classList.contains('hidden')) { modalEl.classList.add('hidden'); content.classList.remove('modal-closing'); } }, 250);
5227  } else {
5228    modalEl.classList.add('hidden');
5229  }
5230}
5231
5232const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
5233
5234
5235export default settingsModule;

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.