// Plugins tab renderer: reads window.dsh.plugins.list() and paints the // effective plugin table; toggles/adds mutate the overlay via the preload // bridge, then a single "Apply + restart" click respawns the daemon so the // changes take effect. // // Design notes: // - The tab keeps a "dirty" flag; toggles do NOT auto-restart because the // daemon respawn cost is high enough that batching wins during setup. // - Vibe entry point is gated on plugins.list().vibeCapable so the mock // profile shows it visibly disabled with a tooltip explanation, rather // than hiding it entirely. // - Adding a plugin is a small prompt-flow: user types id + name; we do // the light validation and pass through to plugins.add(). The demo // doesn't have a package browser — that's a future enhancement. 'use strict' ;(function () { const state = { dirty: false, lastList: null, lastDiagnostics: [], lastRuntime: null, } // B-P0-1 fix (2026-07-16): the runtime column used to render the raw // internal state names (`active`, `absent`, `pending`, …), which read as // "nothing installed" for a first-time user. Translate each state to a // plain-language phrase with an optional hint clarifying the failure mode. // Raw states remain on the hover for operators. English kept to match the // demo-audience default (see C-P0-2 ruling 2026-07-16). const RUNTIME_LABEL = { active: { text: 'Running' }, loading: { text: 'Loading' }, pending: { text: 'Waiting on deps', hint: 'blocked until another plugin loads' }, failed: { text: 'Failed', hint: 'see the diagnostic row below' }, disposed: { text: 'Disposed' }, unloading: { text: 'Unloading' }, absent: { text: 'Not loaded', hint: 'Apply + restart runtime to activate' }, } const tbody = document.getElementById('plugins-tbody') const meta = document.getElementById('plugins-meta') const applyBtn = document.getElementById('plugins-apply') const refreshBtn = document.getElementById('plugins-refresh') const addBtn = document.getElementById('plugins-add') const vibeBtn = document.getElementById('plugins-vibe') const sidebarNote = document.getElementById('plugins-sidebar-note') // Diagnostics summary strip at the top of the plugin body. Created lazily // if the host document didn't declare it (keeps the index.html edit small). let diagStrip = document.getElementById('plugins-diagnostics') if (!diagStrip) { const body = document.querySelector('.plugins-body') if (body) { diagStrip = document.createElement('div') diagStrip.id = 'plugins-diagnostics' diagStrip.className = 'plugins-diagnostics' body.insertBefore(diagStrip, body.firstChild) } } // A3 effect-heuristics info bar. Sits above the diagnostics strip. Same // lazy-create pattern: declaring it in index.html isn't required, so this // file stays the only touchpoint when the summary shape evolves. let summaryBar = document.getElementById('plugins-summary') if (!summaryBar) { const body = document.querySelector('.plugins-body') if (body) { summaryBar = document.createElement('div') summaryBar.id = 'plugins-summary' summaryBar.className = 'plugins-summary' body.insertBefore(summaryBar, diagStrip || body.firstChild) } } function setDirty(dirty) { state.dirty = dirty applyBtn.disabled = !dirty sidebarNote.textContent = dirty ? 'Overlay edited. Apply to restart the runtime.' : 'No unsaved changes.' sidebarNote.classList.toggle('dirty', dirty) } function renderMeta(list) { const bits = [] bits.push(`profile: ${escapeHtml(list.profileName || '')}`) bits.push(`base leaf: ${escapeHtml(list.base)}`) bits.push(list.overlayExists ? `overlay: ${escapeHtml(list.overlayPath)}` : `overlay: (none — using base directly)`) bits.push(list.vibeCapable ? `vibe: available` : `vibe: disabled (switch to a vibe-capable profile)`) meta.innerHTML = bits.join('  ·  ') } // Group diagnostics by id (for row anchoring) + `overall` (for the strip). function bucketDiagnostics(diags) { const byId = new Map() const overall = [] for (const d of diags) { if (d.scope === 'overall' && !d.id) { overall.push(d); continue } const key = d.id || `line-${d.line}` if (!byId.has(key)) byId.set(key, []) byId.get(key).push(d) } return { byId, overall } } // A3 effect-heuristics bar. Not a diagnostics surface — every field is an // at-a-glance fact. The near-name conflict field only appears when there's // a pair to surface; likewise the tool-count warning. function renderSummaryBar(summary) { if (!summaryBar) return if (!summary) { summaryBar.innerHTML = ''; return } const bits = [] // Preflight (2026-07-18) blind-test #9a: dropped the "enabled X of Y" // field here because the diagnostics strip below already prints // "X enabled · Y running · Z not loaded". Two adjacent cards saying the // same number reads as cognitive-load noise. The strip carries the // authoritative count; the summary bar keeps only the CONDITIONAL // surfaces (conflicts, tool-count warnings) that don't appear elsewhere. if (summary.conflicts && summary.conflicts.length > 0) { const pairs = summary.conflicts.map((c) => { const label = c.kind === 'prefix' ? 'prefix overlap' : 'near-name' return `${escapeHtml(c.a)}↔${escapeHtml(c.b)}` }).join(' ') bits.push( `` + `possible conflict:` + `${pairs}` + ``, ) } if (summary.toolWarning) { bits.push( `` + `tool count:` + `${summary.toolWarning.count} > ${summary.toolWarning.threshold}` + ``, ) } summaryBar.innerHTML = bits.join('') } function renderDiagnosticsStrip(diags) { if (!diagStrip) return // B-P0-1 fix: keep configuration status // and runtime status as separate facts on one line so they can't // contradict. The previous shape shouted "Configuration OK" in bright // green while every runtime cell below showed ABSENT — the user reads // that as "the tool is lying to me". The new shape is // "5 enabled · 3 running · 2 not loaded" // one number per state, mismatch tint (warn) whenever running != enabled. // Config-side diagnostics still take precedence when present (they hide // this row's OK story behind their own error/warn count). // // Follow-up: on stdio profiles there // is no daemon connection at all — `plugins/list` isn't returning // MethodNotFound, main-side plugins:listRuntime returns // `{supported:false, reason:'no-daemon'}` up front. Fold that into the // existing 'unknown' status (per team-lead: no fourth state) but vary // the phrase so the user sees "runtime state unavailable (no daemon on // this profile)" instead of the generic "runtime status unknown". const runtimeSnapshot = summarizeRuntimeHealth() if (diags.length === 0) { diagStrip.innerHTML = '' if (runtimeSnapshot.status === 'unknown') { diagStrip.className = 'plugins-diagnostics muted' diagStrip.textContent = globalThis.PluginRuntimeFold ? globalThis.PluginRuntimeFold.unknownReasonPhrase(runtimeSnapshot, state.lastRuntime) : 'runtime status unknown' return } diagStrip.className = 'plugins-diagnostics ' + // Preflight (2026-07-18) blind-test #9b: the earlier // `active ? ok : warn` mapping painted an orange border whenever // running != enabled. But "3 running out of 5 enabled" during a // fresh boot is not a warning — the daemon is just still spinning // up. Team-lead call: keep ok tint for a clean roll-up, drop the // colour class entirely for the partial-yet-not-erroring case so // it reads as neutral information, not a problem. (runtimeSnapshot.status === 'active' ? 'ok' : '') diagStrip.textContent = globalThis.PluginRuntimeFold ? globalThis.PluginRuntimeFold.healthPhrase(runtimeSnapshot) : `${runtimeSnapshot.expected} enabled · ${runtimeSnapshot.active} running` return } const errors = diags.filter((d) => d.severity === 'error') const warns = diags.filter((d) => d.severity === 'warn') diagStrip.className = 'plugins-diagnostics ' + (errors.length > 0 ? 'error' : 'warn') const parts = [] if (errors.length > 0) parts.push(`${errors.length} error${errors.length === 1 ? '' : 's'}`) if (warns.length > 0) parts.push(`${warns.length} warning${warns.length === 1 ? '' : 's'}`) // Include the whole-list heuristics inline; the per-row ones are anchored // to their row in renderTable. const overall = diags.filter((d) => d.scope === 'overall') const overallLine = overall.map((d) => escapeHtml(d.message)).join(' · ') diagStrip.innerHTML = parts.join('  ·  ') + (overallLine ? `  ·  ${overallLine}` : '') } // Roll up the last runtime snapshot into a compact health status the strip // and future callers can read. `unknown` == daemon has not reported yet; // `active` == every enabled row shows as active/loading in the runtime; // `partial` == some enabled rows are absent/pending so the config is not // fully in effect. See B-P0-1 in docs/product-flow-review.md. function summarizeRuntimeHealth() { const list = state.lastList const runtime = state.lastRuntime if (!runtime || runtime.supported !== true || !globalThis.PluginRuntimeFold || !list) { return { status: 'unknown', active: 0, pending: 0, notLoaded: 0, expected: 0 } } // Fold + healthSnapshot live in plugin-runtime-fold.js so the bucketing // and phrasing can be exercised under node --test without JSDOM. See // test/plugin-runtime-fold.test.js `healthSnapshot` cases. const fold = globalThis.PluginRuntimeFold.foldRuntime(list.entries || [], runtime.plugins || []) return globalThis.PluginRuntimeFold.healthSnapshot(fold) } function renderTable(list, diagsById) { tbody.innerHTML = '' // The runtime column is folded in from state.lastRuntime.plugins when // the daemon supports plugins/list; otherwise it stays absent (dash). // The fold logic itself lives in plugin-runtime-fold.js so it can be // exercised under node --test. const runtimeSupported = state.lastRuntime && state.lastRuntime.supported === true const fold = runtimeSupported && globalThis.PluginRuntimeFold ? globalThis.PluginRuntimeFold.foldRuntime(list.entries, state.lastRuntime.plugins || []) : { rows: list.entries.map((e) => ({ ...e, runtime: null })), extras: [] } for (const entry of fold.rows) { const tr = document.createElement('tr') tr.dataset.pluginId = entry.id if (entry.disabled) tr.classList.add('disabled') const diags = diagsById.get(entry.id) || [] if (diags.some((d) => d.severity === 'error')) tr.classList.add('has-error') else if (diags.some((d) => d.severity === 'warn')) tr.classList.add('has-warn') const cbCell = document.createElement('td') const cb = document.createElement('input') cb.type = 'checkbox' cb.checked = !entry.disabled cb.addEventListener('change', async () => { try { await window.dsh.plugins.toggle(entry.id, !cb.checked) tr.classList.toggle('disabled', !cb.checked) setDirty(true) // Re-validate silently after every edit so cross-row heuristics // (near-duplicates, disabled → tool count) reflect the new state. void revalidate() } catch (err) { cb.checked = !entry.disabled // roll back the UI alert(`toggle failed: ${err.message}`) } }) cbCell.appendChild(cb) tr.appendChild(cbCell) const idCell = document.createElement('td') idCell.textContent = entry.id idCell.className = 'mono' tr.appendChild(idCell) const nameCell = document.createElement('td') nameCell.textContent = entry.name nameCell.className = 'mono muted' tr.appendChild(nameCell) const srcCell = document.createElement('td') srcCell.className = 'source-cell' srcCell.textContent = entry.source if (entry.source === 'user') srcCell.classList.add('user') tr.appendChild(srcCell) // Runtime column: absent when the daemon lacks plugins/list; otherwise // one of active/loading/pending/failed/…/absent from the fold, with a // yellow dot when the runtime disagrees with the authored state. // // Wording (B-P0-1 fix, 2026-07-16): the raw states used to render // verbatim in the cell — a user opening Plugins for the first time // read "ABSENT" for every row and thought nothing had installed at // all, while the strip above read "Configuration OK". We translate // each state to a plain-language phrase now, with the raw name kept // as the hover so operators can still read the wire truth. const rtCell = document.createElement('td') rtCell.className = 'runtime-cell' if (!entry.runtime) { rtCell.textContent = '—' rtCell.classList.add('muted') // The per-row dash and the strip above share the same "why is this // unknown" reasoning — see unknownStripText. Match the phrasing so // hover on any cell answers the same question the strip does. const rt = state.lastRuntime || {} if (rt.reason === 'no-daemon') { rtCell.title = 'No daemon on this profile, so no live state is available.' } else if (rt.reason === 'MethodNotFound') { rtCell.title = 'This runtime does not implement plugins/list, so no live state is available.' } else { rtCell.title = 'No live state available yet.' } } else { const label = document.createElement('span') label.className = `runtime-state runtime-${entry.runtime.state}` const friendly = RUNTIME_LABEL[entry.runtime.state] || entry.runtime.state label.textContent = friendly.text label.title = `runtime state: ${entry.runtime.state}` rtCell.appendChild(label) if (friendly.hint) { const hint = document.createElement('span') hint.className = 'runtime-state-hint muted' hint.textContent = ` · ${friendly.hint}` rtCell.appendChild(hint) } if (entry.runtime.mismatch) { const dot = document.createElement('span') dot.className = 'runtime-mismatch' dot.textContent = '● ' dot.title = entry.runtime.reason || 'runtime disagrees with configuration' rtCell.insertBefore(dot, label) tr.classList.add('runtime-mismatch-row') } // A resolved fiber name that differs from the configured specifier // is useful for debugging; keep it in the hover. if (entry.runtime.name) rtCell.title = `runtime fiber: ${entry.runtime.name}` } tr.appendChild(rtCell) tbody.appendChild(tr) // Per-row diagnostic message sits in a follow-up row so the table // layout doesn't need a fifth column. `title` also carries the same // text for a hover tooltip. for (const d of diags) { const detail = document.createElement('tr') detail.className = 'plugin-diagnostic ' + d.severity const td = document.createElement('td') td.colSpan = 5 td.textContent = (d.severity === 'error' ? '! ' : '· ') + d.message detail.appendChild(td) tbody.appendChild(detail) } // MCP-client entries expose an inline server- // config card as a follow-up row. The card renders the shallow // `config:` block (transport / serverName / command|url / env|headers) // and writes back through `plugins.setConfig` — this is the "装了没 // 感觉、没入口配置" (installed but nothing to configure) gap the audit // called out. if (globalThis.__dshMcpConfigCard && globalThis.__dshMcpConfigCard.isMcpClientRow(entry)) { const card = globalThis.__dshMcpConfigCard.buildMcpConfigCard( document, entry, { onCommit: async (cfg) => { await window.dsh.plugins.setConfig(entry.id, cfg) setDirty(true) // A silent revalidate so summary/diagnostic strips reflect // the write; full table redraw would collapse the open card. void revalidate() }, onClear: async () => { await window.dsh.plugins.setConfig(entry.id, null) setDirty(true) void revalidate() }, }, ) tbody.appendChild(card) } } // Runtime plugins the fs list did not claim (e.g. agent-spine bundle // members). Render as an informational tail block so the user can see // the whole live registry, not just the entries they authored. if (fold.extras.length > 0) { const head = document.createElement('tr') head.className = 'runtime-extras-head' const th = document.createElement('td') th.colSpan = 5 th.textContent = `Runtime-loaded (not in overlay): ${fold.extras.length}` head.appendChild(th) tbody.appendChild(head) for (const rp of fold.extras) { const tr = document.createElement('tr') tr.className = 'runtime-extras-row' // The 5-column layout is [Enabled, ID, Package, Source, Runtime]. // Extras have no user-authored id/checkbox, so Enabled + ID stay // blank — but the Package column DOES have a name (rp.name is the // runtime fiber name, e.g. `agent-spine`'s bundle members). A // previous version collapsed the first three cells into a single // colSpan=3 blank, which is what the user was seeing: 25 rows with // only RUNTIME / ACTIVE badges and no plugin name to identify them. const enabledTd = document.createElement('td') enabledTd.className = 'muted' tr.appendChild(enabledTd) const idTd = document.createElement('td') idTd.className = 'muted' tr.appendChild(idTd) const nameTd = document.createElement('td') nameTd.className = 'mono muted' nameTd.textContent = rp.name || '' tr.appendChild(nameTd) const srcTd = document.createElement('td'); srcTd.textContent = 'runtime' srcTd.className = 'source-cell' tr.appendChild(srcTd) const stateTd = document.createElement('td') stateTd.className = 'runtime-cell' const label = document.createElement('span') label.className = `runtime-state runtime-${rp.state}` label.textContent = rp.state stateTd.appendChild(label) stateTd.title = `runtime fiber: ${rp.name}` tr.appendChild(stateTd) tbody.appendChild(tr) } } if (list.entries.length === 0) { const tr = document.createElement('tr') const td = document.createElement('td') td.colSpan = 5 td.className = 'empty' td.textContent = 'No entries — base leaf not found or empty.' tr.appendChild(td) tbody.appendChild(tr) } } async function refresh() { try { const list = await window.dsh.plugins.list() state.lastList = list renderMeta(list) const diagResult = await window.dsh.plugins.validate().catch(() => ({ diagnostics: [] })) state.lastDiagnostics = diagResult.diagnostics || [] // Runtime column is best-effort: MethodNotFound / no daemon / crash all // collapse to `{ supported: false }` so the table still renders. The // yellow-dot mismatch signal only appears when the daemon supplied a // real answer. state.lastRuntime = await window.dsh.plugins.listRuntime().catch(() => ({ supported: false })) // publish the current mounted-plugin set to the global // known-plugins registry the inject classifier reads (family B/G // split). Only overwrite when the runtime actually answered — a // MethodNotFound fallback would otherwise clobber a stale-but-real // list on the next tab switch. if (state.lastRuntime && state.lastRuntime.supported === true && Array.isArray(state.lastRuntime.plugins)) { window.__dshKnownPlugins = new Set( state.lastRuntime.plugins .map((p) => p && p.name) .filter((n) => typeof n === 'string' && n.length > 0), ) } const { byId } = bucketDiagnostics(state.lastDiagnostics) renderSummaryBar(list.summary) renderDiagnosticsStrip(state.lastDiagnostics) renderTable(list, byId) vibeBtn.disabled = !list.vibeCapable vibeBtn.title = list.vibeCapable ? 'Start a chat with cordis_inspect / cordis_mount / cordis_unmount in scope.' : `Requires a vibe-capable profile (current: ${list.profileName}). Switch to "stdio-vibe-deepseek".` // Keep the Create-zone cards in sync with the (now-hidden) header // buttons. See src/renderer/plugin-market.js syncCreateZone. if (window.__dshMarket && typeof window.__dshMarket.syncCreateZone === 'function') { window.__dshMarket.syncCreateZone(list) } } catch (err) { meta.innerHTML = `plugin list unavailable: ${escapeHtml(err.message)}` } } // Validate-only path — no list reflow. Used by toggle so the strip and // row highlights stay in sync without a full redraw. The row-diagnostic // detail rows are inside the same tbody so we do have to recompute those, // but the base entries themselves don't change. // // Toggle also flips the disabled flag on the effective entry list, which // changes the A3 summary (enabled count, conflicts among ENABLED ids). We // refetch the list so the summary bar tracks reality. This is one extra // IPC per toggle, which is fine — plugins:list is cached and cheap. async function revalidate() { if (!state.lastList) return try { const [freshList, diagResult] = await Promise.all([ window.dsh.plugins.list().catch(() => state.lastList), window.dsh.plugins.validate(), ]) state.lastList = freshList state.lastDiagnostics = diagResult.diagnostics || [] const { byId } = bucketDiagnostics(state.lastDiagnostics) renderSummaryBar(freshList.summary) renderDiagnosticsStrip(state.lastDiagnostics) renderTable(freshList, byId) } catch (_) { /* validation is best-effort; a stale strip is fine */ } } async function apply() { applyBtn.disabled = true applyBtn.textContent = 'Restarting…' try { await window.dsh.plugins.restart() setDirty(false) applyBtn.textContent = 'Apply + restart' // A soft toast; the statusbar already reflects respawn state. sidebarNote.textContent = 'Runtime restarted with new overlay.' } catch (err) { applyBtn.textContent = 'Apply + restart' applyBtn.disabled = false alert(`restart failed: ${err.message}`) } } async function addPlugin() { // A modest inline prompt. The demo intentionally doesn't ship a package // picker; the user types a valid cordis plugin specifier (workspace // package name, absolute path, or relative path resolved against the // dev-clone root). const id = (window.prompt('Plugin id (short, unique):') || '').trim() if (!id) return const name = (window.prompt('Plugin package or path:', '@deepseek-ai/dsh-') || '').trim() if (!name) return try { await window.dsh.plugins.add(id, name) setDirty(true) await refresh() } catch (err) { alert(`add failed: ${err.message}`) } } async function vibe() { try { const { sessionId } = await window.dsh.plugins.vibeStart() // Return to the chat tab; renderer.js exposes selectSession so this // module can hand off cleanly. If for some reason it hasn't loaded // yet, we just switch the tab and let refreshSessionList pick it up. if (window.__dshTabs) window.__dshTabs.switchTo('chat') if (window.__dshChat && typeof window.__dshChat.selectSession === 'function') { await window.__dshChat.selectSession(sessionId) } } catch (err) { alert(`vibe failed: ${err.message}`) } } // Shared with market-ui, playground-ui, bench-page — see html-escape.js. const escapeHtml = (window.__dshHtmlEscape || {}).escapeHtml || ((s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))) refreshBtn.addEventListener('click', () => { void refresh() }) applyBtn.addEventListener('click', () => { void apply() }) addBtn.addEventListener('click', () => { void addPlugin() }) vibeBtn.addEventListener('click', () => { void vibe() }) // Boot probe (A2). Explicit user gesture — slow enough (a real daemon // boot) that we don't run it automatically. On success, the diagnostics // strip clears any prior boot warnings; on failure the fail-loud stderr // is anchored to the offending row. const probeBtn = document.getElementById('plugins-probe') if (probeBtn) { probeBtn.addEventListener('click', async () => { probeBtn.disabled = true const original = probeBtn.textContent probeBtn.textContent = 'Booting probe…' try { const result = await window.dsh.plugins.probe() // Merge probe diagnostics with the last static-validation set so the // strip shows both layers. Static diagnostics don't have an isBoot // marker; probe ones are prefixed 'boot:' in their message. const merged = [ ...state.lastDiagnostics.filter((d) => !/^boot:/.test(d.message)), ...(result.diagnostics || []), ] state.lastDiagnostics = merged const { byId } = bucketDiagnostics(merged) renderDiagnosticsStrip(merged) if (state.lastList) renderTable(state.lastList, byId) } catch (err) { alert(`probe failed: ${err.message}`) } finally { probeBtn.disabled = false probeBtn.textContent = original } }) } // Exposed so the tab-switch handler can lazy-load on first activation and // the boot flow can force a refresh after onboarding writes an overlay. window.__dshPlugins = { refresh, setDirty } // First render happens when the user opens the tab; nothing eager here so // a slow plugins:list on the daemon doesn't block chat. })()