// Tools tab on the app detail page. // // Each app may declare per-app actions ("tools") in // containers//.tools.json // served by GET /api/apps//tools. Each tool is rendered as a card // with a button. Clicking the button either runs the tool directly (no // fields, no confirm) or opens a generic modal that collects the tool's // inputs. // // On submit we dispatch through the existing TaskRouter: // TaskRouter.routeAction('tool', { appName, toolName, toolArgs }) // which builds: libreportal app tool '' // β€” same task pipeline as install/restart/etc., so the Tasks tab and // log streaming come for free. // // Manifest schema (containers//.tools.json): // { // "tools": [ // { // "id": "", // matches the case in Tool() // "label": "Refresh providers", // "description": "...", // optional, shown on card + modal // "icon": "πŸ”„", // optional emoji // "destructive": false, // optional, renders red button // "confirm": "Are you sure?", // optional, forces a modal // "fields": [ // { "name": "duration", // "label": "Duration (s)", // "type": "number", // text|password|number|select|checkbox|textarea // "default": 5, // "placeholder": "5", // "required": true, // "min": 1, "max": 60 }, // { "name": "mode", // "label": "Mode", // "type": "select", // "options": [ // { "value": "fast", "label": "Fast" }, // { "value": "slow", "label": "Slow" } // ] } // ] // } // ] // } class ToolsManager { constructor() { this.currentApp = null; this.tools = []; // Cache of manifest results per app so prepare() doesn't re-fetch on // every tab switch. Cleared by reset(). this._manifestCache = new Map(); // Re-check Tools tab visibility on focus + task completion. if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { if (!document.hidden && this.currentApp) this._revalidateCurrent(); }); } if (typeof window !== 'undefined') { window.addEventListener('taskCompleted', (ev) => { if (this.currentApp) this._revalidateCurrent(); this._maybeOpenUserListModal(ev?.detail); }); } } // When a list_users task completes, fetch its log, parse EZ_USER\t lines, // and open an interactive modal where each row has action buttons. async _maybeOpenUserListModal(detail) { if (!detail || detail.status !== 'completed') return; const cmd = String(detail.command || detail.task?.command || ''); const m = cmd.match(/libreportal app tool (\S+) list_users\b/); if (!m) return; const appName = m[1]; const taskId = detail.taskId || detail.id || detail.task?.id; if (!taskId) return; let logText = ''; try { const r = await fetch(`/read-file?path=tasks/${encodeURIComponent(taskId)}.log`, { cache: 'no-store' }); if (r.ok) logText = await r.text(); } catch (_) {} const users = []; logText.split(/\r?\n/).forEach(line => { const i = line.indexOf('EZ_USER\t'); if (i < 0) return; const parts = line.slice(i + 8).split('\t'); const [email = '', username = '', roles = ''] = parts; if (!email && !username) return; users.push({ email: email.trim(), username: username.trim(), roles: roles.trim() }); }); this._openUserListModal(appName, users); } _openUserListModal(appName, users) { const tools = (window.toolsCatalog?.apps?.[appName]?.tools) || []; const resetTool = tools.find(t => t.id === 'reset_password'); const deleteTool = tools.find(t => t.id === 'delete_user'); const adminTool = tools.find(t => t.id === 'set_admin'); const appLabel = (window.getAppDisplayName ? window.getAppDisplayName(appName) : appName); const iconUrl = `/core/icons/apps/${encodeURIComponent(appName)}.svg`; // Reopen this exact modal (used as returnTo when a row action's // sub-modal is cancelled β€” keeps the user in flow instead of // dumping them out to the Tools tab). const reopen = () => this._openUserListModal(appName, users); const rowsHtml = users.length ? users.map((u, idx) => { const isAdmin = /admin/i.test(u.roles || ''); return `
${escapeHtml(u.email || u.username || 'β€”')}
${u.username && u.username !== u.email ? `
${escapeHtml(u.username)}
` : ''} ${u.roles && u.roles !== 'β€”' ? `
${escapeHtml(u.roles)}
` : ''}
${resetTool ? `` : ''} ${adminTool ? `` : ''} ${deleteTool ? `` : ''}
`; }).join('') : window.eoEmpty('No users found.'); const mod = window.openEoModal({ id: 'user-list-modal', size: 'sm', icon: iconUrl, iconAlt: appLabel, eyebrow: 'Users', title: `${appLabel} Accounts`, desc: users.length ? `${users.length} account${users.length === 1 ? '' : 's'}` : '', body: `
${rowsHtml}
`, actions: [{ label: 'Close', variant: 'secondary' }] }); const fillIdentifier = (tool, idValue) => { const prefill = {}; if (tool.fields?.some(f => f.name === 'email')) prefill.email = idValue; if (tool.fields?.some(f => f.name === 'username')) prefill.username = idValue; return prefill; }; mod.contentEl.querySelectorAll('.user-row-btn').forEach(btn => { btn.addEventListener('click', () => { const u = users[parseInt(btn.dataset.idx, 10)]; const idValue = u.email || u.username; const act = btn.dataset.act; const tool = act === 'reset' ? resetTool : act === 'delete' ? deleteTool : adminTool; if (!tool) return; const prefill = fillIdentifier(tool, idValue); // For toggle-admin we also pre-set the boolean. if (act === 'admin' && tool.fields?.some(f => f.name === 'admin')) { prefill.admin = !(/admin/i.test(u.roles || '')); } mod.close(); this._activate(tool, { prefill, returnTo: reopen }); }); }); } async _revalidateCurrent() { const app = this.currentApp; if (!app) return; this._manifestCache.delete(app); this._aggregatePromise = null; try { await this.prepare(app); } catch (_) { /* swallow */ } } // Called when the app changes. Fetches the manifest once and toggles the // Tools tab buttons' visibility. Apps with no tools never expose the tab. async prepare(appName) { if (!appName) { this._toggleTabVisibility(false); return { tools: [] }; } let result = this._manifestCache.get(appName); if (!result) { result = await this._fetchManifest(appName); // Only cache non-empty success results. An empty list usually means // apps-tools.json was briefly stale (mid-regen during install) β€” if // we cache that, the tab stays hidden until full page reload. By // also dropping the aggregate promise we force the next prepare() // call to refetch the JSON fresh. if (!result.error && result.tools.length > 0) { this._manifestCache.set(appName, result); } else { this._aggregatePromise = null; } } // Tools act on a live container β€” only expose the tab when the // app is installed. apps-manager.js already does this on render // for backup/services/tools, but prepare() can re-fire after that // (visibilitychange, taskCompleted, app switches), so re-check // here too β€” otherwise we'd silently re-show the tab for a // not-installed app whenever we revalidate. const installed = this._isAppInstalled(appName); this._toggleTabVisibility(installed && result.tools.length > 0); return result; } _isAppInstalled(appName) { if (!appName) return false; const target = String(appName).toLowerCase(); const entry = (window.apps || []).find(a => ((a.command || '').split(' ').pop() || '').toLowerCase() === target ); return !!entry?.installed; } // Drop cached manifests so the next prepare() re-fetches. Called when // an app's compose/install changes β€” currently a no-op caller side, but // keeps the door open for cache invalidation. reset() { this._manifestCache.clear(); this._aggregatePromise = null; } // The whole apps-tools.json aggregate, fetched once per page load. // Generated by scripts/webui/data/generators/apps/webui_tools.sh. // Also populates window.toolsCatalog so other components (e.g. // tasks-manager.js's formatCommandForUser) can look up tool labels // without re-fetching. async _loadAggregate() { if (this._aggregatePromise) return this._aggregatePromise; this._aggregatePromise = (async () => { try { const resp = await fetch('/data/apps/generated/apps-tools.json', { cache: 'no-store' }); if (!resp.ok) return { apps: {}, error: `HTTP ${resp.status}` }; const data = await resp.json(); const apps = data && typeof data.apps === 'object' ? data.apps : {}; window.toolsCatalog = { apps }; return { apps }; } catch (err) { return { apps: {}, error: err.message || String(err) }; } })(); return this._aggregatePromise; } async _fetchManifest(appName) { const agg = await this._loadAggregate(); if (agg.error) return { tools: [], error: agg.error }; const entry = agg.apps[appName]; return { tools: Array.isArray(entry?.tools) ? entry.tools : [] }; } _toggleTabVisibility(show) { const buttons = document.querySelectorAll('[data-tab="tools"]'); buttons.forEach(btn => { btn.style.display = show ? '' : 'none'; }); } async load(appName) { this.currentApp = appName; const list = document.getElementById('tools-list'); if (!list) return; list.innerHTML = ` ${this._titleBlock(appName)}
Loading tools…
`; const result = await this.prepare(appName); this.tools = this._sortTools(result.tools); if (result.error) { list.innerHTML = ` ${this._titleBlock(appName)}
⚠️

