fix(commands): show slash picker only for prefixes
Keep postfix slash commands resolvable when messages are sent, but stop opening the composer menu for trailing slash tokens such as filesystem paths.\n\nAdd regression coverage and document the picker behavior.
Alessandro committed
Aug 1, 2026 at 12:09 UTC
771d670ac1e80e3e1a7f3f1ecb3442ddc430aa17
4 files changed
+34
-5
plugins/_commands/AGENTS.md
+1
-1
@@ -30,7 +30,7 @@
30
- On startup, `_commands` copies legacy `usr/plugins/commands` command and skill files into `usr/plugins/_commands` without overwriting existing files, copies scoped legacy command folders to `_commands`, and disables the legacy `commands` plugin roots to prevent duplicate WebUI popovers.
31
- Script commands must expose `run(payload)` and return a string or a dict with `text` and optional `effects`; `show_markdown` effects render as auto-dismissing toast notifications.
32
- Script commands may emit `send_message` with `text` to submit the rendered composer text immediately after command resolution.
33
-- Commands accept prefix syntax (`/goal objective`) and exact postfix syntax (`objective /goal`); ordinary mid-sentence mentions are not invocations.
33
+- Commands accept prefix syntax (`/goal objective`) and exact postfix syntax (`objective /goal`); ordinary mid-sentence mentions are not invocations. The composer picker opens only for prefix syntax, while postfix commands resolve when sent.
34
- WebUI sends resolve through the picker effect path, while backend-originated messages resolve before reaching the agent.
35
- `/stop` uses the same shared cancellation operation as the composer Stop button, including progress cleanup and terminal logging.
36
- Built-in `/computer-use on|off` emits a bounded `computer_use` effect. WebUI
plugins/_commands/README.md
+1
-1
@@ -7,7 +7,7 @@ This plugin lets you define reusable `/commands` as `.command.yaml` files with e
7
- a `.txt` template body
8
- a `.py` script hook
9
10
-Commands are managed from the plugin modal and can be inserted directly from the chat composer with prefix syntax (`/goal objective`) or an exact trailing command (`objective /goal`).
10
+Commands are managed from the plugin modal and can be inserted directly from the chat composer with prefix syntax (`/goal objective`) or an exact trailing command (`objective /goal`). The picker opens only for prefix syntax; trailing commands resolve when sent.
11
12
## Features
13
plugins/_commands/tests/test_commands_plugin.py
+29
@@ -1,5 +1,7 @@
1
from __future__ import annotations
2
3
+import shutil
4
+import subprocess
5
import threading
6
import uuid
7
from dataclasses import dataclass, field
@@ -65,6 +67,33 @@ def _save_command(
67
return _track_paths(scope, command)
68
69
70
+def test_composer_picker_ignores_postfix_slashes() -> None:
71
+ if not shutil.which("node"):
72
+ pytest.skip("Node.js is required to execute the slash-picker regression.")
73
+
74
+ source = (Path(__file__).resolve().parents[1] / "webui" / "commands-slash-store.js").read_text(
75
+ encoding="utf-8"
76
+ )
77
+ start = source.index("function parseSlashInput(")
78
+ function_source = source[start : source.index("\n\nfunction notifyError", start)]
79
+ script = f"""
80
+{function_source}
81
+
82
+const leading = parseSlashInput("/goal objective", false);
83
+if (!leading.active || leading.query !== "goal") throw new Error("leading command hidden");
84
+
85
+const trailing = parseSlashInput("objective /goal", false);
86
+if (trailing.active) throw new Error("postfix command opened the picker");
87
+
88
+const path = parseSlashInput("Review /a0/usr/projects/example", false);
89
+if (path.active) throw new Error("path opened the picker");
90
+
91
+const resolvable = parseSlashInput("objective /goal");
92
+if (!resolvable.active || resolvable.query !== "goal") throw new Error("postfix resolution broke");
93
+"""
94
+ subprocess.run(["node", "-e", script], check=True, text=True)
95
+
96
+
97
@pytest.fixture
98
def scope_fixture() -> ScopeFixture:
99
suffix = uuid.uuid4().hex[:8]
plugins/_commands/webui/commands-slash-store.js
+3
-3
@@ -22,10 +22,10 @@ function sanitizeCommandName(rawName) {
22
.replace(/^[-_]+|[-_]+$/g, "");
23
}
24
25
-function parseSlashInput(message) {
25
+function parseSlashInput(message, allowPostfix = true) {
26
const text = String(message || "");
27
const prefixMatch = text.match(/^\s*\/([^\s]*)(?:\s+([\s\S]*))?$/);
28
- const postfixMatch = prefixMatch
28
+ const postfixMatch = prefixMatch || !allowPostfix
29
? null
30
: text.match(/^([\s\S]*\S)\s+\/([^\s]*)\s*$/);
31
if (!prefixMatch && !postfixMatch) {
@@ -238,7 +238,7 @@ const model = {
238
this.dismissed = false;
239
240
const message = this.getInputMessage(event);
241
- const parsed = parseSlashInput(message);
241
+ const parsed = parseSlashInput(message, false);
242
243
this.active = parsed.active;
244
this.query = parsed.query;