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) {