Couldn't load tools right now.

`; return; } if (this.tools.length === 0) { // Tab should already be hidden by prepare(); render a soft message // anyway in case the user got here via a deep link. list.innerHTML = ` ${this._titleBlock(appName)}
🧰

This app has no tools.

`; return; } const grouped = this._groupByCategory(this.tools); if (grouped.size > 1) { const cats = [...grouped.keys()]; const tabsHtml = cats.map((cat, i) => ` `).join(''); const panesHtml = cats.map((cat, i) => `
${grouped.get(cat).map(t => this._renderRow(t)).join('')}
`).join(''); list.innerHTML = ` ${this._titleBlock(appName)}
${tabsHtml}
${panesHtml}`; this._wireTabs(list); } else { list.innerHTML = ` ${this._titleBlock(appName)}
${this.tools.map(t => this._renderRow(t)).join('')}
`; } this._wireActions(list); } // Bucket tools by their `category` field into an insertion-ordered map. // Tools without a category land in "general". Categories appear in the // order they're first seen (matches authoring order in webui_tools.sh). _groupByCategory(tools) { const out = new Map(); for (const t of tools) { const cat = (typeof t.category === 'string' && t.category.trim()) ? t.category.trim() : 'general'; if (!out.has(cat)) out.set(cat, []); out.get(cat).push(t); } return out; } _categoryLabel(cat) { return String(cat).replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); } _wireTabs(root) { const bar = root.querySelector('.tools-tab-bar'); if (!bar) return; bar.addEventListener('click', (ev) => { const btn = ev.target.closest('.tools-tab'); if (!btn) return; const cat = btn.dataset.cat; bar.querySelectorAll('.tools-tab').forEach(b => b.classList.toggle('active', b === btn)); root.querySelectorAll('.tools-cat-pane').forEach(p => p.classList.toggle('active', p.dataset.cat === cat)); }); } unload() { /* no timers/streams */ } _titleBlock(appName) { const display = (appName || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); return `

