App._settingsMode = 'form';
App._settingsDraft = null;
App._settingsDraftKey = null;
App._settingsModelOrder = null;
App._settingsDragModelIndex = null;
App._settingsRawText = null;
App._settingsDirty = false;
// ── Missing function stubs (lost in 2.27 decomposition) ─────────────
// These provide safe defaults so Settings/Agents pages render.
// Full implementations tracked for 0.11.4.
App._resolveActiveAgentId = function() {
// Returns a promise resolving to the active agent ID
return api('/api/health').then(function(h) { return h.agent || 'duncan'; }).catch(function() { return 'duncan'; });
};
App._renderModelOptionsHtml = function(opts) {
// Render <option> tags for a model selector from discovered + configured models
var entries = App._buildModelOptionEntries(opts);
return entries.map(function(e) {
var sel = (opts.selected || []).indexOf(e.value) >= 0 ? ' selected' : '';
return '<option value="' + esc(e.value) + '"' + sel + '>' + esc(e.label) + '</option>';
}).join('');
};
App._buildModelOptionEntries = function(opts) {
// Build a deduped list of {value, label} model entries from all sources
var seen = {};
var entries = [];
function add(model, source) {
if (!model || seen[model]) return;
seen[model] = true;
entries.push({ value: model, label: model + (source ? ' (' + source + ')' : '') });
}
// Config models
var cfg = (opts.config || {}).models || {};
if (cfg.primary) add(cfg.primary, 'primary');
(cfg.fallbacks || []).forEach(function(m) { add(m, 'fallback'); });
// Discovered models
(opts.discoveredModels || []).forEach(function(m) {
var name = typeof m === 'string' ? m : (m.id || m.name || '');
if (name) add(name, 'discovered');
});
return entries;
};
App._buildMutableConfigPatch = function(draft) {
// Build a config patch from the settings draft for saving
if (!draft) return {};
var patch = JSON.parse(JSON.stringify(draft));
// Remove read-only / computed fields
delete patch.providers;
return patch;
};
App._initModelOrderFromDraft = function() {
// Initialize the model order drag list from the current settings draft
var draft = App._getSettingsDraft();
var models = draft.models || {};
var order = [];
if (models.primary) order.push(models.primary);
(models.fallbacks || []).forEach(function(m) { if (order.indexOf(m) < 0) order.push(m); });
App._settingsModelOrder = order;
};
App._syncModelOrderToDraft = function() {
// Write the current model order back to the settings draft
var order = App._settingsModelOrder || [];
var draft = App._getSettingsDraft();
if (!draft.models) draft.models = {};
draft.models.primary = order[0] || '';
draft.models.fallbacks = order.slice(1);
App._settingsDirty = true;
};
App._renderProviderDiscoveryAlerts = function() {
// Provider reachability alerts — rendered from cached config provider data.
// Returns HTML string with alert banners for unreachable providers.
if (!_cachedConfig || !_cachedConfig.providers) return '';
var alerts = [];
var providers = _cachedConfig.providers || {};
Object.keys(providers).forEach(function(name) {
var p = providers[name];
if (p._key_status === 'missing' && !p.is_local) {
alerts.push('<div class="provider-alert-link" data-provider="' + esc(name) + '" style="padding:0.5rem 0.75rem;margin-bottom:0.5rem;border-left:3px solid var(--warning);background:rgba(245,158,11,0.08);font-size:0.75rem;border-radius:var(--radius);cursor:pointer" title="Click to scroll to provider">'
+ '<strong>' + esc(name) + '</strong>: API key not configured'
+ (p.api_key_env ? ' (set <code>' + esc(p.api_key_env) + '</code> or add to keystore)' : '')
+ '</div>');
}
});
return alerts.join('');
};
App._getSettingsDraft = function() {
if (!this._settingsDraft && _cachedConfig) {
this._settingsDraft = JSON.parse(JSON.stringify(_cachedConfig));
}
return this._settingsDraft || {};
};
App.renderSettings = function() {
var self = this;
var mode = self._settingsMode;
return Promise.all([
api('/api/config'),
api('/api/config/capabilities').catch(function() { return { immutable_sections: ['server', 'a2a', 'wallet'] }; }),
mode === 'raw' ? api('/api/config/raw').catch(function() { return ''; }) : Promise.resolve(null),
api('/api/keystore/status').catch(function() { return { unlocked: true }; })
]).then(function(arr) {
var cfg = arr[0] || {};
var caps = arr[1] || {};
var rawToml = arr[2];
var keystoreStatus = arr[3] || {};
cfg._keystore_locked = !keystoreStatus.unlocked;
self._configCapabilities = caps;
var immutableSet = {};
(caps.immutable_sections || []).forEach(function(s) { immutableSet[s] = true; });
_cachedConfig = cfg;
if (!self._settingsDraft) self._settingsDraft = JSON.parse(JSON.stringify(cfg));
var draft = self._getSettingsDraft();
var dirty = self._settingsDirty;
var dirtyDot = dirty ? '<span class="settings-dirty-dot" title="Unsaved changes"></span>' : '';
var tabs = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem"><div class="tabs" style="margin-bottom:0"><button class="' + (mode === 'form' ? 'active' : '') + '" data-settings-mode="form">Form</button><button class="' + (mode === 'models' ? 'active' : '') + '" data-settings-mode="models">Model Order</button><button class="' + (mode === 'access' ? 'active' : '') + '" data-settings-mode="access">Access Control</button></div><div style="display:flex;background:var(--surface-2);border:1px solid var(--border-ghost);border-radius:9999px;padding:2px;gap:0"><button data-settings-mode="' + (mode === 'raw' ? 'form' : 'raw') + '" style="padding:0.375rem 1rem;border-radius:9999px;border:none;font-family:var(--font-headline);font-size:0.625rem;font-weight:700;letter-spacing:0.15em;text-transform:uppercase;cursor:pointer;transition:all 0.2s;' + (mode !== 'raw' ? 'background:var(--accent);color:#000;' : 'background:transparent;color:var(--outline);') + '">Form</button><button data-settings-mode="raw" style="padding:0.375rem 1rem;border-radius:9999px;border:none;font-family:var(--font-headline);font-size:0.625rem;font-weight:700;letter-spacing:0.15em;text-transform:uppercase;cursor:pointer;transition:all 0.2s;' + (mode === 'raw' ? 'background:var(--accent);color:#000;' : 'background:transparent;color:var(--outline);') + '">TOML</button></div></div>';
var actions = '<div class="settings-actions"><button class="btn" id="settings-save" ' + (dirty ? '' : 'disabled style="opacity:0.4;pointer-events:none"') + '>Save</button><button class="btn secondary" id="settings-apply" ' + (dirty ? '' : 'disabled style="opacity:0.4;pointer-events:none"') + '>Apply</button><button class="btn secondary" id="settings-cancel" ' + (dirty ? '' : 'disabled style="opacity:0.4;pointer-events:none"') + '>Cancel</button>' + dirtyDot + '</div>';
if (mode === 'raw') {
var rawText = self._settingsRawText != null ? self._settingsRawText : String(rawToml || '');
return tabs + '<div class="settings-editor settings-editor-fullheight"><div class="json-editor-wrap"><pre class="json-highlight" id="settings-raw-highlight" aria-hidden="true"></pre><textarea id="settings-raw-editor" spellcheck="false">' + esc(rawText) + '</textarea><div class="json-editor-focus-ring"></div></div><div class="settings-lint ok">\u2713 Raw TOML from disk-backed config</div></div>' + actions;
}
if (mode === 'models') {
if (!self._settingsModelOrder) self._initModelOrderFromDraft();
var modelOrder = (self._settingsModelOrder || []).slice();
var modelsImmutable = !!immutableSet.models;
var modelRows = modelOrder.map(function(name, idx) {
var roleBadge = idx === 0
? '<span class="badge success" style="font-size:0.6rem">Primary</span>'
: '<span class="badge muted" style="font-size:0.6rem">Fallback ' + idx + '</span>';
var makePrimaryBtn = idx === 0 ? '' : '<button class="model-order-btn" data-model-order-make-primary="' + idx + '">Make primary</button>';
var removeBtn = '<button class="model-order-btn" data-model-order-remove="' + idx + '">Remove</button>';
var dragAttrs = modelsImmutable ? '' : (' draggable="true" data-model-order-item="' + idx + '"');
return '<div class="model-order-item"' + dragAttrs + '>'
+ '<div class="model-order-handle" title="' + (modelsImmutable ? 'Read only' : 'Drag to reorder') + '">\u22ee\u22ee</div>'
+ '<div class="model-order-name" title="' + esc(name) + '">' + esc(name) + '</div>'
+ roleBadge
+ '<div class="model-order-actions">' + makePrimaryBtn + '</div>'
+ (modelsImmutable ? '' : removeBtn)
+ '</div>';
}).join('');
if (!modelRows) modelRows = '<div class="card" style="padding:0.75rem;color:var(--muted)">No models configured. Add one below to set a primary model.</div>';
var knownModelOptions = self._renderModelOptionsHtml({
config: draft,
includeControlModes: false
});
var immutableNote = modelsImmutable
? '<div style="font-size:0.75rem;color:var(--muted);margin:0.35rem 0 0.85rem">Model order is immutable at runtime for this instance. Edit `roboticus.toml` and restart.</div>'
: '<div style="font-size:0.75rem;color:var(--muted);margin:0.35rem 0 0.85rem">Drag rows to reorder preference. Top row is primary; all others are fallback order.</div>';
var addRow = modelsImmutable ? '' : ('<div class="model-order-add"><input id="model-order-add-input" class="settings-input" list="model-order-known-list" type="text" placeholder="Add model (provider/name)"><datalist id="model-order-known-list">' + knownModelOptions + '</datalist><button class="btn secondary" id="model-order-add-btn">Add model</button></div>');
var providerAlerts = self._renderProviderDiscoveryAlerts();
var autoOrder = draft.models && draft.models.auto_order;
var autoCheckbox = '<div style="margin-top:0.75rem;display:flex;align-items:center;gap:0.5rem">'
+ '<input type="checkbox" id="model-order-auto"' + (autoOrder ? ' checked' : '') + '>'
+ '<label for="model-order-auto" style="font-size:0.8125rem;color:var(--text)">Auto</label>'
+ '<span title="When enabled, the model order is treated as a default and may be adjusted dynamically at runtime based on availability and performance" style="cursor:help;color:var(--muted);font-size:0.65rem">(?)</span>'
+ '</div>';
return tabs
+ '<div class="settings-form"><div class="settings-section"><div class="settings-section-title">Model Order</div>'
+ providerAlerts
+ immutableNote
+ '<div class="model-order-list" id="model-order-list">' + modelRows + '</div>'
+ addRow
+ autoCheckbox
+ '</div></div>'
+ actions;
}
if (mode === 'access') {
var sec = draft.security || {};
var authLevels = [['External','External \u2014 safe tools only'],['Peer','Peer \u2014 safe + caution'],['SelfGenerated','SelfGenerated \u2014 safe + caution + dangerous'],['Creator','Creator \u2014 full access']];
var ceilLevels = [['External','External \u2014 safe tools only'],['Peer','Peer \u2014 safe + caution'],['SelfGenerated','SelfGenerated \u2014 safe + caution + dangerous']];
var mkOpts = function(levels, cur) { return levels.map(function(l) { return '<option value="' + l[0] + '"' + (cur === l[0] ? ' selected' : '') + '>' + l[1] + '</option>'; }).join(''); };
var accHtml = '';
accHtml += '<div class="settings-section"><div class="settings-section-title">Authority Matrix</div>'
+ '<div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.75rem">What each authority level can do:</div>'
+ '<table style="width:100%;font-size:0.72rem;border-collapse:collapse">'
+ '<thead><tr style="border-bottom:1px solid var(--border)"><th style="text-align:left;padding:0.35rem 0.5rem">Level</th><th style="text-align:center;padding:0.35rem">Safe</th><th style="text-align:center;padding:0.35rem">Caution</th><th style="text-align:center;padding:0.35rem">Dangerous</th><th style="text-align:center;padding:0.35rem">Forbidden</th></tr></thead>'
+ '<tbody>'
+ '<tr style="border-bottom:1px solid var(--border)"><td style="padding:0.35rem 0.5rem"><strong>Creator</strong></td><td style="text-align:center">\u2705</td><td style="text-align:center">\u2705</td><td style="text-align:center">\u2705</td><td style="text-align:center">\u274c</td></tr>'
+ '<tr style="border-bottom:1px solid var(--border)"><td style="padding:0.35rem 0.5rem"><strong>SelfGenerated</strong></td><td style="text-align:center">\u2705</td><td style="text-align:center">\u2705</td><td style="text-align:center">\u2705</td><td style="text-align:center">\u274c</td></tr>'
+ '<tr style="border-bottom:1px solid var(--border)"><td style="padding:0.35rem 0.5rem"><strong>Peer</strong></td><td style="text-align:center">\u2705</td><td style="text-align:center">\u2705</td><td style="text-align:center">\u274c</td><td style="text-align:center">\u274c</td></tr>'
+ '<tr><td style="padding:0.35rem 0.5rem"><strong>External</strong></td><td style="text-align:center">\u2705</td><td style="text-align:center">\u274c</td><td style="text-align:center">\u274c</td><td style="text-align:center">\u274c</td></tr>'
+ '</tbody></table>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin-top:0.5rem">'
+ '<strong>Safe:</strong> conversation, summarization, web search. '
+ '<strong>Caution:</strong> read_file, write_file, delegation. '
+ '<strong>Dangerous:</strong> run_script, shell execution.'
+ '</div></div>';
accHtml += '<div class="settings-section"><div class="settings-section-title">Security Policy</div>'
+ '<div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.75rem">'
+ 'Authority resolution: effective = min(max(positive grants\u2026), min(negative ceilings\u2026))'
+ '</div>';
var denyEmpty = sec.deny_on_empty_allowlist !== false;
accHtml += '<div class="settings-row"><div class="settings-label">Deny on empty allow-list</div>'
+ '<div class="settings-toggle-wrap"><label class="toggle">'
+ '<input type="checkbox" data-settings-path="security.deny_on_empty_allowlist"' + (denyEmpty ? ' checked' : '') + '>'
+ '<span class="toggle-track"></span></label>'
+ '<span class="settings-toggle-label">' + (denyEmpty ? 'On' : 'Off') + '</span></div></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'When enabled, channels with no allow-list entries reject all messages (secure default). When disabled, empty allow-lists permit all senders.'
+ '</div>';
var alAuth = sec.allowlist_authority || 'Peer';
accHtml += '<div class="settings-row"><div class="settings-label">Allow-list authority</div>'
+ '<select class="settings-input" data-settings-path="security.allowlist_authority" style="max-width:280px">'
+ mkOpts(authLevels, alAuth) + '</select></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'Authority granted to senders who pass a channel\u2019s allow-list check.'
+ '</div>';
var trAuth = sec.trusted_authority || 'Creator';
accHtml += '<div class="settings-row"><div class="settings-label">Trusted sender authority</div>'
+ '<select class="settings-input" data-settings-path="security.trusted_authority" style="max-width:280px">'
+ mkOpts(authLevels, trAuth) + '</select></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'Authority granted to senders listed in channels.trusted_sender_ids.'
+ '</div>';
var apiAuth = sec.api_authority || 'Creator';
accHtml += '<div class="settings-row"><div class="settings-label">API authority</div>'
+ '<select class="settings-input" data-settings-path="security.api_authority" style="max-width:280px">'
+ mkOpts(authLevels, apiAuth) + '</select></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'Authority for HTTP API and WebSocket requests.'
+ '</div>';
var threatCeil = sec.threat_caution_ceiling || 'External';
accHtml += '<div class="settings-row"><div class="settings-label">Threat scanner ceiling</div>'
+ '<select class="settings-input" data-settings-path="security.threat_caution_ceiling" style="max-width:280px">'
+ mkOpts(ceilLevels, threatCeil) + '</select></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'Maximum authority when the threat scanner flags input as Caution. Must be below Creator.'
+ '</div>';
accHtml += '</div>';
// ── Filesystem Security section ────────────────────────────
var fsSec = (sec.filesystem || {});
var wsOnly = fsSec.workspace_only !== false;
var scriptConf = fsSec.script_fs_confinement !== false;
var protectedPaths = (fsSec.protected_paths || []).join('\n');
var extraProtected = (fsSec.extra_protected_paths || []).join('\n');
var scriptAllowed = (fsSec.script_allowed_paths || []).join('\n');
accHtml += '<div class="settings-section"><div class="settings-section-title">Filesystem Security</div>'
+ '<div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.75rem">'
+ 'Controls filesystem access for agent tools and skill scripts.'
+ '</div>';
accHtml += '<div class="settings-row"><div class="settings-label">Workspace-only mode</div>'
+ '<div class="settings-toggle-wrap"><label class="toggle">'
+ '<input type="checkbox" data-settings-path="security.filesystem.workspace_only"' + (wsOnly ? ' checked' : '') + '>'
+ '<span class="toggle-track"></span></label>'
+ '<span class="settings-toggle-label">' + (wsOnly ? 'On' : 'Off') + '</span></div></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'Restrict agent file tools to the workspace directory. Absolute paths outside workspace are denied.'
+ '</div>';
accHtml += '<div class="settings-row"><div class="settings-label">Script FS confinement</div>'
+ '<div class="settings-toggle-wrap"><label class="toggle">'
+ '<input type="checkbox" data-settings-path="security.filesystem.script_fs_confinement"' + (scriptConf ? ' checked' : '') + '>'
+ '<span class="toggle-track"></span></label>'
+ '<span class="settings-toggle-label">' + (scriptConf ? 'On' : 'Off') + '</span></div></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'OS-level sandbox for skill scripts (macOS sandbox-exec). Confines writes to workspace + /tmp.'
+ '</div>';
accHtml += '<div class="settings-row" style="align-items:start"><div class="settings-label" style="padding-top:0.3rem">Protected paths</div>'
+ '<textarea class="settings-input" data-settings-path="security.filesystem.protected_paths" data-settings-array="1" '
+ 'rows="6" style="font-family:var(--font-mono);font-size:0.7rem;resize:vertical"'
+ ' placeholder="One pattern per line">' + esc(protectedPaths) + '</textarea></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'Blacklisted path patterns (case-insensitive substring match). One per line.'
+ '</div>';
accHtml += '<div class="settings-row" style="align-items:start"><div class="settings-label" style="padding-top:0.3rem">Extra protected paths</div>'
+ '<textarea class="settings-input" data-settings-path="security.filesystem.extra_protected_paths" data-settings-array="1" '
+ 'rows="3" style="font-family:var(--font-mono);font-size:0.7rem;resize:vertical"'
+ ' placeholder="Your custom patterns (merged with defaults)">' + esc(extraProtected) + '</textarea></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'Additional patterns merged with the default list above.'
+ '</div>';
accHtml += '<div class="settings-row" style="align-items:start"><div class="settings-label" style="padding-top:0.3rem">Script allowed paths</div>'
+ '<textarea class="settings-input" data-settings-path="security.filesystem.script_allowed_paths" data-settings-array="1" '
+ 'rows="2" style="font-family:var(--font-mono);font-size:0.7rem;resize:vertical"'
+ ' placeholder="Absolute paths scripts may write to (one per line)">' + esc(scriptAllowed) + '</textarea></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'Additional absolute paths that sandboxed scripts may access for writing. One per line.'
+ '</div>';
accHtml += '</div>';
accHtml += '<div class="settings-section"><div class="settings-section-title">How Claims Are Resolved</div>'
+ '<div style="font-size:0.72rem;line-height:1.6;color:var(--text)">'
+ '<strong>Positive grants</strong> OR across layers \u2014 any layer can grant authority (best grant wins):<br>'
+ '<span style="color:var(--muted)">\u2022 Channel allow-list \u2192 allow-list authority \u2022 trusted_sender_ids \u2192 trusted authority \u2022 API key \u2192 API authority</span><br><br>'
+ '<strong>Negative ceilings</strong> AND across layers \u2014 strictest restriction wins:<br>'
+ '<span style="color:var(--muted)">\u2022 Threat scanner (Caution) \u2192 threat ceiling</span><br><br>'
+ '<strong>Final authority</strong> = min(best grant, strictest ceiling)'
+ '</div></div>';
return tabs + '<div class="settings-form">' + accHtml + '</div>' + actions;
}
// Ensure all known config sections appear in the form even when not yet configured.
var CONFIG_SCHEMA = {
agent: { name: '', id: '', workspace: '', log_level: 'info' },
server: { bind: 'localhost', port: 18789 },
database: { path: '' },
models: { primary: '', fallbacks: [] },
providers: {},
channels: { telegram: null, discord: null, whatsapp: null, signal: null, email: null, web: null, voice: null },
personality: { tone: '', greeting: '' },
skills: { enabled: true },
wallet: {},
treasury: {},
context_budget: { l0: 4000, l1: 8000, l2: 16000, l3: 32000, channel_minimum: 'L1' }
};
Object.keys(CONFIG_SCHEMA).forEach(function(k) {
if (draft[k] === undefined || draft[k] === null) draft[k] = JSON.parse(JSON.stringify(CONFIG_SCHEMA[k]));
});
var fieldTooltips = {
'name': 'The agent\'s display name, shown in chat and dashboard headers',
'id': 'Unique identifier for this agent instance',
'workspace': 'Root directory for agent file operations',
'log_level': 'Logging verbosity: trace, debug, info, warn, error',
'port': 'HTTP server port. Default: 18789. Requires restart.',
'bind': 'Network interface to bind. 127.0.0.1 = local only, 0.0.0.0 = all interfaces.',
'primary': 'Primary LLM model in provider/model format',
'path': 'Database file path. Use :memory: for in-memory (dev only).',
'l0': 'Token budget for trivial queries. Min: 512.',
'l1': 'Token budget for low complexity queries.',
'l2': 'Token budget for moderate complexity queries.',
'l3': 'Token budget for high complexity queries.',
'soul_max_context_pct': 'Max context budget percentage (0.0-1.0) for personality. Default: 0.4.',
'channel_minimum': 'Minimum budget tier for channel messages: L0, L1, L2, L3.',
'tone': 'Personality tone directive for the agent',
'greeting': 'Initial greeting message for new conversations'
};
var html = tabs + '<div class="settings-form">';
var specialSections = ['models', 'providers', 'wallet'];
var sections = Object.keys(draft);
sections.forEach(function(section) {
var sectionData = draft[section];
if (typeof sectionData !== 'object' || sectionData === null) return;
if (section === 'security') return; // rendered in Access Control tab
var isImmutableSection = !!immutableSet[section];
if (isImmutableSection) {
html += '<div class="settings-section"><div class="settings-section-title">' + esc(section) + ' <span class="badge muted" style="font-size:0.6rem">restart required</span></div>'
+ '<div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.5rem">This section is immutable at runtime. Edit `roboticus.toml` and restart.</div>';
// Render as disabled form fields instead of raw JSON
Object.keys(sectionData).forEach(function(key) {
var val = sectionData[key];
var displayVal = typeof val === 'object' && val !== null ? JSON.stringify(val) : String(val != null ? val : '');
if (typeof val === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div>'
+ '<div style="display:flex;align-items:center;gap:0.5rem"><div class="toggle ' + (val ? 'on' : '') + '" style="opacity:0.5;pointer-events:none"></div><span style="font-size:0.8125rem;color:var(--muted)">' + (val ? 'On' : 'Off') + '</span></div></div>';
} else {
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div>'
+ '<input type="text" class="settings-input" value="' + esc(displayVal) + '" disabled style="opacity:0.6;cursor:not-allowed"></div>';
}
});
html += '</div>';
return;
}
if (section === 'models') {
html += '<div class="settings-section"><div class="settings-section-title">Models</div>';
html += self._renderProviderDiscoveryAlerts();
var routingMode = (sectionData.routing && sectionData.routing.mode) || 'auto';
html += '<div class="settings-row"><div class="settings-label">Routing</div><select class="settings-input" data-settings-path="models.routing.mode" style="max-width:200px">'
+ '<option value="primary"' + (routingMode === 'primary' ? ' selected' : '') + '>Primary Only</option>'
+ '<option value="fallback"' + (routingMode === 'fallback' ? ' selected' : '') + '>Fallback</option>'
+ '<option value="auto"' + (routingMode === 'auto' || routingMode === 'metascore' || routingMode === 'routed' ? ' selected' : '') + '>Auto / Routed</option>'
+ '</select></div>';
var primaryVal = sectionData.primary || '';
var modelOpts = self._renderModelOptionsHtml({
config: draft,
includeControlModes: false
});
html += '<div class="settings-row"><div class="settings-label">Primary</div><div style="position:relative;width:100%"><input class="settings-input" list="model-list" type="text" data-settings-path="models.primary" value="' + esc(primaryVal) + '" placeholder="Select or type a model"><datalist id="model-list">' + modelOpts + '</datalist></div></div>';
Object.keys(sectionData).forEach(function(key) {
if (key === 'mode' || key === 'routing' || key === 'primary') return;
var val = sectionData[key]; var path = 'models.' + key;
if (typeof val === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(path) + '"' + (val ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (val ? 'On' : 'Off') + '</span></div></div>';
} else if (typeof val !== 'object') {
var type = typeof val === 'number' ? 'number' : 'text';
var sv = (val == null || val === '') ? '' : esc(String(val));
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(path) + '" value="' + sv + '" placeholder="none"></div>';
}
});
html += '</div>';
return;
}
if (section === 'providers') {
var keystoreLocked = !!cfg._keystore_locked;
html += '<div class="settings-section"><div class="settings-section-title">Providers</div>';
if (keystoreLocked) {
html += '<div style="background:var(--surface-2);border:1px solid var(--warning, #f59e0b);border-radius:0.5rem;padding:0.75rem 1rem;margin-bottom:1rem;display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap">'
+ '<span style="font-size:0.8rem;color:var(--warning, #f59e0b)">\u26a0 Keystore is locked \u2014 key status may be inaccurate and saves will fail.</span>'
+ '<button class="btn secondary" id="keystore-unlock-btn" style="font-size:0.7rem;padding:0.25rem 0.75rem">Unlock keystore</button>'
+ '</div>';
}
var providerKeys = Object.keys(sectionData);
var knownProviders = ['anthropic', 'openai', 'google', 'ollama', 'mistral', 'cohere', 'deepseek', 'groq', 'xai', 'together', 'openrouter'];
providerKeys.forEach(function(pName) {
var pData = sectionData[pName];
if (typeof pData !== 'object' || pData === null) return;
var keyStatus = pData._key_status || 'missing';
var keySource = pData._key_source || 'unknown';
var keyBadge = '';
if (keystoreLocked && keyStatus === 'missing') {
keyBadge = '<span class="badge" style="font-size:0.6rem;padding:0.1rem 0.4rem;background:var(--warning, #f59e0b);color:#000">Locked</span>';
} else if (keyStatus === 'configured') {
keyBadge = '<span class="badge success" style="font-size:0.6rem;padding:0.1rem 0.4rem">Key: ' + esc(keySource) + '</span>';
} else if (keyStatus === 'not_required') {
keyBadge = '<span class="badge muted" style="font-size:0.6rem;padding:0.1rem 0.4rem">Local</span>';
} else {
keyBadge = '<span class="badge error" style="font-size:0.6rem;padding:0.1rem 0.4rem">Key missing</span>';
}
html += '<div class="settings-provider-card"><div class="settings-provider-header"><span class="settings-provider-name">' + esc(pName) + '</span>' + keyBadge
+ '<button class="btn secondary remove-provider-btn" data-provider="' + esc(pName) + '" style="margin-left:auto;font-size:0.65rem;padding:0.15rem 0.5rem;color:var(--error, #ef4444)" title="Remove provider">\u2715 Remove</button>'
+ '</div>';
Object.keys(pData).forEach(function(pKey) {
if (pKey.charAt(0) === '_') return;
var pVal = pData[pKey]; var pPath = 'providers.' + pName + '.' + pKey;
if (typeof pVal === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(pKey) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(pPath) + '"' + (pVal ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (pVal ? 'On' : 'Off') + '</span></div></div>';
} else if (typeof pVal === 'object' && pVal !== null) {
html += '<div class="settings-row"><div class="settings-label">' + esc(pKey) + '</div><span class="badge muted" style="font-size:0.6rem">' + Object.keys(pVal).length + ' entries</span></div>';
} else {
var type = typeof pVal === 'number' ? 'number' : 'text';
var sv = (pVal == null || pVal === '') ? '' : esc(String(pVal));
html += '<div class="settings-row"><div class="settings-label">' + esc(pKey) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(pPath) + '" value="' + sv + '" placeholder="none"></div>';
}
});
if (keyStatus === 'missing' && !keystoreLocked) {
html += '<div class="key-manage-row" data-provider="' + esc(pName) + '">'
+ '<input type="password" placeholder="Paste API key\u2026" class="key-input" autocomplete="off">'
+ '<button class="key-manage-btn save" data-action="save-key">Save to keystore</button>'
+ '<span class="key-manage-msg"></span></div>';
} else if (keyStatus === 'configured' && keySource === 'keystore') {
html += '<div class="key-manage-row" data-provider="' + esc(pName) + '">'
+ '<span style="font-size:0.7rem;color:var(--muted)">Key stored in encrypted keystore</span>'
+ '<button class="key-manage-btn remove" data-action="remove-key">Remove</button>'
+ '<span class="key-manage-msg"></span></div>';
}
html += '</div>';
});
var providerSuggestions = {};
knownProviders.forEach(function(name) { providerSuggestions[name] = true; });
providerKeys.forEach(function(name) { providerSuggestions[name] = true; });
var providerOptions = Object.keys(providerSuggestions).sort().map(function(name) {
return '<option value="' + esc(name) + '"></option>';
}).join('');
html += '<div class="model-order-add" style="margin-top:0.5rem">'
+ '<input id="add-provider-input" class="settings-input" list="provider-known-list" type="text" placeholder="Add provider (e.g. mistral, cohere, deepseek)">'
+ '<datalist id="provider-known-list">' + providerOptions + '</datalist>'
+ '<button class="btn secondary" id="add-provider">Add Provider</button>'
+ '</div></div>';
return;
}
if (section === 'channels') {
html += '<div class="settings-section"><div class="settings-section-title">channels</div>';
// ── Channel metadata: the ONLY place channel-specific formatting lives ──
var CHANNEL_META = {
telegram: { listField: 'allowed_chat_ids', listLabel: 'Allowed Chat IDs', placeholder: 'Add chat id (e.g. 123456789)', validate: function(v) { return /^-?[0-9]+$/.test(v) ? null : 'Chat id must be an integer'; }, coerce: Number },
whatsapp: { listField: 'allowed_numbers', listLabel: 'Allowed Numbers', placeholder: 'Add number (e.g. +15551234567)', validate: null, coerce: String },
signal: { listField: 'allowed_numbers', listLabel: 'Allowed Numbers', placeholder: 'Add number (e.g. +15551234567)', validate: null, coerce: String },
discord: { listField: 'allowed_guild_ids', listLabel: 'Allowed Guild IDs', placeholder: 'Add guild id', validate: null, coerce: String },
email: { listField: 'allowed_senders', listLabel: 'Allowed Senders', placeholder: 'Add sender email', validate: null, coerce: String },
matrix: { listField: 'allowed_rooms', listLabel: 'Allowed Rooms', placeholder: 'Add room ID (e.g. !abc123:matrix.org)', validate: null, coerce: String }
};
// ── Shared list-editor renderer (unchanged logic, channel-agnostic) ──
var renderListEditor = function(channel, listField, values, suggestions, label, placeholder) {
var uniqMap = {};
(Array.isArray(values) ? values : []).forEach(function(v) {
var asStr = String(v == null ? '' : v).trim();
if (asStr) uniqMap[asStr] = true;
});
var vals = Object.keys(uniqMap);
var suggMap = {};
(Array.isArray(suggestions) ? suggestions : []).forEach(function(v) {
var asStr = String(v == null ? '' : v).trim();
if (asStr) suggMap[asStr] = true;
});
vals.forEach(function(v) { suggMap[v] = true; });
var optionHtml = Object.keys(suggMap).sort().map(function(v) { return '<option value="' + esc(v) + '"></option>'; }).join('');
var chipsHtml = vals.map(function(v) {
return '<span class="badge muted" style="display:inline-flex;align-items:center;gap:0.3rem;margin:0.15rem 0.25rem 0.15rem 0">'
+ '<span>' + esc(v) + '</span>'
+ '<button class="btn secondary ch-list-remove" data-ch-name="' + esc(channel) + '" data-ch-field="' + esc(listField) + '" data-ch-value="' + esc(v) + '" style="font-size:0.6rem;padding:0.05rem 0.25rem;line-height:1">x</button>'
+ '</span>';
}).join('');
return '<div class="settings-row"><div class="settings-label">' + esc(label) + '</div><div>'
+ '<div class="model-order-add" style="margin-bottom:0.35rem">'
+ '<input class="settings-input ch-list-input" data-ch-name="' + esc(channel) + '" data-ch-field="' + esc(listField) + '" type="text" placeholder="' + esc(placeholder || 'Add item') + '">'
+ '<button class="btn secondary ch-list-add" data-ch-name="' + esc(channel) + '" data-ch-field="' + esc(listField) + '">Add</button>'
+ '</div>'
+ '<div>' + (chipsHtml || '<span style="font-size:0.72rem;color:var(--muted)">No values configured.</span>') + '</div>'
+ '</div></div>';
};
// ── Global channel settings: startup announcements ──
var startupRaw = sectionData.startup_announcements;
var startupSelected = [];
if (Array.isArray(startupRaw)) startupSelected = startupRaw.map(function(v) { return String(v || '').trim().toLowerCase(); }).filter(Boolean);
else if (typeof startupRaw === 'string') startupSelected = startupRaw.split(',').map(function(v) { return String(v || '').trim().toLowerCase(); }).filter(Boolean);
var startupSet = {};
startupSelected.forEach(function(v) { startupSet[v] = true; });
var startupKnown = Object.keys(CHANNEL_META);
var startupChecks = startupKnown.map(function(ch) {
return '<label style="display:inline-flex;align-items:center;gap:0.35rem;margin:0.2rem 0.5rem 0.2rem 0"><input type="checkbox" class="startup-ann-check" data-startup-channel="' + esc(ch) + '"' + (startupSet[ch] ? ' checked' : '') + '> <span style="font-size:0.75rem">' + esc(ch) + '</span></label>';
}).join('');
html += '<div class="settings-row"><div class="settings-label">startup_announcements</div><div>'
+ '<div style="display:flex;flex-wrap:wrap">' + startupChecks + '</div>'
+ '<div style="font-size:0.7rem;color:var(--muted);margin-top:0.35rem">Select channels to announce on startup. Leave all unchecked to disable announcements.</div>'
+ '</div></div>';
// ── Global channel settings: trusted sender IDs ──
var trustedList = Array.isArray(sectionData.trusted_sender_ids) ? sectionData.trusted_sender_ids.map(function(v) { return String(v || '').trim(); }).filter(Boolean) : [];
var suggestionSet = {};
Object.keys(CHANNEL_META).forEach(function(ch) {
var chData = sectionData[ch] || {};
var meta = CHANNEL_META[ch];
if (meta && meta.listField && Array.isArray(chData[meta.listField])) {
chData[meta.listField].forEach(function(v) { suggestionSet[String(v)] = true; });
}
});
var suggestionOptions = Object.keys(suggestionSet).sort().map(function(v) { return '<option value="' + esc(v) + '"></option>'; }).join('');
var trustedBadges = trustedList.map(function(v) {
return '<span class="badge muted" style="display:inline-flex;align-items:center;gap:0.3rem;margin:0.15rem 0.25rem 0.15rem 0">'
+ '<span>' + esc(v) + '</span>'
+ '<button class="btn secondary trusted-sender-remove" data-trusted-sender="' + esc(v) + '" style="font-size:0.6rem;padding:0.05rem 0.25rem;line-height:1">x</button>'
+ '</span>';
}).join('');
html += '<div class="settings-row"><div class="settings-label">trusted_sender_ids</div><div>'
+ '<div class="model-order-add" style="margin-bottom:0.35rem">'
+ '<input id="trusted-sender-input" class="settings-input" list="trusted-sender-known-list" type="text" placeholder="Add sender id and click Add">'
+ '<datalist id="trusted-sender-known-list">' + suggestionOptions + '</datalist>'
+ '<button class="btn secondary" id="trusted-sender-add">Add</button>'
+ '</div>'
+ '<div id="trusted-sender-chiplist">' + (trustedBadges || '<span style="font-size:0.72rem;color:var(--muted)">No trusted senders configured.</span>') + '</div>'
+ '</div></div>';
html += '<hr class="channels-divider">';
// ── Per-channel subsections: one unified loop ──
var handledChannelKeys = {};
var knownChannels = Object.keys(CHANNEL_META);
knownChannels.forEach(function(ch) {
handledChannelKeys[ch] = true;
var meta = CHANNEL_META[ch];
var chData = sectionData[ch];
var configured = chData && typeof chData === 'object';
if (!configured) {
html += '<div class="channel-subsection disabled">'
+ '<div class="channel-subsection-title">' + esc(ch) + ' <span class="badge muted">not configured</span></div>'
+ '<button class="btn secondary channel-enable-btn" data-enable-channel="' + esc(ch) + '" style="font-size:0.72rem">Enable ' + esc(ch.charAt(0).toUpperCase() + ch.slice(1)) + '</button>'
+ '</div>';
return;
}
html += '<div class="channel-subsection">'
+ '<div class="channel-subsection-title">' + esc(ch) + '</div>';
// Render the allow-list editor if this channel type has one
if (meta.listField && chData) {
var listVals = Array.isArray(chData[meta.listField]) ? chData[meta.listField] : [];
var listSuggestions = trustedList.concat(listVals.map(function(v) { return String(v); }));
html += renderListEditor(ch, meta.listField, listVals, listSuggestions, meta.listLabel, meta.placeholder);
}
// Render all other properties generically
if (chData) {
Object.keys(chData).forEach(function(subKey) {
if (meta.listField && subKey === meta.listField) return;
var subVal = chData[subKey]; var subPath = 'channels.' + ch + '.' + subKey;
var type = typeof subVal === 'number' ? 'number' : 'text';
if (typeof subVal === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(subPath) + '"' + (subVal ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (subVal ? 'On' : 'Off') + '</span></div></div>';
} else if (subVal !== null && typeof subVal !== 'object') {
var ssv = (subVal == null || subVal === '') ? '' : esc(String(subVal));
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(subPath) + '" value="' + ssv + '" placeholder="none"></div>';
}
});
}
html += '</div>';
});
// Render any remaining non-channel keys in the channels section
Object.keys(sectionData).forEach(function(key) {
if (key === 'startup_announcements' || key === 'trusted_sender_ids' || handledChannelKeys[key]) return;
var val = sectionData[key];
var path = section + '.' + key;
if (typeof val === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(path) + '"' + (val ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (val ? 'On' : 'Off') + '</span></div></div>';
} else if (typeof val === 'object' && val !== null) {
html += '<div class="channel-subsection">';
html += '<div class="channel-subsection-title">' + esc(key) + '</div>';
Object.keys(val).forEach(function(subKey) {
var subVal = val[subKey]; var subPath = path + '.' + subKey;
var type = typeof subVal === 'number' ? 'number' : 'text';
if (typeof subVal === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(subPath) + '"' + (subVal ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (subVal ? 'On' : 'Off') + '</span></div></div>';
} else {
var ssv = (subVal == null || subVal === '') ? '' : esc(String(subVal));
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(subPath) + '" value="' + ssv + '" placeholder="none"></div>';
}
});
html += '</div>';
} else {
var type = typeof val === 'number' ? 'number' : 'text';
var sv = (val == null || val === '') ? '' : esc(String(val));
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(path) + '" value="' + sv + '" placeholder="none"></div>';
}
});
html += '</div>';
return;
}
if (section === 'wallet') {
html += '<div class="settings-section"><div class="settings-section-title">Wallet</div>';
var chainPresetBtns = '<div style="display:flex;gap:0.375rem;flex-wrap:wrap;margin-bottom:0.75rem">';
Object.keys(self._CHAIN_PRESETS).forEach(function(name) {
var preset = self._CHAIN_PRESETS[name];
var isActive = String(sectionData.chain_id) === String(preset.chain_id);
chainPresetBtns += '<button class="chain-preset-btn' + (isActive ? ' active' : '') + '" data-chain-preset="' + esc(name) + '">' + esc(name) + '</button>';
});
chainPresetBtns += '</div>';
html += '<div class="settings-row"><div class="settings-label">Chain</div><div style="width:100%">' + chainPresetBtns + '</div></div>';
Object.keys(sectionData).forEach(function(key) {
var val = sectionData[key]; var path = 'wallet.' + key;
if (key === 'private_key' || key === 'mnemonic') return;
var isRpc = key.toLowerCase().indexOf('rpc') !== -1;
if (typeof val === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(path) + '"' + (val ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (val ? 'On' : 'Off') + '</span></div></div>';
} else {
var type = typeof val === 'number' ? 'number' : 'text';
var sv = (val == null || val === '') ? '' : esc(String(val));
var ph = isRpc ? 'Auto-populated from chain preset' : 'none';
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(path) + '" value="' + sv + '" placeholder="' + ph + '"></div>';
}
});
html += '</div>';
return;
}
if (section === 'treasury') {
html += '<div class="settings-section"><div class="settings-section-title">Treasury</div>';
Object.keys(sectionData).forEach(function(key) {
if (key === 'revenue_swap') return;
var val = sectionData[key];
var path = section + '.' + key;
if (typeof val === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(path) + '"' + (val ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (val ? 'On' : 'Off') + '</span></div></div>';
} else if (typeof val !== 'object') {
var type = typeof val === 'number' ? 'number' : 'text';
var sv = (val == null || val === '') ? '' : esc(String(val));
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(path) + '" value="' + sv + '" placeholder="none"></div>';
}
});
html += '</div>';
html += self._renderRevenueSwapSettings(sectionData.revenue_swap || {});
return;
}
var ENUM_FIELDS = {
'agent.log_level': ['trace', 'debug', 'info', 'warn', 'error'],
'agent.composition_policy': ['autonomous', 'propose', 'manual'],
'agent.skill_creation_rigor': ['generate', 'validate', 'full'],
'agent.output_validation_policy': ['strict', 'sample', 'off'],
'session.scope_mode': ['agent', 'peer', 'group']
};
html += '<div class="settings-section"><div class="settings-section-title">' + esc(section) + '</div>';
Object.keys(sectionData).forEach(function(key) {
var val = sectionData[key];
var path = section + '.' + key;
var tooltipHtml = fieldTooltips[key] ? ' <span title="' + esc(fieldTooltips[key]) + '" style="cursor:help;color:var(--muted);font-size:0.65rem">(?)</span>' : '';
if (typeof val === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + tooltipHtml + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(path) + '"' + (val ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (val ? 'On' : 'Off') + '</span></div></div>';
} else if (typeof val === 'object' && val !== null) {
html += '<div class="settings-nested"><div style="font-size:0.625rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);margin-bottom:0.5rem">' + esc(key) + '</div>';
Object.keys(val).forEach(function(subKey) {
var subVal = val[subKey]; var subPath = path + '.' + subKey;
var type = typeof subVal === 'number' ? 'number' : 'text';
var subTooltipHtml = fieldTooltips[subKey] ? ' <span title="' + esc(fieldTooltips[subKey]) + '" style="cursor:help;color:var(--muted);font-size:0.65rem">(?)</span>' : '';
if (typeof subVal === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + subTooltipHtml + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(subPath) + '"' + (subVal ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (subVal ? 'On' : 'Off') + '</span></div></div>';
} else {
var ssv = (subVal == null || subVal === '') ? '' : esc(String(subVal));
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + subTooltipHtml + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(subPath) + '" value="' + ssv + '" placeholder="none"></div>';
}
});
html += '</div>';
} else if (ENUM_FIELDS[path]) {
var opts = ENUM_FIELDS[path];
var sv = (val == null || val === '') ? '' : String(val);
var selectHtml = '<select class="settings-input" data-settings-path="' + esc(path) + '">';
opts.forEach(function(opt) {
selectHtml += '<option value="' + esc(opt) + '"' + (opt === sv ? ' selected' : '') + '>' + esc(opt) + '</option>';
});
selectHtml += '</select>';
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + tooltipHtml + '</div>' + selectHtml + '</div>';
} else {
var type = typeof val === 'number' ? 'number' : 'text';
var sv = (val == null || val === '') ? '' : esc(String(val));
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + tooltipHtml + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(path) + '" value="' + sv + '" placeholder="none"></div>';
}
});
html += '</div>';
});
html += '</div>' + actions;
return html;
});
};
App._setNestedValue = function(obj, path, value) { var parts = path.split('.'); var cur = obj; for (var i = 0; i < parts.length - 1; i++) { if (!cur[parts[i]]) cur[parts[i]] = {}; cur = cur[parts[i]]; } cur[parts[parts.length - 1]] = value; };
App._getNestedValue = function(obj, path) { var parts = path.split('.'); var cur = obj; for (var i = 0; i < parts.length; i++) { if (cur == null) return undefined; cur = cur[parts[i]]; } return cur; };