diff --git a/resources/web/dialog/PluginsConfigDialog/index.html b/resources/web/dialog/PluginsConfigDialog/index.html
new file mode 100644
index 0000000000..8e44bac35d
--- /dev/null
+++ b/resources/web/dialog/PluginsConfigDialog/index.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+ 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..013ec7ac16
--- /dev/null
+++ b/resources/web/dialog/PluginsConfigDialog/index.js
@@ -0,0 +1,437 @@
+// Capability rows the active preset uses, as PluginConfig::capabilities_payload emits them:
+// {plugin_key, name, type, type_key, has_config_ui}.
+let capabilities = [];
+
+// The selected row's identity; plugin_key is part of it because this list spans plugins.
+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 presetName = document.getElementById("pagePresetName");
+ if (presetName)
+ presetName.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 if the capability is still there, else select the first.
+ 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;
+
+ // The native reply is async: clear now so the old config cannot appear under the new selection.
+ ClearCapabilityConfigView();
+ RequestCapabilityConfig();
+ RenderCapabilities();
+}
+
+// Config editor: the host's JSON editor, or the capability's own HTML UI in a sandboxed frame.
+// Both edit the same stored config; the page renders what the native side sends.
+
+// Replies are async: apply one only if it still matches the selected row (plugin_key included,
+// since this list spans plugins), so a stale reply never lands under another capability.
+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 and the footer, so nothing from the previous capability lingers while the
+// next one is in flight.
+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");
+
+ 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;
+ selectedHasPresetOverride = false;
+ selectedReadOnly = false;
+ SetConfigValidation("");
+}
+
+// A read-only capability cannot be saved, and there is nothing to restore until the preset overrides
+// the global configuration.
+function UpdateConfigActions(payload) {
+ selectedHasPresetOverride = payload?.has_preset_override === true;
+ selectedReadOnly = payload?.read_only === true;
+
+ const save = document.getElementById("configSaveBtn");
+ const restore = document.getElementById("configRestoreBtn");
+ if (save)
+ save.disabled = selectedReadOnly;
+ if (restore)
+ restore.disabled = selectedReadOnly || !selectedHasPresetOverride;
+}
+
+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 || "");
+ UpdateConfigActions(payload);
+
+ // The footer belongs to the JSON editor. A custom UI owns its whole surface, including whatever
+ // save/restore controls it wants, and reaches the host through the window.orca bridge.
+ const footer = document.getElementById("configFooter");
+ if (footer)
+ footer.hidden = html !== "";
+
+ if (html) {
+ if (custom) {
+ custom.hidden = false;
+ custom.srcdoc = BuildCustomConfigDocument(html, config);
+ }
+ if (editor)
+ editor.hidden = true;
+ return;
+ }
+
+ // Default editor: any reason a custom UI is unavailable already arrived in payload.error.
+ if (custom) {
+ custom.hidden = true;
+ custom.removeAttribute("srcdoc");
+ }
+ if (editor)
+ editor.hidden = false;
+ if (text)
+ text.value = JSON.stringify(config, null, 2);
+ SetConfigValidation("");
+}
+
+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 is never saved: Save is the only way to persist. The native side re-validates.
+ 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
+ });
+}
+
+// "Restore defaults" here drops the preset's override, so the capability falls back to the global
+// configuration. The native side confirms, then re-sends the config that is now effective.
+function RestoreCapabilityConfig() {
+ 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 persisted, not from what 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, save one, drop the preset's
+// override, and be told when either lands. The frame is sandboxed into an opaque origin, so this
+// bridge is its only channel.
+function BuildCustomConfigDocument(html, config) {
+ // Inlined into a " would close the tag early, so escape "<" — the
+ // literal stays valid JSON.
+ 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 }, "*"); },
+ restoreDefaults: function () { parent.postMessage({ __orca: "restore" }, "*"); },
+ 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 || !selectedPluginKey || !selectedCapabilityName)
+ return;
+
+ if (data.__orca === "save") {
+ SendMessage("save_capability_config", {
+ plugin_key: selectedPluginKey,
+ capability_name: selectedCapabilityName,
+ capability_type: selectedCapabilityType,
+ config: data.config === undefined ? {} : data.config
+ });
+ return;
+ }
+
+ if (data.__orca === "restore")
+ RestoreCapabilityConfig();
+}
+
+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 text = document.getElementById("configText");
+ if (text)
+ text.addEventListener("input", ValidateConfigText);
+
+ // The custom UI is sandboxed into an opaque origin, so postMessage is its only channel.
+ // OnCustomConfigMessage matches on the frame's contentWindow, not the origin ("null" when
+ // sandboxed), and ignores anything else.
+ 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..be5b5e2914
--- /dev/null
+++ b/resources/web/dialog/PluginsConfigDialog/styles.css
@@ -0,0 +1,251 @@
+/* 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;
+}
+
+/* ---- 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-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.html b/resources/web/dialog/PluginsDialog/index.html
index 9add22a95f..1e879339ed 100644
--- a/resources/web/dialog/PluginsDialog/index.html
+++ b/resources/web/dialog/PluginsDialog/index.html
@@ -81,12 +81,19 @@