Add plugin config listing/deletion + UI improvements

Expose plugin config management and improve UI/UX. Backend: add plugins API actions list_configs and delete_config (with validation and file deletion), and refine find_plugin_assets to infer project/agent from paths when wildcards are used. Frontend: update download endpoints to /api/download_work_dir_file, redesign plugin-configs list layout, add delete confirmation and show action, and adjust styles. Enhance plugin-settings-store with unsaved-changes detection, scope-change confirmation, config listing and deletion via the new API, and settings snapshot lifecycle. Also update message path replacement to use the /api prefix.

frdel committed Feb 20, 2026 at 18:12 UTC a1589c27f54f8aa6461c278966eb90a78864380c
7 files changed +208 -23
python/api/plugins.py
+46
@@ -1,3 +1,5 @@
1 +import os
2 +
3 from python.helpers.api import ApiHandler, Request, Response
4 from python.helpers import plugins, files
5
@@ -49,6 +51,50 @@ class Plugins(ApiHandler):
51 "data": settings,
52 }
53
54 + if action == "list_configs":
55 + plugin_name = input.get("plugin_name", "")
56 + if not plugin_name:
57 + return Response(status=400, response="Missing plugin_name")
58 +
59 + configs = plugins.find_plugin_assets(
60 + plugins.CONFIG_FILE_NAME,
61 + plugin_name=plugin_name,
62 + project_name="*",
63 + agent_profile="*",
64 + only_first=False,
65 + )
66 +
67 + return {"ok": True, "data": configs}
68 +
69 + if action == "delete_config":
70 + plugin_name = input.get("plugin_name", "")
71 + path = input.get("path", "")
72 + if not plugin_name:
73 + return Response(status=400, response="Missing plugin_name")
74 + if not path:
75 + return Response(status=400, response="Missing path")
76 +
77 + configs = plugins.find_plugin_assets(
78 + plugins.CONFIG_FILE_NAME,
79 + plugin_name=plugin_name,
80 + project_name="*",
81 + agent_profile="*",
82 + only_first=False,
83 + )
84 + allowed_paths = {c.get("path", "") for c in configs}
85 + if path not in allowed_paths:
86 + return Response(status=400, response="Invalid path")
87 +
88 + if not files.exists(path):
89 + return {"ok": True}
90 +
91 + try:
92 + os.remove(path)
93 + except Exception as e:
94 + return Response(status=500, response=f"Failed to delete config: {str(e)}")
95 +
96 + return {"ok": True}
97 +
98 if action == "save_config":
99 plugin_name = input.get("plugin_name", "")
100 project_name = input.get("project_name", "")
python/helpers/plugins.py
+21 -2
@@ -275,12 +275,31 @@ def find_plugin_assets(
275 if "*" in path
276 else ([path] if files.exists(path) else [])
277 )
278 +
279 + need_proj = proj == "*"
280 + need_prof = profile == "*"
281 +
282 + def _after(s: str, marker: str, last: bool = False) -> str:
283 + i = s.rfind(marker) if last else s.find(marker)
284 + if i == -1:
285 + return ""
286 + start = i + len(marker)
287 + end = s.find("/", start)
288 + return s[start:] if end == -1 else s[start:end]
289 +
290 for matched in matched_paths:
291 + inferred_proj = _after(matched, "/projects/") if need_proj else proj
292 + inferred_prof = _after(matched, "/agents/", last=True) if need_prof else profile
293 results.append(
280 - {"project_name": proj, "agent_profile": profile, "path": matched}
294 + {
295 + "project_name": inferred_proj,
296 + "agent_profile": inferred_prof,
297 + "path": matched,
298 + }
299 )
300 if only_first:
301 return True
302 + return False
303
304 # project/.a0proj/agents/<profile>/plugins/<plugin_name>/...
305 if project_name:
@@ -295,7 +314,7 @@ def find_plugin_assets(
314 )
315 if _collect(path, project_name, agent_profile):
316 return results
298 - else:
317 + if not agent_profile or agent_profile == "*":
318 # project/.a0proj/plugins/<plugin_name>/...
319 path = projects.get_project_meta(
320 project_name, files.PLUGINS_DIR, plugin_name, *subpaths
webui/components/modals/file-browser/file-browser-store.js
+1 -1
@@ -453,7 +453,7 @@ const model = {
453
454 downloadFile(file) {
455 const link = document.createElement("a");
456 - link.href = `/download_work_dir_file?path=${encodeURIComponent(file.path)}`;
456 + link.href = `/api/download_work_dir_file?path=${encodeURIComponent(file.path)}`;
457 link.download = file.name;
458 document.body.appendChild(link);
459 link.click();
webui/components/plugins/plugin-configs.html
+45 -9
@@ -1,6 +1,6 @@
1 <html>
2 <head>
3 - <title>Plugin Configurations</title>
3 + <title>Existing plugin configurations</title>
4 <script type="module">
5 import { store } from "/components/plugins/plugin-settings-store.js";
6 </script>
@@ -8,7 +8,7 @@
8 <body>
9 <div x-data>
10 <template x-if="$store.pluginSettings">
11 - <div x-create="$store.pluginSettings.loadConfigList()">
11 + <div x-create="$store.pluginSettings.loadConfigList()" class="plugin-configs-container">
12
13 <div x-show="$store.pluginSettings.configsError" class="plugin-configs-error">
14 <span class="material-symbols-outlined">error</span>
@@ -28,18 +28,26 @@
28 <template x-for="cfg in ($store.pluginSettings.configs || [])" :key="(cfg.project_name || '') + '|' + (cfg.agent_profile || '')">
29 <div class="plugin-configs-row">
30 <div class="plugin-configs-scope">
31 - <div class="plugin-configs-scope-title" x-text="$store.pluginSettings.projectLabel(cfg.project_name || '')"></div>
32 - <div class="plugin-configs-scope-sub" x-text="$store.pluginSettings.agentProfileLabel(cfg.agent_profile || '')"></div>
31 + <div class="plugin-configs-scope-top">
32 + <div class="plugin-configs-scope-line">
33 + <span class="plugin-configs-scope-key">Project:</span>
34 + <span x-text="$store.pluginSettings.projectLabel(cfg.project_name || '')"></span>
35 + </div>
36 + <div class="plugin-configs-scope-line">
37 + <span class="plugin-configs-scope-key">Agent profile:</span>
38 + <span x-text="$store.pluginSettings.agentProfileLabel(cfg.agent_profile || '')"></span>
39 + </div>
40 + </div>
41 + <div class="plugin-configs-scope-sub" x-text="cfg.path || ''"></div>
42 </div>
43
44 <div class="plugin-configs-actions">
45 <button type="button" class="button" @click="$store.pluginSettings.switchToConfig(cfg.project_name || '', cfg.agent_profile || '')">
46 <span class="icon material-symbols-outlined">list</span>
38 - Switch
47 + Show
48 </button>
40 - <button type="button" class="button cancel" @click="$store.pluginSettings.deleteConfig(cfg.project_name || '', cfg.agent_profile || '')">
49 + <button type="button" class="button cancel icon-button" title="Delete" @click="$confirmClick($event, () => $store.pluginSettings.deleteConfig(cfg.project_name || '', cfg.agent_profile || ''))">
50 <span class="icon material-symbols-outlined">delete</span>
42 - Delete
51 </button>
52 </div>
53 </div>
@@ -61,6 +69,10 @@
69 font-size: var(--font-size-small);
70 }
71
72 + .plugin-configs-container {
73 + padding: 0.75rem;
74 + }
75 +
76 .plugin-configs-error {
77 color: var(--color-error);
78 background: rgba(255, 0, 0, 0.07);
@@ -85,21 +97,45 @@
97 background: var(--color-surface);
98 }
99
88 - .plugin-configs-scope-title {
100 + .plugin-configs-scope {
101 + flex: 1 1 auto;
102 + min-width: 0;
103 + }
104 +
105 + .plugin-configs-scope-top {
106 + display: flex;
107 + gap: 1rem;
108 + flex-wrap: wrap;
109 + align-items: baseline;
110 + }
111 +
112 + .plugin-configs-scope-line {
113 + display: flex;
114 + gap: 0.35rem;
115 + align-items: baseline;
116 + }
117 +
118 + .plugin-configs-scope-key {
119 font-weight: 600;
120 + color: var(--color-text-secondary);
121 + white-space: nowrap;
122 }
123
124 .plugin-configs-scope-sub {
125 font-size: var(--font-size-small);
126 color: var(--color-text-secondary);
127 margin-top: 0.2rem;
128 + white-space: normal;
129 + overflow-wrap: break-word;
130 + word-break: normal;
131 }
132
133 .plugin-configs-actions {
134 display: flex;
135 gap: 0.5rem;
101 - flex-wrap: wrap;
136 + flex-wrap: nowrap;
137 justify-content: flex-end;
138 + flex: 0 0 auto;
139 }
140
141 .plugin-configs-empty {
webui/components/plugins/plugin-settings-store.js
+89 -5
@@ -16,6 +16,44 @@ const model = {
16 // plugin settings data (plugins bind their fields here)
17 settings: {},
18
19 + settingsSnapshotJson: "",
20 + previousProjectName: "",
21 + previousAgentProfileKey: "",
22 +
23 + _toComparableJson(value) {
24 + try {
25 + return JSON.stringify(value ?? {});
26 + } catch {
27 + return "";
28 + }
29 + },
30 +
31 + get hasUnsavedChanges() {
32 + return this._toComparableJson(this.settings) !== (this.settingsSnapshotJson || "");
33 + },
34 +
35 + confirmDiscardUnsavedChanges() {
36 + if (!this.hasUnsavedChanges) return true;
37 + return window.confirm("You have unsaved changes that will be lost. Continue?");
38 + },
39 +
40 + async onScopeChanged() {
41 + const nextProject = this.projectName || "";
42 + const nextProfile = this.agentProfileKey || "";
43 + const prevProject = this.previousProjectName || "";
44 + const prevProfile = this.previousAgentProfileKey || "";
45 +
46 + if (nextProject === prevProject && nextProfile === prevProfile) return;
47 +
48 + if (!this.confirmDiscardUnsavedChanges()) {
49 + this.projectName = prevProject;
50 + this.agentProfileKey = prevProfile;
51 + return;
52 + }
53 +
54 + await this.loadSettings();
55 + },
56 +
57 // where the settings were actually loaded from
58 loadedPath: "",
59 loadedProjectName: "",
@@ -58,8 +96,17 @@ const model = {
96 this.isListingConfigs = true;
97 this.configsError = null;
98 try {
61 - // TODO: list existing plugin config scopes without API calls
62 - this.configs = [];
99 + const response = await fetchApi("/plugins", {
100 + method: "POST",
101 + headers: { "Content-Type": "application/json" },
102 + body: JSON.stringify({
103 + action: "list_configs",
104 + plugin_name: this.pluginName,
105 + }),
106 + });
107 + const result = await response.json().catch(() => ({}));
108 + this.configs = result.ok ? (result.data || []) : [];
109 + if (!result.ok) this.configsError = result.error || "Failed to load configurations";
110 } catch (e) {
111 this.configsError = e?.message || "Failed to load configurations";
112 this.configs = [];
@@ -69,6 +116,7 @@ const model = {
116 },
117
118 async switchToConfig(projectName, agentProfile) {
119 + if (!this.confirmDiscardUnsavedChanges()) return;
120 this.projectName = projectName || "";
121 this.agentProfileKey = agentProfile || "";
122 await this.loadSettings();
@@ -78,8 +126,32 @@ const model = {
126 async deleteConfig(projectName, agentProfile) {
127 if (!this.pluginName) return;
128 try {
81 - // TODO: delete existing plugin config scope without API calls
82 - this.configsError = "Delete is not implemented yet";
129 + const cfg = (this.configs || []).find(
130 + (c) => (c?.project_name || "") === (projectName || "") && (c?.agent_profile || "") === (agentProfile || "")
131 + );
132 + const path = cfg?.path || "";
133 + if (!path) {
134 + this.configsError = "Configuration path not found";
135 + return;
136 + }
137 +
138 + const response = await fetchApi("/plugins", {
139 + method: "POST",
140 + headers: { "Content-Type": "application/json" },
141 + body: JSON.stringify({
142 + action: "delete_config",
143 + plugin_name: this.pluginName,
144 + path,
145 + }),
146 + });
147 + const result = await response.json().catch(() => ({}));
148 + if (!result.ok) {
149 + this.configsError = result.error || "Delete failed";
150 + return;
151 + }
152 +
153 + this.configsError = null;
154 + await this.loadConfigList();
155 } catch (e) {
156 this.configsError = e?.message || "Delete failed";
157 }
@@ -98,10 +170,13 @@ const model = {
170 this.pluginName = pluginName;
171 this.pluginMeta = null;
172 this.settings = {};
173 + this.settingsSnapshotJson = "";
174 this.error = null;
175 this.saveMode = 'plugin';
176 this.projectName = "";
177 this.agentProfileKey = "";
178 + this.previousProjectName = "";
179 + this.previousAgentProfileKey = "";
180 this.loadedPath = "";
181 this.loadedProjectName = "";
182 this.loadedAgentProfile = "";
@@ -167,6 +242,9 @@ const model = {
242 this.error = e?.message || "Failed to load settings";
243 this.settings = {};
244 } finally {
245 + this.settingsSnapshotJson = this._toComparableJson(this.settings);
246 + this.previousProjectName = this.projectName || "";
247 + this.previousAgentProfileKey = this.agentProfileKey || "";
248 this.isLoading = false;
249 }
250 },
@@ -201,7 +279,10 @@ const model = {
279 });
280 const result = await response.json().catch(() => ({}));
281 if (!result.ok) this.error = result.error || "Save failed";
204 - else window.closeModal?.();
282 + else {
283 + this.settingsSnapshotJson = this._toComparableJson(this.settings);
284 + window.closeModal?.();
285 + }
286 } catch (e) {
287 this.error = e?.message || "Save failed";
288 } finally {
@@ -213,6 +294,9 @@ const model = {
294 this.pluginName = null;
295 this.pluginMeta = null;
296 this.settings = {};
297 + this.settingsSnapshotJson = "";
298 + this.previousProjectName = "";
299 + this.previousAgentProfileKey = "";
300 this.loadedPath = "";
301 this.loadedProjectName = "";
302 this.loadedAgentProfile = "";
webui/components/plugins/plugin-settings.html
+5 -5
@@ -23,7 +23,7 @@
23 <label class="plugin-settings-toolbar-item">
24 <span class="plugin-settings-toolbar-label">Project</span>
25 <select x-model="$store.pluginSettings.projectName"
26 - @change="$store.pluginSettings.loadSettings()">
26 + @change="$store.pluginSettings.onScopeChanged()">
27 <option value="">Global</option>
28 <template x-for="project in $store.pluginSettings.projects" :key="project.key">
29 <option :value="project.key" x-text="project.label"></option>
@@ -34,7 +34,7 @@
34 <label class="plugin-settings-toolbar-item">
35 <span class="plugin-settings-toolbar-label">Agent profile</span>
36 <select x-model="$store.pluginSettings.agentProfileKey"
37 - @change="$store.pluginSettings.loadSettings()">
37 + @change="$store.pluginSettings.onScopeChanged()">
38 <option value="">All profiles</option>
39 <template x-for="profile in $store.pluginSettings.agentProfiles" :key="profile.key">
40 <option :value="profile.key" x-text="profile.label"></option>
@@ -42,7 +42,7 @@
42 </select>
43 </label>
44
45 - <button type="button" class="button plugin-settings-toolbar-button" @click="$store.pluginSettings.openConfigListModal()">
45 + <button type="button" class="button plugin-settings-toolbar-button" title="Show existing configurations" @click="$store.pluginSettings.openConfigListModal()">
46 <span class="icon material-symbols-outlined">list</span>
47 </button>
48
@@ -98,7 +98,7 @@
98 .plugin-settings-toolbar-row {
99 display: flex;
100 align-items: center;
101 - gap: 0.75rem;
101 + gap: clamp(0.75rem, 2vw, 2rem);
102 flex-wrap: wrap;
103 }
104
@@ -106,7 +106,7 @@
106 display: flex;
107 align-items: center;
108 gap: 0.5rem;
109 - flex: 0 1 18rem;
109 + flex: 1 1 0;
110 min-width: 12rem;
111 margin: 0;
112 }
webui/js/messages.js
+1 -1
@@ -1642,7 +1642,7 @@ function convertImgFilePaths(str) {
1642 }
1643
1644 function convertFilePaths(str) {
1645 - return str.replace(/file:\/\//g, "/download_work_dir_file?path=");
1645 + return str.replace(/file:\/\//g, "/api/download_work_dir_file?path=");
1646 }
1647
1648 function escapeHTML(str) {