Improve built-in command management

List bundled slash commands in their own section and create same-name project or global overrides when users edit them. Remove the Commands sidebar shortcut and redundant scope banner, then simplify command cards by dropping badges and argument hints while tightening description typography. Add regression coverage for built-in listing and override precedence.

Alessandro committed Jul 10, 2026 at 17:50 UTC 56608489401f2842f98c529e60c58f58b6d4b191
9 files changed +137 -118
plugins/_commands/AGENTS.md
+3 -1
@@ -12,7 +12,7 @@
12 - `api/commands.py` owns the Commands API actions used by the WebUI.
13 - `webui/` owns the manager/editor modal stores, HTML surfaces, and thumbnail asset.
14 - `commands/` owns bundled read-only slash command definitions shipped by `_commands`.
15 -- `extensions/` owns the chat composer picker and sidebar quick-action entry.
15 +- `extensions/` owns the chat composer slash picker.
16 - `extensions/python/startup_migration/` owns one-time migration from the legacy community `commands` plugin namespace.
17 - `skills/commands-create-slash-command/` owns the agent-facing authoring workflow for reusable slash commands.
18 - `tests/` owns regression coverage for parsing, CRUD, scope precedence, plugin-distributed commands, legacy migration, and skill discovery.
@@ -23,6 +23,7 @@
23 - Each command is one `.command.yaml` config plus one same-directory `.txt` text template or `.py` script hook.
24 - Project commands override global commands, global commands override bundled `_commands/commands/` defaults, and bundled defaults override other plugin-distributed commands with the same name.
25 - Bundled `_commands/commands/` definitions and commands contributed by other plugins are read-only from this manager.
26 +- The manager lists bundled commands separately; editing one copies it unchanged into the selected project or global scope under the same name, then edits that higher-precedence override.
27 - Bundled command files use canonical command names only; do not ship alias-only built-ins such as `/img` for `/attach`.
28 - Command configs may set `webui_hidden: true` to stay resolvable but be omitted from the chat composer picker.
29 - Commands contributed by enabled plugins live in their `commands/` directory and must not be rediscovered through the generic plugin-distributed path from `_commands` itself.
@@ -34,6 +35,7 @@
35
36 - Keep the command storage and route namespace aligned with `_commands`.
37 - Preserve unknown command config keys when editing commands.
38 +- Keep built-in source files immutable; user edits must be same-name scope overrides.
39 - Keep WebUI paths pointed at `/plugins/_commands/...`.
40
41 ## Verification
plugins/_commands/README.md
+1 -2
@@ -137,8 +137,7 @@ When the built-in `_commands` plugin starts, it migrates files from the older co
137
138 ## UI Surfaces
139
140 -- Plugin modal: open the Commands manager from the Plugins dialog
141 -- Sidebar quick action: terminal icon next to the Plugins button
140 +- Plugin modal: manage project/global commands and create editable same-name overrides of bundled commands
141 - Chat composer: type `/` at the start of the inline input to browse commands
142
143 ## Agent Skill
plugins/_commands/api/commands.py
+1
@@ -51,6 +51,7 @@ class Commands(ApiHandler):
51 return {
52 "ok": True,
53 "commands": commands,
54 + "builtin_commands": commands_helper.list_builtin_commands(),
55 "scope": scope,
56 }
57
plugins/_commands/extensions/webui/sidebar-quick-actions-main-start/commands-entry.html deleted
-9
@@ -1,9 +0,0 @@
1 -<div x-data>
2 - <button x-move-after=".config-button#plugins"
3 - class="config-button"
4 - id="commands-plugin"
5 - title="Commands"
6 - @click="import('/plugins/_commands/webui/commands-store.js').then(({ store }) => store.openManager())">
7 - <span class="material-symbols-outlined">terminal</span>
8 - </button>
9 -</div>
plugins/_commands/helpers/commands.py
+11 -2
@@ -336,6 +336,11 @@ def list_effective_commands(
336 return effective, strip_private_scope(resolved_scope)
337
338
339 +def list_builtin_commands() -> list[dict[str, Any]]:
340 + """Return bundled commands for display in the command manager."""
341 + return sorted(_discover_builtin_commands(), key=lambda item: item["name"])
342 +
343 +
344 def get_command(
345 path: str,
346 project_name: str = "",
@@ -489,7 +494,7 @@ def duplicate_command(
494 project_name: str = "",
495 agent_profile: str = "",
496 ) -> dict[str, Any]:
492 - """Duplicate an existing command, assigning it a unique ``-copy`` suffixed name.
497 + """Duplicate a command, preserving built-in names so the copy overrides the default.
498
499 Returns the newly created command dict.
500
@@ -499,7 +504,11 @@ def duplicate_command(
504
505 """
506 command = get_command(path, project_name, "")
502 - duplicated_name = _generate_duplicate_name(command["name"], project_name=project_name)
507 + duplicated_name = (
508 + command["name"]
509 + if command.get("scope_key") == "builtin"
510 + else _generate_duplicate_name(command["name"], project_name=project_name)
511 + )
512 return save_command(
513 project_name=project_name,
514 name=duplicated_name,
plugins/_commands/tests/test_commands_plugin.py
+26
@@ -190,6 +190,32 @@ def test_list_effective_commands_project_overrides_global(
190 assert scoped_command["override_scopes"] == ["Global"]
191
192
193 +def test_duplicate_builtin_creates_same_name_project_override(
194 + scope_fixture: ScopeFixture,
195 +) -> None:
196 + builtin = next(
197 + command
198 + for command in commands_helper.list_builtin_commands()
199 + if command["name"] == "new"
200 + )
201 +
202 + override = _track_paths(
203 + scope_fixture,
204 + commands_helper.duplicate_command(
205 + builtin["path"],
206 + project_name=scope_fixture.project_name,
207 + ),
208 + )
209 +
210 + assert override["name"] == builtin["name"]
211 + assert override["scope_label"] == "Project"
212 + assert override["body"] == builtin["body"]
213 +
214 + effective, _ = commands_helper.list_effective_commands(scope_fixture.project_name)
215 + resolved = next(command for command in effective if command["name"] == "new")
216 + assert resolved["path"] == override["path"]
217 +
218 +
219 def test_models_command_always_opens_modal():
220 result = connector_commands.run(
221 {
plugins/_commands/tests/test_plugin_command_discovery.py
+3
@@ -147,6 +147,9 @@ def test_discover_builtin_commands_marks_own_commands_read_only():
147 with pytest.raises(ValueError, match="Built-in commands are read-only"):
148 commands_helper.delete_command(command["path"])
149
150 + response = object.__new__(Commands)._list_scope({"project_name": ""})
151 + assert "new" in {item["name"] for item in response["builtin_commands"]}
152 +
153
154 def test_builtin_commands_use_canonical_names_only():
155 discovered = commands_helper._discover_builtin_commands()
plugins/_commands/webui/commands-store.js
+31 -10
@@ -81,6 +81,7 @@ const model = {
81 scope: null,
82 contextScope: { project_name: "" },
83 commands: [],
84 + builtinCommands: [],
85 pendingScope: null,
86 pendingCreate: null,
87 editor: createEmptyEditor(),
@@ -90,10 +91,6 @@ const model = {
91 return this.scope?.scope_label || "Global";
92 },
93
93 - get selectedScopeDirectory() {
94 - return this.scope?.directory_path || "";
95 - },
96 -
94 get hasCommands() {
95 return (this.commands || []).length > 0;
96 },
@@ -139,6 +136,7 @@ const model = {
136 console.error("Failed to initialize commands manager:", error);
137 this.scope = null;
138 this.commands = [];
139 + this.builtinCommands = [];
140 notifyError(error?.message || "Failed to open the commands manager.");
141 }
142
@@ -157,6 +155,7 @@ const model = {
155 this.scope = null;
156 this.contextScope = { project_name: "" };
157 this.commands = [];
158 + this.builtinCommands = [];
159 this.pendingScope = null;
160 this.pendingCreate = null;
161 this.resetEditor();
@@ -205,10 +204,14 @@ const model = {
204 });
205
206 this.commands = Array.isArray(response?.commands) ? response.commands : [];
207 + this.builtinCommands = Array.isArray(response?.builtin_commands)
208 + ? response.builtin_commands
209 + : [];
210 this.scope = response?.scope || null;
211 } catch (error) {
212 console.error("Failed to load commands:", error);
213 this.commands = [];
214 + this.builtinCommands = [];
215 this.scope = null;
216 notifyError(error?.message || "Failed to load commands.");
217 } finally {
@@ -225,13 +228,31 @@ const model = {
228 await this.loadCommands();
229 },
230
228 - overrideBadgeLabel(command) {
229 - const count = Number(command?.override_count || 0);
230 - if (!count) return "";
231 - if (count === 1) {
232 - return `Overrides ${command.override_scopes[0]}`;
231 + scopedOverride(command) {
232 + return (this.commands || []).find((item) => item.name === command?.name);
233 + },
234 +
235 + async editBuiltinCommand(command) {
236 + const existing = this.scopedOverride(command);
237 + if (existing) {
238 + await this.openEditCommand(existing);
239 + return;
240 + }
241 +
242 + try {
243 + const response = await callJsonApi(COMMANDS_API_PATH, {
244 + action: "duplicate",
245 + path: command.path,
246 + project_name: this.projectName || "",
247 + });
248 + await this.loadCommands();
249 + emitCommandsUpdated();
250 + notifySuccess(`Created ${this.selectedScopeLabel} override for /${command.name}`);
251 + if (response?.command) await this.openEditCommand(response.command);
252 + } catch (error) {
253 + console.error("Failed to create command override:", error);
254 + notifyError(error?.message || "Failed to create command override.");
255 }
234 - return `Overrides ${count} lower scopes`;
256 },
257
258 async browseScopeFolder() {
plugins/_commands/webui/main.html
+61 -94
@@ -45,20 +45,17 @@
45 </div>
46 </div>
47
48 - <div class="commands-scope-banner" x-show="$store.commandsManager.scope">
49 - <div class="commands-scope-banner-label">
50 - <span class="material-symbols-outlined">target</span>
51 - <span x-text="$store.commandsManager.selectedScopeLabel"></span>
52 - </div>
53 - <div class="commands-scope-banner-path" x-text="$store.commandsManager.selectedScopeDirectory"></div>
54 - </div>
55 -
48 <div class="commands-loading" x-show="$store.commandsManager.loading">
49 <span class="material-symbols-outlined spinning">progress_activity</span>
50 <span>Loading commands...</span>
51 </div>
52
53 <div class="commands-list" x-show="!$store.commandsManager.loading">
54 + <div class="commands-section-heading">
55 + <div class="commands-section-title" x-text="`${$store.commandsManager.selectedScopeLabel} commands`"></div>
56 + <div class="commands-section-copy">Editable commands saved in the selected scope.</div>
57 + </div>
58 +
59 <template x-if="$store.commandsManager.hasCommands">
60 <div class="commands-grid">
61 <template x-for="command in $store.commandsManager.commands" :key="command.path">
@@ -84,21 +81,6 @@
81 </div>
82 </div>
83
87 - <div class="commands-card-badges">
88 - <span class="commands-badge scope" x-text="command.scope_label"></span>
89 - <span class="commands-badge type" x-text="command.command_type === 'script' ? 'Python Hook' : 'Text Template'"></span>
90 - <template x-if="command.override_count">
91 - <span class="commands-badge override" x-text="$store.commandsManager.overrideBadgeLabel(command)"></span>
92 - </template>
93 - </div>
94 -
95 - <template x-if="command.argument_hint">
96 - <div class="commands-card-hint">
97 - <span class="material-symbols-outlined">subdirectory_arrow_right</span>
98 - <span x-text="command.argument_hint"></span>
99 - </div>
100 - </template>
101 -
84 <div class="commands-card-path" x-text="command.path"></div>
85 </article>
86 </template>
@@ -117,6 +99,41 @@
99 </button>
100 </div>
101 </template>
102 +
103 + <template x-if="$store.commandsManager.builtinCommands.length">
104 + <section class="commands-section">
105 + <div class="commands-section-heading">
106 + <div class="commands-section-title">Built-in commands</div>
107 + <div class="commands-section-copy">
108 + Editing creates an override in <span x-text="$store.commandsManager.selectedScopeLabel"></span>; core files stay unchanged.
109 + </div>
110 + </div>
111 +
112 + <div class="commands-grid">
113 + <template x-for="command in $store.commandsManager.builtinCommands" :key="command.path">
114 + <article class="commands-card">
115 + <div class="commands-card-header">
116 + <div class="commands-card-copy">
117 + <div class="commands-card-title">
118 + <span class="commands-slash">/</span><span x-text="command.name"></span>
119 + </div>
120 + <div class="commands-card-description" x-text="command.description"></div>
121 + </div>
122 +
123 + <button type="button"
124 + class="button icon"
125 + :title="$store.commandsManager.scopedOverride(command) ? 'Edit override' : 'Create editable override'"
126 + :aria-label="$store.commandsManager.scopedOverride(command) ? `Edit /${command.name} override` : `Create editable /${command.name} override`"
127 + @click="$store.commandsManager.editBuiltinCommand(command)">
128 + <span class="material-symbols-outlined">edit</span>
129 + </button>
130 + </div>
131 +
132 + </article>
133 + </template>
134 + </div>
135 + </section>
136 + </template>
137 </div>
138 </div>
139 </template>
@@ -184,31 +201,6 @@
201 margin-left: auto;
202 }
203
187 - .commands-scope-banner {
188 - display: flex;
189 - flex-wrap: wrap;
190 - gap: 0.75rem;
191 - align-items: center;
192 - padding: 0.85rem 1rem;
193 - border: 1px solid var(--color-border);
194 - border-radius: 12px;
195 - background: color-mix(in srgb, var(--color-background) 84%, var(--color-panel));
196 - }
197 -
198 - .commands-scope-banner-label {
199 - display: inline-flex;
200 - align-items: center;
201 - gap: 0.45rem;
202 - font-weight: 600;
203 - }
204 -
205 - .commands-scope-banner-path {
206 - color: var(--color-text-secondary);
207 - font-family: "Roboto Mono", monospace;
208 - font-size: 0.82rem;
209 - word-break: break-all;
210 - }
211 -
204 .commands-loading {
205 display: flex;
206 align-items: center;
@@ -228,6 +220,25 @@
220 gap: 0.9rem;
221 }
222
223 + .commands-section {
224 + margin-top: 1.25rem;
225 + }
226 +
227 + .commands-section-heading {
228 + margin-bottom: 0.75rem;
229 + }
230 +
231 + .commands-section-title {
232 + font-size: 1rem;
233 + font-weight: 600;
234 + }
235 +
236 + .commands-section-copy {
237 + margin-top: 0.2rem;
238 + color: var(--color-text-secondary);
239 + font-size: 0.86rem;
240 + }
241 +
242 .commands-card {
243 display: flex;
244 flex-direction: column;
@@ -262,8 +273,8 @@
273 .commands-card-description {
274 margin-top: 0.25rem;
275 color: var(--color-text-secondary);
265 - font-size: 0.9rem;
266 - line-height: 1.45;
276 + font-size: 0.82rem;
277 + line-height: 1.35;
278 }
279
280 .commands-card-actions {
@@ -272,50 +283,6 @@
283 flex-shrink: 0;
284 }
285
275 - .commands-card-badges {
276 - display: flex;
277 - flex-wrap: wrap;
278 - gap: 0.45rem;
279 - }
280 -
281 - .commands-badge {
282 - display: inline-flex;
283 - align-items: center;
284 - padding: 0.22rem 0.5rem;
285 - border-radius: 999px;
286 - font-size: 0.74rem;
287 - font-weight: 600;
288 - border: 1px solid transparent;
289 - }
290 -
291 - .commands-badge.scope {
292 - background: color-mix(in srgb, var(--color-highlight) 12%, transparent);
293 - border-color: color-mix(in srgb, var(--color-highlight) 24%, transparent);
294 - }
295 -
296 - .commands-badge.override {
297 - background: color-mix(in srgb, #f39c12 12%, transparent);
298 - border-color: color-mix(in srgb, #f39c12 28%, transparent);
299 - }
300 -
301 - .commands-badge.type {
302 - background: color-mix(in srgb, #5b8def 12%, transparent);
303 - border-color: color-mix(in srgb, #5b8def 30%, transparent);
304 - }
305 -
306 - .commands-card-hint {
307 - display: inline-flex;
308 - align-items: flex-start;
309 - gap: 0.35rem;
310 - color: var(--color-text-secondary);
311 - font-size: 0.86rem;
312 - }
313 -
314 - .commands-card-hint .material-symbols-outlined {
315 - font-size: 1rem;
316 - margin-top: 0.05rem;
317 - }
318 -
286 .commands-card-path {
287 color: var(--color-text-secondary);
288 font-family: "Roboto Mono", monospace;