diff --git a/resources/web/dialog/PluginsConfigDialog/index.html b/resources/web/dialog/PluginsConfigDialog/index.html
new file mode 100644
index 0000000000..04225986b2
--- /dev/null
+++ b/resources/web/dialog/PluginsConfigDialog/index.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+ This preset does not use any plugin capabilities
+
+
+
+
+
+
+
+
diff --git a/resources/web/dialog/PluginsConfigDialog/index.js b/resources/web/dialog/PluginsConfigDialog/index.js
new file mode 100644
index 0000000000..cd7032d029
--- /dev/null
+++ b/resources/web/dialog/PluginsConfigDialog/index.js
@@ -0,0 +1,499 @@
+// The capabilities the active preset uses, as sent by PluginsConfigDialog::send_capabilities.
+// Each row is {plugin_key, name, type, type_key, has_config_ui} — the shape
+// PluginConfig::capabilities_payload emits, shared with the Plugins dialog's Config tab.
+let capabilities = [];
+
+// The selected row's full identity. plugin_key is part of it because this list spans plugins,
+// unlike the Plugins dialog's config tab where every row belongs to the one selected plugin.
+let selectedPluginKey = "";
+let selectedCapabilityName = "";
+let selectedCapabilityType = "";
+let selectedHasPresetOverride = false;
+let selectedReadOnly = false;
+
+function SafeJsonParse(text) {
+ try {
+ return JSON.parse(text);
+ } catch (err) {
+ return null;
+ }
+}
+
+function SendWXMessage(message) {
+ if (window.wx && typeof window.wx.postMessage === "function")
+ window.wx.postMessage(message);
+}
+
+function SendMessage(command, payload = {}) {
+ const message = {
+ sequence_id: Math.round(Date.now() / 1000),
+ command: command
+ };
+ Object.keys(payload).forEach((key) => {
+ message[key] = payload[key];
+ });
+ SendWXMessage(JSON.stringify(message));
+}
+
+function HandleStudio(value) {
+ const payload = (typeof value === "string") ? SafeJsonParse(value) : value;
+ if (!payload || typeof payload !== "object")
+ return;
+
+ if (payload.command === "list_capabilities") {
+ ApplyCapabilities(payload);
+ } else if (payload.command === "status_message") {
+ ShowStatusMessage(String(payload.message || ""), String(payload.level || "info"));
+ } else if (payload.command === "capability_config") {
+ ApplyCapabilityConfig(payload);
+ } else if (payload.command === "capability_config_saved") {
+ ApplyCapabilityConfigSaved(payload);
+ }
+}
+
+function ShowStatusMessage(message, level) {
+ const bar = document.getElementById("statusBar");
+ const text = document.getElementById("statusText");
+ if (!bar || !text)
+ return;
+
+ const normalizedLevel = ["success", "warn", "error", "info"].includes(level) ? level : "info";
+ text.textContent = message;
+ text.title = message;
+ bar.classList.remove("is-empty", "level-success", "level-warn", "level-error", "level-info");
+ bar.classList.add(`level-${normalizedLevel}`);
+}
+
+function ApplyCapabilities(payload) {
+ capabilities = Array.isArray(payload.data) ? payload.data : [];
+
+ const title = document.getElementById("pageTitle");
+ if (title)
+ title.textContent = String(payload.title || "Plugin configuration");
+
+ const subtitle = document.getElementById("pageSubtitle");
+ if (subtitle)
+ subtitle.textContent = String(payload.preset_name || "");
+
+ RenderCapabilities();
+}
+
+function IsSameCapability(capability) {
+ return String(capability.plugin_key || "") === selectedPluginKey
+ && String(capability.name || "") === selectedCapabilityName
+ && String(capability.type_key || "") === selectedCapabilityType;
+}
+
+function RenderCapabilities() {
+ const empty = document.getElementById("configEmpty");
+ const layout = document.getElementById("configLayout");
+ const sidebar = document.getElementById("configSidebar");
+ if (!empty || !layout || !sidebar)
+ return;
+
+ if (capabilities.length === 0) {
+ empty.hidden = false;
+ layout.hidden = true;
+ sidebar.replaceChildren();
+ ClearCapabilityConfigView();
+ selectedPluginKey = "";
+ selectedCapabilityName = "";
+ selectedCapabilityType = "";
+ return;
+ }
+
+ empty.hidden = true;
+ layout.hidden = false;
+
+ // Keep the selection across a refresh when the capability is still there; otherwise fall back to
+ // the first one, which is also the initial selection.
+ if (!capabilities.some(IsSameCapability)) {
+ selectedPluginKey = String(capabilities[0].plugin_key || "");
+ selectedCapabilityName = String(capabilities[0].name || "");
+ selectedCapabilityType = String(capabilities[0].type_key || "");
+ ClearCapabilityConfigView();
+ RequestCapabilityConfig();
+ }
+
+ sidebar.replaceChildren();
+ for (const capability of capabilities) {
+ const item = document.createElement("button");
+ item.type = "button";
+ item.className = "config-cap";
+ item.dataset.pluginKey = String(capability.plugin_key || "");
+ item.dataset.capabilityName = String(capability.name || "");
+ item.dataset.capabilityType = String(capability.type_key || "");
+ item.setAttribute("role", "option");
+
+ const isSelected = IsSameCapability(capability);
+ item.classList.toggle("selected", isSelected);
+ item.setAttribute("aria-selected", isSelected ? "true" : "false");
+
+ const label = document.createElement("span");
+ label.className = "config-cap-name";
+ label.textContent = String(capability.name || "");
+ item.appendChild(label);
+
+ const type = document.createElement("span");
+ type.className = "config-cap-type";
+ type.textContent = String(capability.type || "");
+ item.appendChild(type);
+
+ sidebar.appendChild(item);
+ }
+}
+
+function OnConfigSidebarClick(event) {
+ const item = event.target.closest(".config-cap");
+ if (!item)
+ return;
+
+ const pluginKey = String(item.dataset.pluginKey || "");
+ const name = String(item.dataset.capabilityName || "");
+ const typeKey = String(item.dataset.capabilityType || "");
+ if (!name || (pluginKey === selectedPluginKey && name === selectedCapabilityName && typeKey === selectedCapabilityType))
+ return;
+
+ selectedPluginKey = pluginKey;
+ selectedCapabilityName = name;
+ selectedCapabilityType = typeKey;
+
+ // Drop the outgoing capability's view immediately: the native reply is asynchronous and its
+ // content must never appear under the newly selected capability.
+ ClearCapabilityConfigView();
+ RequestCapabilityConfig();
+ RenderCapabilities();
+}
+
+// ---------------------------------------------------------------------------
+// Config editor
+//
+// The right side shows either the host's JSON editor or, when the capability ships one, its own
+// HTML UI in a sandboxed frame. Both edit the same stored config: the page holds no config state
+// of its own, it renders what the native side sends and sends back what the user saves. Ported
+// from PluginsDialog/index.js, whose config tab lives inside a single selected plugin (it reads
+// selectedPluginId from a page-global); this list spans plugins, so every row carries its own
+// plugin_key and the page tracks selectedPluginKey instead.
+// ---------------------------------------------------------------------------
+
+// A reply is only applied when it still matches the selected row. The native side is asynchronous,
+// and unlike the Plugins dialog this list spans plugins, so plugin_key is part of the match.
+function IsCurrentCapability(payload) {
+ return String(payload?.plugin_key || "") === selectedPluginKey
+ && String(payload?.capability_name || "") === selectedCapabilityName
+ && String(payload?.capability_type || "") === selectedCapabilityType;
+}
+
+function RequestCapabilityConfig() {
+ if (!selectedPluginKey || !selectedCapabilityName)
+ return;
+
+ SendMessage("get_capability_config", {
+ plugin_key: selectedPluginKey,
+ capability_name: selectedCapabilityName,
+ capability_type: selectedCapabilityType
+ });
+}
+
+// Empties both editors, so nothing from the previously selected capability can linger while the
+// next one is still in flight. The footer goes with them: until a config has actually loaded there
+// is nothing to save or restore.
+function ClearCapabilityConfigView() {
+ const editor = document.getElementById("configEditor");
+ const custom = document.getElementById("configCustom");
+ const text = document.getElementById("configText");
+ const error = document.getElementById("configError");
+ const footer = document.getElementById("configFooter");
+ const meta = document.getElementById("configMeta");
+
+ if (editor)
+ editor.hidden = true;
+ if (custom) {
+ custom.hidden = true;
+ custom.removeAttribute("srcdoc");
+ }
+ if (text)
+ text.value = "";
+ if (error) {
+ error.hidden = true;
+ error.textContent = "";
+ }
+ if (footer)
+ footer.hidden = true;
+ if (meta)
+ meta.hidden = true;
+ selectedHasPresetOverride = false;
+ selectedReadOnly = false;
+ SetConfigValidation("");
+}
+
+function UpdateConfigMeta(payload) {
+ selectedHasPresetOverride = payload?.has_preset_override === true;
+ selectedReadOnly = payload?.read_only === true;
+
+ const meta = document.getElementById("configMeta");
+ const badge = document.getElementById("configSourceBadge");
+ const useGlobal = document.getElementById("configUseGlobalBtn");
+ const save = document.getElementById("configSaveBtn");
+ const restore = document.getElementById("configRestoreBtn");
+ if (!meta || !badge || !useGlobal)
+ return;
+
+ const source = String(payload?.source || "none");
+ badge.textContent = source === "preset" ? "Preset override" :
+ (source === "base" ? "Inherited from global configuration" : "No saved configuration");
+ useGlobal.hidden = !selectedHasPresetOverride;
+ useGlobal.disabled = selectedReadOnly;
+ if (save)
+ save.disabled = selectedReadOnly;
+ if (restore)
+ restore.disabled = selectedReadOnly;
+ meta.hidden = false;
+}
+
+function ApplyCapabilityConfig(payload) {
+ if (!IsCurrentCapability(payload))
+ return;
+
+ const editor = document.getElementById("configEditor");
+ const custom = document.getElementById("configCustom");
+ const text = document.getElementById("configText");
+ const error = document.getElementById("configError");
+
+ const message = String(payload?.error || "");
+ if (error) {
+ error.textContent = message;
+ error.hidden = message === "";
+ }
+
+ const config = payload && Object.prototype.hasOwnProperty.call(payload, "config") ? payload.config : {};
+ const html = String(payload?.custom_html || "");
+ UpdateConfigMeta(payload);
+
+ // Restore is host chrome and applies to either editor; Save and the validation message belong to
+ // the JSON editor, since a custom UI saves through its own controls via the bridge.
+ ShowConfigFooter(!html);
+
+ if (html) {
+ // A capability with its own UI: hand it the config through the bridge, never the raw file.
+ if (custom) {
+ custom.hidden = false;
+ custom.srcdoc = BuildCustomConfigDocument(html, config);
+ }
+ if (editor)
+ editor.hidden = true;
+ return;
+ }
+
+ // Default editor. The native side already reported why a custom UI is unavailable (if it was
+ // meant to have one) in payload.error, and we fall back to editing the same config here.
+ if (custom) {
+ custom.hidden = true;
+ custom.removeAttribute("srcdoc");
+ }
+ if (editor)
+ editor.hidden = false;
+ if (text)
+ text.value = JSON.stringify(config, null, 2);
+ SetConfigValidation("");
+}
+
+// Reveals the footer for the loaded capability. `withEditorControls` is false for a custom UI,
+// leaving Restore on its own.
+function ShowConfigFooter(withEditorControls) {
+ const footer = document.getElementById("configFooter");
+ const save = document.getElementById("configSaveBtn");
+ const validation = document.getElementById("configValidation");
+
+ if (footer)
+ footer.hidden = false;
+ if (save)
+ save.hidden = !withEditorControls;
+ if (validation)
+ validation.hidden = !withEditorControls;
+}
+
+function SetConfigValidation(message) {
+ const node = document.getElementById("configValidation");
+ const save = document.getElementById("configSaveBtn");
+ if (node) {
+ node.textContent = message;
+ node.classList.toggle("invalid", message !== "");
+ }
+ // Invalid JSON can never be saved: the button is the only way to persist, and it is disabled
+ // while the text does not parse. The native side re-validates regardless.
+ if (save)
+ save.disabled = selectedReadOnly || message !== "";
+}
+
+function ValidateConfigText() {
+ const text = document.getElementById("configText");
+ if (!text)
+ return false;
+
+ try {
+ JSON.parse(text.value);
+ SetConfigValidation("");
+ return true;
+ } catch (err) {
+ SetConfigValidation(String(err?.message || "Invalid JSON"));
+ return false;
+ }
+}
+
+function SaveCapabilityConfig() {
+ if (!selectedPluginKey || !selectedCapabilityName)
+ return;
+
+ const text = document.getElementById("configText");
+ if (!text)
+ return;
+
+ if (!ValidateConfigText())
+ return;
+
+ // Sent as text on purpose: the native side is the authority on validity and parses it itself.
+ SendMessage("save_capability_config", {
+ plugin_key: selectedPluginKey,
+ capability_name: selectedCapabilityName,
+ capability_type: selectedCapabilityType,
+ config: text.value
+ });
+}
+
+// Asks the native side to write the capability's default config over whatever is stored. The
+// defaults come from the capability's get_default_config(), never from this page — the host does not
+// know what a given plugin considers default. The native side confirms before discarding anything,
+// and replies with the same "saved" payload, so both editors reload from what was persisted.
+function RestoreCapabilityConfig() {
+ if (!selectedPluginKey || !selectedCapabilityName)
+ return;
+
+ SendMessage("restore_preset_defaults", {
+ plugin_key: selectedPluginKey,
+ capability_name: selectedCapabilityName,
+ capability_type: selectedCapabilityType
+ });
+}
+
+function UseGlobalCapabilityConfig() {
+ if (!selectedPluginKey || !selectedCapabilityName || !selectedHasPresetOverride)
+ return;
+
+ SendMessage("remove_preset_override", {
+ plugin_key: selectedPluginKey,
+ capability_name: selectedCapabilityName,
+ capability_type: selectedCapabilityType
+ });
+}
+
+function ApplyCapabilityConfigSaved(payload) {
+ if (!IsCurrentCapability(payload))
+ return;
+
+ const error = document.getElementById("configError");
+ const message = String(payload?.error || "");
+ if (error) {
+ error.textContent = message;
+ error.hidden = message === "";
+ }
+ if (payload?.ok !== true)
+ return;
+
+ // Reload from what was actually persisted, so both editors show the stored state rather than
+ // whatever was typed.
+ const config = payload && Object.prototype.hasOwnProperty.call(payload, "config") ? payload.config : {};
+ const custom = document.getElementById("configCustom");
+ const text = document.getElementById("configText");
+
+ if (custom && !custom.hidden && custom.contentWindow)
+ custom.contentWindow.postMessage({ __orca: "config", config: config }, "*");
+ else if (text)
+ text.value = JSON.stringify(config, null, 2);
+
+ SetConfigValidation("");
+}
+
+// The whole host surface a custom config UI gets: read the config it was opened with, save a new
+// one, and be told when a save lands. Everything else about the dialog stays out of reach — the
+// frame is sandboxed into an opaque origin, so this bridge is its only way to talk to the host.
+function BuildCustomConfigDocument(html, config) {
+ // The config is inlined into a " would
+ // otherwise close the tag early and inject the rest as markup. Escaping "<" keeps the literal
+ // valid JSON while making that impossible.
+ const seed = JSON.stringify(config).replace(/
+(function () {
+ var handlers = [];
+ var current = ${seed};
+ window.orca = {
+ getConfig: function () { return current; },
+ saveConfig: function (cfg) { parent.postMessage({ __orca: "save", config: cfg }, "*"); },
+ onConfig: function (cb) {
+ if (typeof cb !== "function") return;
+ handlers.push(cb);
+ try { cb(current); } catch (e) {}
+ }
+ };
+ window.addEventListener("message", function (event) {
+ if (!event.data || event.data.__orca !== "config") return;
+ current = event.data.config || {};
+ handlers.forEach(function (handler) {
+ try { handler(current); } catch (e) {}
+ });
+ });
+})();
+<\/script>`;
+ return bridge + html;
+}
+
+function OnCustomConfigMessage(event) {
+ const custom = document.getElementById("configCustom");
+ // Only the frame we created, and only while it is actually showing.
+ if (!custom || custom.hidden || !custom.contentWindow || event.source !== custom.contentWindow)
+ return;
+
+ const data = event.data;
+ if (!data || data.__orca !== "save")
+ return;
+ if (!selectedPluginKey || !selectedCapabilityName)
+ return;
+
+ // The custom UI persists through the same native command as the JSON editor, so there is one
+ // stored config and one code path that writes it.
+ SendMessage("save_capability_config", {
+ plugin_key: selectedPluginKey,
+ capability_name: selectedCapabilityName,
+ capability_type: selectedCapabilityType,
+ config: data.config === undefined ? {} : data.config
+ });
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ const sidebar = document.getElementById("configSidebar");
+ if (sidebar)
+ sidebar.addEventListener("click", OnConfigSidebarClick);
+
+ const saveBtn = document.getElementById("configSaveBtn");
+ if (saveBtn)
+ saveBtn.addEventListener("click", SaveCapabilityConfig);
+
+ const restoreBtn = document.getElementById("configRestoreBtn");
+ if (restoreBtn)
+ restoreBtn.addEventListener("click", RestoreCapabilityConfig);
+
+ const useGlobalBtn = document.getElementById("configUseGlobalBtn");
+ if (useGlobalBtn)
+ useGlobalBtn.addEventListener("click", UseGlobalCapabilityConfig);
+
+ const text = document.getElementById("configText");
+ if (text)
+ text.addEventListener("input", ValidateConfigText);
+
+ // The custom capability UI is sandboxed into an opaque origin, so it reaches us only through
+ // postMessage. OnCustomConfigMessage matches on the frame's own contentWindow rather than the
+ // origin (which is "null" for a sandboxed frame) and ignores anything else on the channel.
+ window.addEventListener("message", OnCustomConfigMessage);
+
+ SendMessage("request_capabilities");
+});
diff --git a/resources/web/dialog/PluginsConfigDialog/styles.css b/resources/web/dialog/PluginsConfigDialog/styles.css
new file mode 100644
index 0000000000..7fe45a721d
--- /dev/null
+++ b/resources/web/dialog/PluginsConfigDialog/styles.css
@@ -0,0 +1,278 @@
+/* Buttons (ButtonStyleRegular/ButtonStyleConfirm/ButtonTypeChoice), scrollbars (.thin-scroll) and
+ the host-injected theme contract (--bg, --text, --border, --panel, --muted, --plugin-status-*,
+ ...) all come from the shared sheets linked in index.html (global.css, common.css, theme.css —
+ the same three every resources/web/dialog/* page links). This file only carries what is unique
+ to this page: the fixed-size reset those shared sheets assume, the new page chrome, and the
+ config-related rules ported from PluginsDialog/styles.css (its Config tab uses the same classes).
+*/
+
+/* common.css hardcodes body to the PluginsDialog-era fixed 820x660 with overflow:hidden; this
+ dialog is resizable/maximizable, so neutralize that the same way PluginsDialog/styles.css does. */
+html,
+body {
+ width: 100% !important;
+ height: 100%;
+ max-width: none !important;
+ max-height: none !important;
+ margin: 0;
+ overflow: hidden;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+}
+
+.page {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ padding: 16px;
+ box-sizing: border-box;
+ gap: 12px;
+}
+
+.page-header { flex: 0 0 auto; }
+
+.page-title {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.page-subtitle {
+ margin: 4px 0 0;
+ font-size: 12px;
+ opacity: 0.7;
+}
+
+/* ---- Ported from resources/web/dialog/PluginsDialog/styles.css (Config tab) ---- */
+
+.detail-empty {
+ padding: 18px 8px;
+ color: var(--muted);
+}
+
+.detail-empty[hidden] {
+ display: none;
+}
+
+.config-layout {
+ display: grid;
+ grid-template-columns: 180px 1fr;
+ gap: 10px;
+ height: 100%;
+ min-height: 0;
+ padding: 6px;
+ box-sizing: border-box;
+ flex: 1 1 auto;
+}
+
+.config-layout[hidden] {
+ display: none;
+}
+
+.config-sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-height: 0;
+ overflow-y: auto;
+ padding-right: 4px;
+ border-right: 1px solid var(--border-soft);
+}
+
+.config-cap {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ width: 100%;
+ padding: 6px 8px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--text);
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.config-cap:hover {
+ background: var(--row-hover);
+}
+
+.config-cap.selected {
+ background: var(--row-selected);
+ border-color: var(--row-selected-outline);
+}
+
+.config-cap-name {
+ font-size: 12px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.config-cap-type {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.config-view {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-height: 0;
+ min-width: 0;
+}
+
+.config-error {
+ padding: 6px 8px;
+ border-radius: 4px;
+ background: var(--plugin-status-warn-bg);
+ color: var(--plugin-status-warn);
+ font-size: 12px;
+}
+
+.config-error[hidden] {
+ display: none;
+}
+
+.config-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.config-meta[hidden] {
+ display: none;
+}
+
+.config-source-badge {
+ display: inline-flex;
+ align-items: center;
+ min-height: 24px;
+ padding: 0 10px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.config-editor {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 6px;
+ min-height: 0;
+}
+
+.config-editor[hidden] {
+ display: none;
+}
+
+.config-textarea {
+ flex: 1;
+ min-height: 0;
+ padding: 8px;
+ box-sizing: border-box;
+ /* common.css applies `user-select: none` to *, so without this the user could type into the
+ editor but not select, drag or copy what they had typed. */
+ -webkit-user-select: text;
+ user-select: text;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--bg);
+ color: var(--text);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 12px;
+ line-height: 1.5;
+ resize: none;
+ white-space: pre;
+ overflow: auto;
+}
+
+.config-textarea:focus {
+ outline: none;
+ border-color: var(--main-color);
+}
+
+.config-custom {
+ flex: 1;
+ min-height: 0;
+ width: 100%;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--bg);
+}
+
+.config-custom[hidden] {
+ display: none;
+}
+
+.config-view-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.config-view-footer[hidden] {
+ display: none;
+}
+
+.config-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.config-actions > button[hidden] {
+ display: none;
+}
+
+.config-validation {
+ color: var(--muted);
+ font-size: 11px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.config-validation.invalid {
+ color: var(--plugin-status-danger);
+}
+
+/* Footer status bar: single-line, fixed-height strip mirroring PluginsDialog's. */
+.status-bar {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 28px;
+ padding: 6px 14px;
+ box-sizing: border-box;
+ border-top: 1px solid var(--border);
+ background: var(--panel);
+ color: var(--text);
+ font-size: 13px;
+}
+
+.status-text {
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.status-bar.level-success .status-text {
+ color: var(--plugin-status-ok);
+}
+
+.status-bar.level-error .status-text {
+ color: var(--plugin-status-danger);
+}
+
+.status-bar.level-warn .status-text {
+ color: var(--plugin-status-warn);
+}
diff --git a/resources/web/dialog/PluginsDialog/index.js b/resources/web/dialog/PluginsDialog/index.js
index d0ad2310a8..0d07757244 100644
--- a/resources/web/dialog/PluginsDialog/index.js
+++ b/resources/web/dialog/PluginsDialog/index.js
@@ -504,9 +504,8 @@ function HasMixedCapabilityState(plugin) {
String(capability?.type_key || "")
);
- const hasEnabled = toggleableCapabilities.some((capability) => capability?.enabled === true);
const hasDisabled = toggleableCapabilities.some((capability) => capability?.enabled !== true);
- return hasEnabled && hasDisabled;
+ return toggleableCapabilities.length > 0 && hasDisabled;
}
function IsPluginLoading(plugin) {
@@ -826,12 +825,12 @@ function RenderDetails() {
// native side sends and sends back what the user saves.
// ---------------------------------------------------------------------------
-// Every capability is configurable — it always gets at least the default JSON editor over its
-// stored config — so the sidebar lists them all. The only exception is the descriptor-only rows
-// shown for a plugin that is not activated: those carry no capability name, so there is nothing to
-// address on the native side and nothing to configure yet.
+// The config sidebar's rows, built natively by PluginConfig::capabilities_payload and shared with
+// PluginsConfigDialog. Only loaded, addressable capabilities appear: the descriptor-only rows the
+// list tab shows for a plugin that is not activated carry no capability name, so there is nothing to
+// configure and nothing to address on the native side.
function GetConfigurableCapabilities(plugin) {
- return GetCapabilities(plugin).filter((capability) => String(capability?.name || ""));
+ return Array.isArray(plugin?.config_capabilities) ? plugin.config_capabilities : [];
}
function RenderConfig(plugin) {
diff --git a/src/libslic3r/Config.hpp b/src/libslic3r/Config.hpp
index 0d776c7599..a8fe7ab988 100644
--- a/src/libslic3r/Config.hpp
+++ b/src/libslic3r/Config.hpp
@@ -2228,6 +2228,8 @@ public:
// Vector value, but edited as a single string.
one_string,
plugin_picker,
+ // Raw JSON string value, edited through a dialog behind a button rather than in the row.
+ plugin_config,
};
// Identifier of this option. It is stored here so that it is accessible through the by_serialization_key_ordinal map.
diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp
index be48fc2730..7c7b9cad64 100644
--- a/src/libslic3r/Preset.cpp
+++ b/src/libslic3r/Preset.cpp
@@ -1194,6 +1194,7 @@ static std::vector