🧰 Tools

Run app-specific actions for ${escapeHtml(display)} β€” each tool creates a task.

`; } _renderRow(tool) { const icon = tool.icon || 'βš™οΈ'; const desc = tool.description ? `

${escapeHtml(tool.description)}

` : ''; const btnClass = tool.destructive ? 'tool-run-btn destructive' : 'tool-run-btn'; return `
${escapeHtml(icon)} ${escapeHtml(tool.label || tool.id)}
${desc}
`; } _wireActions(root) { if (root.dataset.wired === '1') return; root.dataset.wired = '1'; root.addEventListener('click', (ev) => { const btn = ev.target.closest('[data-action="run"]'); if (!btn) return; const item = btn.closest('.tool-item'); if (!item) return; const toolId = item.dataset.toolId; const tool = this.tools.find(t => t.id === toolId); if (!tool) return; this._activate(tool); }); } // Tools render in this order: explicit `order` field if present, // otherwise the default precedence map (least β†’ most destructive), // otherwise their authoring order from webui_tools.sh. _sortTools(tools) { if (!Array.isArray(tools) || tools.length < 2) return tools || []; const defaults = { reset_password: 10, apply_dns_updater: 15, manage_shortcuts: 20, refresh_providers: 20, create_account: 30, list_users: 40, set_admin: 50, delete_user: 90, }; const weight = (t, idx) => { if (typeof t.order === 'number') return t.order; if (defaults[t.id] !== undefined) return defaults[t.id]; return 1000 + idx; }; return tools.map((t, i) => [weight(t, i), i, t]) .sort((a, b) => a[0] - b[0] || a[1] - b[1]) .map(x => x[2]); } _activate(tool, opts) { const hasFields = Array.isArray(tool.fields) && tool.fields.length > 0; if (!hasFields && !tool.confirm) { this._dispatch(tool, ''); return; } this._openModal(tool, opts); } _openModal(tool, opts) { const fields = Array.isArray(tool.fields) ? tool.fields : []; const prefill = (opts && opts.prefill) || {}; const returnTo = opts && opts.returnTo; let submitted = false; const appIconUrl = this.currentApp ? `/core/icons/apps/${encodeURIComponent(this.currentApp)}.svg` : ''; const bodyHtml = ` ${tool.confirm ? `
${escapeHtml(tool.confirm)}
` : ''}
${fields.map(f => this._renderField(prefill[f.name] !== undefined ? { ...f, default: prefill[f.name] } : f)).join('')}
`; const widePicker = fields.some(f => f.type === 'app_urls_multi' || f.type === 'installed_apps_multi'); const m = window.openEoModal({ id: 'tool-run-modal', className: 'tool-modal', size: widePicker ? 'md' : 'sm', icon: appIconUrl || undefined, iconAlt: this.currentApp || '', title: tool.label || tool.id, endIcon: tool.icon || undefined, body: bodyHtml, actions: [ { label: 'Run', variant: 'primary', onClick: (modal) => { const args = this._collectFormArgs(modal.contentEl, fields); if (args === null) return; submitted = true; modal.close(); this._dispatch(tool, args); }}, { label: 'Cancel', variant: 'secondary', onClick: (modal) => modal.close() } ], onClose: () => { if (!submitted && typeof returnTo === 'function') { try { returnTo(); } catch (e) { console.error(e); } } } }); const modal = m.contentEl; // Wire the installed_apps_multi / app_urls_multi search + bulk // select buttons. For app_urls_multi we also hide whole group // headers when none of their rows match the search. modal.querySelectorAll('.installed-apps-multi').forEach((root) => { const isUrlMode = root.classList.contains('app-urls-multi'); const search = root.querySelector('.installed-apps-search'); const all = root.querySelector('.installed-apps-all'); const none = root.querySelector('.installed-apps-none'); const filterRows = (q) => { if (!isUrlMode) { root.querySelectorAll('.installed-apps-item').forEach((item) => { const text = item.querySelector('.installed-apps-name')?.textContent.toLowerCase() || ''; item.style.display = text.includes(q) ? '' : 'none'; }); return; } root.querySelectorAll('.app-url-row').forEach((item) => { const label = (item.querySelector('.app-url-label')?.textContent || '').toLowerCase(); item.style.display = (!q || label.includes(q)) ? '' : 'none'; }); }; if (search) search.addEventListener('input', (e) => filterRows(e.target.value.toLowerCase())); if (all) all.addEventListener('click', () => { root.querySelectorAll('.installed-apps-item').forEach((item) => { if (item.offsetParent !== null) item.querySelector('input').checked = true; }); }); if (none) none.addEventListener('click', () => { root.querySelectorAll('.installed-apps-item input').forEach((cb) => cb.checked = false); }); }); // Async-fill app_urls_multi rows from apps-services.json after // mount so the modal opens instantly and rows fade in. this._hydrateAppUrlsMulti(modal); return m; } _renderField(field) { const name = String(field.name || ''); const label = escapeHtml(field.label || name); const id = `tool-field-${name}`; const required = field.required ? 'required' : ''; const placeholder = field.placeholder ? `placeholder="${escapeHtml(field.placeholder)}"` : ''; const def = field.default !== undefined ? String(field.default) : ''; const wrap = (inner) => `
${inner}
`; switch (field.type) { case 'password': return wrap(``); case 'number': { const min = field.min !== undefined ? `min="${Number(field.min)}"` : ''; const max = field.max !== undefined ? `max="${Number(field.max)}"` : ''; return wrap(``); } case 'checkbox': { const checked = (def === 'true' || def === '1' || field.default === true) ? 'checked' : ''; return ` `; } case 'select': { const opts = Array.isArray(field.options) ? field.options : []; const optsHtml = opts.map(o => { const v = escapeHtml(String(o.value)); const l = escapeHtml(String(o.label || o.value)); const sel = String(o.value) === def ? 'selected' : ''; return ``; }).join(''); return wrap(``); } case 'textarea': return wrap(``); case 'installed_apps_multi': return wrap(this._renderInstalledAppsMulti(field, name)); case 'app_urls_multi': // Label rendered inside the picker's container as a title bar // so the field reads as a single visual unit (no floating // disconnected label above the box). Skip wrap(). return `
${this._renderAppUrlsMulti(field, name, label, !!field.required)}
`; case 'text': default: return wrap(``); } } // Multi-select list of installed apps, styled to mirror the gluetun // country picker: search box + select-all/clear + compact rows. // Collected as CSV by _collectFormArgs. _renderInstalledAppsMulti(field, name) { const apps = (window.apps || []).filter(a => a && a.installed); const currentAppEntry = (window.apps || []).find(a => ((a.command || '').split(' ').pop()) === this.currentApp ); const cfgKey = field.prefillFromCfgKey || `CFG_${(this.currentApp || '').toUpperCase()}_${(name || '').toUpperCase()}`; const currentCsv = (currentAppEntry && currentAppEntry.config && currentAppEntry.config[cfgKey]) || ''; const selected = new Set(currentCsv.split(',').map(s => s.trim()).filter(Boolean)); const exclude = new Set(Array.isArray(field.excludeApps) ? field.excludeApps : [this.currentApp]); const items = apps .map(a => ({ app: a, slug: (a.command || '').split(' ').pop() })) .filter(({ slug }) => slug && !exclude.has(slug)) .sort((x, y) => (x.app.name || x.slug).localeCompare(y.app.name || y.slug)) .map(({ app, slug }) => { const checked = selected.has(slug) ? 'checked' : ''; const iconUrl = `/core/icons/apps/${encodeURIComponent(slug)}.svg`; const displayName = escapeHtml(app.name || slug); return ` `; }).join(''); if (!items) { return `
No other installed apps to choose from.
`; } return `
${items}
`; } // URL-level multi-select. Same data source as the WebUI URL buttons // (apps-services.json + window.expandServiceLinks), so what the user // picks here matches what they'd see hovering an app on the // dashboard. Each row is one URL, grouped under its parent app. // Stable id per URL: ":". Pre-checked from // a CSV in CFG__ (override via prefillFromCfgKey). _renderAppUrlsMulti(field, name, labelHtml, required) { const cfgKey = field.prefillFromCfgKey || `CFG_${(this.currentApp || '').toUpperCase()}_${(name || '').toUpperCase()}`; const currentAppEntry = (window.apps || []).find(a => ((a.command || '').split(' ').pop()) === this.currentApp ); const currentCsv = (currentAppEntry && currentAppEntry.config && currentAppEntry.config[cfgKey]) || ''; const selected = new Set(currentCsv.split(',').map(s => s.trim()).filter(Boolean)); const exclude = new Set(Array.isArray(field.excludeApps) ? field.excludeApps : [this.currentApp]); const titleHtml = labelHtml ? `
${labelHtml}${required ? ' *' : ''}
` : ''; return `
${titleHtml}
Loading services…
`; } // Async hydration for the app_urls_multi field β€” fetch // apps-services.json and render rows. Called after the modal has // been mounted (see open() below). async _hydrateAppUrlsMulti(modal) { const roots = modal.querySelectorAll('.app-urls-multi'); if (roots.length === 0) return; // Re-fetch apps.json so the pre-checked state reflects the // latest CFG_* values (a previous tool save may have updated // them after window.apps was first loaded). Cheap and avoids // a "ticked nothing even though I just saved" UX bug. try { const appsResp = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' }); if (appsResp.ok) { const appsData = await appsResp.json(); if (Array.isArray(appsData?.apps)) window.apps = appsData.apps; } } catch (_) { /* keep stale window.apps if fetch fails */ } // Recompute data-prefill from the fresh window.apps. The render // pass encoded it from whatever data was current at modal-open // time; if the user saved shortcuts in a different tab/session // we'd otherwise show stale ticks here. roots.forEach((root) => { const cfgKey = root.dataset.cfgKey; const appSlug = root.dataset.appSlug; if (!cfgKey || !appSlug) return; const entry = (window.apps || []).find(a => ((a.command || '').split(' ').pop()) === appSlug ); const csv = (entry && entry.config && entry.config[cfgKey]) || ''; const fresh = csv.split(',').map(s => s.trim()).filter(Boolean); root.dataset.prefill = JSON.stringify(fresh); }); let services = []; try { const resp = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' }); if (resp.ok) { const data = await resp.json(); services = Array.isArray(data?.services) ? data.services : []; } } catch (e) { /* fall through, render empty state */ } const expand = typeof window.expandServiceLinks === 'function' ? window.expandServiceLinks : ((s) => [{ url: s.externalURL || `http://localhost:${s.externalPort}`, label: s.buttonText || s.name }]); const displayName = (slug) => (window.getAppDisplayName ? window.getAppDisplayName(slug) : slug); roots.forEach((root) => { const name = root.dataset.field; const selected = new Set(JSON.parse(root.dataset.prefill || '[]')); const exclude = new Set(JSON.parse(root.dataset.exclude || '[]')); const list = root.querySelector('.app-urls-list'); const byApp = new Map(); for (const svc of services) { if (!svc || !svc.app || exclude.has(svc.app)) continue; if (svc.buttonEnabled === false) continue; const links = expand(svc); if (!links || links.length === 0) continue; if (!byApp.has(svc.app)) byApp.set(svc.app, []); links.forEach((lnk, idx) => { if (!lnk?.url) return; byApp.get(svc.app).push({ id: `${svc.name}:${idx}`, label: lnk.label || svc.buttonText || svc.name, url: lnk.url, traefik: !!svc.traefikManaged, locked: !!svc.loginRequired }); }); } if (byApp.size === 0) { list.innerHTML = `
No service URLs available yet β€” install some apps first.
`; return; } // Flat task-style rows: one URL per row, app icon + name + URL // inline. Sorted by app name then URL label so related entries // sit together without needing per-app group cards. const flat = []; [...byApp.keys()].sort((a, b) => displayName(a).localeCompare(displayName(b))).forEach(slug => { byApp.get(slug).forEach(u => flat.push({ slug, ...u })); }); list.innerHTML = flat.map(u => { const checked = selected.has(u.id) ? 'checked' : ''; const iconUrl = `/core/icons/apps/${encodeURIComponent(u.slug)}.svg`; const appLabel = displayName(u.slug); const showButton = u.label && u.label !== appLabel; const fullLabel = showButton ? `${escapeHtml(appLabel)} β€” ${escapeHtml(u.label)}` : escapeHtml(appLabel); return ` `; }).join(''); }); } // Collect form values into a pipe-encoded args string for the bash side. // Returns null if a required field is missing. _collectFormArgs(modal, fields) { const pairs = []; for (const field of fields) { const name = String(field.name || ''); if (!name) continue; // installed_apps_multi has no [name=name] element directly; it // collects from [name=name+'__opt'] checkboxes below. Other field // types have a single matching element to validate against. const isMultiPickerType = field.type === 'installed_apps_multi' || field.type === 'app_urls_multi'; const el = modal.querySelector(`[name="${cssEscape(name)}"]`) || (isMultiPickerType ? modal.querySelector(`.installed-apps-multi[data-field="${cssEscape(name)}"]`) : null); if (!el) continue; let value; if (field.type === 'checkbox') { value = el.checked ? 'true' : 'false'; } else if (isMultiPickerType) { const checked = modal.querySelectorAll(`[name="${cssEscape(name + '__opt')}"]:checked`); value = Array.from(checked).map(c => c.value).join(','); } else { value = el.value; } if (field.required && (value === '' || value === undefined || value === null)) { if (window.notificationSystem) { window.notificationSystem.show(`Missing field
${escapeHtml(field.label || name)} is required.`, 'warning'); } else { alert(`${field.label || name} is required.`); } return null; } // Strip pipes/newlines from values β€” they're our delimiters. const safe = String(value).replace(/\|/g, '%7C').replace(/[\r\n]/g, ' '); pairs.push(`${name}=${safe}`); } return pairs.join('|'); } async _dispatch(tool, toolArgs) { if (!this.currentApp) return; if (!window.tasksManager || !window.tasksManager.router) { console.error('TasksManager router not available'); if (window.notificationSystem) { window.notificationSystem.error('Task system is not ready yet β€” try again in a moment.'); } return; } try { const task = await window.tasksManager.router.routeAction('tool', { appName: this.currentApp, toolName: tool.id, toolArgs, toolLabel: tool.label || tool.id }); // Mirror the install flow: jump to the Tasks tab and auto-expand // the new task so the user sees its log streaming in. setTimeout(() => { if (window.appTabbedManager) { window.appTabbedManager.switchTab('tasks'); setTimeout(() => { if (task && window.appTabbedManager.tasksManager) { window.appTabbedManager.tasksManager.highlightedTaskId = task.id; window.appTabbedManager.tasksManager.renderTasks(); } }, 300); } }, 200); } catch (err) { console.error('Tool dispatch failed', err); if (window.notificationSystem) { window.notificationSystem.error(`Tool failed: ${err.message}`); } } } } // Tiny helpers ---------------------------------------------------------- function escapeHtml(s) { return String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function cssEscape(s) { if (window.CSS && CSS.escape) return CSS.escape(s); return String(s).replace(/[^a-zA-Z0-9_-]/g, c => '\\' + c); } window.toolsManager = new ToolsManager();